简介:针对SQL Server中一类看似怪异、实则与索引及锁机制密切相关的死锁问题,本资源提供完整的问题重现步骤与系统化排查思路,适合数据库管理员、开发人员以及希望深入理解锁机制的中高级SQL Server使用者。包内含1个docx文档,大小约694KB,文档以实际案例方式展开:从创建含clustered与non-clustered索引的测试表、复现死锁,到对比删除include选项或调整varchar(max)字段类型后死锁消失的现象,逐步剖析锁冲突的根源。分析环节重点讲解如何通过开启1222跟踪标记、结合SQL Profiler按SPID筛选事件,并借助sp_readerrorlog提取死锁日志,最终定位资源等待链与死锁牺牲者。对需要掌握真实生产环境死锁诊断方法、提升SQL Server故障排查能力的读者,这份文档提供了可复现的实验脚本和典型分析路径,兼具理论解释与实操参考价值。已有326人学习,适合作为死锁专题学习与排错方法演练的参考资料。
1. 两条一模一样的 UPDATE 互相死锁:这个案例到底怪在哪
两条完全相同的 UPDATE 语句,各自在独立的连接里并发执行,竟然会稳定地互相死锁。第一次看到这个现场时,绝大多数人的第一反应都是:这怕不是玄学。可它就是在一个普普通通的 SQL Server 2008 测试库上复现了,而且浑身都是反直觉的点——只要把非聚集索引里的INCLUDE(d)去掉,死锁就消失;或者把d的类型从VARCHAR(MAX)改成VARCHAR(200),死锁也消失。索引定义和字段类型,这两个看起来八竿子打不着的改动,居然同时决定着一个 Deadlock 出不出现。这篇文章完整复盘我对这个案例的整个排查过程:从稳定重现、开启 1222 追踪、读锁等待环,到用 SQL Trace 还原锁申请顺序、对照执行计划锁定根因,最后落到可抄作业的解法与预防手段。正被同类死锁卡住的开发者,按这套流程走一遍,能少折腾好几个晚上。
2. 稳定复现:三步造出必现死锁,三种改动让死锁消失
排错最怕「偶发」,死锁尤其怕。偶发意味着你没法验证修复有没有生效,只能靠猜。这个案例幸运的地方在于,它有一个极其简单的重现路径:一张表、两个索引、一万行数据、两个连接,死锁必然出现。我把复现过程拆成三步,每一步都能解释「为什么这么设计」。
2.1 建表与数据准备:一张表、三个索引、一万条记录
先看原始表结构。这个表设计得很克制,没有任何花活,却精准地踩中了死锁的触发条件。
CREATE TABLE tt ( id INT IDENTITY PRIMARY KEY, -- 聚集索引所在列 a CHAR(36), b CHAR(36), d VARCHAR(MAX) ); GO CREATE INDEX ix_a_bc ON tt(a) INCLUDE(d); CREATE INDEX ix_b_cd ON tt(b) INCLUDE(d); GO这个表有一个显式的主键(同时就是聚集索引),另有两个非聚集索引ix_a_bc和ix_b_cd,分别建立在a、b两列上,并且都通过INCLUDE把d字段包含进了索引的叶子节点。d是VARCHAR(MAX),一个可以容纳 2GB 文本的大对象类型。
-- 插入一万条数据,a 用 NEWID() 保证每个值唯一 INSERT INTO tt SELECT NEWID(), 'bbb', 'ddd'; GO 10000复制这段脚本时有个细节:GO 10000表示把上面的INSERT语句连续执行 10000 次,靠IDENTITY自增让id从 1 到 10000,同时每条记录的a都是不同的NEWID()随机值。这一步是为了让WHERE a = '某个具体值'能精确命中一条记录,避免同一值被多条记录共享而干扰锁行为分析。
-- 找到第 10 条记录,记下它的 a 字段值 SELECT * FROM tt WHERE id = 10;这里得到的a值每次重建表都不一样,比如我当时拿到的是EF211985-EA72-4A40-81DA-0AAB076E7AA3。后面所有 UPDATE 语句都只会更新这一条记录——单行更新,注意是单行,避免有人第一反应是「是不是更新范围太大扫了太多锁」。
2.2 三个对照实验:改一处,必现死锁就变成不死锁
复现场景准备好后,用两个查询窗口分别跑同一个循环 UPDATE,死锁在几秒内必然出现。
-- 连接 1 与连接 2 同时运行这段循环 WHILE 1 = 1 UPDATE tt WITH (ROWLOCK) SET d = 'cd' WHERE a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3';先解释这段循环在做什么。WHILE 1 = 1是无限循环,目的是让 UPDATE 语句在很短的时间内反复执行,把锁竞争的概率无限放大——现实中不会有哪个业务这么写,但复现死锁时,循环是最高效的「压力放大器」。WITH (ROWLOCK)是锁粒度提示,告诉 SQL Server 尽量在行级别加锁,而不是升级到页锁或表锁,保证两个连接竞争的是同一个 Key 上的锁,而不是被大粒度锁掩盖问题。
同样的数据、同样的 UPDATE 语句,只改动一处定义,死锁立刻消失。三个测试的对照关系如下。
| 测试 | 索引定义 | d 类型 | 死锁 |
|---|---|---|---|
| 测试 1 | ix_a_bc(a) INCLUDE(d)、ix_b_cd(b) INCLUDE(d) | VARCHAR(MAX) | 必现 |
| 测试 2 | ix_a_bc(a)、ix_b_cd(b),去掉 INCLUDE | VARCHAR(MAX) | 不出现 |
| 测试 3 | 保留INCLUDE(d) | VARCHAR(200) | 不出现 |
这两个改动看似一个动索引、一个动类型,指向的却是同一个变量——UPDATE 语句的执行计划。SQL Server 加多少把锁、按什么顺序加锁,不是由表结构直接决定的,而是由优化器选出来的执行计划决定的。索引叶子节点里有没有d、d是不是大对象类型,都会改写执行计划的分支,进而改写整个加锁顺序。这就是「差一处定义、差一个死锁」的本质。
3. 根因定位:从 1222 输出到锁顺序表,还原死锁全过程
现象摆清楚之后,接下来就是深挖根因。这个案例的分析过程非常有代表性,它同时用上了两种死锁分析手段:一个是DBCC TRACEON(1222)输出的死锁报告,一个是 SQL Trace 里逐条记录的Lock:Acquired与Lock:Released。前者回答「死锁长什么样」,后者回答「死锁为什么会变成这样」。
3.1 先开 1222 开关:把死锁信息写进错误日志
SQL Server 自带的死锁捕获开关有两个,1204和1222。1204输出格式偏老,信息密度低;1222的输出是结构化的 XML 文本,包含完整的deadlock-list、process-list、resource-list,是当前分析死锁的主流选择。
-- 开启 1222 追踪,-1 表示对所有数据库生效 DBCC TRACEON(1222, -1);执行后,后续发生的每一次死锁,都会在 SQL Server 错误日志里留下一份完整报告。注意-1是全局开关,意味着影响整个实例,生产环境要谨慎使用;确认分析完毕后,用DBCC TRACEOFF(1222, -1)及时关闭。在测试环境复现死锁时,这个是必开的开关,没有它就没有第一手证据。
-- 在两个连接里分别执行,记录各自的 SPID SELECT @@spid;假设两个连接返回的 SPID 分别是 54 和 60。记下这两个号,后续做 SQL Trace 过滤要用。死锁发生后,用sp_readerrorlog就能读到错误日志里的死锁记录。
3.2 读 1222 报告:锁等待环直接告诉我「谁在等谁」
1222 输出里最关键的是resource-list段,它描述了死锁那一刻两个进程互相等待的锁资源。当时报告里的关键信息,整理成锁等待环是这样:
- 连接 SPID 60 已经持有
PK__tt__3213E83F10E07F16(主键索引)上的X锁,正请求ix_a_bc索引上的X锁,等待中。 - 连接 SPID 54 已经持有
ix_a_bc索引上的U锁,正请求PK__tt__3213E83F10E07F16上的U锁,等待中。 - 两个进程各自持有一把对方想要的锁,等待关系首尾相连,形成了闭环。
从executionStack看,两个进程执行的确实是同一条语句:UPDATE tt WITH(rowlock) set d='cd' where a='...'。两个进程、同一条 SQL、互相等对方手里的锁,SQL Server 的锁管理器无法打破这个环,只能选一个牺牲者回滚——deadlock victim=process5e27708就是被选中的那个。
但是,1222 报告只回答到「是怎么死锁的」这一层。它没有回答更根本的问题:为什么一次简单的单行 UPDATE 需要同时申请两个不同索引上的锁?为什么持有ix_a_bc的U锁还不够,还要去拿主键索引上的X锁?这个问题,1222 已经无能为力了,必须去看更细粒度的锁申请记录。
3.3 用 SQL Trace 还原锁顺序:一次 UPDATE 竟然分两步执行
打开 SQL Profiler,新建一个 Trace,事件选择Locks分类下的Lock:Acquired、Lock:Released、Lock:Deadlock,再选上TSQL分类下的SQL:BatchCompleted等事件。在Column Filters里按 SPID 过滤,只保留 54、60、6、20 这几个会话——6 和 20 通常是后台系统会话,保留它们是为了观察是否有系统层面的干扰。然后跑两个连接的 UPDATE 循环,让死锁发生。
Lock:Acquired和Lock:Released默认展示的字段很乱,要把Mode(锁模式)、ObjectID2(资源编号)、Type(锁类型)拖到前面。这里有个关键技巧:ObjectID2字段里的值,就是 1222 报告里hobtid的值,它不是一个容易读懂的对象编号,需要换算回索引名。
-- 把 hobtid / ObjectID2 换算成具体的索引名 SELECT o.name, i.name, i.type FROM sys.indexes i INNER JOIN sys.objects o ON i.object_id = o.object_id INNER JOIN sys.partitions p ON p.index_id = i.index_id AND p.object_id = i.object_id WHERE p.partition_id = 72057594065518592; -- 替换成你 Trace 里看到的 ObjectID2我当时查到的对应关系是:72057594065518592对应主键索引PK__tt__3213E83F10E07F16,72057594066108416对应ix_a_bc,72057594066173952对应ix_b_cd。有了这张对照表,Trace 里那一长串数字立即变成了可读的锁序列。测试 1(必现死锁)的锁顺序如下。
| 索引 | 锁类型 | 连接 1(SPID 54) | 连接 2(SPID 60) |
|---|---|---|---|
ix_a_bc | U | 申请 | |
| 主键 | U | 申请 | |
| 主键 | X | 申请 | |
ix_a_bc | U | 释放 | |
ix_a_bc | X | 申请 | |
ix_b_cd | X | 申请 | |
ix_b_cd | X | 释放 | |
ix_a_bc | X | 释放 | |
| 主键 | X | 释放 |
这张表暴露了真相:这条 UPDATE 在测试 1 下是「两步走」的。第一步,先拿ix_a_bc上的U锁定位记录,再拿主键上的U锁和X锁,完成对聚集索引记录的修改;第二步,因为两个非聚集索引的INCLUDE(d)里存了d字段的副本,聚集索引上的d一改,这两个索引必须跟着同步更新,于是又要申请ix_a_bc和ix_b_cd上的X锁。问题就出在「第一步结束后、第二步开始前」:连接 1 释放了ix_a_bc的U锁,连接 2 立刻拿到了ix_a_bc的U锁并开始它的第一步;而连接 2 此时已经持有了主键的X锁,连接 1 又在等主键锁——环在那一瞬间闭合了。
再看测试 2(去掉INCLUDE(d))和测试 3(VARCHAR(200))的锁顺序,两条路径收敛到了同一个结果。
| 索引 | 锁类型 | 申请/释放 |
|---|---|---|
ix_a_bc | U | 申请 |
| 主键 | U | 申请 |
| 主键 | X | 申请 |
ix_a_bc | U | 释放 |
| 主键 | X | 释放 |
两步变成一步:UPDATE 只需要通过ix_a_bc定位到主键记录并完成修改,不再需要回头同步两个非聚集索引。锁的持有时间大幅缩短,第二个连接根本等不到「对方持有我想要的锁」的窗口,死锁自然消失。
3.4 执行计划对照:是谁决定了「走两步还是走一步」
锁顺序的变化只是表象,真正的幕后推手是执行计划。在 UPDATE 前加上SET STATISTICS PROFILE ON,可以看到语句实际使用的计划。
SET STATISTICS PROFILE ON; GO UPDATE tt WITH (ROWLOCK) SET d = 'cd' WHERE a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3';三个测试的执行计划差异非常直观:
- 测试 1:计划里有三个
Index Update操作。一个负责主键索引,两个分别负责ix_a_bc和ix_b_cd。对应到锁行为,就是要申请三把X锁,且分散在两步完成。 - 测试 2:只有一个
Index Update。因为d没有被任何非聚集索引包含,SQL Server 只需要更新聚集索引。 - 测试 3:同样只有一个
Update操作,但这个Update内部包含了三个 Object。意思是,SQL Server 把三个索引的更新合并进了一次操作,一次性完成。
测试 3 是最有意思的对照组:索引定义没变,INCLUDE(d)还在,但因为d从VARCHAR(MAX)变成了定长容量可控的VARCHAR(200),优化器认为可以把它放进一次原子更新里完成,而不是拆成多次索引维护。VARCHAR(MAX)之所以被特殊对待,是因为它属于大对象(LOB)类型,在索引维护、日志记录、页分裂行为上和普通变长字段完全不同,优化器对它的处理路径也更复杂。一个类型变更,改写了整个加锁路径——这就是「玄学」背后真正的机制。
到这里,根因链条完整闭合了:INCLUDE(d)+VARCHAR(MAX)→ 执行计划选择两步更新 → 锁的申请与释放在中途出现「间隙」 → 两个并发的同语句 UPDATE 在这个间隙里互相咬住 → 死锁。
4. 死锁排查避坑清单:这些常见误判会让方向跑偏
这个案例排查过程中踩过的、以及平时答疑时常看到别人踩的坑,我整理成一份避坑清单。每条都按「现象 → 原因 → 解决」讲清楚,能帮你少走弯路。
4.1 只看报错编号就搜解决方案,忽略锁等待环
现象:拿到的是Error 1205死锁报错,直接复制编号去搜索引擎找答案,照着别人给的方案改完,死锁依然复现。
原因:1205 只是 SQL Server 通知客户端「你被选为牺牲者」的通用错误,本身不含任何锁资源信息。两个完全不同的死锁场景可能报同一个 1205,靠错误号反推原因基本是盲人摸象。
解决:必须先拿到死锁报告本身。测试环境用DBCC TRACEON(1222, -1),生产环境可以在AlwaysOn里用扩展事件抓取deadlock_report。没有resource-list和owner-list/waiter-list,就别动手改代码。
4.2 把死锁当成阻塞来处理
现象:看到process-list里有status=suspended、waittime字段,就以为这是普通的锁阻塞,等其中一个事务结束就恢复。于是只加了索引、调了超时,死锁还是反复出现。
原因:阻塞是两个进程一前一后、单向等待;死锁是等待关系成环、互不相让。处理阻塞的思路是「让持有锁的人尽快提交」,处理死锁的思路是「打破环」。把死锁当阻塞治,方向就反了。
解决:拿到 1222 报告后,第一件事就是画锁等待环——谁持有谁想要的锁、谁又持有另一个人想要的锁。环画出来,死锁的性质就定了,后续才谈得上改执行计划、改隔离级别。
4.3 拿到 1222 就当结论,不去还原锁的申请顺序
现象:1222 报告已经把死锁瞬间的两个锁和两个进程列得很清楚,于是直接在这份报告上做文章,改隔离级别、加WITH (NOLOCK),问题却时好时坏。
原因:1222 记录的是「死锁那一帧」的资源状态,不是「死锁如何演化到这个状态」的全过程。这个案例里,如果只看 1222,只能看到两个进程各持一把锁、互相等待,但看不到「为什么一次 UPDATE 要先释放ix_a_bc的 U 锁,再去申请同索引上的 X 锁」——这个关键演化过程来自 Trace 里逐条锁记录的时序。
解决:遇到「看不懂」的死锁,养成开 SQL Trace(或扩展事件捕获lock_acquired/lock_released)的习惯,分析锁的申请与释放顺序,再结合执行计划下结论。1222 给结论,Trace 给证据链。
4.4 一遇到死锁就全局加 NOLOCK
现象:查不出根因,干脆在 SELECT 语句里全加WITH (NOLOCK),UPDATE 上加不了就用READ UNCOMMITTED隔离级别,指望躲开锁竞争。
原因:NOLOCK防的是「读被写阻塞」,这个案例里的死锁发生在两个UPDATE之间,全是写锁之间的互斥,NOLOCK完全无效。而且NOLOCK还会引入脏读风险,属于掩盖问题而不是解决问题。
解决:先分清死锁发生在读-写之间还是写-写之间。写-写死锁的出路是让每条语句申请更少的锁、更短地持有锁,手段是改执行计划、改索引设计、缩短事务边界,而不是给 SELECT 加提示。
4.5 忽略了INCLUDE字段对 UPDATE 锁行为的影响
现象:表上非聚集索引带了INCLUDE大字段,UPDATE 这个小字段时频繁死锁,DBA 检查执行计划发现索引都被正常使用,「索引没问题」就被放过了。
原因:INCLUDE不在WHERE/JOIN/ORDER BY里出现时,优化器会认为它对查询「有用」;但它出现在UPDATE的SET列上时,每次修改都会触发索引叶子节点的同步写。索引维护的成本和锁的范围,就这样悄悄叠加到了每条 UPDATE 上。这个案例的测试 2 证明:去掉INCLUDE(d)后,死锁立即消失。
解决:审查表上每个非聚集索引的INCLUDE列时,把「这些列会不会被 UPDATE 修改」作为必问问题。大对象字段放进INCLUDE,尤其要警惕——优化器对 LOB 类型走的是另一条维护路径,锁更重、计划更复杂。
5. 解决方案:从改索引到控制执行计划,三条路都能消死锁
根因清楚了,解法其实就摆在桌面上。这个案例给了三条路,每条路对应一个层面的干预:改索引结构、改字段类型、改语句行为。实际项目中按成本和风险选择。
5.1 方案一:从非聚集索引中移除INCLUDE(d)字段
这是最直接的做法,对应测试 2。既然死锁源于非聚集索引要跟着更新d,那把d从索引里拿掉,就斩断了第二步的根源。
DROP INDEX ix_a_bc ON tt; DROP INDEX ix_b_cd ON tt; -- 重建时不带 INCLUDE(d) CREATE INDEX ix_a_bc ON tt(a); CREATE INDEX ix_b_cd ON tt(b); GO这个方案的逻辑是:ix_a_bc和ix_b_cd的定位是加速WHERE a = ?、WHERE b = ?这类查找,d字段并不参与索引查找。当初把d放进去多半是想做覆盖索引,让查询不需要回表。但如果d是一个频繁被 UPDATE 的大字段,这个「覆盖」收益会被索引维护的代价吃掉,死锁风险更是火上浇油。实际业务里,如果确实需要覆盖查询,更合理的做法是评估d的更新频率:高频更新就不该进INCLUDE,低频更新也要控制字段长度。
5.2 方案二:给大字段一个有界的长度
对应测试 3,保留INCLUDE(d)但改变类型。VARCHAR(MAX)换成VARCHAR(200),UPDATE 从两步合并成一步,死锁消失。
DROP INDEX ix_a_bc ON tt; DROP INDEX ix_b_cd ON tt; ALTER TABLE tt ALTER COLUMN d VARCHAR(200); CREATE INDEX ix_a_bc ON tt(a) INCLUDE(d); CREATE INDEX ix_b_cd ON tt(b) INCLUDE(d); GO注意一个细节:修改列类型前必须先删掉依赖这个列的索引,否则ALTER TABLE会因为有索引引用而失败。这个方案的适用前提是业务上d的最长值确实可控——比如它存的是状态描述、短备注这类数据。把VARCHAR(MAX)收敛成VARCHAR(200),不只是省存储,更重要的是让优化器把这条路径当普通变长字段处理,不再为 LOB 类型选择特殊的两步更新计划。
5.3 方案三:保持表结构不变,改变锁的持有行为
如果索引结构不能动、字段类型也不能动,还有第三条路:用锁提示或计划指南干预语句行为。比如用HOLDLOCK把第一步的U锁一直持有到语句结束。
WHILE 1 = 1 UPDATE tt WITH (ROWLOCK, HOLDLOCK) SET d = 'cd' WHERE a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3';HOLDLOCK等价于SERIALIZABLE级别下的锁语义,它会让U锁一直保持到事务结束,而不是中途释放。这听起来反而「锁更久」,为什么能解死锁?因为这个案例的死锁间隙恰恰出现在「第一步结束后释放了ix_a_bc的 U 锁、第二步又去申请同索引的 X 锁」这个空档。HOLDLOCK把这个空档堵住了,第二个连接永远拿不到ix_a_bc的U锁,也就不可能和第一个连接形成环。当然,代价是并发度下降——它用「串行化」换「不死锁」,适用场景有限。也有同行指出,对这种「一条 UPDATE 只有一个热行」的场景,用计划指南把执行计划固定成测试 3 那样的单步更新,也能根治,但维护成本高,一般作为最后的备选。
5.4 预防思路:把执行计划当成「加锁路线图」来审
这个案例真正的价值不在某一条解法,而在于一个可复用的认知:SQL Server 的加锁数量、顺序、持续时间,是由执行计划决定的。索引越多、数据类型越复杂,计划分支越复杂,锁就越容易在多个索引之间「来回切换」,阻塞和死锁的窗口就越大。
所以预防死锁,不能只盯着死锁那一瞬间的锁报告,要养成顺手审执行计划的习惯。看完 1222 之后,下一步就是把出问题的语句执行一遍,打开STATISTICS PROFILE或SET SHOWPLAN_ALL,看它到底有几个Index Update、锁是集中在一个对象上还是散在多个对象上。凡是看到计划里对同一语句存在多阶段锁申请,就要警惕并发下的锁间隙。这个习惯,比背一百条死锁处理口诀都管用。
三种方案适用场景对比如下。
| 方案 | 改动面 | 风险 | 适用场景 |
|---|---|---|---|
移除INCLUDE(d) | 索引定义 | 覆盖查询可能回表 | d高频更新,索引覆盖收益低 |
VARCHAR(MAX)→VARCHAR(200) | 字段类型 | 需确认业务长度上限 | d长度天然有界 |
HOLDLOCK/ 计划指南 | 语句行为 | 并发度下降,维护成本高 | 表结构无法改动时的兜底 |
6. 快速定位死锁:三个动作照做,十分钟拿到结论
死锁问题的排查效率,取决于你能不能快速拿到「正在演化的锁过程」而不是「死锁后的遗照」。我把这个案例里用到的流程压缩成三个动作,任何死锁问题都按这个顺序过一遍。
第一步,开 1222 并把死锁信息落盘。测试环境直接DBCC TRACEON(1222, -1);生产环境用扩展事件抓deadlock_report,事件名sqlserver.deadlock_report,文件目标配成.xel。这一步唯一要记住的原则是:宁可先开追踪再复现,也不要等复现了再回头找证据。死锁是瞬时事件,错过就是错过。
第二步,记录现场连接与锁资源的换算关系。用SELECT @@spid拿到参与会话的 SPID,在 Trace 过滤里只保留这些 SPID;同时把 1222 输出里的hobtid用sys.partitions关联sys.indexes换算成索引名。没有这张换算表,后面对锁顺序的分析就是一堆数字猜谜。
第三步,对照执行计划画锁等待环。拿到 Trace 里Lock:Acquired/Lock:Released的时序后,把每个进程的锁申请顺序和释放顺序列成表,再用执行计划解释「为什么是这个顺序」。只要锁等待环画出来、两阶段锁申请找到对应的Index Update节点,根因基本就浮出水面了。
验证是否根治的方法也很简单:保留原来的两个连接,在改动后的表结构或语句上重跑同样的循环 UPDATE。持续跑几分钟不死锁,才算真正闭环。如果改动后死锁频率降低但仍有偶发,说明锁的路径被压缩了但没有完全消除,回到第二步把新的锁顺序表再画一次,通常会发现剩下的是另一条锁路径。
这个案例给我留下的教训,是那句已经被验证了很多次的话:死锁分析没有捷径,但一定有方法——先拿全证据,再谈解决方案。从那以后,我每次遇到死锁都强制自己走一遍「1222 + Trace + 执行计划」的流程,没做完前两步绝不动代码。这套流程救过我不少次,希望帮到你。
本文还有配套的精品资源,点击获取