☰
两条相同UPDATE为何互锁:执行计划与锁序列中的INCLUDE陷阱
2026/10/27 2:50:58 网站建设 项目流程

简介:SQL Server死锁排查常卡在难复现、难定位两步,这份文档正围绕一个可稳定重现的反常死锁案例展开,面向数据库管理员与后端开发人员。压缩包内仅1个docx,约694KB,已有326人学习。文档完整记录了复现过程:包含聚集索引与两个含include字段的非聚集索引的测试表、以rowlock更新触发死锁的脚本,以及移除include、将字段改为varchar(200)后死锁消失的三组对比,直观呈现索引与锁机制的关联。排查部分则整理了1222跟踪开关的开启方式、SQL Profiler中Locks与TSQL事件的采集与SPID过滤设置,并附deadlock-list内waitresource、executionStack等字段的解读。篇幅紧凑、脉络清晰,适合需要建立死锁分析思路的读者参考。

1. 两条一模一样的 UPDATE 为什么会互锁

两条字符级完全一致的 UPDATE 语句,分别跑在两个连接里,各自只改一行,却在两秒内稳定地互相卡死——这是 SQL Server 上 Deadlock 排查里最让人抓头的一类现场。更反直觉的是:把非聚集索引上的 INCLUDE(d) 删掉,死锁没了;把 d 从 varchar(max) 改成 varchar(200),死锁也没了。语句没动、数据没动、隔离级别没动,动的只是一个列的数据类型和一个索引的包含列。

如果只把死锁当成「撞上了」的玄学,下次换个表还会再翻车。真正值得搞懂的是这条链路:列的数据类型决定执行计划的形状,执行计划的形状决定加锁的数量与顺序,加锁的顺序决定存不存在可以相互咬住的窗口。把这条链路走通一次,死锁就从「猜」变成了「算」——看到索引定义和 UPDATE 语句,大致能预判它会不会在并发下互相咬住。

2. 现场取证:1222 输出与 Locks 事件怎么把死锁还原成锁序列

死锁是可以稳定重现的,这意味着取证条件非常理想:可以同时开两种采集手段,一种给终态快照,一种给过程轨迹。少了任何一种,结论都只能靠猜。这一章先把「怎么拿到证据」讲清楚,因为后面所有的因果推断都建立在这两份材料上。

2.1 为什么 1204 不够,1222 多了什么

SQL Server 有两个专门输出死锁信息的跟踪标志:1204 和 1222。1204 是老格式,只告诉你「谁和谁在什么资源上撞了」;1222 输出的是结构化 XML,额外带上两样东西——executionStack和inputbuf。前者能定位到具体是哪条语句、哪个存储过程行号,甚至能看到参数化之后的语句形态;后者直接贴上客户端发过来的原始批处理文本。

没有这两样,你只知道 spid 60 和 spid 54 互相等,却不知道它们跑的是不是同一条语句。这个案例里恰恰是「两条完全相同的 UPDATE」,如果只看 1204,很容易误判成两个不同业务逻辑在抢同一行。

打开方式很直接,-1表示全局作用域,开启之后新建立的连接也会继承这个标志:

-- 全局打开 1222,新连接同样生效 DBCC TRACEON (1222, -1); GO -- 确认当前会话与全局作用域下的标志状态 DBCC TRACESTATUS (1222, -1); GO -- 死锁发生后,从错误日志里捞出记录(第二个参数 1 表示错误日志,第三个参数做文本过滤) EXEC sp_readerrorlog 0, 1, N'deadlock';

这里有两个容易忽略的细节。第一,-1打开的标志在实例重启后失效,要长期保留得在启动参数里加-T1222,否则某次重启之后你以为还开着,实际早就没了。第二,错误日志会滚动覆盖,死锁频繁发生时旧记录很快被挤掉,sp_readerrorlog的参数可以指定归档文件序号,配合时间范围过滤能翻到更早的日志。

除了错误日志,系统健康会话的环形缓冲区里也留着死锁图的副本,作为补充取证手段很实用:

-- 从系统健康环形缓冲区里取最近记录的死锁 XML,适合错误日志已经被覆盖的场景 SELECT DATEADD(ms, -1 * (SELECT cpu_ticks FROM sys.dm_os_sys_info) + timestamp, GETDATE()) AS approx_time, CAST(record AS XML).query('//deadlock') AS deadlock_xml FROM sys.dm_os_ring_buffers WHERE ring_buffer_type = 'RING_BUFFER_SYSTEM_HEALTH' AND CAST(record AS XML).exist('//deadlock') = 1;

ring_buffer_type固定为RING_BUFFER_SYSTEM_HEALTH,里面的record是一段 XML,用exist()过滤出含deadlock节点的记录。这个缓冲区容量有限,只能兜底,不能替代 1222。

提示:1222 的信息量大,但对高频死锁的系统会产生可观的日志写入量,问题定位完之后用DBCC TRACEOFF (1222, -1)关掉。

2.2 Profiler 里只盯死锁图是不够的:Lock:Acquired / Lock:Released 怎么抓

1222 给的是死锁发生那一瞬间的终态:谁持有、谁在等。但「为什么走到这一步」是过程问题,终态快照回答不了。这个案例里最关键的一环——连接 2 在完成第一步之后把ix_a_bc上的 U 锁释放掉了——在死锁图上是完全看不出来的。

过程证据来自 SQL Trace。新建 Trace 时先勾上「Show all events」和「Show all columns」,否则很多字段根本不出现在结果集里。事件选择两部分:Locks 类别下的Lock:Acquired、Lock:Released、Lock:Deadlock、Lock:Deadlock Chain;TSQL 类别下的批处理和语句开始结束事件,用来把锁事件对齐到具体语句上。

然后在 Column Filters 里按 SPID 过滤。这里有个经验:除了两个业务连接的 SPID(本例是 54 和 60),把系统会话也带上。后台任务偶尔也会参与锁竞争,过滤太干净反而会丢掉上下文。原文里过滤了 54、60、6、20 四个 SPID,前两个是业务连接,后两个是系统会话。

结果集默认按时间排序,但字段太多,横向看很痛苦。把Mode、ObjectID、ObjectID2、Type拖到最前面,读起来就顺了:Type告诉你锁在 KEY 还是 PAGE 还是 RID 上,Mode是 S/U/X/IX 这些模式,ObjectID2是索引所在分区的 hobtid,也就是 1222 输出里hobtid那个数字。

一条锁的完整生命周期是这样读出来的:Lock:Acquired出现表示申请成功,Lock:Released出现表示释放,两者之间的时间差就是这个锁被持有的窗口。把所有事件按 SPID 分组、按时间排序,就得到每个连接各自的加锁轨迹;两条轨迹叠在一起看,就能看到那个致命的交错点。

2.3 hobtid 到索引名的翻译:sys.partitions 与 sys.indexes 的对应

Trace 和 1222 输出里全是数字:72057594065518592、72057594066108416……不翻译成人能看懂的名字,分析根本推进不下去。

这里有个常见误解:把 hobtid 当成object_id去套OBJECT_NAME(),结果返回 NULL。hobt 是 heap or b-tree 的缩写,hobt_id标识的是某个分区上的一棵 B 树(或者一个堆),跟对象 ID 完全不是一个层级的东西。正确的落点是sys.partitions:

-- 用 1222 / Trace 里的 hobtid 反查表名与索引名 SELECT o.name AS table_name, i.name AS index_name, i.type_desc AS index_type, p.hobt_id, p.partition_id FROM sys.partitions AS p JOIN sys.indexes AS i ON i.object_id = p.object_id AND i.index_id = p.index_id JOIN sys.objects AS o ON o.object_id = i.object_id WHERE p.hobt_id = 72057594065518592; -- 换成实际抓到的 hobtid

sys.partitions上hobt_id和partition_id是两个不同的列,但在单分区表上它们的数值恰好相同,所以用partition_id做条件也能查到——这也是很多老脚本能跑通的原因,换到分区表上就会翻车。稳妥的写法是直接匹配hobt_id。

本例翻译出来的对应关系如下,后面所有分析都基于这张表:

hobtid索引名类型含义
72057594065518592PK__tt__3213E83F10E07F16CLUSTERED主键 id 上的聚集索引
72057594066108416ix_a_bcNONCLUSTEREDa 上的非聚集索引,INCLUDE(d)
72057594066173952ix_b_cdNONCLUSTEREDb 上的非聚集索引,INCLUDE(d)

三个 hobtid 对应三个物理结构,UPDATE 要动 d 字段,就意味着三个结构都要改,这是后面一切问题的物理基础。

3. 锁序列复盘:一条 UPDATE 为什么会被拆成「定位」和「回写索引」两段

拿到两份材料之后,问题就变成了拼图:把锁事件按时间轴排开,看看两个连接各自走到哪一步、手里攥着什么、在等什么。这一步做完,死锁的成因基本就水落石出了。

3.1 正常一次 UPDATE 的加锁放锁轨迹

先看单个连接、没有并发干扰时,这条 UPDATE 的锁是怎么申请的。理解 U 锁的语义是前提:S 锁用于读,X 锁用于写,U 锁是给「先读后写」场景准备的中间态——它允许持有者后续把锁升级为 X,同时不允许第二个事务也拿到 U,从而避免两个事务同时持 S 锁、又同时想升 X 的经典死锁。

在 read committed 隔离级别下,这条 UPDATE 的轨迹是这样的:

顺序索引锁模式动作
1ix_a_bcU (KEY)申请,用于按 a 定位到目标行
2PKU (KEY)申请,在读聚集索引行时加
3PKX (KEY)申请,正式修改聚集索引中的行
4ix_a_bcU释放,定位使命完成

到第 4 步为止,只有聚集索引被真正改掉了。但 d 字段被两个非聚集索引 INCLUDE 了,非聚集索引的叶级也存着 d 的副本,d 一变,两个索引的叶级记录必须同步更新。所以还有第二阶段:

顺序索引锁模式动作
5ix_a_bcX (KEY)申请,改叶级记录里的 d
6ix_b_cdX (KEY)申请,改叶级记录里的 d
7ix_b_cdX释放(事务提交时)
8ix_a_bcX释放
9PKX释放

两个阶段之间有明显的缝隙:第 4 步释放了ix_a_bc上的 U 锁,第 5 步才重新申请 X 锁。这个缝隙本来无所谓,单连接跑一百遍也不会出问题。并发一进来,它就成了整个死锁的入口。

3.2 死锁图两侧的 owner 和 waiter 分别在等什么

把 1222 输出精简一下,去掉噪音字段,结构是这样的:

<deadlock-list> <deadlock victim="process5e27708"> <process-list> <process id="process5e27708" spid="60" lockMode="X" status="suspended" waitresource="KEY: 8:72057594066108416 (ef8a9edf5a1e)" isolationlevel="read committed (2)" trancount="2"> <executionStack> <frame procname="adhoc" line="2"> UPDATE [tt] WITH(rowlock) SET [d] = @1 WHERE [a] = @2 </frame> </executionStack> <inputbuf> update tt with(rowlock) set d='cd' where a='EF211985-EA72-4A40-81DA-0AAB076E7AA3' </inputbuf> </process> <process id="process5e09dc8" spid="54" lockMode="U" status="suspended" waitresource="KEY: 8:72057594065518592 (d08358b1108f)"> <!-- 同上结构,略 --> </process> </process-list> <resource-list> <keylock hobtid="72057594066108416" objectname="DemoDB.dbo.tt" indexname="ix_a_bc" mode="U"> <owner-list> <owner id="process5e09dc8" mode="U"/> </owner-list> <waiter-list> <waiter id="process5e27708" mode="X" requestType="wait"/> </waiter-list> </keylock> <keylock hobtid="72057594065518592" objectname="DemoDB.dbo.tt" indexname="PK__tt__3213E83F10E07F16" mode="X"> <owner-list> <owner id="process5e27708" mode="X"/> </owner-list> <waiter-list> <waiter id="process5e09dc8" mode="U" requestType="wait"/> </waiter-list> </keylock> </resource-list> </deadlock> </deadlock-list>

读这张图的正确姿势是从resource-list入手,每一个keylock就是一条「持有—等待」关系。第一条:ix_a_bc上的 U 锁被 spid 54 持有,spid 60 想要在这上面拿 X,在等。第二条:聚集索引上的 X 锁被 spid 60 持有,spid 54 想要在这上面拿 U,在等。两条边一连,环就闭合了。

process-list补的是个人状态:victim 是 spid 60,status="suspended"说明它确实在等锁,lockMode是它当前请求的模式,trancount="2"说明这条看似自动提交的 UPDATE 其实处在两层嵌套事务里——这解释了为什么第一步的 X 锁没有在第 4 步跟着 U 锁一起释放,而是一直挂到事务结束。

3.3 半拍窗口:连接 1 停在第一步、连接 2 走到第二步

把两条轨迹叠到同一根时间轴上,死锁的成因就非常直白了:

时刻连接 1(spid 54)连接 2(spid 60)
T1—第一步完成,持 PK 的 X 锁,释放了 ix_a_bc 的 U
T2申请并拿到 ix_a_bc 的 U进入第二步,申请 ix_a_bc 的 X → 被 T2 的 U 挡住,等待
T3申请 PK 的 U → 被连接 2 的 X 挡住,等待持 PK 的 X 等待 ix_a_bc
T4互相等待,死锁检测触发被选为 victim,回滚

关键在于 T1 那个「释放」动作。连接 2 在第一步结束时把ix_a_bc上的 U 锁放掉了,这个锁本来是用来定位的临时资源,用完就还——单看这一步完全合理。但它在第二步又要重新申请同一把锁,模式还升级成了 X。这两次申请之间的空档,正好被连接 1 钻了进去。

如果没有第二步,连接 2 在 T1 之后就走完了,连接 1 无论什么时候进入都不会形成环。如果第二步的加锁和第一步是原子的、中间没有释放,连接 1 也插不进来。偏偏 SQL Server 在这里做了两件事:先放、后重新申请。两个连接各自跑同一条语句,交错的相位差就构成了一把经典的 AB-BA 死锁——只不过 A 和 B 不是一个连接内的两个资源,而是同一条语句内两个阶段的同一批资源。

WITH (ROWLOCK)在这里完全帮不上忙。它控制的是锁的粒度(行锁而不是页锁),而问题出在锁的顺序和时序上。粒度再细,两个连接还是按同样的顺序、同样的间隙去抢同一批键。

4. 执行计划分水岭:三个 Index Update 和一个宽更新算子的差别

锁序列解释的是「怎么死的」,还没回答「为什么去掉 INCLUDE 或改个类型就不死了」。这两个动作改变的是执行计划的形状,而执行计划的形状才是加锁数量和顺序的源头。

4.1 statistics profile 下三种情况的操作符数

在 UPDATE 语句前加一句SET STATISTICS PROFILE ON,就能拿到文字型的实际执行计划,不用图形化工具也能看清有几个更新操作符:

SET STATISTICS PROFILE ON; GO -- 与死锁现场完全一致的更新语句 UPDATE tt WITH (rowlock) SET d = 'cd' WHERE a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3'; GO SET STATISTICS PROFILE OFF;

三种情况跑下来,计划形态差异非常明显:

场景列类型INCLUDE(d)更新操作符数是否死锁
测试 1varchar(max)有3 个 Index Update死锁
测试 2varchar(max)无1 个 Index Update不死锁
测试 3varchar(200)有1 个 Update,覆盖 3 个对象不死锁

测试 1 里三个 Index Update 分别对应聚集索引和两个非聚集索引,这就是为什么 1222 输出里能看到三个 X 锁的申请。测试 2 里 d 没有被任何非聚集索引包含,只需要改聚集索引,一个操作符搞定。测试 3 最有意思:操作符只有一个,但它内部挂了三个对象,一次扫描就把三个索引全改完了。

4.2 varchar(max) 为什么破坏了宽更新计划

SQL Server 的优化器在编译 UPDATE 时,会判断能不能生成所谓的「宽更新计划」(wide update plan)。能生成的话,一个 Update 算子在一次扫描中把聚集索引和所有受影响的非聚集索引一起改掉,定位只做一次、锁的申请集中在一小段时间内、改完一起释放。测试 3 就是这种形态。

宽计划有前提条件:被更新的列必须能在这个算子里直接处理。大对象类型(varchar(max)、nvarchar(max)、varbinary(max) 以及更老的 text/ntext/image)不满足这个条件——它们可能行外存储,写入要走专门的 LOB 语义,优化器没法把它们和非 LOB 列塞进同一个更新算子。常见的实现思路是:一旦目标列里出现 LOB,计划就退化成窄计划,按索引拆成多个更新算子,每个算子单独定位、单独加 X 锁、单独释放。

所以 varchar(max) 的影响不是「太长了」,而是「类型类别不对」。这一点可以用反证验证:把 d 改成 varchar(8000),长度更大,但已经不是 LOB 类型,宽计划照样能生成,死锁也就不出现了。真正的分水岭在类型类别,不在字节数。

窄计划把原本原子的写入拆成了三段,每段之间都有释放和重新申请的间隙。第 3 章里那条「先放 U 锁、再申请 X 锁」的缝隙,就是宽计划退化的直接产物。计划越碎,窗口越多,并发下互相咬住的概率就越高。

4.3 从 XML 计划里量化「一次更新碰了几个索引」

图形化计划适合人眼看,但要批量判断「哪些 UPDATE 被拆碎了」,得从计划缓存里做统计。Showplan 的 XML 有固定命名空间,查询时要用WITH XMLNAMESPACES声明,否则 XQuery 匹配不到节点:

WITH XMLNAMESPACES ('http://schemas.microsoft.com/sqlserver/2004/07/showplan' AS sp) SELECT TOP (20) qs.execution_count, -- 更新操作符的个数:大于 1 说明计划被拆成了多段 qp.query_plan.value('count(//sp:RelOp[@PhysicalOp="Update"])', 'int') AS update_operator_count, -- 被更新对象的总数:反映这条 UPDATE 要维护几个物理结构 qp.query_plan.value('count(//sp:Update/sp:Object)', 'int') AS updated_object_count, SUBSTRING(st.text, 1, 200) AS stmt_text FROM sys.dm_exec_query_stats AS qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp WHERE qp.query_plan.exist('//sp:RelOp[@PhysicalOp="Update"]') = 1 ORDER BY qs.last_execution_time DESC;

update_operator_count对应原文里 Index Update 的行数,测试 1 是 3,测试 2 和 3 都是 1。updated_object_count反映这条语句总共要维护几个物理结构,三种情况都是 3——因为索引没变,只是维护方式变了。两个指标放在一起看,比值大于 1 就说明这个更新被拆碎了,值得盯一眼。

sys.dm_exec_query_plan返回的query_plan列可能为 NULL(计划已被逐出),实际排查时加个WHERE qp.query_plan IS NOT NULL更省事。

5. 排查这类死锁时的五个常见问题

前面几章是一条顺畅的分析路径,但真实排查里踩的坑远比这多。下面五条是最容易反复出现的,每条都按「看到什么现象、根因在哪、怎么处理」写清楚。

5.1 只开了 1204,拿到的死锁信息缺执行栈

现象:错误日志里确实有死锁记录,但只有双方 SPID 和资源描述,看不出跑的是哪条语句,更看不到参数。

原因:1204 是老格式输出,不包含executionStack和inputbuf这两个节点。而这两个节点恰恰是判断「两个连接是不是在跑同一条语句」的唯一依据。

解决:改用 1222,并且用-1全局打开,确认DBCC TRACESTATUS显示已生效。开完之后先制造一次死锁验证输出格式,别等到生产出事才发现标志根本没打开。

5.2 拿 hobtid 当 object_id 查,什么也查不到

现象:SELECT OBJECT_NAME(72057594066108416)返回 NULL,或者返回一个完全不相关的对象名。

原因:hobtid 标识的是分区上的 B 树或堆,不是数据库对象。两者在数值上毫无关系,只是碰巧都是 bigint。

解决:走sys.partitions.hobt_id关联sys.indexes和sys.objects。注意别用partition_id代替hobt_id——单分区表上数值相同,分区表上就会查错。

5.3 只抓 Deadlock Graph,锁申请释放顺序全丢

现象:死锁图看得很明白,谁持有谁等待一目了然,但想不通「它为什么会在那个时候放掉那把锁」。

原因:Deadlock Graph 是终态快照,只记录死锁检测那一刻的锁状态。中间过程——尤其是 U 锁被释放后又被重新申请的间隙——在快照里是不存在的。

解决:Trace 里同时勾上Lock:Acquired和Lock:Released,按 SPID 过滤后把Mode、ObjectID2、Type拖到前面,按时间排序还原每个连接的完整轨迹。两份材料交叉验证,结论才站得住。

5.4 以为 WITH (ROWLOCK) 能绕开死锁

现象:语句上已经加了WITH (ROWLOCK),锁粒度确实是行级了,并发一上来照样死锁。

原因:锁提示控制的是「锁多大范围」,死锁的成因是「按什么顺序锁」。两个连接按同样顺序申请同一批资源,粒度再细也会互相等待。ROWLOCK 甚至可能让竞争更集中——原本页锁会顺带锁住邻行,反而错开了时序。

解决:把注意力从锁提示转到执行计划上。先看更新操作符有几个,再判断能不能合并成一个宽计划,最后才考虑索引调整。锁提示是最后手段,不是第一选择。

5.5 把 varchar(max) 塞进 INCLUDE 当成「覆盖索引优化」

现象:为了消掉查询的键查找,把一个大文本列加进 INCLUDE,读查询确实快了,写入侧开始随机死锁、日志增长变快、索引重建时间翻倍。

原因:INCLUDE 的列会实打实存进非聚集索引的叶级,大文本列让索引体积成倍膨胀。更隐蔽的代价是它破坏了宽更新计划,让原本一次完成的更新退化成多次,锁窗口被拉开。

解决:INCLUDE 里只放查询真正需要、且类型规整的小列。确实需要大文本的场景,把它留在基表里,用主键回表取,别为了省一次键查找把写入路径拖下水。

6. 索引设计里的成本账:覆盖读、写放大与锁窗口的三角取舍

这个案例最后落到一个很朴素的结论上:把 varchar(max) 放进索引的 INCLUDE 里不太妥当。但「不妥当」三个字背后是一笔要算清楚的账,算不清就还会在别的表上重演。

覆盖索引的收益是显性的:查询不用回表,逻辑读降下来,响应时间看得见地变好。成本则是隐性的,分三层。第一层是空间,INCLUDE 的列实打实复制进叶级,大文本列让索引体积翻几倍。第二层是写放大,每一次更新被包含的列,所有相关索引的叶级都要同步改,日志量和 IO 都跟着涨。第三层最隐蔽——它改变执行计划的形状,把一次原子的多索引维护拆成多段,锁的持有窗口被拉开,并发下的死锁概率随之上升。前两层能在监控里看到,第三层只在并发峰值时才冒头,等发现时往往已经积累了一堆偶发死锁。

所以判断一个 INCLUDE 该不该加,我一般按这个顺序过一遍。先确认这个列是不是查询的必需项,很多「覆盖索引」其实是查询写得太宽,把 SELECT * 收窄之后根本不需要它。再看类型,能不能归到定长或短变长的小类型里,能归就归,归不了就别往索引里塞。最后做一次计划验证:改之前和改之后各跑一次SET STATISTICS PROFILE ON,比更新操作符的个数。个数从 1 变成 3,就说明这次改动把写入路径拆碎了,收益再大也得重新掂量。

验证方法上还有一点值得补充。这套死锁的形态和隔离级别强相关,本例是 read committed,定位阶段会申请 U 锁,才有「先放 U 再要 X」的窗口。换成基于行版本控制的读已提交,查找阶段不再申请 U 锁,锁序列会明显不同,这类死锁往往换个形态出现,而不是简单消失。所以调完隔离级别之后别急着收工,把 Trace 再抓一遍,确认新的锁序列里没有别的环。

最后说个我自己的习惯。早些年遇到死锁,第一反应是加 NOLOCK、加 ROWLOCK、调隔离级别,把一个偶发问题压成「暂时看不见」。有一次一个每周才冒一次的死锁,压了两个月之后在业务高峰期集中爆发,回头去翻错误日志,早期的记录早就滚没了,只能从头复现。从那以后我每次碰到死锁,都强制走一遍完整流程:开 1222 拿终态、抓 Locks 事件拿过程、翻译 hobtid 对齐索引、看执行计划数更新操作符。四步走完再谈方案,宁可慢半天,也不留后悔药。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询