...why with clustered index, the deadlock is still there (though hit rate seems to be dropped)
The question isn't precisely clear (e.g. how many updates and to which id
values are in each transaction) but one obvious deadlock scenario arises with multiple single-row updates within a single transaction, where there is an overlap of [id]
values, and the ids are updated in a different [id]
order:
[T1]: Update id 2; Update id 1;
[T2]: Update id 1; Update id 2;
Deadlock sequence: T1 (u2), T2 (u1), T1 (u1) wait, T2 (u2) wait.
This deadlock sequence might be avoided by updating strictly in id order within each transaction (acquiring locks in the same order on the same path).
When use the clustered index, there is an exclusive lock on the key as well as an exclusive lock on RID when do update, which is expected; while there are two exclusive lock on two different RID if non-clustered index is used, which confuses me.
With a unique clustered index on id
, an exclusive lock is taken on the clustering key to protect writes to the in-row data. A separate RID
exclusive lock is required to protect the write to the LOB text
column, which is stored on a separate data page by default.
When the table is a heap with only a nonclustered index on id
, two things happen. First, one RID
exclusive lock relates to the heap in-row data, and the other is the lock on the LOB data as before. The second effect is that a more complex execution plan is required.
With a clustered index and a simple single-value equality predicate update, the query processor can apply an optimization that performs the update (read and write) in a single operator, using a single path:
The row is located and updated in a single seek operation, requiring only exclusive locks (no update locks are needed). An example locking sequence using your sample table:
acquiring IX lock on OBJECT: 6:992930809:0 -- TABLE
acquiring IX lock on PAGE: 6:1:59104 -- INROW
acquiring X lock on KEY: 6:72057594233618432 (61a06abd401c) -- INROW
acquiring IX lock on PAGE: 6:1:59091 -- LOB
acquiring X lock on RID: 6:1:59091:1 -- LOB
releasing lock reference on PAGE: 6:1:59091 -- LOB
releasing lock reference on RID: 6:1:59091:1 -- LOB
releasing lock reference on KEY: 6:72057594233618432 (61a06abd401c) -- INROW
releasing lock reference on PAGE: 6:1:59104 -- INROW
With only a nonclustered index, the same optimization cannot be applied because we need to read from one b-tree structure and write another. The multi-path plan has separate read and write phases:
This acquires update locks when reading, converting to exclusive locks if the row qualifies. Example lock sequence with the schema given:
acquiring IX lock on OBJECT: 6:992930809:0 -- TABLE
acquiring IU lock on PAGE: 6:1:59105 -- NC INDEX
acquiring U lock on KEY: 6:72057594233749504 (61a06abd401c) -- NC INDEX
acquiring IU lock on PAGE: 6:1:59104 -- HEAP
acquiring U lock on RID: 6:1:59104:1 -- HEAP
acquiring IX lock on PAGE: 6:1:59104 -- HEAP convert to X
acquiring X lock on RID: 6:1:59104:1 -- HEAP convert to X
acquiring IU lock on PAGE: 6:1:59091 -- LOB
acquiring U lock on RID: 6:1:59091:1 -- LOB
releasing lock reference on PAGE: 6:1:59091
releasing lock reference on RID: 6:1:59091:1
releasing lock reference on RID: 6:1:59104:1
releasing lock reference on PAGE: 6:1:59104
releasing lock on KEY: 6:72057594233749504 (61a06abd401c)
releasing lock on PAGE: 6:1:59105
Note the LOB data is read and written at the Table Update iterator. The more complex plan and multiple read and write paths increase the chances of a deadlock.
Finally, I can't help but notice the data types used in the table definition. You should not use the deprecated text
data type for new work; the alternative, if you really need the ability to store up to 2GB of data in this column, is varchar(max)
. One important difference between text
and varchar(max)
is that text
data is stored off-row by default, while varchar(max)
stores in-row by default.
Use Unicode types only if you need that flexibility (e.g. it is hard to see why an IP address would need Unicode). Also, choose appropriate length limits for your attributes - 255 everywhere seems unlikely to be correct.
Additional reading:
Deadlock and livelock common patterns
Bart Duncan's deadlock troubleshooting series
Tracing locks can be done in a variety of ways. SQL Server Express with Advanced Services (2014 & 2012 SP1 onward only) contains the Profiler tool, which is a supported way to view the details of lock acquisition and release.
As documented in Books Online, UPDLOCK
takes update locks and holds them to the end of the transaction.
Without an index to locate the row(s) to be locked, all tested rows are locked, and locks on qualifying rows are held until the transaction completes.
The first transaction holds an update lock on the row where name = 1. The second transaction is blocked when it attempts to acquire an update lock on the same row (to test if name = 2 for that row).
With an index, SQL Server can quickly locate and lock only those rows that qualify, so there is no conflict.
You should review the code with a qualified database professional to validate the reason for the locking hint, and to ensure appropriate indexes are present.
Related information: Data Modifications under Read Committed Snapshot Isolation
Best Answer
The update still has to lock the rows being updated regardless of any nonclustered index, so RID locks are required. The nonclustered index helps limit the number of RID locks obtained (and/or avoids table escalation) so the second query is no longer blocked.