简介:这是一份面向SQL Server数据库开发与运维人员的死锁排查技术资料,以一类由非聚集索引中包含列及大型可变长字符字段引发的“奇怪死锁”为案例,完整还原了问题重现、原因假设、实验对比和解决过程。资料详细对比了三种测试场景,说明更新语句在行锁、非聚集索引与大型字段组合下如何产生锁等待与相互阻塞,并给出移除包含列选项或修改字段类型后的差异结论,帮助读者理解索引设计对死锁的影响。文中先后介绍了开启1222跟踪标记、用SQL Profiler按会话编号筛选锁事件与SQL事件、以及读取错误日志中的死锁记录等典型分析方法,还结合死锁图与语句堆栈进行解读,适合想深入掌握SQL Server锁机制与死锁调优的中高级读者。整份资源打包为一个docx文档,压缩后大小约六百余KB,内容紧凑、步骤完整,包含建表脚本、更新循环和三类测试的具体结果。该资源目前已有326人学习,阅读后可建立一套可复用的死锁分析思路,用于排查生产环境中的同类问题。
1. 一个查了整晚的 Deadlock:先认清它不是玄学
SQL Server 上的 deadlock 报错(1205)有个招人烦的特点:日志里确实留着死锁图,图里也确实有两个会话,可你把每条 SQL 单独拿出来跑一遍都正常,锁的获取顺序看着也不该撞车。我第一次被它困住是在维护模拟项目X的时候,凌晨收到告警,打开错误日志看到一张残缺的死锁图——两个进程各持一把 KEY 锁,资源列表里却只有一个锁条目,环形等待怎么都画不完整。折腾到天亮才明白:不是死锁图骗人,是我没读懂它省略掉的那半截信息。
这篇笔记讲的就是这类「奇怪死锁」的完整分析方法,写给被线上 1205 折磨的 DBA 和后端开发,也写给刚接触锁机制、想读懂死锁图的初学者。我会从死锁的判定原理讲到扩展事件抓取、死锁图 XML 解析,再落到我踩过的几个坑。读完你能独立完成一次死锁归因,而不是对着图猜。
2. 死锁原理与死锁图:SQL Server 在等谁、卡在哪
2.1 死锁的本质:等待成环,而不是锁不够
先纠正一个最常见的误解:死锁不是「锁不够用」,而是「两个或多个会话在等待一条环形的资源链,且 SQL Server 判定这条链永远解不开」。教科书例子是:会话 A 持有订单表的行锁,要申请客户表的行锁;会话 B 持有客户表的行锁,要申请订单表的行锁。A 等 B、B 等 A,谁也不让步,这就是标准的环形等待。SQL Server 的检测器每隔几秒扫描一次锁等待图,发现成环之后,选一个牺牲者回滚,被选中方收到 1205。
但生产环境里的死锁远比这个示例难读,原因有三个。第一,锁粒度不统一——A 等的是行锁(KEY),B 等的可能是页锁(PAGE)或表锁(OBJECT),你得先把粒度换算到同一层才能看懂冲突。第二,进程在等锁的同时还在做别的等待,比如等待日志写入、等待内存、等待下级线程,死锁图只记录与本次死锁裁决有关的等待,其他等待不会出现在环里。第三,也是最常见的:一个 UPDATE 语句会按执行计划里的顺序获取多把锁,计划不同,拿锁顺序就不同;两个语句哪怕更新的是同一批行,也可能因为走的索引不一样而持有完全相反的锁顺序。
所以在分析任何死锁之前,我习惯先确认四件事:当前隔离级别是什么,因为隔离级别决定锁的持有时长;语句实际走的哪个索引,因为索引决定拿锁顺序;有没有隐式转换,因为类型不匹配会导致索引失效、锁定范围变大;有没有锁升级发生,因为行锁一旦升级成表锁,冲突面就完全不同。做完这四件事,一半的死锁原因已经浮出水面。
锁的模式只需要盯住几个常用的:S(共享读锁)、X(排他写锁)、U(更新锁,UPDATE 在定位要修改的行时先用 U,避免两个事务同时把 S 升级成 X 造成阻塞)、以及 IX/SIX 这一类意向锁。死锁图里 owner-list 和 waiter-list 的 mode 字段写的就是这些缩写。看不懂 mode 也没关系,先记住一个方向:两个进程都持有对方下一步要用的锁,且至少一方是 X 或 U,这个死锁十有八九是写写冲突;如果全是 S,基本不会死锁,因为共享锁之间互不排斥。
还有一个绕不开的概念:牺牲者怎么选。默认情况下,回滚成本低的事务更容易被牺牲,比如语句级事务而不是长事务;你也可以用 SET DEADLOCK_PRIORITY 显式指定优先级。我见过一个生产事故,有人把夜里批处理作业的优先级调高,结果所有前台事务在冲突时反复当牺牲者,现象就是「一到整点前台就集体报 1205」。所以看到死锁先别急着调优先级,先想清楚你希望谁活下来,再决定是否干预。
锁升级是让死锁变奇怪的另一只手。单个语句对同一个对象拿的锁超过一定数量(常见的触发线是 5000 个锁左右,实际由内存阈值决定),SQL Server 会把行锁合并升级成表锁,以减少锁管理器开销。升级之后粒度变大,本来不该冲突的两个事务突然开始抢同一张表。死锁图里如果出现 OBJECT 类型的锁,且同一进程带了大量行锁记录,基本可以断定锁升级参与了这场死锁。
2.2 死锁图 XML 里有哪些关键字段:从 process 到 resource
SQL Server 抓到的死锁图是一个 XML 文档,错误日志、扩展事件目标文件里存的都是这份 XML。打开一张完整的死锁图,结构分三段:victim-list 告诉你谁被牺牲;process-list 列出所有参与者,以及每个会话当时正在执行的语句;resource-list 列出争夺的锁资源,每种锁下面又有 owner-list(持锁人)和 waiter-list(等待人)。
我第一次手动解析时觉得它像天书,其实抓三个信息就够了:每个 process 的 id、inputbuf 里的 SQL 文本、以及每个 resource 下 owner 和 waiter 的配对。把所有 owner 和 waiter 连起来画图,就会看到一个或多个环。下面是一段典型死锁图的核心片段,我按字段加了注释:
<deadlock> <victim-list> <victimProcess id="process2b1a4584688" /> </victim-list> <process-list> <!-- 两个会话各持一把锁,同时各自等待对方持有的锁 --> <process id="process2b1a4584688" spid="62"> <executionStack> <frame procname="dbo.usp_UpdateOrdersByCustomer" line="12" /> </executionStack> <inputbuf>UPDATE dbo.Orders SET Status = @s WHERE CustomerId = @cid</inputbuf> </process> <process id="process2b1a4584690" spid="71"> <executionStack> <frame procname="dbo.usp_UpdateOrdersByOrderId" line="18" /> </executionStack> <inputbuf>UPDATE dbo.Orders SET Status = @s WHERE OrderId = @oid</inputbuf> </process> </process-list> <resource-list> <!-- 同一个订单行:spid 71 持有 X 锁,spid 62 正在等待这把锁 --> <keylock hobtid="72057594050838528" objectname="dbo.Orders" indexname="PK_Orders"> <owner-list> <owner id="process2b1a4584690" mode="X" /> </owner-list> <waiter-list> <waiter id="process2b1a4584688" mode="U" /> </waiter-list> </keylock> </resource-list> </deadlock>这段 XML 里最容易被忽略的是 waiter 的 mode:U。U 锁表示「我想更新这一行,正在确认当前行的值」,确认后升级为 X 并持有到事务结束。图里 U 等 X,说明 spid 62 已经定位到了这一行,但 spid 71 握着 X,它只能等一个事务结束。
真正「奇怪」的地方在这:上面的 XML 只画出一个资源。按这个图,62 在等 71,但 71 并没有在等 62——环的另一半不见了。答案通常是这张图被截断,或者第二个资源因为锁升级被合并成了 OBJECT 条目。第四章我会用一个完整案例演示怎么把另一半找回来。这里先记住结论:看到只有单向等待的死锁图,第一反应不是下结论,而是去查完整历史记录和两个会话的完整事务上下文。
为了把死锁图里的 owner-waiter 配对批量抽出来,我常用一段 XQuery,把每个资源节点下的持锁人和等待人平铺成行:
DECLARE @DeadlockXml XML = N'<deadlock>…省略…</deadlock>'; SELECT r.value('name(.)', 'varchar(20)') AS ResourceType, COALESCE(r.value('@objectname','varchar(128)'), r.value('@fileid','varchar(20)')) AS ResourceName, o.value('@id', 'varchar(50)') AS LockOwner, o.value('@mode', 'varchar(10)') AS OwnerMode, w.value('@id', 'varchar(50)') AS LockWaiter, w.value('@mode', 'varchar(10)') AS WaiterMode FROM @DeadlockXml.nodes('//resource-list/*') AS R(r) CROSS APPLY r.nodes('owner-list/owner') AS O(o) CROSS APPLY r.nodes('waiter-list/waiter') AS W(w);这段脚本做的事情很简单:nodes 方法把 resource-list 下的每个资源拆成一行,owner-list 和 waiter-list 各拆成多行,最后按资源做交叉关联。输出的每一行就是一条「某资源上,谁持锁、谁在等」的记录。把同一次死锁的多行记录拼起来,环形等待在哪一环断裂,一眼就能看到。我用它处理过一次 8 个进程参与的大型死锁,手工画图要半小时,脚本跑完 10 秒出结果。
3. 用扩展事件抓现场:最小命令与抓取策略
3.1 开启 system_health 与自定义采集会话:先低成本覆盖,再按需加码
抓死锁有个前提:你必须在死锁发生之前就把「摄像头」装好,否则一切都是事后推理。好消息是 SQL Server 自带的 system_health 会话一直在后台运行,它默认采集 xml_deadlock_report 事件。很多新手不知道这一点,白等了几天错误日志。先查系统里有哪些扩展事件会话、是否在运行:
SELECT s.name, s.create_time, s.is_running FROM sys.dm_xe_sessions s WHERE s.name IN ('system_health', 'AlwaysOn_health'); -- 如果 system_health 没在运行,说明服务配置被改过,手工启动: -- ALTER EVENT SESSION system_health ON SERVER STATE = START;system_health 的采集目标是 ring_buffer,也就是内存里的一块环形缓存。优点是零维护,缺点是容量有限:死锁少时够用,死锁一多或时间一长,旧事件会被覆盖。所以我的习惯是:system_health 用于事后确认「这里发生过死锁」,要拿连续完整的死锁图做分析,必须建一个自定义会话,把事件写到文件里。
下面是我在模拟项目X上用的最小采集配置。关键点有三个:事件选 sqlserver.xml_deadlock_report;目标用 package0.event_file;STARTUP_STATE 设为 ON,保证实例重启后自动拉起来。
CREATE EVENT SESSION [deadlock_capture] ON SERVER ADD EVENT sqlserver.xml_deadlock_report ( ACTION (sqlserver.session_id, sqlserver.tsql_stack, sqlserver.client_app_name) ) ADD TARGET package0.event_file ( SET filename = N'D:\XEvent\deadlock_capture', max_file_size = 50, -- 单个文件 50MB max_rollover_files = 10 -- 最多保留 10 个文件,翻转覆盖 ) WITH (MAX_MEMORY = 8 MB, MAX_DISPATCH_LATENCY = 5 SECONDS, STARTUP_STATE = ON); ALTER EVENT SESSION [deadlock_capture] ON SERVER STATE = START;这里每个参数都不是随手写的,踩过的坑值得交代清楚:
| 参数 | 作用 | 我的设置与理由 |
|---|---|---|
| filename | 目标文件路径前缀 | 实际文件是 deadlock_capture_0_xxx.xel,目录必须存在且服务账户有写权限 |
| max_file_size | 单个文件上限 | 50MB,一张死锁图 XML 几十 KB,够存几千张 |
| max_rollover_files | 翻转文件数 | 10,防止文件无限膨胀拖垮磁盘 |
| MAX_MEMORY | 事件缓冲内存上限 | 8MB 起步,太低会丢事件 |
| MAX_DISPATCH_LATENCY | 缓冲滞留时间 | 5 秒,太大会出现「死锁发生了但文件里还没写到」的假象 |
| STARTUP_STATE | 是否随实例启动 | ON,这是「后悔药」——会话因重启断了,下次死锁就没有现场 |
读取 event_file 目标里的内容,用系统函数 sys.fn_xe_file_target_read_file 按文件顺序读,返回的 event_data 是 XML 文本,需要 CAST 成 XML 再解析:
SELECT CAST(event_data AS XML) AS DeadlockXml FROM sys.fn_xe_file_target_read_file( N'D:\XEvent\deadlock_capture*.xel', NULL, NULL, NULL ) WHERE object_name = 'xml_deadlock_report';注意两个细节:一是文件名通配符必须加,函数按前缀匹配所有翻转文件;二是结果按写入顺序返回,实际用时建议按 XML 里的 @timestamp 属性排序,不要依赖返回顺序。我一般在 Management Studio 里把 DeadlockXml 列的结果另存为 .xml,再用格式化工具展开看,原始的换行会让人抓狂。
3.2 从 ring_buffer 导出死锁图:一条 XQuery 搞定
如果死锁只是偶发一次,你不想为了它专门建会话,也可以直接从 system_health 的 ring_buffer 里捞历史死锁。做法是把 target_data 转成 XML,再用 XQuery 过滤出 xml_deadlock_report 事件。这段脚本我几乎原样背下来了:
WITH RingData AS ( SELECT CAST(t.target_data AS XML) AS Data FROM sys.dm_xe_sessions s JOIN sys.dm_xe_session_targets t ON s.address = t.event_session_address WHERE s.name = 'system_health' AND t.target_name = 'ring_buffer' ) SELECT n.value('@timestamp', 'datetime2') AS EventTime, n.value('(data/value)[1]', 'varchar(max)') AS DeadlockXml FROM RingData CROSS APPLY Data.nodes('/RingBufferTarget/event') AS p(n) WHERE n.value('@name', 'varchar(50)') = 'xml_deadlock_report' ORDER BY EventTime DESC;核心在 CROSS APPLY Data.nodes(...) 这一句:nodes 方法把 ring_buffer 根节点下的每个 event 元素拆成一行,WHERE 过滤事件名,data/value 里就是完整的死锁图 XML。这段查询的输出经常是超大文本,网格显示不全,我习惯把结果导出成 .xml 文件再格式化。
需要清醒的一点:ring_buffer 受会话 MAX_MEMORY 限制,默认只保留最近一批事件。如果你的系统死锁频繁,这里只能查到最近几次。这再次说明,高频死锁场景必须依赖 event_file 目标。我还习惯同时把 trace flag 1222 开着,它的作用是把死锁图以完整文本形式写进错误日志,包含每个进程的完整锁列表和资源描述,在 XML 被截断或损坏时是最可靠的后备:
DBCC TRACEON(1222, -1); -- 全局开启,-1 表示对所有会话生效 DBCC TRACEOFF(1222, -1);特别注意:1222 是仍受支持的传统 trace flag,DBCC TRACEON 对它是有效的。错误日志里的 1222 输出是纯文本,没有 XML 那种嵌套结构,读起来反而直观。我现在每次分析死锁都会同时开三份材料:扩展事件文件、1222 文本输出、两个会话的完整调用栈,缺一不可。这个习惯帮我少走了很多弯路。
4. 解开那个「奇怪」死锁:环形等待的另一半藏在哪
4.1 模拟项目X的现场还原:两个 UPDATE 如何互相踩到脚
现在进入正题。当时模拟项目X的报错很规律:错误日志里每条 1205 都发生在存储过程 usp_UpdateOrdersByCustomer(下面简称 ProcC)和 usp_UpdateOrdersByOrderId(简称 ProcO)之间。两个过程都只更新一张订单表 Orders,简化后的表结构:
CREATE TABLE dbo.Orders ( OrderId INT PRIMARY KEY, CustomerId INT NOT NULL, Status TINYINT NOT NULL, UpdateTime DATETIME NOT NULL ); -- 注意:Status 被包含进非聚集索引,更新 Status 会同时写这个索引 CREATE NONCLUSTERED INDEX IX_Orders_CustomerId ON dbo.Orders(CustomerId) INCLUDE (Status);ProcC 按 CustomerId 更新状态,ProcO 按 OrderId 更新状态,这是业务上再正常不过的两个入口。单看每一条 SQL,谁都会觉得「它们只是改了同一张表的不同入口,锁冲突不至于成环」。可实际死锁图显示 ProcC 在等一把由 ProcO 持有的 KEY 锁,而 ProcO 又等在另一把锁上。我最初拿到的 XML 图就是第二章展示的那张单向图,根本拼不出环。
把 1222 的文本输出调出来之后,第二个资源出现了:ProcC 的执行计划先撞进非聚集索引 IX_Orders_CustomerId,它已经在这把索引的 KEY 上持有了锁;ProcO 从主键 PK_Orders 进来,已经在聚集索引的行上持有了锁。两边各自抓住一半资源,然后同时伸手要另一半:
| 获取顺序 | ProcC(按 CustomerId 更新) | ProcO(按 OrderId 更新) |
|---|---|---|
| 第 1 把锁 | IX_Orders_CustomerId 的 KEY(X) | PK_Orders 的 KEY(X) |
| 第 2 把锁 | PK_Orders 的 KEY(X) | IX_Orders_CustomerId 的 KEY(X) |
两个资源一拼,环才完整:ProcC 持有非聚集索引 KEY,等待聚集索引行锁;ProcO 持有聚集索引行锁,等待非聚集索引 KEY。死锁不是数据冲突,是两条更新路径的拿锁顺序互相颠倒。如果只盯着第一张 XML 图,你永远看不到第二个资源,因为 xml_deadlock_report 事件在某些版本的快照里只记录最终导致裁决的那部分资源对,前面的争夺被省略了。这也是我反复强调用 1222 文本输出和扩展事件双份印证的原因——一张图不能当全部证据。
4.2 定位根因:执行计划决定锁顺序,锁顺序决定生死
死锁图确认了锁资源,但根因得往执行计划里挖。我把两个存储过程实际执行时的计划拉出来对比,关键差异在 ProcC:优化器选择了非聚集索引 IX_Orders_CustomerId 做 Seek,定位到所有目标订单,然后对聚集索引做 Lookup。Lookup 意味着它先拿非聚集索引 KEY 的锁,再回头拿聚集索引行的锁。而 ProcO 按 OrderId 走主键 Seek,先拿聚集索引 KEY,之后再在更新过程中修改非聚集索引的叶子行。
也就是说,ProcC 是「非聚集 → 聚集」,ProcO 是「聚集 → 非聚集」,正好相反。当两个事务更新同一批订单时,各自抓住半把锁谁也不肯先放,死锁就从「看起来不该发生」变成了必然。注意一个前提:这出戏能上演,是因为 Status 被 INCLUDE 进了非聚集索引——更新 Status 这个普通列,逻辑上等同于要在两个索引里改数据,两个索引都会被加锁。如果 Status 不在任何非聚集索引里,ProcC 只需锁聚集索引行,根本不会和非聚集索引的 KEY 纠缠,死锁也就不会发生。
验证这个判断,我用了三个手段。第一,用 SET STATISTICS IO, TIME ON 单独跑两个过程的小样本,确认索引使用情况和 Lookup 次数。第二,把两个过程放在两个会话里手动构造同一批数据,逐步执行,在每个关键语句前后查询锁视图,观察锁从无到有、从 U 到 X 的变化过程:
SELECT request_session_id, resource_type, resource_description, request_mode, request_status FROM sys.dm_tran_locks WHERE resource_database_id = DB_ID(); -- 只看当前库,避免混入系统库的锁注意这个视图只是瞬间快照,两个会话交错执行时必须在每个关键语句前后分别查询,才能看到拿锁顺序。我习惯把结果按 request_session_id 分组、按 resource_description 排序,两列对齐之后先拿谁、在等谁一目了然。第三,也是最快的验证:给 ProcC 加锁提示强制它改走主键后,死锁立刻消失——虽然这不能当正式方案,但足以证明根因在访问路径,不在业务数据。
4.3 可行的修复:从锁提示到程序改版
根因定位之后,修复方案按侵入性从小到大排有三个,我最后选了第二个。第一个是加锁提示,让 ProcC 强制用聚集索引定位:
UPDATE dbo.Orders WITH (INDEX = PK_Orders) SET Status = @s, UpdateTime = GETDATE() WHERE CustomerId = @cid;锁提示不是银弹:ProcC 的过滤条件是 CustomerId,主键是 OrderId,强制用主键意味着扫描整张表再过滤,数据量大时性能不可接受。它只适合用来验证根因,不适合当长期方案。
第二个方案是改 ProcC 的逻辑:先查出该客户的所有 OrderId,再按主键逐批更新。这样两个过程最终都从主键入口拿锁,锁顺序统一为「聚集 → 非聚集」:
BEGIN TRANSACTION; DECLARE @ids TABLE (OrderId INT PRIMARY KEY); INSERT INTO @ids (OrderId) SELECT OrderId FROM dbo.Orders WHERE CustomerId = @cid; UPDATE o SET Status = @s, UpdateTime = GETDATE() FROM dbo.Orders o JOIN @ids i ON o.OrderId = i.OrderId; COMMIT;这段改动的要点在 UPDATE 的 JOIN 写法:目标表 Orders 通过主键与表变量关联,优化器会走聚集索引定位行,锁顺序与 ProcO 一致。执行完之后需要复查执行计划,确认 Orders 确实是按主键 Seek,而不是对 Orders 全表扫描——计划变了,锁顺序就可能变回去。
第三个方案是业务层加重试。死锁是事务型数据库的正常现象,再严密的规约也挡不住极端并发,给 1205 加自动重试是最后的兜底:
DECLARE @retry INT = 0; WHILE @retry < 3 BEGIN BEGIN TRY BEGIN TRANSACTION; -- 这里放更新逻辑 COMMIT; BREAK; END TRY BEGIN CATCH IF ERROR_NUMBER() = 1205 AND @retry < 3 BEGIN SET @retry = @retry + 1; ROLLBACK; WAITFOR DELAY '00:00:00.100'; END ELSE THROW; END CATCH END我最终采用的是方案二。上线后死锁从一天几十次降到零,三个月没有复发。这个案例可以推广成一条判断准则:当死锁图里出现同一个表的聚集索引和非聚集索引上的 KEY 锁,且两个更新语句的过滤条件分别命中不同索引时,优先怀疑锁顺序颠倒,而不是先怀疑事务太长或隔离级别。
5. Deadlock 排查避坑指南:5 个让我翻过车的细节
5.1 现象:图里只有一个资源,环拼不完整
这是我入坑最深的一条。现象是 1205 错误日志里的死锁图只有一条 resource-list 条目,像是「A 在等 B,但 B 没在等任何人」,怎么画都画不出环。原因正如第四章说的,xml_deadlock_report 的 XML 在某些情况下只保留最终裁决所需的资源快照,省略了环里其他锁,或者把多把锁合并进了同一个 keylock 条目;另外,如果两个进程之间隔着第三把锁,而这把锁由另一个没参与本次死锁裁决的进程持有,图里的连线就可能被简化。解决方法是开 1222 trace flag,拿到文本版完整锁列表,再和扩展事件文件互相印证;如果还是对不上,就把两个会话的完整事务上下文(包括它们嵌套调用的其他存储过程)拉出来人工重建等待链。我后来处理过一起 8 个进程参与的死锁,单看 XML 图永远拼不出来,靠的就是 1222 的全量输出。
5.2 现象:日志里找不到这次死锁的任何记录
死锁确实发生了(应用明确报了 1205),但去看 system_health 的 ring_buffer,里面的 xml_deadlock_report 要么没有,要么被新事件覆盖了。原因有两个:ring_buffer 容量有限,死锁一多最早的会被挤掉;或者 system_health 会话被手工关闭过,而自定义的 event_file 会话没设 STARTUP_STATE = ON,实例一重启采集就断了。解决方法是把自定义会话的 STARTUP_STATE 打开,并每周检查一次会话运行状态。我的习惯是顺手加一条查询,把扩展事件会话的运行状态汇总到邮件报表里,断了能马上知道。另外还要注意把 event_file 的目标放在独立磁盘分区,避免文件系统写满导致事件静默丢失。这条的教训是:监控工具的可用性,比监控工具的功能更重要。
5.3 现象:两个 UPDATE 明明更新不同行,怎么也死锁
这是最让新手崩溃的场景:A 更新 OrderId=1001,B 更新 OrderId=1002,提交前互不相关,可还是 1205。原因通常是两个语句都更新了同一个非聚集索引里的条目,比如我们把 Status 放在了 INCLUDE 列里,B 更新 1002 行时改写了非聚集索引的叶子页,而这个叶子页恰好和 A 要更新的 1001 行在同一个页面区间,页级或者相邻 KEY 的锁把两个事务拴在了一起。解决方法是先看死锁图里锁挂在哪个 indexname 上——如果挂的是某个非聚集索引而不是主键,就要检查更新语句是否触碰了该索引的键列或包含列;再看锁粒度是 KEY 还是 PAGE,如果出现 PageLock 或 ExtentLock,考虑是不是索引碎片导致逻辑上的相邻行散落到了相邻页面。我自己遇到的一次,就是 UPDATE 修改了 CustomerId 字段,触发了非聚集索引的页拆分,把不少不相干的行卷了进来。
5.4 现象:开了 READ_COMMITTED_SNAPSHOT,死锁反而变多
有人以为行版本控制能解决一切死锁,把 READ_COMMITTED_SNAPSHOT 打开,结果死锁非但没消失,读写冲突变成了更集中的写写冲突。原因在于快照隔离只消除了「读被写阻塞」这条等待链,两个 UPDATE 对同一行的写写竞争依然存在,依然要拿 X 锁;而在 SNAPSHOT 隔离级别下,更新事务还会因为版本冲突直接报 3966,表现更吓人。解决方法是把期望放对位置:RCSI 解决的是「读到不一致数据」,解决不了「两个写互相打架」。写写冲突要靠锁顺序规约、索引优化和重试机制,不能指望隔离级别救场。如果业务允许,把高频更新事务缩短,或者改成「先读版本号,根据版本号决定是否更新」的乐观并发流程,才是正解。
5.5 现象:死锁图指向的存储过程,和你实际怀疑的不是同一个
很多 1205 的 inputbuf 显示的是最外层调用者,比如一个作业调用了 usp_StartSync,而真正发生锁竞争的是它内部嵌套调用的 usp_WriteAudit;死锁图的 frame procname 只标了最后一个开始执行的存储过程,容易被误判。解决方法是看 executionStack 里的完整调用栈,把 procname 和 line 逐层列出来,再结合 sql_handle 反查语句文本;如果栈里只有一条,多半是被截断或调用方是 ad hoc 语句,需要把 client_app_name 和 session_id 连起来,在应用日志里找调用源头。这个坑让我养成了一个习惯:看到死锁图先看调用栈,再下结论,而不是盯着 inputbuf 里的第一行 SQL 猜。
6. 让死锁不再「奇怪」:锁顺序规约与主动监控脚本
6.1 把锁顺序变成代码评审的检查项
分析完奇怪的死锁之后,最有价值的产出不是修复一个案例,而是把教训沉淀成约束。我在模拟项目X里的做法是:把所有写 Orders 表的存储过程列成一张清单,标明每个过程最终以什么顺序接触聚集索引和非聚集索引,然后约定「凡是更新同一张表的多个过程,必须按相同顺序获取索引上的锁」。这条规约在代码评审阶段特别好用——只要看到 WHERE 条件走的是不同的索引,就多问一句「这个过程的锁顺序和另一个入口一致吗」。对无法调整顺序的核心事务,再增加一个统一入口存储过程做行锁排序,业务侧只调它。
除了顺序规约,值得养成习惯的还有事务边界审核:要求每个事务里只做「定位数据」和「更新数据」两件事,不要夹带长耗时的外部调用,不要在事务里等用户输入。锁持有时间越短,死锁窗口越小。这比任何调优都直观。
6.2 一个可留作值班的监控脚本:自动报告死锁摘要
最后给你一个可以直接值班用的脚本思路:用 SQL 代理作业每 10 分钟跑一次,读取最近 10 分钟内的死锁图,按「涉及的表 + 索引 + 语句模式」聚合,输出到一张审计表。这样死锁一多,你能直接在报表里看到规律,而不是翻几百个 .xel 文件。核心逻辑是把扩展事件文件按时间过滤,再从死锁图 XML 里提取 objectname 和 indexname:
DECLARE @Since DATETIME2 = DATEADD(MINUTE, -10, SYSDATETIME()); SELECT x.value('(//resource-list/keylock/@objectname)[1]', 'varchar(128)') AS ObjectName, x.value('(//resource-list/keylock/@indexname)[1]', 'varchar(128)') AS IndexName, COUNT(*) AS DeadlockCount FROM sys.fn_xe_file_target_read_file( N'D:\XEvent\deadlock_capture*.xel', NULL, NULL, NULL ) AS f CROSS APPLY (SELECT CAST(f.event_data AS XML)) AS x(n) WHERE x.value('@timestamp', 'datetime2') >= @Since GROUP BY x.value('(//resource-list/keylock/@objectname)[1]', 'varchar(128)'), x.value('(//resource-list/keylock/@indexname)[1]', 'varchar(128)') ORDER BY DeadlockCount DESC;这段聚合脚本有两个前提:objectname 和 indexname 在每张死锁图里不一定都存在,遇到 page lock 或 object lock 时 XML 节点名不同,完整统计要把每个 resource 子节点都拆开;而且它只统计了 resource-list 里的第一个资源,想看完整分布还要按资源节点逐个展开。脚本的意义不在精确,而在于把人工翻日志变成趋势告警——当某对索引连续出现在死锁图里,就该主动找 DBA 而不是等用户报障了。
回看我处理过的这些死锁,真正「奇怪」的几乎没有,大多是我一开始没看全证据。那次通宵最后变成一条工作习惯:任何死锁分析,先收集三份材料——扩展事件文件、1222 文本输出、两个会话的完整调用栈,然后再画环。环画出来了,修复方案基本都是锁顺序问题或访问路径问题,根本不用玄学。希望这篇笔记帮到你,下次再看到残缺的死锁图,至少知道去哪里把另一半找回来。
本文还有配套的精品资源,点击获取