做数据库的人,谁没被死锁坑过几次?应用卡死、事务回滚、用户电话打过来质问,排查一圈发现日志里躺着一个死锁号,那种心情我是深有体会。今天不聊玄乎的理论,直接讲清楚“查看SQL数据库死锁的情况”到底怎么查、怎么看、怎么从死锁图里把两个互相抢资源的会话揪出来。
这篇内容适合对数据库事务和锁有一定基础、却对死锁排查无从下手的开发者,也适合运维同学把常用的几个查看手段串起来。我会以 SQL Server 为例,顺带说明 MySQL、PostgreSQL 里的对照思路。所有SQL语句、扩展事件会话、跟踪标志都直接给出,方便你在自己的环境里复现,出了问题也知道从哪里下手。
1. 先把死锁这个事说清楚
1.1 死锁产生的四个条件:没有这四个条件就不会死锁
死锁不是“锁坏了”,而是多个事务在并发执行时,互相持有对方需要的资源,并且彼此都不释放,导致谁都无法继续推进。业界一般归纳为四个必要条件:
- 互斥:一个资源同一时刻只能被一个事务独占。
- 持有并等待:一个事务已经持有一个资源,同时还在等待另一个被其他事务持有的资源。
- 不可剥夺:事务持有的资源,不能被其他事务强行抢走,只能由持有者主动释放。
- 循环等待:两个或多个事务之间,形成一条“你等我、我等你”的闭环。
比如事务A先锁了表X的行1,去等表Y的行2;事务B先锁了表Y的行2,去等表X的行1。双方都持有对方要的资源,又都不撒手,死锁就成立了。
这里有个关键认知:SQL Server 的锁管理器每隔几毫秒会用回溯检测来发现这种循环等待,发现后主动挑一个“受害者”回滚,然后抛错误1205。所以严格来说,SQL Server里绝大多数死锁都会被自动解决,但应用里如果没做重试或补偿逻辑,用户看到的报错就是“事务(进程 ID)与另一个进程已被死锁资源上通信”。我们做排查,不只是为了消除报错,更是为了找到产生死锁的访问路径,从根本上减少无谓的锁争用。
1.2 为什么“查看死锁”比“直接修改代码”更优先
很多人的第一反应是“反正错误信息里有进程ID,我查一下执行计划改改索引”。但实际经验告诉我,没有看清死锁图就直接改代码,十有八九会改错方向。
死锁图里藏着大量信息:两个事务各持有哪些资源、等待什么资源、锁的模式是共享还是排他、对应SQL语句是什么、有没有走索引、隔离级别是否升级成了范围锁。只有把这些都看懂,你才知道死锁根源是访问顺序问题、索引缺失导致的锁范围过大,还是隔离级别引发锁升级。
所以“查看”本身就是排查的第一道工序。与其凭直觉猜,不如先把死锁现场完整记录下来,再慢慢分析。这也是为什么接下来讲的所有方法,核心目标都是“把死锁现场还原出来”。
2. 看死锁的几种常规路径
2.1 用系统视图查当前阻塞关系:最快看到“谁卡住谁”
死锁发生的那一瞬间很快,几十毫秒就没了。但如果是长时间阻塞,比如一个事务持有锁不提交,后面一堆请求都在排队,这种“接近死锁”的阻塞状态,可以用系统视图直观看到。
最常用的组合是sys.dm_exec_requests、sys.dm_exec_sessions和sys.dm_exec_sql_text。我常用的查询如下:
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time, r.wait_resource, DB_NAME(r.database_id) AS dbname, SUBSTRING(st.text, (r.statement_start_offset/2)+1, ((CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(st.text) ELSE r.statement_end_offset END - r.statement_start_offset)/2)+1) AS sql_text FROM sys.dm_exec_requests r LEFT JOIN sys.dm_exec_sessions s ON r.session_id = s.session_id CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) st WHERE r.blocking_session_id > 0 AND r.session_id != @@SPID运行后会得到一张表,直接告诉你哪个会话(session_id)被哪个会话(blocking_session_id)堵住了。wait_resource字段很重要,我见过太多人只看到“被阻塞”,却不去看资源描述。实际上,wait_resource会告诉你是 KEY、RID、PAGE 还是 OBJECT,比如:
KEY: 1:2:3 (a1b2c3...)代表等待一个索引键。PAGE: 1:123代表等待一个数据页。RID: 0:1:234代表等待一行。OBJECT: 1:2代表等待整个表对象上的锁。
顺着blocking_session_id再反查一次,就能找到最上层的阻塞源头。这种查询适合“现在正在发生”的场景,比如系统突然卡住,你可以第一时间抓到当前拥堵点。
2.2 用跟踪标志捕获历史死锁:错误日志里的老黄历
当前阻塞是“进行时”,而死锁本身是“过去时”。要抓历史死锁,最经典的方式是启用跟踪标志 1204 和 1222。
这两个跟踪标志的作用是把死锁信息写入 SQL Server 错误日志。区别在于格式:
- 1204:输出简洁,按会话和资源描述,适合快速判断。
- 1222:输出结构化文本,内容更完整,包含每个会话的 SQL、锁模式、资源列表,适合深入分析。
建议两个同时开启,或者直接开 1222。开启方法有两种:
第一种是全局开启(立即生效但重启失效):
DBCC TRACEON(1204, 1222, -1);第二种是作为启动参数永久开启:在服务启动参数里加上-T1204 -T1222,重启后自动生效。生产环境一般都会把 1222 设为启动参数,因为死锁排查是常态需求。
启用后,死锁发生时,错误日志(默认目录一般为C:\Program Files\Microsoft SQL Server\MSSQLxx.MSSQLSERVER\MSSQL\Log\ERRORLOG)会记录类似下面的内容:
deadlock victim=process abc process-list process id=processabc ... transaction descriptor=X ... resource-list keylock hobtid=... dbid=... objectname=... mode=Range??1222 的文本很长,初看容易发怵。但你只需要抓住几个关键段:process-list里每个process有昵称(如process1a2b3c)、lastbatchstarted、executionStack,resource-list里写明了锁类型和锁住的 KEY。等下在死锁图部分我会专门展开讲。
2.3 用扩展事件捕获死锁图:最推荐的现代玩法
如果你用的 SQL Server 版本不是特别老(2008 以后都行),强烈建议用扩展事件来抓死锁。和跟踪标志相比,扩展事件更精确、开销更低、还能直接在 SSMS 里看死锁图。
系统自带一个system_health扩展事件会话,它默认会收集死锁事件。你可以在 SSMS 的“管理”->“扩展事件”->“会话”->“system_health”里看到 package0.event_file 目标,并查看里面的 deadlock_report。不过更推荐自己建一个独立会话,方便定位和保留历史。
我常用的创建语句如下:
CREATE EVENT SESSION [catch_deadlock] ON SERVER ADD EVENT sqlserver.xml_deadlock_report ( ACTION ( sqlserver.session_id, sqlserver.database_id, sqlserver.sql_text, sqlserver.client_hostname ) ) ADD TARGET package0.event_file ( SET filename = N'C:\XELog\catch_deadlock.xel', max_file_size = 20, max_rollover_files = 5 ) WITH ( MAX_MEMORY = 4 MB, EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS, MAX_DISPATCH_LATENCY = 30 SECONDS, STARTUP_STATE = ON ); GO ALTER EVENT SESSION [catch_deadlock] ON SERVER STATE = START;解释几个关键参数:
xml_deadlock_report:死锁发生时产生完整死锁 XML,也就是SSMS里能画出死锁图的那个事件。event_file:把结果写到磁盘,避免内存不足丢事件。max_rollover_files:最多保留5个文件,防止文件把磁盘塞满。STARTUP_STATE = ON:SQL Server服务重启后会话自动启动,这个在生产环境非常重要。
死锁发生后,你可以用下面语句把XEL里的记录读出来:
SELECT n.value('@name', 'nvarchar(128)') AS event_name, n.value('@timestamp', 'datetime2') AS event_time, n.query('.') AS deadlock_xml FROM ( SELECT CAST(event_data AS XML) AS data FROM sys.fn_xe_file_target_read_file( N'C:\XELog\catch_deadlock_*.xel', NULL, NULL, NULL) ) AS f CROSS APPLY f.data.nodes('/event') AS e(n) WHERE n.value('@name', 'nvarchar(128)') = 'xml_deadlock_report';这样你就能拿到完整的死锁 XML。SSMS 里如果要图形化,可以打开生成的.xel文件,或者直接把 XML 存成.sqlplan后缀打开(死锁图通常就是显示两个圆圈中间一条链)。
很多人只在意“有没有死锁”,却忽略了扩展事件还记录了sql_text和client_hostname。这些信息能直接告诉你死锁发生时刻应用从哪里连过来、执行的是哪条语句。没有它们,你拿到死锁图还要对着应用日志猜半天。
3. 死锁图的详细阅读姿势
3.1 死锁图的骨架:process-list 和 resource-list
扩展事件捕获的死锁 XML 结构其实很固定。整段 XML 里最核心的是<deadlock>根节点下的<process-list>和<resource-list>两部分。
<process-list>里有多个<process>节点,每一个代表一个死锁参与者。每个 process 都有唯一的 id,类似process123abc,同时会有spid(会话ID)、transactionname、lasttranstarted、lockMode等属性。<resource-list>里则列出了发生争用的资源。资源类型可能是<keylock>(索引键锁)、<ridlock>(行锁)、<pagelock>(页锁)、<objectlock>(对象锁)。每个资源节点下会有<owner-list>和<waiter-list>,分别代表当前谁持有这个资源、谁在等待这个资源。
用生活场景打个比方:有两个人都要过一扇窄门。process-list 就是两个人的身份牌,resource-list 就是门的位置、锁的状态。谁已经进了门、谁卡在门口,一目了然。
3.2 抓住两个关键信息:两个会话的SQL语句和锁模式
拿到死锁 XML 后,不用逐行读。我一般先做两个动作:
第一个动作,在process-list里找到<executionStack>节点,里面的<frame>有sqlhandle和statement属性,能还原出两个事务分别执行的 SQL。这一步能帮你立刻判断死锁是不是因为同一段代码发起了反向更新。
第二个动作,看resource-list里每个资源下 owner-list 和 waiter-list 的mode属性。比如一个<keylock>的 owner 是mode="X"(排他锁),waiter 是mode="S"(共享锁),那就是典型的写锁阻塞了读锁请求。
下面我贴一个精简死锁 XML 示例,方便你对照:
<deadlock> <victim-list><victimProcess id="process568a1c0" /></victim-list> <process-list> <process id="process568a1c0" spid="62" ...> <executionStack> <frame sqlhandle="0000000..." statement="UPDATE dbo.Orders SET Status=2 WHERE OrderID=101" /> </executionStack> </process> <process id="process5a8f3c2" spid="81" ...> <executionStack> <frame sqlhandle="0000000..." statement="UPDATE dbo.Orders SET Status=3 WHERE OrderID=101" /> </executionStack> </process> </process-list> <resource-list> <keylock hobtid="720575940..." objectname="dbo.Orders" mode="X"> <owner-list><owner id="process568a1c0" mode="X" /></owner-list> <waiter-list><waiter id="process5a8f3c2" mode="X" /></waiter-list> </keylock> </resource-list> </deadlock>这个例子里,两个会话都在更新同一张表的同一条订单,而且一个事务还有其它未提交的写操作。遇到这种情况,先想到的肯定是“同一行数据被频繁更新”,但真正要问的是:为什么两个会话会同时修改同一行?是用户重复提交、队列并发消费,还是应用层锁失效?
3.3 从死锁图里直接提取SQL文本:省去拆日志的麻烦
如果你不想看原始 XML,可以用一段 SQL 直接解析出每个会话对应的语句。下面这个查询会把死锁 XML 转换成一张明细表:
WITH DeadlockData AS ( SELECT CAST(event_data AS XML) AS data FROM sys.fn_xe_file_target_read_file( N'C:\XELog\catch_deadlock_*.xel', NULL, NULL, NULL) ) SELECT d.data.value('(//process-list/process/@spid)[1]', 'int') AS spid1, d.data.value('(//process-list/process/executionStack/frame/@statement)[1]', 'nvarchar(max)') AS sql1, d.data.value('(//process-list/process/@spid)[2]', 'int') AS spid2, d.data.value('(//process-list/process/executionStack/frame/@statement)[2]', 'nvarchar(max)') AS sql2, d.data.value('(//resource-list/keylock/@objectname)[1]', 'nvarchar(255)') AS object_name, d.data.value('(//resource-list/keylock/owner-list/owner/@mode)[1]', 'nvarchar(20)') AS owner_mode, d.data.value('(//resource-list/keylock/waiter-list/waiter/@mode)[1]', 'nvarchar(20)') AS waiter_mode FROM DeadlockData d;这里有几点提示:
- 如果死锁里只有一个 keylock 资源,
@spid的[1]和[2]能取到两个进程;如果资源多,可以改成使用//process-list/process遍历。 executionStack/frame/@statement在有些版本里可能不显示完整语句,因为sqlhandle对应的文本不在XML内。如果你遇到空值,可以直接用sys.dm_exec_sql_text(sql_handle)反查,但手头只有XML时,还是用@statement最省事。- 如果语句太长,XML字段会被截断,这时候就需要去看扩展事件自带的
sql_textaction,或者用 1222 跟踪标志输出的错误日志。
解析死锁图的过程,本质上就是把“过程化的锁冲突”还原成“文本化的代码路径”。只有明确看到两条SQL语句,才方便继续做代码审查。
4. 从查看到定位:实例排查过程复盘
4.1 搭建一个“必现死锁”的测试环境
纸上谈兵没用,不如自己动手造一个死锁,然后完整走一遍排查流程。下面我用最典型的两表交叉更新场景来演示。
先建两张表:
CREATE TABLE dbo.Account ( AccountID INT PRIMARY KEY, Balance DECIMAL(10,2) ); CREATE TABLE dbo.OrderHeader ( OrderID INT PRIMARY KEY, AccountID INT, Amount DECIMAL(10,2) ); INSERT INTO dbo.Account VALUES (1, 100), (2, 200); INSERT INTO dbo.OrderHeader VALUES (101, 1, 50), (102, 2, 30);接着开两个SQL窗口模拟两个事务:
会话A:
BEGIN TRANSACTION; UPDATE dbo.Account SET Balance = Balance - 50 WHERE AccountID = 1; WAITFOR DELAY '00:00:10'; UPDATE dbo.OrderHeader SET Amount = 0 WHERE OrderID = 101; COMMIT;会话B:
BEGIN TRANSACTION; UPDATE dbo.OrderHeader SET Amount = 0 WHERE OrderID = 102; WAITFOR DELAY '00:00:10'; UPDATE dbo.Account SET Balance = Balance - 30 WHERE AccountID = 2; COMMIT;注意,上面这个场景不一定死锁,因为两边更新的不是同一行,锁各自独立。要造死锁,必须让会话A和会话B在10秒内互相等待对方已持有的资源。更经典的做法是:
会话A:
BEGIN TRANSACTION; UPDATE dbo.Account SET Balance = Balance - 50 WHERE AccountID = 1; WAITFOR DELAY '00:00:10'; UPDATE dbo.OrderHeader SET Amount = 0 WHERE OrderID = 101; COMMIT;会话B:
BEGIN TRANSACTION; UPDATE dbo.OrderHeader SET Amount = 0 WHERE OrderID = 101; WAITFOR DELAY '00:00:10'; UPDATE dbo.Account SET Balance = Balance - 50 WHERE AccountID = 1; COMMIT;这样两个事务都会先锁住一个资源,然后等待对方的资源。运行顺序是:先执行会话A到WAITFOR,再执行会话B到WAITFOR,10秒后A去等B持有的OrderHeader行锁,B去等A持有的Account行锁,死锁立刻产生。
4.2 在整个过程中实时查看系统视图
当你把两个会话跑起来之后,可以在第三个窗口执行第2.1节的阻塞查询。你大概率会看到类似这样的结果:
- session_id=62,blocking_session_id=81,wait_resource=KEY: ...
- session_id=81,blocking_session_id=62,wait_resource=KEY: ...
这其实已经无限接近死锁了,只不过锁管理器还没触发回滚。等WAITFOR结束后,系统检测到两个会话互相等待,会选择其中一个作为牺牲者回滚,同时结束另一个会话的等待。
这个阶段我比较推荐留意三个东西:锁等待时间(wait_time)、等待资源(wait_resource)、事务开始时间(last_tran_started)。如果wait_time持续增长但一直没有被检测到死锁,可能就不是死锁,而是锁持有时间过长造成的阻塞,处理思路完全不同。
4.3 死锁图里还原问题根源
死锁发生后,如果你的扩展事件会话是开着的,catch_deadlock.xel里就会多一条xml_deadlock_report。用第3.3节的解析语句,你会看到两个会话分别执行了:
- 会话A:UPDATE dbo.Account 和 UPDATE dbo.OrderHeader
- 会话B:UPDATE dbo.OrderHeader 和 UPDATE dbo.Account
表面看是“代码里两条UPDATE语句访问顺序不一致”,但如果只看到这一层,很容易犯“把两个UPDATE顺序改一致”的错误。实际上,你还需要看死锁图里资源是行锁还是页锁。如果索引缺失,更新同一张表的不同行可能会去锁同一个数据页,产生页级锁冲突。在这种情况下,哪怕SQL语句访问顺序完全一致,还是可能死锁。
所以我在生产环境里遇到类似死锁,一般会按下述顺序排查:
- 看死锁图确认两个事务的最终语句。
- 翻出这两条语句的执行计划,确认有没有走非聚集索引、有没有发生RID Lookup。
- 检查表的聚集索引定义。如果聚集索引是自增主键,插入顺序通常没问题;如果是随机值主键,则极容易在页层面发生锁冲突。
- 确认隔离级别。默认读提交下,更新操作使用排他锁,范围锁一般不会出现;但如果隔离级别升级成可重复读或序列化,范围锁K就会成为死锁主角。
4.4 修改方案与效果验证
针对上面的场景,常用的解决方向有三个:
- 统一事务内对象的访问顺序,例如约定所有事务都先更新Account再更新OrderHeader。
- 为OrderHeader.AccountID创建合适的索引,让更新走索引查找,避免表扫描导致更多行被锁。
- 如果业务允许,降低隔离级别,或使用行版本控制(读提交快照隔离)减少读写互等。
我通常会先修改代码,再在同一测试环境跑10次并发脚本,验证死锁不再出现。同时保留扩展事件会话,持续观察一周,确保没有隐藏的同类死锁。这个“修改-验证-观察”循环,缺一个都不踏实。
5. 常见问题与排查技巧实录
5.1 死锁排查常见问题速查表
| 症状 | 可能原因 | 优先排查方法 |
|---|---|---|
| 错误日志里没有1205,但应用报死锁 | 错误日志级别限制 / 跟踪标志未生效 | 确认启动参数是否包含 -T1222;检查扩展事件是否存在 xml_deadlock_report |
| 扩展事件捕获到死锁,但死锁图里没有SQL文本 | 事件没有添加 sql_text action;executionStack 被截断 | 重建会话添加 sqlserver.sql_text;或用 1222 跟踪日志 |
| 死锁图显示锁住的是 OBJECT 而不是 KEY | 表扫描导致整个对象被锁 | 检查执行计划,做索引优化 |
| 死锁频率不高,偶尔出现 | 访问顺序不一致只在特定参数组合下出现 | 用自动化测试脚本批量跑并发,尽量复现 |
| 开启1222后错误日志增长极快 | 系统中死锁本来就多 | 优先解决根因,不建议长期开启1204(但1222通常可控) |
| 有阻塞但没死锁 | 长事务持有锁不提交 | 用 sys.dm_exec_requests 查阻塞链,定位最顶层的阻塞会话 |
5.2 几个我从实际项目里踩出来的经验
第一个经验:不要一看到死锁就骂应用。有时候死锁是索引设计问题,甚至是一条SQL语句自身导致的自死锁(同一个事务里先做更新,又触发外键检查,去获取已经持有的锁的另一个模式)。排查时先排除外键约束、触发器、计算列这些“隐式语句”,否则你会错判死锁源头。
第二个经验:务必把死锁XEL文件放到非系统盘,并设置合理的大小。我见过有人把XEL文件放在C盘,结果死锁一多,文件迅速撑爆系统盘,造成二次故障。max_file_size建议设置为20~50MB,max_rollover_files设3~5,如果业务高峰期死锁特别频繁,可以适当调大。
第三个经验:养成定期归档死锁图的习惯。死锁不会每次都报错,但节奏性地出现往往暗含业务增长或数据分布变化。我习惯每天写一条作业,把前一天的 deadlock XML 导出成单独的表,便于日后对比趋势。这对容量规划、压测评估都有帮助。
5.3 用动态管理函数快速定位问题SQL
除了死锁图,SQL Server 还提供了动态管理函数sys.dm_exec_query_plan和sys.dm_exec_sql_text,可以配合xml_deadlock_report提取后使用。下面这段SQL可以把死锁图和执行计划一起拿出来:
SELECT d.d.value('@spid', 'int') AS spid, d.d.value('(@statement)[1]', 'nvarchar(max)') AS statement_text, qp.query_plan FROM ( SELECT CAST(event_data AS XML) AS data FROM sys.fn_xe_file_target_read_file( N'C:\XELog\catch_deadlock_*.xel', NULL, NULL, NULL) ) f CROSS APPLY f.data.nodes('//process-list/process') AS d(d) OUTER APPLY ( SELECT TOP 1 query_plan FROM sys.dm_exec_query_plan(convert(varbinary(64), d.d.value('@sqlhandle', 'varchar(128)'), 1)) ) qp;这段语句里有个坑:@sqlhandle在死锁XML里是十六进制字符串,需要转成 varbinary 才能传给sys.dm_exec_query_plan。如果转换失败或句柄失效(比如语句已经离开缓存),你会拿到NULL。这时候别慌,先用statement属性判断问题,不一定非要拿执行计划。
我个人的习惯是:死锁XML是第一现场,执行计划是第二现场。先看第一现场定位到罪魁祸首,第二现场用来佐证锁范围为什么这么大。两者结合,排查效率会高很多。
5.4 对MySQL和PostgreSQL的简单对照
如果你用的不是 SQL Server,也别觉得这篇文章白看了。MySQL 里可以用SHOW ENGINE INNODB STATUS查看最近一次死锁信息,里面包含LATEST DETECTED DEADLOCK,同样能看出两个事务的SQL和锁等待关系。PostgreSQL 则要开启 log_lock_waits 参数,或者直接查 pg_stat_activity 里 application_name 和 wait_event_type 字段定位阻塞。
背后的核心逻辑是相通的:先记录死锁现场,再还原事务语句,最后优化访问路径。工具不同,思考路径一致。
我最后想分享一个切身体会:排查死锁最怕的不是查不到,而是只看到了“死锁”两个字就急着去加锁或加索引。真正花了力气把死锁图看懂之后,你往往会发现大部分死锁都可以通过规范事务边界、统一对象访问顺序、补一个覆盖索引来彻底消掉。希望这篇基于实操的记录,能帮你下一次遇到死锁时从容一些。