根本原因是不同事务对相同资源加锁顺序不一致,形成循环等待;需统一IN列表顺序、确保索引有效、避免范围查询,并捕获1213错误重试。

为什么大表UPDATE容易触发死锁
根本原因不是“UPDATE本身危险”,而是它在高并发下极易形成「不同事务按不同顺序访问相同资源」的循环等待。比如两个会话同时执行 UPDATE orders SET status = 1 WHERE customer_id IN (101, 202),但执行计划因统计信息偏差或索引选择不同,一个走 IX_customer_id 扫描,另一个走聚集索引扫描——它们加锁的行顺序就可能完全相反,U锁 → X锁升级过程中互相卡住。
特别注意:死锁常发生在有多个非聚集索引且含 INCLUDE 列的表上。当 UPDATE 修改了 INCLUDE 列(如 varchar(max)),SQL Server 必须同时维护主键页和所有相关非聚集索引页,锁范围扩大、锁获取顺序更难统一,死锁概率陡增。
用 UPDATE TOP + ORDER BY 分批控制锁粒度
这是最直接、无需改应用逻辑的落地方案。关键不在“分批”,而在“可控加锁顺序”:
- 必须显式包裹
BEGIN TRANSACTION和COMMIT,否则每批都是独立事务,锁频繁释放/申请反而加重争抢 -
TOP(n)后必须跟ORDER BY(推荐主键或唯一索引列),否则同一批可能重复更新或漏更,且无法保证锁顺序一致 - 批次大小设为
1000–5000:小于 1000 提交太密,日志写压力大;大于 5000 容易触发锁升级(行锁 → 页锁 → 表锁) - 终止条件用
IF @@ROWCOUNT = 0 BREAK,别依赖预估总行数——WHERE 条件可能随更新动态变化(如 status 从 pending 变成 processed)
示例:
WHILE 1 = 1
BEGIN
BEGIN TRANSACTION
UPDATE TOP(2000) orders
SET status = 'shipped'
WHERE status = 'pending'
ORDER BY id;
IF @@ROWCOUNT = 0
BEGIN
COMMIT;
BREAK;
END
COMMIT;
END
查清谁在堵你,而不是只优化那条UPDATE
90% 的“UPDATE死锁”问题,根源不在那条语句本身,而在它前面有个长事务或未提交操作占着锁不放。先定位阻塞源头,比调优SQL更高效:
- 运行
SELECT blocking_session_id, wait_type, wait_resource FROM sys.dm_exec_requests WHERE blocking_session_id > 0,一眼看出哪个 SPID 是根因 - 对
blocking_session_id查sys.dm_exec_sql_text,确认它执行的是什么语句(常见坑:应用层开了事务但忘了COMMIT,或 ORM 自动开启的长事务) - 如果阻塞链很深,用
sys.dm_os_waiting_tasks看等待堆栈,重点关注wait_type是LCK_M_U还是LCK_M_X,对应更新锁或排他锁争抢
避免索引设计埋下的死锁隐患
很多死锁不是代码问题,是表结构设计导致的。典型陷阱:
- 非聚集索引带
INCLUDE大字段(如varchar(max)):UPDATE 该字段时,必须同时锁聚集索引和所有含该字段的非聚集索引页,锁路径变长、顺序难控 - 多个非聚集索引覆盖相同查询条件但顺序不同:比如
IX_a_b和IX_b_a,不同事务可能分别走这两个索引,加锁顺序天然冲突 - 缺失覆盖索引导致查询走聚集索引扫描:UPDATE 和 SELECT 同时扫全表,行锁顺序完全不可预测
验证方式:对问题语句跑 SET STATISTICS XML ON,看执行计划是否出现“Key Lookup”或“Clustered Index Scan”——这些是锁范围失控的信号。
真正难处理的从来不是那条报错的 UPDATE,而是它背后那个没被发现的长事务、那个带 INCLUDE (text) 的索引、或者两个会话对同一组数据却按相反顺序加锁的隐式行为。盯住锁的来源和顺序,比反复重写SQL更接近本质。

















