news 2026/10/27 2:50:58

SQL Server UPDATE 死锁:执行计划与 include 宽列

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL Server UPDATE 死锁:执行计划与 include 宽列

简介:围绕 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。

已持有 \ 请求SUX
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
72057594066108416ix_a_bc
72057594066173952ix_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 数量是否死锁
测试 1varchar(max)是3是
测试 2varchar(max)否1否
测试 3varchar(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:

  1. 开 1222,复现一次,记下牺牲者和两个连接的 SPID。
  2. 用 Profiler 抓 Locks 事件和 TSQL 的 Batch/Stmt 事件,按 SPID 过滤,别把整个实例的锁都抓下来。
  3. 读 errorlog 里的resource-list,把owner-list和waiter-list对上,确认谁持有什么、谁在等什么。
  4. 用ObjectID2映射到索引名,确认等待发生在聚集索引还是非聚集索引。
  5. 对死锁语句跑set statistics profile on,数Index Update的数量,看是一个宽更新计划还是多个操作符。
  6. 对比“改索引前”和“改索引后”的锁序列,确认释放和重新申请的段是否消失。
  7. 回归时用两个连接循环跑,不要只跑一次;死锁是时序问题,跑一次说明不了什么。

这套顺序的核心是“先拿到锁序列,再解释计划”。没有锁序列,执行计划只能告诉你“可能碰了哪些索引”;没有执行计划,锁序列只能告诉你“谁等了谁”,解释不了“为什么会有这一步”。

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 字段对照,想复现的人可以顺着脚本走一遍,对照着看会少走很多弯路。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:50:58

Java数据结构与算法分析复习:递归、复杂度与链表栈队列

简介:《数据结构与算法分析(Java语言描述)》(第三版)习题答案文档,面向正在学习数据结构、算法分析与Java程序设计的高校学生及自学者,用于对照教材习题梳理解题思路、巩固递归与复杂度分析等核…

作者头像 李华
网站建设 2026/10/27 2:50:20

面向对象基础全解析:从封装继承多态到面试实战避坑指南

面试季一到,后台私信里问得最多的一类问题,就是面向对象这块到底该怎么准备。很多人背了一堆定义,什么封装继承多态,张口就来,可真让他写一段代码、解释一下为什么这么设计,立马卡壳。这篇东西就是写给这类…

作者头像 李华
网站建设 2026/10/27 2:50:19

大模型与MCP服务器集成开发实战指南

1. 项目概述:连接大模型与MCP服务器的开发捷径在AI应用开发领域,大语言模型(LLM)与智能体控制平台(MCP)的结合正成为构建复杂AI系统的标准范式。mcp-use工具链的出现,彻底改变了传统开发中需要手动处理API调用、协议转换和状态管理的繁琐流程…

作者头像 李华
网站建设 2026/10/27 2:50:00

运维工程师转型:自动化时代的可靠性工程实践

1. 运维工程师的转型背景与挑战运维行业正在经历一场前所未有的自动化革命。根据2023年全球运维状态报告显示,超过78%的企业已经部署了基础运维自动化工具,而高级AIOps解决方案的采用率在过去两年增长了近300%。这种趋势直接导致了一个尖锐的问题&#x…

作者头像 李华
网站建设 2026/10/27 2:49:20

GaussDB逻辑复制槽导致xlog堆积问题分析与解决

1. 问题现象与背景分析上周在例行巡检某金融系统GaussDB集群时,发现主库的xlog目录空间使用率突然飙升到90%以上,接近警戒阈值。通过df -h命令查看,/data/pg_xlog目录大小已经达到300GB,而正常情况下这个目录应该维持在50GB左右。…

作者头像 李华
网站建设 2026/10/27 2:48:48

Flink Upsert Kafka Sink 配置与事务排错

去年有个做实时数仓的朋友拿着一版 SQL 来找我,说 Flink 在 sink 端往 Kafka 写数据,怎么调都报Table sink doesnt support consuming update changes,他明明在 DDL 里声明了主键,也反复换了几个 format,就是过不去。我…

作者头像 李华