SQL Server报错1205死锁与阻塞链的排查方法(blocking_session_id定位、扩展事件死锁图形与1222锁超时)

应用日志里冒出一句"事务(进程ID 63)与另一个进程被死锁在锁资源上,并且已被选作死锁牺牲品。请重新运行该事务。",这是错误1205。另一类常见的是1222"已超过了锁请求超时时段"。两者都跟锁有关,但成因和处置完全不是一回事,混着查会越查越乱。

1205是数据库引擎主动检测到环之后,挑一个事务整体回滚;1222只是等锁等到了客户端设的超时上限,语句被取消而事务不回滚。前者是引擎干的,后者是客户端自己设的,方向不同。

1、现场先抓阻塞链

死锁是瞬时的,等到去看往往已经散了。阻塞不一样,它会一直在那儿挂着。所以先看当前谁堵着谁:

SELECT session_id, blocking_session_id, wait_type, wait_time, wait_resource,
       status, command, sql_handle
FROM sys.dm_exec_requests
WHERE blocking_session_id <> 0;

这里有个新手容易误判的地方:blocking_session_id等于0表示没有被阻塞;等于自身session_id,说明这个会话在等并行查询的各子线程(wait_type通常是CXPACKET或者CXCONSUMER),那不是锁的问题,往MAXDOP和并行度上调。

要顺着链往上找到源头,用sys.dm_os_waiting_tasks更直接,它会把等待方和阻塞方成对列出来:

SELECT wt.session_id, wt.wait_type, wt.wait_duration_ms,
       wt.blocking_session_id, wt.resource_description,
       s.login_name, s.program_name, s.host_name
FROM sys.dm_os_waiting_tasks wt
JOIN sys.dm_exec_sessions s ON wt.session_id = s.session_id
WHERE wt.blocking_session_id IS NOT NULL
ORDER BY wt.wait_duration_ms DESC;

拿到源头session_id之后查它在跑什么:

SELECT TEXT FROM sys.dm_exec_sql_text(0x0300FF7F...);  -- 填 sql_handle
DBCC INPUTBUFFER(<spid>);

sp_who2 'active' 也能看,输出里BlkBy那一列就是阻塞者。信息更全的是Adam Machanic的sp_WhoIsActive,能直接给出等待类型和阻塞链层级,装一次能用很久。

2、弄确切锁在哪一行

resource_description里经常出现"KEY: 6:72057594043431424 (8194443284a0)"这种,光看数字没意义。用sys.dm_tran_locks反查对象名:

SELECT l.request_session_id, l.resource_type, l.request_mode, l.request_status,
       OBJECT_NAME(p.object_id) AS tbl, i.name AS idx, l.resource_description
FROM sys.dm_tran_locks l
LEFT JOIN sys.partitions p ON l.resource_associated_entity_id = p.hobt_id
LEFT JOIN sys.indexes i ON i.object_id = p.object_id AND i.index_id = p.index_id
WHERE l.request_status = 'WAIT';

要精确到行,用%%lockres%%这个函数,把括号里的哈希值直接代进去:

SELECT %%lockres%% AS lockres, *
FROM dbo.Orders
WHERE %%lockres%% = '(8194443284a0)';

实测这个查询会走全表扫描(哈希值没法走索引),大表上别在业务高峰期跑。

3、死锁信息去哪里捞

死锁发生完就消失了,得靠事前开着的东西。SQL Server默认有一个system_health扩展事件会话,死锁图形会写进去,一般不用额外配置:

SELECT xevent.value('(event/@timestamp)[1]', 'datetime2') AS ts,
       CAST(xevent.query('(event/data/value/deadlock)[1]') AS XML) AS deadlock_graph
FROM (
  SELECT CAST(event_data AS XML) AS xevent
  FROM sys.fn_xe_file_target_read_file('system_health*.xel', NULL, NULL, NULL)
) t
WHERE xevent.value('(event/@name)[1]', 'varchar(50)') = 'xml_deadlock_report'
ORDER BY ts DESC;

查出来是一段XML,粘到SSMS里会自动渲染成死锁图形。老一点的办法是开跟踪标志1222或者1204,把死锁信息写进error log,不用重启实例:

DBCC TRACEON(1222, -1);   -- -1表示全局
DBCC TRACESTATUS(-1);

1222输出的信息比1204全,但日志量涨得快,之前见过一台机器开着1222,error log一周涨到几个GB。抓完记得用DBCC TRACEOFF关掉。

4、读死锁图形看什么

图形打开之后,先找打叉的那个process,那就是被选中的牺牲品。然后看resource-list里每个锁,重点关注这几项:

lock类型:KEY是行锁(走索引)、RID是堆表的行锁、PAGE和OBJECT范围更大。如果看到OBJECT级别的X锁,说明发生了锁升级,一个事务持有的锁超过约5000个时SQL Server会把行锁升成表锁,这时候并发基本就废了。

owner-list与waiter-list:谁持有什么mode(S/U/X/IX),谁在等什么。最常见的形状是两边各持有一个X锁,同时又要对方手上的资源,也就是两个事务按相反的顺序改了同一批表。

process里的executionStack会给出具体语句和isolationlevel。隔离级别是可重复读或者可序列化时,S锁要一直持有到事务结束,死锁概率比默认的读已提交高得多。

还有一种不太直观的:同一条SQL,一条走了索引A、另一条走了索引B,两个执行计划访问同一个数据页的顺序不一样,也会死锁。这种在图形里看到同一个objname有两个不同indexname,基本就能确认。

5、用DEADLOCK_PRIORITY指定牺牲品

SET DEADLOCK_PRIORITY LOW;   -- 或者 -10 到 10 之间的整数
-- 跑完批处理后记得改回
SET DEADLOCK_PRIORITY NORMAL;

给后台批处理、报表类的会话设成LOW,出死锁时让它们先牺牲,核心交易链路不受影响。注意这个设置是会话级的,连接池会复用连接,处理不当会把LOW带到别的请求上,所以要么用完显式设回NORMAL,要么干脆给批处理单独配一个连接串。

6、1222锁超时的处理方式

死锁只能靠改访问顺序根治,锁超时则是可以在客户端兜一下的:

SELECT @@LOCK_TIMEOUT;  -- 默认 -1,表示无限等
SET LOCK_TIMEOUT 5000;  -- 5 秒

超时之后只有那条语句被取消,事务还在。应用层必须捕获1222这个错误号,自己决定是重试还是整体回滚,否则后面的语句会在一个半截的事务上继续跑,数据错得更隐蔽。

7、真正能减少死锁的四种改法

统一访问顺序。同一批表在所有事务里按固定顺序更新,这条最有效,也最难在存量系统里落地,通常只能挑最热的几张表先做。

缩短事务。事务里不要调外部接口、不要等用户输入、不要先查询再大段计算再更新。之前接手的一个系统,死锁全都来自一个把HTTP调用包在事务里的下单接口,把调用挪到事务外就再没出现过。

补索引。UPDATE和DELETE找不到合适索引时会扫全表,锁的范围从行扩大到整张表,死锁概率成倍上升。sys.dm_db_missing_index_details能给出候选,别照单全收,挑命中次数高的加。

开行版本。读已提交快照隔离下,读操作不再加S锁,读写互相不阻塞:

ALTER DATABASE YourDb SET READ_COMMITTED_SNAPSHOT ON;

这个开关从SQL Server 2005就有,代价是tempdb的用量会明显上升(行版本存在tempdb里),开之前先看一眼tempdb的配置和文件数。执行时要保证没有别的事务在连这个库,不然会一直等下去。snapshot隔离是另一个开关ALLOW_SNAPSHOT_ISOLATION,需要改代码显式开启事务才生效。

8、两个容易看错的点

wait_type是PAGELATCH_*或者PAGEIOLATCH_*时,不要按锁的思路处理。前者是内存里数据页的闩锁争用,常见于自增主键的热点页和tempdb的分配页,加索引解决不了,要么改主键类型,要么按分区打散。后者是等磁盘IO,属于存储层的问题。

KILL要谨慎。KILL <spid>会触发回滚,大事务回滚可能持续很久,而且回滚期间阻塞还在。KILL <spid> WITH STATUSONLY可以查回滚进度,但只对正在回滚的会话有效。动手前先用DBCC INPUTBUFFER确认那个会话到底在干什么。

数据截至2026年10月。SQL Server各版本的RTM内部版本号为17.0.1000.7、16.0.1000.6、15.0.2000.5、14.0.1000.169,对应的发布年份分别是2025年、2022年、2019年、2017年;当前常见的累积更新构建号如16.0.4265.3、15.0.4430.1、14.0.3456.2,行为上没有区别。