第六期:并发控制(下)—— 死锁检测、分析与消除
1. 死锁的定义与必要条件
死锁:两个或多个事务各自持有对方需要的资源,且都不释放,导致永久阻塞。
四个必要条件(全部满足才会死锁):
| 条件 | 说明 | 示例 |
|——|——|——|
| 互斥 | 资源一次只能被一个事务持有 | 排他锁(X)同时只能一个事务持有 |
| 持有并等待 | 持有资源的同时请求其他资源 | 事务A持有表T1锁,请求表T2锁 |
| 不可抢占 | 已持有的锁不能被强制释放 | SQL Server不会主动剥夺锁 |
| 循环等待 | 事务间形成等待环 | A等B → B等C → C等A |
现象解释:
-
为什么高并发系统更容易死锁?因为事务间交错执行的概率增加,更容易形成循环等待。
-
为什么死锁通常发生在多个表之间或同一表的不同行?因为需要形成”互相等待”的环。
2. SQL Server的死锁检测与处理机制
死锁检测:
-
锁监视器线程(Lock Monitor):每5秒启动一次,检测系统内是否有死锁。
-
等待图(Wait Graph):维护资源与等待关系的有向图,检测到循环即判定死锁。
-
高频检测:当死锁发生频率较高时,检测频率自动提升(可低至100ms)。
死锁处理:
1. 检测到死锁循环
2. 选择"牺牲品"(Victim)
3. 终止牺牲品事务,回滚其所有操作
4. 释放该事务持有的所有锁
5. 让其他事务继续运行
6. 向牺牲品客户端返回错误号 1205
牺牲品选择依据:
-
死锁优先级:
SET DEADLOCK_PRIORITY(LOW / NORMAL / HIGH / -10~10数值) -
优先级最低的会话被选中
-
相同优先级时,选择回滚代价最小的事务
-
默认所有会话优先级为 NORMAL(0)
-- 设置当前会话为低优先级(更容易被杀)
SET DEADLOCK_PRIORITY LOW;
-- 设置关键事务为高优先级(不容易被杀)
SET DEADLOCK_PRIORITY HIGH;
现象解释:
-
为什么重试能解决死锁错误?因为死锁是偶发的,重新执行通常能成功。
-
为什么长事务更容易成为牺牲品?回滚代价大,但在相同优先级时,SQL Server会选择回滚代价小的(不是长的,而是修改少的)。
3. 典型死锁场景与案例
场景一:两个事务交叉更新两张表
事务A: 事务B:
UPDATE T1 SET ... UPDATE T2 SET ...
WAITFOR '00:00:01' WAITFOR '00:00:01'
UPDATE T2 SET ... UPDATE T1 SET ...
锁过程:
-
A持有T1的X锁,请求T2的X锁 → 等待B释放T2
-
B持有T2的X锁,请求T1的X锁 → 等待A释放T1
-
死锁形成 → SQL Server杀死其中一个
场景二:同一表上的行级锁死锁
事务A: 事务B:
BEGIN TRAN BEGIN TRAN
UPDATE Orders SET Status=1 UPDATE Orders SET Status=1
WHERE OrderID=101 WHERE OrderID=102
-- 此时A持有101的行X锁,B持有102的行X锁
SELECT * FROM Orders SELECT * FROM Orders
WHERE OrderID=102 WHERE OrderID=101
-- 需要共享锁,被B的X锁阻塞 -- 需要共享锁,被A的X锁阻塞
死锁形成!
场景三:范围锁(SERIALIZABLE级别)导致的死锁
-- 事务A:查询并插入缺失行
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRAN
SELECT * FROM Orders WHERE OrderDate = '2024-01-01'; -- 持有范围锁
-- (发现没有数据)
INSERT INTO Orders VALUES ('2024-01-01', ...); -- 等待事务B释放
-- 事务B:同样的操作,死锁形成
场景四:聚集索引与非聚集索引间的死锁
事务A:通过非聚集索引查找,更新聚集索引页
事务B:通过聚集索引扫描,更新非聚集索引页
→ 两个索引页形成循环等待
4. 死锁分析工具与方法
方法一:使用系统扩展事件(Extended Events)
-- 创建死锁捕获会话
CREATE EVENT SESSION DeadlockCapture ON SERVER
ADD EVENT sqlserver.xml_deadlock_report
ADD TARGET package0.event_file(SET filename=N'DeadlockCapture.xel')
WITH (MAX_MEMORY=4096 KB, EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS);
ALTER EVENT SESSION DeadlockCapture ON SERVER STATE = START;
方法二:启用跟踪标志(Trace Flags)
| 跟踪标志 | 作用 |
|———-|——|
| TF 1204 | 以文本格式输出死锁信息到错误日志 |
| TF 1222 | 以XML格式输出死锁信息到错误日志(更详细) |
| TF 1224 | 禁用锁升级(高并发场景慎用) |
-- 启用死锁记录
DBCC TRACEON(1204, 1222, -1);
-- 查看当前跟踪标志
DBCC TRACESTATUS;
方法三:分析死锁图的XML结构
死锁图包含三个关键部分:
-
****:参与死锁的资源(表、页、键)
-
****:参与死锁的事务及其持有/请求的锁
-
****:被选中的牺牲品
<deadlock>
  <victim-list>
  <victimProcess id="process2" />
  </victim-list>
  <process-list>
  <process id="process1" waitresource="KEY: 5:720576... (1234567890)" />
  <process id="process2" waitresource="KEY: 5:720576... (0987654321)" />
  </process-list>
  <resource-list>
  <keylock objectname="MyDB.dbo.Orders" />
  </resource-list>
</deadlock>
方法四:使用DMV实时监控
-- 查看当前死锁相关的请求
SELECT session_id, wait_type, wait_resource, blocking_session_id
FROM sys.dm_exec_requests
WHERE blocking_session_id > 0;
-- 查看锁等待图(仅当前状态)
SELECT * FROM sys.dm_tran_locks
WHERE request_status = 'WAIT';
5. 死锁消除策略
策略一:统一访问顺序(最重要)
-
所有事务以相同顺序访问表(如先T1后T2)
-
所有事务以相同顺序访问行(如ORDER BY强制排序)
-- 坏:两个事务顺序不同
-- 好:统一使用ORDER BY
SELECT * FROM Orders WITH(UPDLOCK)
WHERE OrderID IN (101, 102)
ORDER BY OrderID; -- 总是先锁101,后锁102
策略二:缩短事务长度
-
减少事务内执行的语句数
-
不要在事务中等待用户输入
-
尽早提交或回滚
策略三:降低隔离级别
-
启用RCSI(READ_COMMITTED_SNAPSHOT),读不阻塞写
-
使用快照隔离(SNAPSHOT ISOLATION)
-
报表查询允许脏读(READ UNCOMMITTED或NOLOCK)
-- 启用快照隔离(数据库级别)
ALTER DATABASE YourDB SET ALLOW_SNAPSHOT_ISOLATION ON;
-- 事务级别使用
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
策略四:优化索引
-
添加覆盖索引,避免书签查找
-
减少扫描,尽快定位目标行
-
避免非聚集索引的锁与聚集索引锁形成循环
策略五:使用行版本控制替代锁
-
开启RCSI后,SELECT使用行版本,UPDATE使用X锁
-
读写不互斥,大幅减少死锁
-- 检查RCSI是否开启
SELECT name, is_read_committed_snapshot_on
FROM sys.databases
WHERE name = 'YourDB';
策略六:死锁重试逻辑(应用层)
// C# 示例
int retryCount = 0;
int maxRetries = 3;
bool success = false;
while (!success && retryCount < maxRetries)
{
  try
  {
  ExecuteTransaction();
  success = true;
  }
  catch (SqlException ex) when (ex.Number == 1205) // 死锁错误号
  {
  retryCount++;
  Thread.Sleep(100 * retryCount); // 指数退避
  }
}
6. 常见死锁误区与真相
| 误区 | 真相 |
|——|——|
| 死锁只发生在多个表之间 | 同一张表的不同索引页也可形成死锁 |
| 索引越多越容易死锁 | 合理的覆盖索引能减少书签查找,反而降低死锁 |
| NOLOCK能完全避免死锁 | NOLOCK读不申请锁,但写操作仍可能死锁 |
| 死锁和阻塞是一回事 | 阻塞是等待锁释放;死锁是循环等待,且会被自动处理 |
| 增加索引能解决所有死锁 | 索引能减少扫描,但访问顺序错误仍会死锁 |
7. 死锁排查检查清单
□ 捕获死锁图(启用1222跟踪标志)
□ 分析死锁图,找出参与的事务和资源
□ 查看两个事务的SQL语句和执行计划
□ 检查事务的隔离级别(是否使用了SERIALIZABLE?)
□ 检查访问顺序是否一致(表顺序、行顺序)
□ 检查是否有长事务未提交
□ 检查索引设计(是否扫描过多?)
□ 考虑开启RCSI(如果读多写少)
□ 检查是否使用了非聚集索引书签查找
□ 在应用层实现重试逻辑
第六期小结
死锁由四个必要条件共同形成,SQL Server通过锁监视器检测并选择牺牲品自动解决。死锁图(TF 1222)是分析的关键工具。消除死锁最有效的方法是统一访问顺序、缩短事务长度和开启行版本控制(RCSI)。理解锁的粒度和等待关系,才能在设计中避免死锁,或在发生后快速定位根因。
下期预告:查询优化器与执行计划 —— 如何读懂执行计划?为什么统计信息如此重要?何时应该干预优化器的选择?