Why is TSQL MERGE failing with a Primary Key Violation? Isn’t it atomic?

I have gotten this question a couple of times, so I thought I’d post the answer. 

No, MERGE doesn’t have strong isolation guarantees by default. By default MERGE will perform the "match" phase of the query without using exclusive locking. This increases the concurrency when the data to be merged is disjoint, ie where you won't be attempting to insert/update the same key value from multiple sessions. However when multiple sessions concurrently attempt to insert the same key value, you get a race condition where both sessions to see the row state before either tries to INSERT/UPDATE.

 

A simple MERGE like this:

 

MERGE mt AS target

USING (select 4 id, 4 a) AS source(id, a)

ON (target.id =source.id)

WHEN MATCHED

    THEN UPDATE SET target.a = source.a

WHEN NOT MATCHED THEN  

          INSERT (id, a)

          VALUES (source.id,source.a);

 

that takes the INSERT branch has a lock acquisition sequence that looks like this:

 

8 - IX 5 - OBJECT

7 - IU 6 - PAGE

8 - IX 6 - PAGE

15 - RangeI-N 7 - KEY

5 - X 7 – KEY

 

In particular the matching clause is evaluated while only holding a weak IX or IU lock.

 

That lock sequence is compatible with another copy of the same lock sequence until the last lock, the X lock on the key you are inserting which in incompatible. That will allow two MERGE statements executing at close to the same time to both evaluate the matching criteria, then one inserts the new row and the second gets a PK violation.

 

If you add some lock hints

 

MERGE mt with (paglock,updlock) AS target

USING (select 4 id, 4 a)AS source(id, a)

ON (target.id =source.id)

WHEN MATCHED

    THEN UPDATE SET target.a = source.a

WHEN NOT MATCHED THEN  

          INSERT (id, a)

          VALUES (source.id,source.a);

 

You can perform the matching read with a more restrictive U lock.

 

8 – IX 5 - OBJECT

4 - U 6 - PAGE

5 - X 6 – PAGE

 

Or with a more restrictive range lock

 

MERGE mt with (serializable)  AS target

USING (select 4 id, 4 a)AS source(id, a)

ON (target.id =source.id)

WHEN MATCHED

    THEN UPDATE SET target.a =source.a

WHEN NOT MATCHED THEN  

          INSERT (id, a)

          VALUES (source.id,source.a);

 

 

8 – IX 5 - OBJECT

7 – IU 6 - PAGE

14 - RangeS-U 7 - KEY

8 – IX 6 - PAGE

15 - RangeI-N 7 - KEY

5 - X 7 – KEY

 

nb The SERIALIZABLE hint is the same as HOLDLOCK, but I think SERIALIZABLE is more descriptive as it suggests that you will get range locking.

David