简介:这份文档围绕 SQL Server 中一类由索引与锁机制引发的非典型死锁展开,面向数据库管理员、SQL Server 开发与运维人员,重点解释两条相同更新语句为何会相互死锁,以及非聚集索引的包含列、长字段类型和行锁设置对死锁发生条件的影响。资源以单份 Word 文档交付,共 1 个文件,压缩包约 694KB,内容集中,适合作为案例资料随时查阅。文档保留可复现的建表与索引结构、对照实验现象和死锁分析记录,并梳理跟踪标记、SQL Trace、会话过滤、锁事件及错误日志中死锁清单等排查线索,读者可据此理解索引与锁的交互关系,积累非典型死锁的定位与排错思路,也能为性能诊断和故障复盘提供参考。目前已有 326 人浏览学习,适合需要提升 SQL Server 数据库诊断能力的技术人员。
1. 两条一模一样的 UPDATE 互相死锁:这个报错看着像玄学
两条一模一样的update tt with(rowlock) set d='cd' where a=...,两个连接同时跑,几十秒内必然互相卡死;可只要把两个非聚集索引上的include(d)去掉,或者把d从varchar(max)改成varchar(200),同样的语句跑一整晚都平安无事。SQL Server 死锁本身不稀奇,稀奇的是这两条语句的写法、隔离级别、锁提示完全一致,打开Deadlock Graph也看不出谁先动的手,于是很容易被当成玄学。这种「换个字段类型就好了」的故障最容易让人翻车:它说明问题根本不在 SQL 文本里,而在执行计划给出的加锁顺序里。下面这套分析路径,适合被 1222 输出里那一大段process-list卡住的 DBA,也适合只会看死锁图、不知道下一步该点哪儿的开发者。
2. 先抓黑匣子:1222 开关和 SQL Trace 要这样开才拿得到证据
死锁发生在毫秒级,靠事后猜没有意义,得让 SQL Server 自己把现场留下来。常见做法是两条路一起走:一条是打开 1222 跟踪标记,让引擎在死锁发生的瞬间把进程表、资源表、执行栈和原始语句整段写进 errorlog;另一条是用 Profiler 抓Lock:Acquired和Lock:Released的完整流水,看清每一把锁是什么时候拿的、什么时候放的。缺了前者,你不知道环闭在哪个资源上;缺了后者,你不知道这个环是怎么一步步合上的。只有 1222 的输出,很容易得出「A 持有 X 等 Y,B 持有 Y 等 X」这种正确但没用的结论,因为两条完全相同的语句本来就不该走到互相等待的那一步。
先把环境固定下来,避免边查边变。复现用的表只有三列有意义:id做主键形成聚集索引,a和b上各建一个带覆盖列的非聚集索引,d就是那个会引发问题的varchar(max)。
-- 复现环境:一张聚集索引 + 两个 include(d) 的非聚集索引 create table tt( id int identity primary key, -- 聚集索引,后面死锁图里的 PK__tt__... a char(36), b char(36), d varchar(max) -- 本次问题的关键变量:max 类型 + 被 include ); go create index ix_a_bc on tt(a) include(d); create index ix_b_cd on tt(b) include(d); go -- 'go 10000' 是客户端批处理重复,不是 T-SQL 语法 insert into tt(a, b, d) select NEWID(), 'bbb', 'ddd'; go 10000数据灌进去之后,先随便挑一条记录把a的值取出来,后续两个连接都拿这个值做条件。因为NEWID()每次生成不同的值,所以这个字符串在每台机器上都不一样,照抄别人博客里的值必然更新不到任何行,循环跑一天也复现不出来。
-- 取待更新那行的 a 值,记下来供两个会话共用 select top 1 a from tt where id = 10;2.1 打开 1222:让引擎把死锁现场写进 errorlog
跟踪标记要在复现之前打开,而且要用-1让它对后续所有新连接生效,否则你手动开的那个会话能看到日志,跑循环的另外两个会话依旧按老设置走。
-- 全局打开死锁详细日志:-1 表示对所有新连接生效 dbcc traceon (1222, -1); go -- 顺便确认一下当前会话的跟踪标记状态 dbcc tracestatus (-1); go1222和更老的1204都记录死锁,区别在于详细程度。1204只给一段被截断的文本,资源名、索引名、执行栈经常只出来一半;1222输出的是 XML 风格的分段结构,process-list里带spid、waitresource、lockMode、isolationlevel、trancount和executionStack,resource-list里带hobtid、indexname、mode、owner-list、waiter-list,还会把inputbuf也就是原始语句整条贴出来。排查这种「两条语句看起来一样」的问题,inputbuf特别重要,它能确认到底是不是同一条语句,以及有没有被参数化。
这个开关有两个现实缺点得提前知道。一是它只在实例级别生效,服务重启就没了,要长期保留得加启动参数-T1222。二是它只往 errorlog 写,不往表里写,死锁频繁的时候 errorlog 会被刷得很快,翻页找起来痛苦。所以复现期间我一般会先把当前 errorlog 归档一份,或者干脆用下面的语句按关键字直接捞。
-- 参数依次是:日志编号(0=当前)、日志类型(1=errorlog)、过滤关键字 exec sp_readerrorlog 0, 1, N'deadlock'; go第一个参数0表示当前的 errorlog,1到99是轮转出去的归档文件;第二个参数固定1表示读 SQL Server 的 errorlog 而不是 Agent 日志;第三个参数是模糊匹配串,用deadlock能过滤掉绝大部分无关的启动信息和备份信息。如果复现时间跨度长,记得把编号往上加着翻,死锁记录可能已经被轮转走了。
对 2008 及以上版本,还有一个不用重启、不用开标记就能用的补充手段:默认开启的system_health扩展事件会话会自动捕获xml_deadlock_report,存在环形缓冲区里。刚死锁完马上去捞,基本都能捞到,适合线上不方便改配置的场景。
-- 从 system_health 的 ring_buffer 里取最近的死锁报告 select xdr.value('@timestamp', 'datetime') as deadlock_time, xdr.query('.') as deadlock_xml from ( select cast(target_data as xml) as target_data from sys.dm_xe_session_targets t join sys.dm_xe_sessions s on s.address = t.event_session_address where s.name = 'system_health' and t.target_name = 'ring_buffer' ) as d cross apply d.target_data.nodes('//RingBufferTarget/event[@name="xml_deadlock_report"]') as x(xdr);这段的核心是sys.dm_xe_session_targets和sys.dm_xe_sessions按event_session_address关联,拿到system_health会话的ring_buffer目标数据,再转成 XML 用nodes()把每个xml_deadlock_report节点展开成一行。xdr.query('.')保留整段 XML,方便直接丢进图形化工具里渲染。注意ring_buffer是固定大小的环形结构,事件多了会覆盖,只能当临时缓存用,不能当长期审计。
2.2 Profiler 事件和列筛选:别一上来就全选
1222告诉你「谁在等谁」,但不告诉你「锁是按什么顺序拿的」。要还原顺序,必须有Lock:Acquired和Lock:Released。新建 Trace 的时候,先把Show all events和Show all columns两个勾打上,否则你会找不到后面要用的列。
事件只挑这几组就够了,选多了 Trace 文件会大得没法看,还会明显拖慢实例:
- Locks 下选
Lock:Acquired、Lock:Released、Lock:Deadlock、Lock:Deadlock Chain、Lock:Timeout、Lock:Escalation - TSQL 下选
SQL:BatchStarting、SQL:BatchCompleted、SQL:StmtStarting、SQL:StmtCompleted - 如果业务走存储过程或参数化调用,再加
RPC:Starting、RPC:Completed
选完事件,点Column Filters,在SPID上只跟踪跑循环的那两个连接。先把循环跑起来,再各自查一次会话号。
-- 在每个执行 update 循环的会话里各跑一次,记下两个 SPID select @@spid as my_spid, db_name() as current_db, @@trancount as tran_count; go@@spid是会话级变量,一个查询窗口一个值,但要注意应用侧如果有连接池,实际执行语句的 SPID 不一定是你手查的那个,最好在 Profiler 里先不筛、跑几秒看一眼真实的 SPID 分布再回来加过滤条件。@@trancount是为后面读 1222 里的trancount做参照用的,两者对不上就说明中间还有别的嵌套。
列方面,把EventClass、Mode、ObjectID、ObjectID2、Type、SPID、TextData拖到前面来。默认按StartTime排序在很多场景下不够用,因为同一毫秒内会发生好几件事,正确的做法是按EventSequence或者RowNumber排序,那才是引擎真实的执行顺序。Trace 输出建议直接存文件,存表在并发高的时候会引入额外的锁竞争,反而污染你要观察的现象。
2.3 把 hobtid 翻译成索引名:别拿它当 object_id 用
1222的resource-list里给的是hobtid,SQL Trace 的ObjectID2列里也是同一个值,形如72057594066108416。这个数字跟sys.objects里的object_id完全不是一个体系,直接拿去查必然是空结果,很多人到这一步就开始怀疑日志被截断或者类型转换出错。
-- partition_id 就是死锁日志里的 hobtid,通过 sys.partitions 反查表名和索引名 select o.name as table_name, i.name as index_name, i.type_desc as index_type, p.partition_id, p.rows from sys.indexes i join sys.objects o on o.object_id = i.object_id join sys.partitions p on p.object_id = i.object_id and p.index_id = i.index_id where p.partition_id in ( 72057594065518592, -- PK__tt__3213E83F10E07F16 72057594066108416, -- ix_a_bc 72057594066173952 -- ix_b_cd );查询的关键在sys.partitions,它按「对象 + 索引 + 分区」三个维度给每一份数据分配一个全局唯一的partition_id,死锁日志里引用的就是这个值。2005 时代的文档里叫它hobt_id,2008 之后统一到partition_id,所以看到两个名字指同一个东西不用惊讶。这个值是 8 字节的分配单元标识,长得比object_id大得多,看到一长串十几位数字就该反应过来它是分配单元而不是对象。
把三个值翻译完,对照关系就清楚了,后面的分析才有意义:
| 死锁日志里的值 | 实际对象 | 类型 |
|---|---|---|
| 72057594065518592 | PK__tt__3213E83F10E07F16 | 聚集索引(主键) |
| 72057594066108416 | ix_a_bc | 非聚集索引 |
| 72057594066173952 | ix_b_cd | 非聚集索引 |
到这一步,黑匣子算是打开了:你知道死锁发生在哪张表、哪几个索引上,也拿到了完整的锁申请流水。接下来才是真正花时间的部分——把流水读成时序。
3. 读死锁图:U 锁和 X 锁是怎么把两条 UPDATE 扣成环的
拿到 1222 的输出,第一眼很多人会被那一整屏属性吓退,其实只要拆成process-list和resource-list两块看,结构非常规整。前者回答「谁在等」,后者回答「等的是什么」,两块对照着读,环就出来了。
3.1 process-list:先看谁当了牺牲品、各自卡在哪个资源上
下面是精简过的关键片段,只保留了跟本次问题相关的内容。
deadlock-list deadlock victim=process5e27708 process-list process id=process5e27708 spid=60 lockMode=X waitresource=KEY: 8:72057594066108416 (ef8a9edf5a1e) waittime=1750 trancount=2 isolationlevel=read committed (2) executionStack ... UPDATE [tt] WITH(rowlock) set [d] = @1 WHERE [a]=@2 inputbuf while 1=1 update tt with(rowlock) set d='cd' where a='EF211985-EA72-4A40-81DA-0AAB076E7AA3' process id=process5e09dc8 spid=54 lockMode=U waitresource=KEY: 8:72057594065518592 (d08358b1108f) waittime=1750 trancount=2 isolationlevel=read committed (2) executionStack ... UPDATE [tt] WITH(rowlock) set [d] = @1 WHERE [a]=@2 resource-list keylock hobtid=72057594066108416 indexname=ix_a_bc mode=U owner id=process5e09dc8 mode=U waiter id=process5e27708 mode=X requestType=wait keylock hobtid=72057594065518592 indexname=PK__tt__3213E83F10E07F16 mode=X owner id=process5e27708 mode=X waiter id=process5e09dc8 mode=U requestType=wait先看deadlock victim,被选中的是spid=60,引擎回滚它是因为它的回滚代价更低——这部分对定位根因没有帮助,但能解释为什么业务日志里报错的是其中一个连接而不是两个。
再看waitresource,格式是KEY: 数据库ID:分配单元ID (键值哈希)。数据库 ID 是 8,说明两个连接在同一个库;括号里的哈希不能反查具体行,但可以用来判断两次等待是不是同一个键值。两个进程的lockMode一个X一个U,isolationlevel都是read committed,trancount都是 2,说明事务层没有额外嵌套,问题就出在单条语句内部。
executionStack里那一行UPDATE [tt] WITH(rowlock) set [d] = @1 WHERE [a]=@2是参数化之后的形态,inputbuf才是原始文本,两个连接完全一致。这一步基本可以排除「业务写了两条不同的 SQL」这种可能,剩下的就只有执行路径的差异。
3.2 resource-list:交叉等待的环到底闭在哪
resource-list给的是资源视角:每一段keylock先写资源本身(hobtid、indexname、mode),再写owner-list和waiter-list。把两段翻译成人话:
ix_a_bc这个键上,spid=54持有U锁,spid=60请求X锁在等;PK__tt__3213E83F10E07F16这个键上,spid=60持有X锁,spid=54请求U锁在等。
一个标准的环形等待:A 拿着非聚集索引上的 U 锁去要聚集索引,B 拿着聚集索引上的 X 锁去要非聚集索引。单看这一段,很多人会误以为是两个连接同时更新了同一行——它们确实更新同一行,但真正的问题不是「同时」,而是「分了几步」。
把一次成功 update 的锁流水单独拉出来,再和死锁时的流水叠在一起,差异就非常明显了:
| 索引 | 锁模式 | 动作 | 会话 A(SPID 54) | 会话 B(SPID 60) |
|---|---|---|---|---|
| ix_a_bc | U | 申请 | 第 1 步 | 第 1 步 |
| PK | U | 申请 | 第 1 步 | 第 1 步 |
| PK | X | 申请 | 第 1 步 | 第 1 步 |
| ix_a_bc | U | 释放 | 第 1 步结束时 | 第 1 步结束时 |
| ix_a_bc | X | 申请 | — | 第 2 步 |
| ix_b_cd | X | 申请 | — | 第 2 步 |
| ix_b_cd / ix_a_bc / PK | X | 释放 | 语句结束 | 语句结束 |
注意其中那条ix_a_bc上的U锁释放记录,它是整个死锁的开关。会话 A 刚拿到ix_a_bc上的 U 锁,还没来得及拿到 PK 上的锁完成第一步;会话 B 在结束第一步之后把ix_a_bc上的 U 锁放掉了,A 于是补位成功;而 B 紧接着进入第二步,要更新那个include(d)的非聚集索引,于是在同一把键上申请 X 锁,跟 A 手里的 U 锁冲突。A 想等 B 手里的 PK X 锁,B 想等 A 手里的ix_a_bcU 锁,谁都动不了。
如果这条 update 只有一步,A 和 B 顶多是排队,绝不可能互等。所以真正要回答的问题变成了:为什么这条语句会分成两步?
3.3 把复现条件钉死:三个对照实验一次搭好
复现脚本必须是可重复的,否则改一次索引就要重新灌一遍数据,效率极低。我一般会在同一个库下分别建三张结构一样、索引配置不同的表,或者干脆在一张表上做三次「建索引—跑循环—删索引」的循环,把三次的锁流水都抓下来对比。
两个连接跑的内容完全一致,只是各自独立执行:
-- 连接 A 和连接 B 各跑一份,循环更新同一行 while 1 = 1 begin update tt with(rowlock) set d = 'cd' where a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3'; -- 换成实际查到的值 endwith(rowlock)在这里只是把锁粒度压到行,不改变锁模式,也不改变「先查找拿 U 锁、再更新升 X 锁」的两段式流程,所以它救不了这个场景。
然后按下面的顺序做两次对照,每次改完索引都重新跑两个循环,再用同样的方法抓 Profiler 和 1222:
-- 对照实验二:去掉 include(d),索引不再覆盖 d 列 drop index ix_a_bc on tt; drop index ix_b_cd on tt; go create index ix_a_bc on tt(a); create index ix_b_cd on tt(b); go -- 对照实验三:把 d 收窄成定长上限类型后,再把 include 加回来 alter table tt alter column d varchar(200); go create index ix_a_bc on tt(a) include(d); create index ix_b_cd on tt(b) include(d); go两次对照的结论是:去掉include(d)不死锁,把varchar(max)改成varchar(200)也不死锁。到这里,现象层面已经闭环,但要说明白「为什么」,还得去看执行计划。
4. 为什么去掉 include 或改小 varchar 就不锁了:执行计划里的 Index Update 数量
锁的数量和顺序由执行计划决定,这句话说起来像口号,落到这个案例上却非常具体:锁之所以交叉,是因为计划里出现了多个独立的更新算子;而算子的数量,又取决于被更新列的数据类型和它参与哪些索引。
4.1 用 statistics profile 打出文字计划
图形计划看着直观,但不方便贴进记录、做 diff,也不方便在纯命令行环境里看。我一般用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; go输出里每一行是一个算子,重点看三类字段:PhysicalOp和LogicalOp告诉你这是什么算子,Object告诉你作用在哪张表哪个索引上,ActualRows和Executes告诉你执行了几次。要找的就是Index Update或者Table Update这一类算子出现了几行、各自覆盖了哪个索引。想留档的话换成set statistics xml on,把结果另存成.sqlplan文件,之后可以直接打开对比。
4.2 三份计划对照:一步完成还是拆成三步
三次实验的计划放到一张表里,差别一眼就能看出来:
| 实验 | d 的类型 | include(d) | 计划中的更新算子 | 结果 |
|---|---|---|---|---|
| 实验 1 | varchar(max) | 是 | 3 个独立的 Index Update(PK、ix_a_bc、ix_b_cd) | 稳定死锁 |
| 实验 2 | varchar(max) | 否 | 1 个 Index Update(只更新聚集索引) | 不死锁 |
| 实验 3 | varchar(200) | 是 | 1 个 Update 算子覆盖 3 个索引对象 | 不死锁 |
实验 2 最好理解:d不在两个非聚集索引里,更新它只需要动聚集索引,一个算子、一把 X 锁,天然没有第二步,也就没有「放锁之后又回来要锁」的窗口。
实验 1 的问题在于,三个独立算子意味着语句内部按顺序分阶段申请三把 X 锁,中间还可能夹着释放动作,从「拿到第一把」到「拿到最后一把」之间的时间窗口被拉得很长。两个连接只要节奏稍微错开一点,就会精确地插进对方的窗口里。
实验 3 最有意思:计划里只有一次 update,但这一次覆盖了三个对象,也就是引擎把三个索引的更新合并到一步完成,锁的申请高度集中,窗口被压得极短,两条语句几乎不可能在这个窗口里互相插进来。
4.3 varchar(max) 为什么让计划拆成三步
varchar(max)、nvarchar(max)、varbinary(max)这几种类型在存储层走的是 LOB 路径:值小的时候可以存在行内,超过阈值就搬到单独的 LOB 页上。优化器编译语句时如果没法排除「这次要写的是 LOB 数据」,对 include 了该列的非聚集索引就会采用更保守的更新策略,一个算子只负责一个索引,逐个走完。而varchar(200)的上限在编译期就已知,引擎可以确定数据落在行内,于是把三个索引的维护合并成一个算子。
这个行为跟版本、补丁级别、统计信息都有关系,同一条语句在不同实例上看到不同形状是正常的。也正因为如此,别人环境里「varchar(8000)也不死锁」的结论不能直接搬——8000已经接近行内上限,某些情况下同样会走 LOB 路径。要下结论,只能在自己的实例上打出计划来数算子。
顺便说一句,把 LOB 类型塞进索引的include里本身就是个不太妥当的设计:索引行变宽、页分裂变多、维护成本上升,还额外带来锁和并发的复杂度。这个案例里死锁只是它最刺眼的表现而已。
4.4 三种修法怎么选:从索引设计入手,别从 SQL 文本入手
明确了根因在「多步更新 + 长窗口」,修法就有优先级了。
第一种是把d从覆盖列里拿掉。这是首选,前提是业务查询不需要靠这两个索引覆盖d,拿掉之后会退化成 Key Lookup 回表,性能要重新评估。
-- 方案一:索引瘦身,让 update 回到单步 drop index ix_a_bc on tt; drop index ix_b_cd on tt; go create index ix_a_bc on tt(a); -- 不再 include(d) create index ix_b_cd on tt(b); go第二种是把类型收窄。业务上d如果确实只有几十上百个字符,varchar(200)比varchar(max)合适得多,索引可以保留覆盖能力,计划也回到单步。
-- 方案二:先确认没有超长值,再改类型,最后重建覆盖索引 select max(len(d)) as max_len from tt; -- 必须先跑,超过 200 会直接报错 go alter table tt alter column d varchar(200) not null; goalter column不是免费的:它会做数据校验,表大时锁的时间不短;如果这一列已经被索引引用,某些情况下需要先删索引再改列再重建。所以务必在业务低峰做,并且提前用max(len(d))确认没有超长值,否则改到一半报截断错误,回滚都麻烦。
第三种是保留现状、用其它手段绕开,比如用 Plan Guide 固定计划、在应用层做死锁重试、或者把d拆到单独的明细表做垂直拆分。这几种都属于兜底,不是根治。重试能消掉错误日志,但锁冲突还在,表现出来就是更新延迟升高、偶发超时,等于把显性故障换成了隐性抖动。
5. 避坑清单:五个让我多熬两小时的死锁排查错误
下面这几条都是在这个案例里真实踩过的,每条按「现象、原因、解决」写,照着对一遍能省掉不少来回。
5.1 只抓 Deadlock Graph,不抓 Lock:Acquired / Lock:Released
现象:死锁图看得清清楚楚,两个会话交叉等锁,但怎么都想不通两条完全相同的语句为什么会产生交叉。
原因:死锁图只是「死锁那一刻」的快照,它告诉你谁持有什么、谁在等什么,但不告诉你这些锁是什么时候拿的、什么时候放的。没有释放动作的记录,就看不出「放锁之后又回来要同一把锁」这个关键动作。
解决:Profiler 里必须同时勾上Lock:Acquired和Lock:Released,按 SPID 过滤后,把每个会话的锁流水单独拉出来,按EventSequence排序,再按时间轴对齐两个会话。只看一方的流水是看不出环的。
5.2 以为加了 rowlock 就只在行上锁、不会出现 U 锁
现象:语句里明明写了with(rowlock),还是死锁,第一反应是提示没生效或者被优化器忽略了。
原因:rowlock管的是粒度,不管模式。更新语句的查找阶段在read committed下拿 U 锁是标准行为,找到之后再升级成 X 锁,这跟粒度提示没有关系。看到lockMode=U就怀疑提示失效,方向从一开始就偏了。
解决:判断锁行为要看死锁日志里的lockMode字段和 Profiler 里的Mode列,不要靠推断。真要控制锁的行为,得从索引设计、隔离级别或者计划形状入手,rowlock帮不上忙。
5.3 拿 hobtid 当 object_id 去查 sys.objects,查不到就以为日志坏了
现象:把72057594066108416丢进select * from sys.objects where object_id = 72057594066108416,返回空集,于是怀疑日志被截断或者版本不兼容。
原因:这个值是partition_id,旧文档里叫hobt_id,是分配单元级别的标识,跟object_id属于两套完全不同的编号体系,永远不会相等。
解决:统一走sys.partitions关联sys.indexes和sys.objects来翻译。写成一个固定的查询收藏起来,每次排查直接改where里的值,比临时拼 SQL 快得多。
5.4 拿 varchar(200) 改完在测试库跑通就收工,上线又死
现象:测试环境改完类型,跑了两小时没再死锁,上线之后一周内又零星报了几次。
原因:执行计划的形状受统计信息、参数化方式、数据分布、补丁级别共同影响。测试库的行数、值的长度分布和生产不一样,算子数量完全可能不同。测试库不死锁,只说明那个环境下窗口没被撞上。
解决:改完必须在生产环境(或者与生产同规模、同分布的库)抓一次实际执行计划,确认Index Update的数量真的降下来了,然后再观察一段时间的 1222 日志确认没有复发。
5.5 一上来就加重试或者 nolock,把问题盖住
现象:应用层加了死锁重试之后,错误日志干净了,但更新接口的 P99 延迟明显升高,偶尔还会超时。
原因:死锁被回滚重试,冲突本身一点没减少,只是从「报错」变成了「等待 + 重试」。加上重试次数有限,高并发时重试也可能连续失败,最终还是会漏到用户侧。
解决:重试只作为兜底,不能替代根因修复。先按第 4 章的路径把计划打出来,确认是不是多步更新导致的锁窗口,从索引设计或类型入手把窗口收窄,再考虑要不要保留重试。
6. 十分钟定位同类死锁:一套固定顺序的排查动作
再遇到「两条相同 DML 互相死锁」这种场景,我现在的顺序是固定的,不做任何跳跃。
第一步,确认是不是同一类问题。看 1222 的inputbuf,两条语句是否一致;看waitresource,是否分别指向同一张表的不同索引;看lockMode,是不是U和X交叉。三条都成立,基本可以判定是多步更新导致的锁窗口问题,而不是业务逻辑写错。
第二步,把现场抓全。dbcc traceon (1222, -1)加上system_health的xml_deadlock_report,两条路同时开着,谁先出结果用谁。
第三步,抓锁流水。Profiler 只筛两个 SPID,事件只留Lock:Acquired和Lock:Released,按EventSequence排,把两个会话的锁序列画成一张时序表。这一步决定你能不能看到那把「先释放、又被要回去」的锁。
第四步,翻译资源。用sys.partitions把hobtid翻成索引名,确认交叉点落在哪个索引上。
第五步,数算子。set statistics profile on打出文字计划,数Index Update的出现次数。多于一个,就说明语句在内部被拆成了多个阶段,问题定位完成。
第六步,改设计。优先去掉不必要的include,其次收窄max类型,最后才考虑 Plan Guide 和重试。
| 排查动作 | 关键命令 / 事件 | 看什么 |
|---|---|---|
| 抓死锁现场 | dbcc traceon (1222, -1) | inputbuf、waitresource、lockMode |
| 抓历史记录 | system_health的xml_deadlock_report | 复发频率、涉及对象 |
| 抓锁顺序 | Lock:Acquired/Lock:Released | 释放后是否再次申请同一资源 |
| 翻译资源 | sys.partitions按partition_id查 | 交叉点落在哪个索引 |
| 数更新算子 | set statistics profile on | Index Update的条数 |
还有一条省时间的习惯:把exec sp_readerrorlog 0, 1, N'deadlock'结果里的hobtid一次性全部提取出来批量翻译,别一个一个查。一个死锁日志里通常涉及两三个索引,逐个查很容易漏掉那个真正咬合的资源。
从那以后我每次看到「两条一模一样的语句互相死锁」,都不先去改 SQL 文本,也不急着加nolock或者重试,而是先把执行计划打出来数一遍Index Update——多数时候答案就摆在那几行算子里,只是没人愿意往下翻。希望帮到你。
本文还有配套的精品资源,点击获取