简介:一份面向 SQL Server 数据库管理员与开发者的死锁分析案例资料,围绕 Deadlock 标签系统讲解了一个由索引和锁机制引发的奇怪死锁。文档从问题重现步骤入手,先给出创建含聚集索引与非聚集索引的测试表、插入10000条记录、并发执行update操作等具体场景,再逐步分析non-clustered index中包含include(d)或使用varchar(max)字段时行锁升级和锁冲突的变化,直击死锁难以复现的要点。文档还对比了删除include选项或缩短字段类型后死锁消失的实验结果,能加深对不同索引设计影响锁行为的理解。分析方法上,重点介绍了开启1222跟踪标志、使用SQL Trace按SPID过滤Locks与TSQL事件、通过sp_readerrorlog读取死锁链等典型手段,可帮助读者掌握从现象到根因的排查思路。资源为单个docx文档,压缩包约694KB,内容为完整技术分析文章,结构清晰便于阅读保存;已有326人学习下载,对希望深入理解索引锁与死锁关系的DBA和开发人员尤具实用参考价值。

1. 一个锁等待,为何会让业务全部卡死

先说结论:SQL Server 上的死锁(Deadlock)不一定是两个连接互相等锁这么简单。真正让人头疼的,是那种“看起来完全没有交集”的会话,却在同一个时间点互相阻塞,最后被锁监视器(Lock Monitor)随机杀掉一个受害者。这个问题几乎每个 DBA 都遇到过,但很多人拿到死锁图之后,看半天也看不出所以然,最后只能重启服务或清缓存,过几天又复现。文章要讲的,就是一套从捕获到定位再到验证的分析方法,能把“奇怪的 Deadlock”变成一个可解释、可复现、可修复的具体问题,适合开发、运维以及所有被“锁”折磨过的人。

我在生产环境处理过不少锁等待案例,印象最深的是某次半夜告警,所有业务库的连接数瞬间打满,SQL Server 错误日志里刷出一大片死锁记录。用户端的现象是:查询卡住、超时、偶尔报错。但打开死锁图之后,发现参与死锁的两个会话分别访问的是两张完全不同的表,谁也不挨着谁。这种案例,用常规的“看两个资源互相占有”的思路根本解释不通。后来我花了两个晚上把 Extended Events 的捕获数据完整拉下来,配合执行计划、索引使用情况和应用端的调用链,才定位到问题的真正根源——一个被忽略的外键锁升级。这篇文章会把这种“非典型死锁”的完整分析路径拆开讲,每一步都带着可复现的操作命令和参数说明,力争让你下次遇到类似问题时,不用再靠玄学去猜。

2. 从“杀掉一个”到“定位根因”:死锁分析的基础设施

2.1 死锁不是 bug,是锁调度的一种极端结果

在 SQL Server 里,死锁的本质是“两个或多个会话各自持有对方需要的资源,并且都不释放,导致无法继续推进”。锁监视器(Lock Monitor)会周期性检测这种循环等待,一旦发现,就选择一个代价最小的会话作为牺牲者(Victim),回滚它的整个事务,并释放所有锁,让其他会话继续运行。整个过程通常只需要几秒钟,但受害者的事务会被回滚,应用端就会看到类似 1205 的错误码。

这听起来像一个标准机制,但为什么有些死锁让人觉得“奇怪”呢?因为默认的死锁报告只展示“锁资源、锁模式、会话状态”这些内核数据,它根本不关心你业务上是怎么调用的。于是经常出现一个场景:死锁图里明明只有两条 SQL,但它们对应的存储过程、触发器、外键约束、索引维护任务,甚至服务层的 ORM 缓存策略,全都不会出现在报告里。你看到的只是冰山一角,真正导致死锁的是水面下面的那些行为。

另一个关键点是:死锁图会优先展示“进程持有锁”和“进程请求锁”的直接关系,但对于部分特殊场景,比如同一事务内多次获取同一范围内的锁、键范围锁(Key-Range Lock)、自旋锁(Spinlock)、甚至外部代码里长时间占有连接导致的事务超长,死锁报告只会给一个非常简化的结果。要想把问题看清楚,必须把捕获层建好,让数据“说话”的粒度足够细。这也是整篇文章反复强调的一件事: 没有捕捉能力,就不要谈死锁分析 。

2.2 建一个“黑匣子”:用 Extended Events 捕获死锁会话

SQL Server 2008 以上版本自带 Extended Events(扩展事件),这是捕获死锁会话的首选工具。它比旧的 SQL Profiler 更轻量,对生产环境性能的影响更小,而且能精确记录死锁发生时的 XML 死锁图(Deadlock Graph)。常见的做法是建一个专门收集死锁的会话,放到系统里长期运行,相当于给数据库加了一个黑匣子。

下面的脚本可以创建一个名为“DeadlockMonitor”的扩展事件会话,捕获所有死锁事件,并把数据写到本地文件里:

-- 创建一个扩展事件会话,用于捕获死锁图
CREATE EVENT SESSION [DeadlockMonitor]
ON SERVER
ADD EVENT sqlserver.xml_deadlock_report(
    ACTION (sqlserver.database_name, sqlserver.session_id, sqlserver.sql_text, sqlserver.tsql_stack, sqlserver.client_app_name)
),
ADD EVENT sqlserver.lock_deadlock(
    ACTION (sqlserver.database_name, sqlserver.session_id, sqlserver.sql_text, sqlserver.tsql_stack, sqlserver.client_app_name)
)
ADD TARGET package0.event_file(
    SET filename = N'D:\XE_Deadlock\DeadlockMonitor.xel',
        max_file_size = 50,          -- 单个文件最大 50 MB
        max_rollover_files = 10,     -- 最多保留 10 个文件,超出自动轮转
        metadatafile = N'D:\XE_Deadlock\DeadlockMonitor.xem'
)
WITH (
    MAX_MEMORY = 4 MB,
    EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS,
    MAX_DISPATCH_LATENCY = 30 SECONDS,
    MEMORY_PARTITION_MODE = NONE,
    STARTUP_STATE = ON          -- 设为开机自动启动,防止漏捕获
);
GO
-- 启动会话
ALTER EVENT SESSION [DeadlockMonitor] ON SERVER STATE = START;
GO

脚本的逻辑分三层:先定义“要监听什么事件”,再定义“事件发生时额外抓取哪些上下文”,最后定义“把数据写到哪”。这里我加了 sqlserver.lock_deadlock 和 sqlserver.xml_deadlock_report 两个事件,前者在发生死锁时第一时间触发,后者会把完整的 XML 死锁图写下来。注意 STARTUP_STATE = ON 这个参数,如果忘了开,SQL Server 服务重启后会话不会自动拉起来,死锁就漏掉了。

写文件的路径也需要先建好,SQL Server 服务账户必须有对应目录的写权限。 max_file_size 和 max_rollover_files 用于控制磁盘占用,50 MB 一个、轮转 10 个,对绝大多数环境足够。如果生产环境的死锁频率特别高,可以调大 MAX_DISPATCH_LATENCY (分发延迟)来减少磁盘写频率,但也要承担“临时丢失事件”的风险。

2.3 死锁图怎么读:从 XML 里提取会话、资源和调用栈

死锁图是一个 XML 结构,里面包含了 <deadlock> 根节点、 <victim-list> 受害者列表、 <process-list> 进程列表和 <resource-list> 资源列表。传统做法是用 SSMS 直接打开 .xel 文件,在图形化界面里看到两个进程节点互连的箭头图。但生产环境里真正有用的,是从 XML 里把关键字段提取成表格,然后用时间线去对比应用日志。

下面这段脚本从扩展事件文件中读取死锁图并解析出每个进程的关键信息:

-- 读取扩展事件文件,并解析死锁图的进程列表
SELECT
    n.value('(//process/@id)[1]', 'varchar(20)') AS ProcessId,
    n.value('(//process/@spid)[1]', 'int') AS SPID,
    n.value('(//process/@waitresource)[1]', 'varchar(200)') AS WaitResource,
    n.value('(//process/@lockmode)[1]', 'varchar(50)') AS LockMode,
    n.value('(//process/executionStack/frame/@sqlhandle)[1]', 'varchar(200)') AS SqlHandle
FROM
(
    SELECT CAST(event_data AS XML) AS event_data
    FROM sys.fn_xe_file_target_read_file(
        N'D:\XE_Deadlock\DeadlockMonitor*.xel',
        NULL, NULL, NULL
    )
) AS raw_data
CROSS APPLY raw_data.event_data.nodes('//deadlock/process-list') AS p(n);

sys.fn_xe_file_target_read_file 可以按通配符读取所有轮转出来的 .xel 文件, nodes() 函数把 XML 拆成行集,然后取每个进程节点的 ID、SPID、等待资源和锁模式。实际排查时,我会重点看 WaitResource 列,它能直接告诉你这个会话正在等哪把锁,格式通常是 KEY: 数据库ID: schema_id: object_id: index_id: hash_value 。拿到这些参数后,再用 dm_exec_sql_text 去反查当时的 SQL 文本,基本就能定位到是哪条语句参与了死锁。

死锁图里最容易忽略的是 executionStack 节点,它记录了进程当前的调用栈。如果死锁来自存储过程嵌套,或者链式触发器,调用栈里的 sqlhandle 能帮你还原整条执行链路。经验不足的时候,我只看 SPID 和资源,结果把责任推给了错误的 SQL,后来才意识到: 死锁图里的进程,往往只是链条上最后被卡住的那个环节 。

3. 那个“奇怪”的死锁:一场由外键和索引“合力”造成的意外

3.1 两个毫不相关的表,为何会互相锁

回到开头提到的那个案例:死锁图里,会话 A 在更新订单表(Order)的一行,会话 B 在更新客户表(Customer)的一行,两个表只有业务逻辑上的关联,没有直接的 SQL 互相引用。按常理说,不同表之间的更新操作根本不会产生锁竞争,但死锁确实发生了。

真正的凶手是外键约束。订单表上有一个 CustomerId 外键指向客户表,而应用代码在新增订单时,会先更新客户表的某列(比如累计积分),再插入订单表。从数据库视角看,这两个操作在一个事务里,顺序是:先持有客户表行的排他锁,再尝试在订单表上插入新行。问题是,SQL Server 在插入订单表时需要检查外键约束,这时它要在客户表的对应客户行上加一个共享锁(S 锁)来验证父记录存在。如果此时另一个事务正好持有该客户行的排他锁(X 锁),并反过来需要订单表上的锁,就形成了一个典型的“等待循环”。

这就是“奇怪”的来源:两个事务表面上操作的是不同表,但因为外键约束的存在,SQL Server 在内部额外请求了另一张表上的锁。这种锁不在应用代码的预期范围内,所以死锁图上看不太出来。要想确认外键是不是元凶,可以查系统视图里有多少外键没有配套索引,因为 外键列上缺少索引,会让“检查父表存在性”这种操作变成全表扫描,导致锁范围和锁持有时间被放大 ,死锁概率直接上一个台阶。

3.2 锁模式与锁升级:为什么“S 和 X”会打架

分析这个案例时,还需要把锁模式(Lock Mode)弄清楚。SQL Server 的锁分为共享锁(S)、排他锁(X)、更新锁(U)、意向锁(IX 等),死锁图里最常见的组合是 S 锁和 X 锁互等。但还有一种容易被忽略的情况:同一张表上,更新锁(U)与排他锁(IX)在特定隔离级别下也会形成等待。比如在 READ COMMITTED 隔离级别下,UPDATE 先读取目标行,加的是 U 锁,然后再更新为 X 锁;如果两个事务同时对同一行执行“先读后写”的操作,就会互相持有 U 锁并等待对方的 X 锁释放。

下表的对比能帮你快速判断死锁图里的锁模式组合是否合理:

锁模式 兼容性(同粒度) 常见来源 典型死锁场景
S(共享锁) 与 S、U 兼容,与 X 不兼容 SELECT 默认读 读操作与写操作互等
U(更新锁) 与 S 兼容,与 U、X 不兼容 UPDATE 的前置读取 两个 UPDATE 互相等待
X(排他锁) 与任何锁都不兼容 INSERT/UPDATE/DELETE 写入竞争
IX(意向排他) 与 S 兼容,与 X 不兼容 较高粒度的写操作 外键检查触发 S 锁
键范围锁(RR) 与 S/U 部分兼容 可重复读隔离级别 间隙锁互锁

在死锁图里看到“S 锁与 X 锁冲突”时,不能只针对 SQL 本身去优化,而要往上层找:这条 SELECT 是在哪个事务里?是不是被 ORM 自动包到了一个更大的事务里?或者是触发器或外键隐式引入了额外的锁请求?这些信息在死锁图里不一定全,但至少能引导你去查调用链。

3.3 定位方法:锁定索引、确认执行计划和隔离级别

遇到死锁,我一般按这个顺序操作:

先查死锁里每个进程的 WaitResource , 解析出 object_id 和 index_id ,然后去确认这个索引的外键关联情况。命令很简单:

-- 根据死锁图中的 object_id 和 index_id 反查表与索引信息
SELECT
    OBJECT_NAME(i.object_id) AS TableName,
    i.name AS IndexName,
    i.index_id,
    i.type_desc,
    c.name AS ColumnName,
    ic.key_ordinal
FROM sys.indexes i
JOIN sys.index_columns ic
    ON i.object_id = ic.object_id AND i.index_id = ic.index_id
JOIN sys.columns c
    ON ic.object_id = c.object_id AND ic.column_id = c.column_id
WHERE i.object_id = OBJECT_ID(N'Order')   -- 替换为死锁图里的表名
  AND i.index_id = 5                       -- 替换为死锁图里的 index_id
ORDER BY ic.key_ordinal;

如果查出来外键列上没有索引,或者返回的 key_ordinal 里根本没有外键列,那基本就能确认问题所在: 外键列缺索引,导致子表插入时父表的查找效率极差,锁持有时间拉长 。此时解决方案有两个层面:逻辑层要调整事务内语句顺序,物理层给外键列补一个索引。多数场景下,补索引是最快、最稳的方法,它能让外键检查直接走窄索引的 seek,而不是扫父表的主键或堆,锁的粒度和时间都会明显下降。

隔离级别也是必查项。如果应用连接串里把隔离级别设置成了 SNAPSHOT 或 SERIALIZABLE ,死锁的触发条件会和默认的 READ COMMITTED 完全不同。 SERIALIZABLE 会引入范围锁,对范围查询造成大范围阻塞; SNAPSHOT 虽然以读不阻塞写为目标,但它也有自身的更新冲突机制,报告的表现形式是“更新冲突”而不是经典死锁。排查死锁前,先确认所有相关连接的隔离级别,这是避免误判的基本功。

4. 用系统视图和脚本“复刻”现场:把死锁从玄学变成可解释数据

4.1 收集死锁的历史记录:sys.dm_exec_requests 与日志

扩展事件负责实时捕获,但要分析“这次死锁”是否曾经发生过,就得翻历史记录。SQL Server 的系统视图 sys.dm_exec_requests 只能看到当前正在执行的请求,对历史死锁无能为力,但错误日志里其实会留下死锁的记录(取决于错误日志级别设置)。另外,SQL Server 默认会在 sys.dm_tran_locks 里保留当前锁的信息,虽然不能在死锁发生瞬间抓到现场,但可以结合阻塞监控脚本,记录“即将发生死锁”前的锁分布。

一个常见的做法是建一个带作业的监控表,每小时采集一次锁的数量和等待情况。下面是一个简单的抢占脚本,它的作用是找出当前有哪些会话在等待锁资源,并输出等待数量最多的前 10 条:

-- 收集当前的锁等待情况,按等待资源聚合
SELECT
    request_session_id AS SessionID,
    resource_type AS ResourceType,
    resource_database_id AS DBID,
    resource_description AS ResourceDesc,
    request_mode AS LockMode,
    request_status AS LockStatus,
    COUNT(*) AS WaitCount
FROM sys.dm_tran_locks
WHERE request_status = 'WAIT'
GROUP BY
    request_session_id,
    resource_type,
    resource_database_id,
    resource_description,
    request_mode,
    request_status
ORDER BY WaitCount DESC;

这个查询的意义在于,它能让你在死锁发生前捕捉到锁等待的“前兆”。如果某个资源长期处于 WAIT 状态,往往说明两个业务之间有锁竞争,死锁只是这种竞争在某个时间点的爆发。生产环境里,我一般会把这种监控脚本放到 SQL Agent 作业里,每 5 分钟采集一次,存到单独的监控库里,事后可以用来回放整个死锁形成的过程。注意别用太长的时间间隔,否则抓不到 10 秒级的死锁瞬间。

4.2 编写一个“死锁前侦察”存储过程,自动记录会话状态

只靠系统视图,在死锁发生后拿不到当时每个会话都在执行什么。所以更成熟的做法是写一个存储过程,死锁告警触发时,自动把 sys.dm_exec_requests 、 sys.dm_exec_sql_text 、 sys.dm_exec_plan_attributes 这些数据全部落库。这也是我踩过坑之后总结出的习惯: 没有上下文数据,死锁图就是半张地图 。

下面是一个侦察存储过程的骨架:

-- 创建一个存储过程,用于死锁发生时快速记录现场信息
CREATE PROCEDURE dbo.usp_DumpLockActivity
AS
BEGIN
    SET NOCOUNT ON;

    -- 把当前所有请求的文本、计划、锁信息写入一张表
    INSERT INTO dbo.DeadlockDump (
        CaptureTime,
        SPID,
        SqlText,
        LockMode,
        WaitResource,
        IsVictim,
        QueryPlanXML
    )
    SELECT
        GETDATE(),
        r.session_id,
        t.text,
        l.request_mode,
        l.resource_description,
        0,    -- 实际标记受害者需要额外判断,这里先用 0
        CAST(p.query_plan AS XML)
    FROM sys.dm_exec_requests r
    JOIN sys.dm_exec_connections c
        ON r.session_id = c.session_id
    CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
    LEFT JOIN sys.dm_exec_query_plan(r.plan_handle) p
        ON 1 = 1
    LEFT JOIN sys.dm_tran_locks l
        ON r.session_id = l.request_session_id
    WHERE l.request_status = 'WAIT'
       OR r.session_id IN (SELECT session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0);
END;
GO

这个存储过程的核心是 CROSS APPLY ,它从 SQL Handle 反查出原始 SQL 文本,再从 Plan Handle 拿到执行计划 XML,最终把所有正在等待的会话和它们要执行的语句关联出来。实际使用时,可以在死锁告警邮件触发的地方调用它,比如 SQL Agent 作业或监控工具的执行动作里。

需要注意的是,存储过程本身执行需要时间,而死锁过程中的会话很快会被杀掉,所以有可能抓不到受害者的完整语句。为了解决这个问题,我更愿意配合扩展事件里的 sql_text action 一起记录,两边数据交叉对比,才能把死锁链完整还原出来。不要觉得重复,生产环境的死锁现场,重复数据永远比缺数据好一百倍。

4.3 死锁复现:用并发脚本稳定重现问题

要验证一个死锁是不是由特定 SQL 引起的,最好的办法不是直接改生产代码,而是用一个精简的并发脚本去复现。SQL Server 没有专门“制造死锁”的工具,但我们可以用两个会话 + 延时 + 锁提示,人为制造出一个可重复的比赛条件。

下面是我常用的“最小死锁复现法”,两个会话分别执行相反的加锁顺序:

-- 会话 1:先锁 Order 表 A 行,再锁 Customer 表 B 行
BEGIN TRAN;
UPDATE dbo.[Order] SET CreateTime = GETDATE() WHERE OrderId = 1;
WAITFOR DELAY '00:00:05';
UPDATE dbo.Customer SET Score = Score + 1 WHERE CustomerId = 2;
COMMIT;
GO

-- 会话 2:先锁 Customer 表 B 行,再锁 Order 表 A 行
BEGIN TRAN;
UPDATE dbo.Customer SET Score = Score + 1 WHERE CustomerId = 2;
WAITFOR DELAY '00:00:05';
UPDATE dbo.[Order] SET CreateTime = GETDATE() WHERE OrderId = 1;
COMMIT;
GO

两个会话会互相等对方的锁,5 秒后锁监视器触发,其中一个被牺牲。这个脚本本身很简单,但它能验证一个现象: 如果两个会话的加锁顺序一致,就不会死锁;交错加锁就会出现比赛条件 。当你在生产环境里没法直接改业务逻辑时,用这种方式测试不同顺序对死锁的影响,是很高效的验证手段。注意 WAITFOR DELAY 不要太长,否则锁监视器判断的时间窗口会被拉长,不容易复现;也不要太短,否则两个 UPDATE 可能恰好顺序执行完,不会形成等待。

通过这种复现,你可以确认死锁是否真的由“加锁顺序”导致。如果复现不出来,但生产环境告警还在继续,说明死锁的原因比表面看更复杂,可能需要回到第 3 章的锁模式分析,去考虑外键、触发器、索引缺失等因素。

5. 死锁分析避坑指南:这些常见的“坑”可能让你白忙一场

5.1 死锁图看不懂?先排除“假死锁”

现象:打开死锁图,发现两个进程的 WaitResource 都指向同一个资源,但看不出任何循环等待关系,杀死任何一个会话都无法解决。

原因:有些“死锁”实际上根本不是锁冲突,而是外部资源竞争或自旋锁(Spinlock)导致会话长期无法推进,被锁监视器误判为死锁。比如大内存页面交换、IT 部门运维任务抢占 CPU、甚至是文件 I/O 太慢导致事务进展受阻。死锁图只是把当时的等待关系记录下来,它不会告诉你“为什么会这样等”。

解决:遇到这种情况,需要把 sys.dm_os_wait_stats 拉出来看会话的等待类型。如果 WRITELOG 或 PAGEIOLATCH_XX 的累计等待时间特别长,那问题就出在磁盘子系统,而不是锁调度。死锁分析要跳出“锁”本身,去看整个等待链。

5.2 受害者选错:SQL Server 选“最便宜”的,不代表最合适

现象:死锁告警里,被回滚的会话往往不是占用资源最多、也不是业务最关键的会话,而是那个“代价最小”的会话。应用端经常抱怨:明明我只是执行了一个很小的查询,为什么把我回滚了?

原因:锁监视器计算死锁代价时会参考 sys.dm_exec_requests 里的 estimated_completion_time 、事务日志写入量、锁数量等因素,选择代价最低的会话作为牺牲者。它不关心这个会话是不是核心业务。

解决:如果需要让“更重要的”会话不被牺牲,可以在事务里设置 SET DEADLOCK_PRIORITY LOW (降低代价)或 SET DEADLOCK_PRIORITY HIGH (提高代价)。这个参数只能在事务会话里生效,要在事务开始之前设置。实际应用中,可以把核心写操作调到 HIGH,把后台批处理调到 LOW,形成明确的牺牲次序。

5.3 补了索引,死锁还在?可能是索引方向写反了

现象:给外键列补了索引,死锁频率不降反升。

原因:索引不是越宽越好。死锁场景里,索引的键顺序和查询的过滤条件顺序必须匹配。比如外键列是 CustomerId ,但你的查询条件是 Status + CustomerId 的组合条件,你顺手建了 (CustomerId, Status) 的索引,实际查询要走 Status 开头才能命中,这个索引对死锁分析来说就失效了。更麻烦的是,窄索引没有覆盖查询需要的其他列,SQL Server 需要回表,引入了额外锁请求。

解决:建索引前,先看死锁图里 WaitResource 指向的具体索引 ID,确认 SQL Server 实际使用的是哪个索引。然后对比执行计划里 Index Seek 还是 Index Scan ,如果还是扫描,就要调整索引的键顺序,或者考虑加 INCLUDE 列,让它成为一个完全覆盖的索引,减少不必要的锁持有。

5.4 隔离级别是隐藏变量:READ_COMMITTED_SNAPSHOT 和死锁的纠葛

现象:应用降级部署后,死锁突然消失,一段时间后又回来。代码没有改动,SQL 也没有改动。

原因:数据库的隔离级别不是单一属性。如果数据库开启了 READ_COMMITTED_SNAPSHOT (RCSI),普通的读语句不会持有 S 锁,死锁率会大幅下降;但写语句之间的冲突依然存在。一旦 DBA 因为别的原因关掉了 RCSI,所有读操作回归传统 READ COMMITTED 行为,S 锁死锁又回来了。这种“时有时无”的死锁最容易被误判。

解决:检查数据库属性里 is_read_committed_snapshot_on 值。如果是 1,说明读操作走快照隔离;如果是 0,常规读会加 S 锁。另外,即使 RSCI 开着,可重复读和序列化级别的显式事务仍会使用传统锁机制,所以还要检查应用代码里有没有显式的 SET TRANSACTION ISOLATION LEVEL 语句。

5.5 长期运行的“拍照”脚本,本身会引入新的锁

现象:加了死锁监控会话后,系统反而出现新的死锁,且死锁图里能看到监控脚本自己的会话 ID。

原因:扩展事件的 sql_text action、 session_id action 本身也会有轻量锁操作;如果你再用 sys.dm_exec_requests 频繁采样,也会对系统视图造成额外负载。极少数场景下,监控会话自己会成为死锁的一部分。

解决:扩展事件本身我没遇到过问题,但高频轮询系统视图确实容易踩坑。把监控作业的频率降到 30 秒以上,避免 sys.dm_exec_requests 这类视图与业务 SQL 产生干扰。死锁图里如果看到监控会话,直接通过 client_app_name 过滤掉即可,不要被它带偏。

6. 从分析到预防:让死锁成为可控指标而不是黑匣子

6.1 用锁等待阈值代替死锁次数告警

死锁次数是一条滞后指标,等它触发告警时,业务已经受影响。更好的做法是把 lock_wait_time (锁等待时间)这个动态指标作为预警。SQL Server 的性能计数器里有 Lock Wait Time (ms) 和 Lock Waits/sec ,通过 sys.dm_os_performance_counters 可以查到当前值:

-- 查询锁等待时间相关的性能计数器
SELECT
    counter_name,
    cntr_value,
    cntr_type
FROM sys.dm_os_performance_counters
WHERE counter_name LIKE 'Lock Wait Time%'
   OR counter_name LIKE 'Lock Waits/sec%';

性能计数器是累积值,用法是两次采样做差再除以采样间隔。如果单次锁等待时间超过 1000 毫秒,或者锁等待速率持续上升,就说明系统的资源竞争已经逼近死锁临界点,这时候介入要比等死锁发生再清理更从容。配合前面建好的 Extended Events 会话,相当于给数据库装了一个“提前哨兵”。

6.2 一个我自己的复盘习惯:死锁周报与代码走查

每次处理完一个死锁案例,我会把死锁图、相关 SQL、索引情况、隔离级别按时间归档,形成一份“死锁案例分析表”。表格的列很简单:时间、数据库、SPID、资源描述、锁模式、根因分类(索引缺失/事务过长/加锁顺序/外键)、修复方案。积累几十条之后,你会发现多数死锁的根因高度重复,来来回回就是那几类。

这种做法还有一个隐藏价值:做代码走查时,把历史上出现过的死锁资源作为关注点,新需求只要碰到这些表,就要特别注意事务边界和加锁顺序。我现在的习惯是, 任何新上线的写操作,先在测试环境用并发脚本模拟 50 个线程同时执行 10 分钟,再放生产 。死锁分析和预防不是一次性工作,而是一条持续反馈链。希望这套方法能帮到你,让你下次面对死锁时,不用靠“重启大法”来续命。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐