简介:本资源是一份深入剖析SQL Server死锁机制的技术文档,面向数据库管理员、后端开发工程师及性能调优从业者,聚焦一个典型却易被忽视的非对称死锁场景——由non-clustered索引中INCLUDE大字段(varchar(max))引发的循环等待问题。文档完整复现了包含建表、批量插入、双连接并发UPDATE等6步可验证实验,并系统对比了三种关键干预措施(移除INCLUDE、缩小字段长度、调整索引结构)对死锁发生的决定性影响。包内为1个694KB的Word文档(.docx),涵盖死锁原理图解、1222跟踪开关配置、SQL Profiler事件筛选策略、sp_readerrorlog日志解析要点及executionStack堆栈定位方法,内容兼具理论深度与实操指导性。目前已有327人学习下载,适合希望掌握SQL Server锁升级路径、索引设计陷阱与生产级死锁诊断闭环能力的中高级DBA。
1. “两条一模一样的 UPDATE,凭什么会死锁?”——这个 SQL Server 死锁让三个 DBA 熬了通宵才看懂
你有没有遇到过这种玄学现场:两条完全相同的UPDATE ... WHERE a = @value语句,并发执行时稳稳触发死锁,但只要把varchar(max)改成varchar(200),或者删掉索引里的INCLUDE(d),死锁就凭空消失?没有网络抖动、没有长事务、没开显式事务、甚至加了WITH(ROWLOCK)都拦不住——它就卡在那儿,像一个黑匣子,日志里只甩给你一行victim=processXXXXX。这不是配置错误,也不是代码 bug,而是 SQL Server 底层加锁行为与执行计划耦合后暴露出的典型“隐性死锁链”。它高频出现在 OLTP 系统的高并发更新场景(比如订单状态变更、库存扣减),尤其当业务表带宽字段(如memo,content,payload)被塞进非聚集索引INCLUDE时,极易翻车。本文不讲抽象理论,只复盘一次真实可复现的死锁闭环:从建表、压测、抓日志、读 executionStack、对齐 hobtid、比对执行计划,到最终定位到SQL Server 对 LOB 字段 + INCLUDE 索引的特殊锁拆分逻辑——所有命令、参数、判断依据,都来自某跨平台系统上线前的压测血泪经验。
2. 死锁不是“抢同一行”,而是“抢同一行的两把不同锁”:从 1222 日志看懂 victim 和 waitresource
死锁发生后,第一反应不该是改代码或加重试,而是先确认:到底哪两个资源在互锁?谁先持有什么锁?谁在等什么锁?SQL Server 提供的DBCC TRACEON(1222, -1)是最直接的“黑匣子记录仪”,它不依赖 Profiler,也不需要提前埋点,只要开启,死锁瞬间就会写入 Error Log。关键在于——你得会读。
2.1 开启并捕获 1222 死锁日志
-- 全局开启死锁详细日志(需 sysadmin 权限) DBCC TRACEON(1222, -1); -- 验证是否生效(返回 1222 表示已启用) DBCC TRACESTATUS(1222);提示:
-1表示全局生效;生产环境建议仅在问题复现窗口期开启,避免日志爆炸。关闭用DBCC TRACEOFF(1222, -1)。
开启后,复现死锁(两个 SSMS 窗口同时运行WHILE 1=1 UPDATE...),再执行:
-- 读取最新 errorlog 中的死锁记录(默认查最近 6 个日志文件) EXEC sp_readerrorlog 0, 1, 'deadlock'; -- 若想查更早的日志,把第一个参数改为 1, 2...(0=当前,1=上一个)你会看到一段 XML 结构的deadlock-list,核心信息藏在resource-list和process-list里。
2.2 解析 resource-list:锁定对象与锁模式的精确映射
摘出关键片段(已脱敏):
<resource-list> <keylock hobtid="72057594066108416" dbid="8" objectname="PerfAnalysis.dbo.tt" indexname="ix_a_bc" id="lock5e66c00" 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" dbid="8" objectname="PerfAnalysis.dbo.tt" indexname="PK__tt__3213E83F10E07F16" id="lock6648e80" 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>这里暴露了死锁的本质:
- 进程
process5e09dc8(SPID 54)持有ix_a_bc上的U(Update)锁,等待PK__tt__...(主键聚集索引)上的U锁; - 进程
process5e27708(SPID 60)持有PK__tt__...上的X(Exclusive)锁,等待ix_a_bc上的X锁。
→ 形成经典的U → X / X → U 循环等待链。注意:U锁是升级为X锁前的中间态,SQL Server 为避免锁升级冲突,会先申请U,再升级。但这里双方都在等对方释放“自己下一步要升级的锁”,于是僵住。
2.3 关联 process-list:定位具体 SQL 与执行上下文
继续看process-list中对应进程的<executionStack>:
<process id="process5e09dc8" ...> <executionStack> <frame procname="adhoc" line="2" stmtstart="24" stmtend="188" sqlhandle="0x02000000a1ec593be12cd271e013ded3a22a397943e8f879"> UPDATE [tt] WITH(rowlock) set [d] = @1 WHERE [a]=@2 </frame> </executionStack> <inputbuf> while 1 =1 update tt with(rowlock) set d='cd' where a='EF211985-EA72-4A40-81DA-0AAB076E7AA3' </inputbuf> </process>这证实了:
- 死锁确实发生在
UPDATE tt SET d=... WHERE a=...这条语句; stmtstart/stmtend指向语句在批处理中的字节偏移,可用于精确定位(配合sys.dm_exec_sql_text);sqlhandle是该语句的唯一哈希,可用于后续查缓存计划(sys.dm_exec_query_plan(sqlhandle))。
参数说明:
hobtid(Heap or B-tree ID)是内部对象标识符,不是object_id或index_id,它指向物理存储单元(partition)。必须通过sys.partitions关联才能知道它对应哪个索引——这是很多人卡住的第一步。
2.4 将 hobtid 映射到真实索引名:三步定位法
-- Step 1: 根据 hobtid 查 partition_id(hobtid 就是 partition_id) SELECT p.partition_id, o.name AS table_name, i.name AS index_name, i.type_desc AS index_type, p.rows AS row_count FROM sys.partitions p JOIN sys.indexes i ON p.object_id = i.object_id AND p.index_id = i.index_id JOIN sys.objects o ON p.object_id = o.object_id WHERE p.partition_id IN (72057594066108416, 72057594065518592, 72057594066173952);执行后你会得到:
| partition_id | table_name | index_name | index_type |
|---|---|---|---|
| 72057594065518592 | tt | PK__tt__3213E83F10E07F16 | CLUSTERED |
| 72057594066108416 | tt | ix_a_bc | NONCLUSTERED |
| 72057594066173952 | tt | ix_b_cd | NONCLUSTERED |
→ 瞬间明白:死锁发生在ix_a_bc(非聚集索引)和主键聚集索引之间,而非两个非聚集索引之间。这直接否定了“索引太多导致混乱”的直觉,把根因锚定在单条 UPDATE 如何触达多个索引的执行路径上。
3. 执行计划才是锁行为的“源代码”:为什么 varchar(max) + INCLUDE 会分裂加锁步骤?
1222 日志告诉你“发生了什么”,但不解释“为什么发生”。要回答“为什么同样的 UPDATE,在改一个字段类型后锁行为剧变”,必须下潜到执行计划层。SQL Server 的锁申请顺序、粒度、数量,完全由执行计划驱动,而非 SQL 文本本身。这就是为什么SET STATISTICS PROFILE ON是死锁分析的必杀技。
3.1 获取并对比三组执行计划
在三个测试场景下分别执行(确保清空计划缓存):
-- 清理缓存(仅测试环境!) DBCC FREEPROCCACHE; -- 场景1:死锁版(d varchar(max), INCLUDE(d) in both NCIs) SET STATISTICS PROFILE ON; GO UPDATE tt WITH(ROWLOCK) SET d = 'cd' WHERE a = 'EF211985-EA72-4A40-81DA-0AAB076E7AA3'; GO SET STATISTICS PROFILE OFF; -- 场景2:无死锁版(d varchar(max), 但 DROP INDEX ix_a_bc & ix_b_cd 后重建,去掉 INCLUDE(d)) -- 场景3:无死锁版(d varchar(200), 保留 INCLUDE(d))观察STATISTICS PROFILE输出的实际行数(Actual Rows)和操作符(PhysicalOp):
| 场景 | 执行计划关键操作符 | Index Update 次数 | 锁申请阶段数 | 是否死锁 |
|---|---|---|---|---|
| 1(varchar(max)+INCLUDE) | Index Update×3(ix_a_bc, PK, ix_b_cd) | 3 | 2(先U锁再X锁) | ✅ |
| 2(varchar(max)-INCLUDE) | Index Update×1(仅 PK) | 1 | 1(U→X 一步到位) | ❌ |
| 3(varchar(200)+INCLUDE) | Index Update×1(含3个 Object) | 1 | 1(合并更新) | ❌ |
重点看
Index Update操作符的Object属性:场景1中它明确列出三个独立索引;场景3中它显示Object: [tt].[ix_a_bc], [tt].[PK__tt__...], [tt].[ix_b_cd]—— 说明 SQL Server 选择了一次性批量更新所有受影响索引,跳过了分步锁。
3.2 深挖根源:LOB 字段如何迫使 SQL Server 拆分更新?
varchar(max)在 SQL Server 中属于LOB(Large Object)数据类型,其存储机制与普通varchar(n)截然不同:
- 普通
varchar(200):数据直接存于数据页(in-row),索引INCLUDE时,值随索引键一起存储; varchar(max):若值 ≤ 8000 字节,可能存于 in-row;但一旦超过或定义为 max,SQL Server 默认将其存于 LOB 数据页(text/image page),主键页只存一个 16 字节指针(text pointer)。
当INCLUDE(d)存在时:
- 更新
d字段,不仅需更新聚集索引(主键页)上的指针,还需更新ix_a_bc和ix_b_cd索引页上的指针副本; - 但 LOB 指针更新涉及跨页操作(主键页 + 两个非聚集索引页),SQL Server 为保证原子性,采用分阶段加锁:
→ 阶段1:对ix_a_bc加U锁,定位到目标行,读取旧指针;
→ 阶段2:对PK加U锁,更新主键页指针,再升级为X;
→ 阶段3:对ix_a_bc和ix_b_cd加X锁,更新各自索引页指针。
而varchar(200)因数据完全 in-row,SQL Server 可以在单次 I/O 中完成所有索引页的修改,自然合并为一步Index Update,锁也一步到位。
3.3 验证:用 sys.dm_exec_query_plan 看计划 XML 细节
-- 获取最近执行的 UPDATE 计划(按 sql_handle 匹配) SELECT t.text AS sql_text, p.query_plan FROM sys.dm_exec_query_stats s CROSS APPLY sys.dm_exec_sql_text(s.sql_handle) t CROSS APPLY sys.dm_exec_query_plan(s.plan_handle) p WHERE t.text LIKE '%UPDATE%tt%SET%d%WHERE%a%';在计划 XML 中搜索<RelOp LogicalOp="Index Update",查看Table和Index属性。场景1会看到三个独立的<RelOp>节点;场景3则是一个<RelOp>节点内嵌三个<Object>。这从引擎层证实了锁行为差异的底层原因。
注意:
SET STATISTICS XML ON输出更全,但PROFILE更轻量,适合快速比对。生产环境若无法开 XML,SET STATISTICS IO ON配合SET STATISTICS TIME ON也能看出逻辑读差异(场景1逻辑读 ≈ 场景2×3)。
4. 避坑指南:那些让 DBA 悔青肠子的“合理配置”如何悄悄埋下死锁雷
死锁不是偶发事故,而是设计决策在高并发下的必然暴露。以下 5 条是某高校实验室在迁移 200+ 个遗留系统时,踩出的高频深坑,每一条都附带“现象→原因→解决”闭环:
4.1 现象:加了WITH(ROWLOCK)却更易死锁
原因:ROWLOCK仅提示引擎“尽量锁行”,但当 SQL Server 判定锁行成本过高(如扫描大量行),仍会升级为页锁或表锁。更危险的是,它强制绕过锁升级优化——原本可能一步X锁的更新,被拆成U→X两步,反而制造等待窗口。
解决:删除WITH(ROWLOCK),让 SQL Server 自主选择最优锁粒度;若必须行锁,改用UPDLOCK+HOLDLOCK组合(见 4.4)。
4.2 现象:INCLUDE字段加了索引,查询快了,更新却崩了
原因:INCLUDE本质是“冗余存储”,字段越大(尤其varchar(max),xml,json),索引页越臃肿,更新时需同步修改的索引越多,锁竞争面指数级扩大。
解决:INCLUDE仅保留高频查询的小字段(如status,created_date);大字段一律移出INCLUDE,改用覆盖索引(Covering Index)+ 主键回表,或应用层缓存。
4.3 现象:varchar(max)字段设了默认值,插入时莫名超时
原因:DEFAULT值对varchar(max)会触发 LOB 初始化,即使值为空字符串,也可能分配 LOB 页并加锁,阻塞其他并发操作。
解决:将varchar(max)默认值设为NULL;业务层保证非空逻辑;或改用varchar(8000)(足够覆盖 99% 场景)。
4.4 现象:用SELECT ... FOR UPDATE模拟悲观锁,结果锁表
原因:SQL Server 无原生FOR UPDATE,常用SELECT ... WITH(UPDLOCK, HOLDLOCK)替代。但HOLDLOCK等价于SERIALIZABLE,会锁住查询范围(如WHERE a > 100锁整个范围),而非单行。
解决:严格限定WHERE条件为唯一键(如WHERE id = @id);或改用sp_getapplock应用层锁,粒度可控。
4.5 现象:分区表上死锁频发,但单表测试一切正常
原因:分区表的hobtid是分区级 ID,sys.partitions中一个表有 N 个分区就有 N 行记录。1222 日志里的hobtid若未关联到具体分区,会误判锁对象。
解决:查sys.partitions时加WHERE p.partition_number = @pnum;或用sys.dm_db_partition_stats辅助定位;分区列务必是高频更新条件的一部分,避免跨分区扫描。
5. 快速定位死锁的四步工作流:从报警到根因,我只留 15 分钟
死锁报警响了,你只有 15 分钟黄金时间——不是写报告,是止血、取证、定位。我给自己定的铁律是:任何死锁,必须在 15 分钟内拿到 1222 日志 + 执行计划 + hobtid 映射表 + 锁序列图。以下是我在某金融系统 SRE 岗位沉淀出的标准化动作:
5.1 第 1 分钟:确认死锁是否仍在发生 & 快速止损
-- Step 1: 查看当前阻塞链(是否有活锁?) SELECT blocking_session_id AS blocker, session_id AS blocked, wait_time, wait_type, last_wait_type, status, command FROM sys.dm_exec_requests WHERE blocking_session_id <> 0; -- Step 2: 若 blocker 是可疑会话(如长时间 sleeping),KILL 它(谨慎!) -- KILL <spid>;目标:确认是瞬时死锁(自动回滚)还是持续阻塞。前者看日志,后者先 Kill 再查。
5.2 第 2–5 分钟:提取 1222 日志并结构化解析
-- Step 1: 读最新死锁日志(带时间戳,避免混淆) EXEC sp_readerrorlog 0, 1, 'deadlock', NULL, GETDATE()-1/24; -- 近1小时 -- Step 2: 提取关键 hobtid(正则匹配,复制粘贴到记事本) -- 搜索 "hobtid=\"(\d+)\"" → 得到数组 [72057594066108416, 72057594065518592] -- Step 3: 一键映射 hobtid 到索引(存为脚本,秒执行) DECLARE @hobtids TABLE(hobtid BIGINT); INSERT @hobtids VALUES (72057594066108416), (72057594065518592); SELECT h.hobtid, o.name AS table_name, i.name AS index_name, i.type_desc, p.rows FROM @hobtids h JOIN sys.partitions p ON h.hobtid = p.partition_id JOIN sys.indexes i ON p.object_id = i.object_id AND p.index_id = i.index_id JOIN sys.objects o ON p.object_id = o.object_id;输出即为锁对象清单,无需人工查表。
5.3 第 6–12 分钟:获取并比对执行计划
-- Step 1: 根据 1222 中的 sqlhandle 获取计划(若 handle 存在) -- 若 handle 不可见(如 ad-hoc 批处理),用文本模糊匹配 SELECT TOP 3 qs.execution_count, qs.total_elapsed_time / qs.execution_count AS avg_duration_ms, qs.total_logical_reads / qs.execution_count AS avg_logical_reads, t.text AS sql_text, qp.query_plan FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp WHERE t.text LIKE '%UPDATE%tt%SET%d%WHERE%a%' AND t.text NOT LIKE '%sys.%' ORDER BY qs.last_execution_time DESC; -- Step 2: 复制 query_plan XML,用 SSMS “显示执行计划” 功能可视化 -- 重点看:Index Update 数量、是否含 Spool/Sort(暗示排序开销大)、警告图标(如“缺少统计信息”)技巧:在 SSMS 中右键执行计划 → “查找” → 输入
Index Update,快速定位操作符。
5.4 第 13–15 分钟:绘制锁序列图 & 给出根因结论
基于前三步数据,手绘或脑内构建锁序列:
| 时间轴 | SPID 54(进程A) | SPID 60(进程B) |
|---|---|---|
| T0 | ix_a_bc: U lock acquired | ix_a_bc: U lock acquired |
| T1 | PK: U lock acquired | PK: U lock acquired |
| T2 | PK: X lock requested → blocked | PK: X lock acquired |
| T3 | ix_a_bc: U lock released | ix_a_bc: X lock requested → blocked |
→ 结论直击要害:死锁由 LOB 字段d强制 SQL Server 分两阶段更新索引引发,根本解法是消除varchar(max)与INCLUDE的组合。
从那以后我每次设计索引,都会在纸上画三遍:
- 这个
INCLUDE字段最大可能多大? - 它会被高频更新吗?
- 如果它变成
varchar(max),执行计划会裂成几块?
——这三问,就是我的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取