### 第六期:并发控制(下)—— 死锁检测、分析与消除
#### 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 ...
锁过程:
1. A持有T1的X锁,请求T2的X锁 → 等待B释放T2
2. B持有T2的X锁,请求T1的X锁 → 等待A释放T1
3. 死锁形成 → 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)。理解锁的粒度和等待关系,才能在设计中避免死锁,或在发生后快速定位根因。
**下期预告**:查询优化器与执行计划 —— 如何读懂执行计划?为什么统计信息如此重要?何时应该干预优化器的选择?