简介:围绕 SQL Server 中一例难以理解的死锁展开的实战分析文档,面向数据库管理员与后端开发人员,帮助读者建立从现象重现、取证到定位的完整排查思路。文中以可稳定复现的测试表为例:同一张表建有聚簇索引与两个带 include 列的非聚簇索引,两条相同的 rowlock 更新并发执行即触发死锁,而去掉 include 选项或把 varchar(max) 改为 varchar(200) 后死锁消失,借此讨论索引与锁机制对并发的影响。取证部分给出收集 SQL Trace 与开启 1222 跟踪开关两条路径,并用 SQL Profiler 按 SPID 过滤锁与 TSQL 事件、读取死锁报告逐层还原等待关系。资源包仅含 1 个 docx 文件,约 694KB,已有 326 人学习。
1. 反直觉的死锁:两条相同 UPDATE 为什么会互相等待
SQL Server Deadlock 最让人难受的地方,不是它发生了,而是两条看起来一模一样的 UPDATE 互相咬住,查到最后发现既不是隔离级别太低,也不是锁升级。一次带 rowlock 的更新,明明只改一行,却在两个非聚集索引和一个聚集索引之间来回申请锁;两个连接以相同顺序进入,却在不同的步骤上交错,形成一个很小的交叉等待。更反直觉的是,去掉 include(d) 或把 varchar(max) 改成 varchar(200),死锁就消失。这个案例值得拆,因为它把“执行计划决定加锁顺序”这件事暴露得很彻底:死锁不是玄学,是锁序列和计划形状对不上。
2. 现场还原:从 1222 输出里读出持有与等待
先把现场搭出来。这个死锁的复现条件不复杂:一张有聚集索引和两个非聚集索引的表,非聚集索引的 include 列里放了稍后要被更新的宽字段,然后用两个连接反复执行同一条按非聚集索引过滤的 UPDATE。问题稳定出现,才有资格谈分析;否则只能靠猜,猜到最后往往把锅甩给隔离级别。
2.1 最小复现:一张表、两个非聚集索引、一个循环 UPDATE
建表语句里最关键的三个点:id 是 identity 主键,默认形成聚集索引;a 和 b 上各有一个非聚集索引;d 是 varchar(max),同时被两个非聚集索引 include。更新 d 时,SQL Server 不只要改聚集索引里的行,还要改两个非聚集索引叶级里的副本。
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 -- 插入一批随机数据,让 a、b 的分布接近真实业务 insert into tt select NEWID(), 'bbb', 'ddd'; go 10000插入完成后,随便找一条记录,拿到它的 a 值。下面以第 10 条为例,实际值每次测试都会变:
select * from tt where id = 10; -- 假设得到 a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3'然后开两个连接,各自跑同一个循环。注意这里用了rowlock,很多人第一眼会以为“只锁一行,不会出大事”,但 rowlock 只影响锁的粒度,不改变锁的模式,也不阻止一条 UPDATE 去碰多个索引。
while 1 = 1 update tt with(rowlock) set d = 'cd' where a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3';两个连接同时跑,测试 1 稳定死锁。把两个非聚集索引上的include(d)去掉,测试 2 不死锁。把 d 从varchar(max)改成varchar(200),保留 include,测试 3 也不死锁。三个测试只改了索引或类型,UPDATE 语句一个字没动,死锁却时有时无,这正是需要看锁序列而不是只看语句的原因。
2.2 resource-list 怎么读:owner-list 与 waiter-list 的对照
开启 1222 跟踪标志后,死锁信息会写进 SQL Server 的 errorlog。1222 的输出比死锁图更“啰嗦”,但也更完整,尤其适合看谁持有什么、谁在等什么。
dbcc traceon (1222, -1);复现之后,用sp_readerrorlog找最近的死锁记录。输出里先看process-list,再看resource-list。process-list里的spid、waitresource、lockMode告诉你每个连接卡在哪个资源上;resource-list里的owner-list和waiter-list告诉你资源的持有者和等待者。
把关键行抽出来,结构大致是这样:
process id=process5e09dc8 spid=54 waitresource=KEY: 8:72057594065518592 (d08358b1108f) lockMode=U process id=process5e27708 spid=60 waitresource=KEY: 8:72057594066108416 (ef8a9edf5a1e) lockMode=X keylock hobtid=72057594066108416 indexname=ix_a_bc mode=U owner id=process5e09dc8 mode=U waiter id=process5e27708 mode=X keylock hobtid=72057594065518592 indexname=PK__tt__3213E83F10E07F16 mode=X owner id=process5e27708 mode=X waiter id=process5e09dc8 mode=U读法很直接:连接 54 在ix_a_bc上持有一个 U 锁,同时想申请聚集索引上的 U 锁;连接 60 在聚集索引上持有 X 锁,同时想申请ix_a_bc上的 X 锁。两边各持有一个对方需要的资源,谁也不松手,死锁检测器只能选一个牺牲者。这里的hobtid是 Heap or B-Tree ID,落到分区级别,和sys.partitions.partition_id对得上,不是object_id。这一点后面会专门说,因为映射错表是排查时很常见的翻车点。
2.3 锁兼容矩阵:U 锁为什么是死锁的中间态
U 锁是更新锁,通常出现在 UPDATE、DELETE 的定位阶段。它的作用是先“占住一个准备更新的位置”,防止两个连接都先拿 S 锁、再同时想升级成 X 锁,最后互相等。U 锁和 U 锁不兼容,U 和 X 也不兼容,但 U 和 S 在一定条件下可以共存。这个设计减少了 S→X 转换死锁,但也让 U 锁成了一个中间态:持有 U 的人还没拿到 X,等待 U 的人可能已经持有别处的 X。
| 已持有 \ 请求 | S | U | X |
|---|---|---|---|
| S | 兼容 | 兼容 | 不兼容 |
| U | 兼容 | 不兼容 | 不兼容 |
| X | 不兼容 | 不兼容 | 不兼容 |
在这个案例里,连接 54 在ix_a_bc上拿到 U 锁后,还要去聚集索引上申请 U 锁;连接 60 已经在聚集索引上拿到 X 锁,还要回ix_a_bc申请 X 锁。U 与 X 不兼容,X 与 U 也不兼容,交叉等待就成立了。rowlock在这里没有帮上忙,因为锁的冲突发生在不同索引的键上,而不是同一行上的粒度问题。
提示:
rowlock约束的是锁粒度,不是锁模式,也不是“只碰一个索引”的承诺。
3. 锁的申请顺序:一次 UPDATE 为什么要碰三个索引
知道谁等谁,只回答了“死锁长什么样”,还没回答“为什么会走到这个交叉点”。要回答后者,得把一次成功 UPDATE 的锁申请和释放按时间顺序摊开。SQL Server 的锁不是语句结束时一次性申请的,而是跟着执行计划里的操作符一步步走;操作符先碰哪个索引,锁就先落在哪个索引上。
3.1 成功路径:Lock:Acquired 与 Lock:Released 的时间线
用 SQL Profiler 建 Trace,勾上 Locks 下的Lock:Acquired、Lock:Released,再配上 TSQL 下的 Batch/Stmt 事件,按 SPID 过滤。Lock:Acquired和Lock:Released的字段比较多,Mode、ObjectID、ObjectID2、Type要拖到前面看,不然一屏都是时间戳和事务 ID。
测试 1 的锁序列可以整理成下面这样:
| 步骤 | 连接 1(SPID 54) | 连接 2(SPID 60) |
|---|---|---|
| 1 | 在 ix_a_bc 上申请 U 锁 | 在 ix_a_bc 上申请 U 锁 |
| 2 | 在聚集索引 PK 上申请 U 锁 | 在聚集索引 PK 上申请 U 锁 |
| 3 | 在聚集索引 PK 上申请 X 锁 | 在聚集索引 PK 上申请 X 锁 |
| 4 | 释放 ix_a_bc 上的 U 锁 | 释放 ix_a_bc 上的 U 锁 |
| 5 | 在 ix_a_bc 上申请 X 锁 | 在 ix_a_bc 上申请 X 锁 |
| 6 | 在 ix_b_cd 上申请 X 锁 | 在 ix_b_cd 上申请 X 锁 |
| 7 | 释放 ix_b_cd 上的 X 锁 | 释放 ix_b_cd 上的 X 锁 |
| 8 | 释放 ix_a_bc 上的 X 锁 | 释放 ix_a_bc 上的 X 锁 |
| 9 | 释放 PK 上的 X 锁 | 释放 PK 上的 X 锁 |
这张表说明 UPDATE 被分成了两段。第一段是定位和更新聚集索引:先用ix_a_bc找到目标键,再到聚集索引上定位并改成 X 锁。第二段是维护非聚集索引:因为 d 被 include 在两个非聚集索引里,d 一改,两个索引的叶级都要更新,所以要分别在ix_a_bc和ix_b_cd上申请 X 锁。第一段结束时,ix_a_bc上的 U 锁被释放;第二段重新申请ix_a_bc的 X 锁时,窗口就打开了。
3.2 hobtid 映射:ObjectID2 不是 object_id
Trace 里的ObjectID2就是 1222 输出里的hobtid。它对应的是分区 ID,不是对象 ID,所以拿它去查sys.objects.object_id会对不上。正确的映射要连sys.indexes、sys.objects和sys.partitions,用partition_id去匹配。
select o.name as table_name, i.name as index_name, i.type_desc 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; -- 72057594065518592 来自 trace 的 ObjectID2 / 1222 的 hobtid这个案例里,三个值分别对应:
| hobtid / ObjectID2 | 索引 |
|---|---|
| 72057594065518592 | 聚集索引 PK__tt__3213E83F10E07F16 |
| 72057594066108416 | ix_a_bc |
| 72057594066173952 | ix_b_cd |
映射清楚之后,锁序列里那些数字才有意义。否则你只能看到“某个 KEY 上等了很久”,却不知道等的是过滤索引、覆盖索引还是聚集索引,后面的执行计划分析也无从下手。
3.3 死锁时序复盘:连接 1 停在第一步,连接 2 走到第二步
把两个连接的交错放慢,死锁的形成过程并不复杂。连接 2 先完成第一段:它在ix_a_bc上拿到 U 锁,在聚集索引上拿到 X 锁,然后把ix_a_bc的 U 锁释放掉,准备进入第二段。连接 1 趁这个窗口进来,在ix_a_bc上拿到 U 锁,接着去聚集索引申请 U 锁,但聚集索引的 X 锁还在连接 2 手里,连接 1 被挡住。
连接 2 进入第二段,要更新ix_a_bc,于是申请ix_a_bc的 X 锁;这个锁和连接 1 持有的 U 锁不兼容,连接 2 也被挡住。此时连接 1 等连接 2 的聚集索引 X 锁,连接 2 等连接 1 的ix_a_bcU 锁,闭环成立。死锁检测器介入,选一个代价较小的连接回滚,另一个继续。
这也是为什么“两条语句一模一样”并不重要。死锁不要求语句不同,只要求两个连接以相同顺序进入、却在不同的阶段持有对方下一步需要的锁。执行计划把 UPDATE 拆成几段,段与段之间又有释放和重新申请,交叉点就出现了。
4. 执行计划分水岭:include varchar(max) 为什么把更新拆成三步
到这里,锁序列已经解释清楚,但还剩一个关键问题:为什么测试 1 的 UPDATE 要分两段,测试 2 和测试 3 却不用?答案在执行计划里。加set statistics profile on拿到文字型计划,重点看Index Update操作符的数量和每个操作符下面的 Object。
4.1 三个 Index Update 与一个宽更新计划
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。三个操作符意味着三段维护动作,锁也分三段申请和释放。测试 3 把 d 改成varchar(200)后,计划里只有一次 update,但这个操作符下面带了三个 Object,也就是常说的宽更新计划:一个操作符一次性维护多个索引,锁的窗口更短,不会在释放定位 U 锁之后重新申请非聚集索引 X 锁。
| 测试 | d 的类型 | include(d) | Index Update 数量 | 是否死锁 |
|---|---|---|---|---|
| 测试 1 | varchar(max) | 是 | 3 | 是 |
| 测试 2 | varchar(max) | 否 | 1 | 否 |
| 测试 3 | varchar(200) | 是 | 1(含 3 个 Object) | 否 |
宽更新计划不是“优化器心情好”,而是有代价的:它要把多个索引的更新合并到一个操作符里,对列的类型和行内存储更友好。遇到 LOB 类型或更复杂的更新路径,计划更容易拆成多个Index Update。我一般会这么理解:计划拆得越碎,锁的申请和释放就越碎,两个连接踩到交叉点的概率就越高。
4.2 varchar(max) 的 LOB 路径与 varchar(200) 的行内路径
varchar(max)在 SQL Server 里是 LOB 类型。它不一定每行都放到行外,但优化器在生成更新计划时会按 LOB 的可能性处理,索引维护的路径和普通varchar(200)不一样。把varchar(max)放进非聚集索引的 include 列,等于让每个非聚集索引叶级都可能携带一个“大字段副本”,索引行变宽,页密度下降,更新时还要处理 LOB 的额外路径。
varchar(200)是行内类型,长度上限明确,索引行更紧凑,优化器更容易把多个索引维护合并进一个宽更新计划。测试 3 的计划里只有一个 update,却带了三个 Object,说明三个索引是一步更新掉的。锁序列也相应变短:ix_a_bc的 U 锁申请、聚集索引的 U/X 锁申请、然后释放,没有“释放 U 锁再重新申请 X 锁”的第二段。
注意:
varchar(max)作为 include 列并不是语法错误,但它的维护成本和锁行为跟窄列完全不是一个量级。索引设计时不能只看“查询能不能覆盖”,还要看“更新会不会被放大”。
4.3 去掉 include 后为什么只剩聚集索引更新
测试 2 把两个非聚集索引的include(d)去掉,d 不再存在于非聚集索引叶级。更新 d 时,非聚集索引的键和 include 列都没变,SQL Server 只需要更新聚集索引,计划里只剩一个Index Update。锁序列也只剩“ix_a_bc上 U 锁定位、聚集索引 U/X 锁、释放”,没有第二段非聚集索引维护,自然没有交叉等待。
这也解释了一个常见误判:看到 UPDATE 的 WHERE 条件走的是非聚集索引,就以为“只碰这个索引”。实际上,只要更新列被 include 在别的索引里,或者更新列是某个索引的键列,SQL Server 就要维护那些索引。索引越多、include 列越宽,一条 UPDATE 碰到的对象就越多,锁的申请顺序也越复杂。
5. 避坑与排查:索引设计如何把普通 UPDATE 放大成死锁
这个案例最有价值的地方,不是记住“varchar(max) 不要 include”,而是学会一套从现象回到计划的排查路径。下面几条踩坑记录都来自类似的现场,现象看起来各不相同,原因却常常绕回同一个点:语句的锁序列和执行计划的形状不匹配。
5.1 五条踩坑记录:从现象到解决
踩坑一:以为加了 rowlock 就不会死锁。
现象:UPDATE 已经写了with(rowlock),两个连接还是死锁。原因:rowlock 只约束粒度,不约束模式,也不限制一条语句去维护多个索引。解决:不要用 rowlock 当免死金牌,先看执行计划里有几个Index Update,再看锁序列里有没有“释放后再申请”的段。
踩坑二:把 varchar(max) 当普通 varchar 放进 include。
现象:覆盖查询变快了,但更新开始间歇性死锁,索引页也膨胀得厉害。原因:宽 LOB 列让非聚集索引叶级变宽,更新计划拆成多个Index Update,锁窗口变长。解决:先确认查询是否真的需要这个宽列;能用窄类型就加长度约束;实在要覆盖,考虑单独做一个窄覆盖索引,而不是把宽列塞进多个索引。
踩坑三:只看死锁图,不看 Lock:Acquired / Lock:Released。
现象:死锁图能看出两个连接互相等,但看不出为什么会走到那一步。原因:死锁图是结果快照,锁的申请和释放序列才是过程。解决:开 1222 拿到完整 resource-list,再用 Profiler 抓 Locks 事件,把Mode、ObjectID2、Type拖到前面,按时间排一次锁序列。
踩坑四:拿 hobtid 去查 object_id。
现象:Trace 里的ObjectID2在sys.objects里查不到,或者查出来是错的表。原因:ObjectID2是分区 ID,对应sys.partitions.partition_id,不是object_id。解决:用sys.indexes、sys.objects、sys.partitions三表关联,按partition_id映射到索引名。
踩坑五:只改隔离级别,不改执行计划。
现象:把隔离级别调低后死锁少了一些,但高并发下还是会出现。原因:隔离级别影响锁的持有时间,但不改变“一条 UPDATE 要维护三个索引”这个事实。解决:先看计划能不能减少Index Update数量,再考虑索引调整;隔离级别是辅助手段,不是根治方案。
5.2 一套可复用的排查顺序:trace、1222、执行计划、索引映射
遇到这类死锁,我一般按下面顺序走,避免一上来就改语句或加nolock:
- 开 1222,复现一次,记下牺牲者和两个连接的 SPID。
- 用 Profiler 抓 Locks 事件和 TSQL 的 Batch/Stmt 事件,按 SPID 过滤,别把整个实例的锁都抓下来。
- 读 errorlog 里的
resource-list,把owner-list和waiter-list对上,确认谁持有什么、谁在等什么。 - 用
ObjectID2映射到索引名,确认等待发生在聚集索引还是非聚集索引。 - 对死锁语句跑
set statistics profile on,数Index Update的数量,看是一个宽更新计划还是多个操作符。 - 对比“改索引前”和“改索引后”的锁序列,确认释放和重新申请的段是否消失。
- 回归时用两个连接循环跑,不要只跑一次;死锁是时序问题,跑一次说明不了什么。
这套顺序的核心是“先拿到锁序列,再解释计划”。没有锁序列,执行计划只能告诉你“可能碰了哪些索引”;没有执行计划,锁序列只能告诉你“谁等了谁”,解释不了“为什么会有这一步”。
6. 设计取舍:include 列的边界与可验证的验证方法
include 列不是免费的覆盖查询。它的收益很直接:把查询需要的列放到非聚集索引叶级,减少回表;它的成本也很直接:索引行变宽,页密度下降,更新列被 include 后,每次更新都要维护索引。宽列、LOB 列、多个索引同时 include 同一列,成本会叠加。这个案例里,varchar(max)被两个非聚集索引 include,更新 d 就要维护两个索引,计划拆成三个Index Update,锁的窗口被拉长,死锁就出现了。
判断一个 include 列该不该留,我一般看三件事。第一,查询是否真的高频到需要覆盖,能不能用更窄的列或单独的覆盖索引替代。第二,更新频率有多高,如果这一列经常变,include 到多个索引里等于把每次更新放大成多次索引维护。第三,执行计划里Index Update的数量是否可接受,能不能通过收窄类型或去掉 include 合并成一个宽更新计划。验证方法不复杂:保留两个连接循环 UPDATE,开 1222 和 Locks 事件,改索引前后各跑一次,对比锁序列和Index Update数量。能稳定复现的死锁,也能稳定验证修复。
| 取舍点 | 倾向保留 include | 倾向去掉 include |
|---|---|---|
| 查询频率 | 高频覆盖查询 | 低频或可回表 |
| 列宽 | 窄列、行内类型 | varchar(max)、宽列 |
| 更新频率 | 几乎不更新 | 经常更新 |
| 索引数量 | 只在一个索引里 include | 多个索引同时 include |
从那以后我每次给 include 加宽列,都先跑一遍两个连接的循环 UPDATE,看执行计划里Index Update的数量,再决定要不要收手。这个习惯不优雅,但能挡住很多本来可以避免的交叉等待。资源里保留了原始 1222 输出和 trace 字段对照,想复现的人可以顺着脚本走一遍,对照着看会少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取