第六期:并发控制(下)—— 死锁检测、分析与消除

4 阅读7分钟

### 第六期:并发控制(下)—— 死锁检测、分析与消除

#### 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>

&#x20; <victim-list>

&#x20;   <victimProcess id="process2" />

&#x20; </victim-list>

&#x20; <process-list>

&#x20;   <process id="process1" waitresource="KEY: 5:720576... (1234567890)" />

&#x20;   <process id="process2" waitresource="KEY: 5:720576... (0987654321)" />

&#x20; </process-list>

&#x20; <resource-list>

&#x20;   <keylock objectname="MyDB.dbo.Orders" />

&#x20; </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)

{

&#x20;   try

&#x20;   {

&#x20;       ExecuteTransaction();

&#x20;       success = true;

&#x20;   }

&#x20;   catch (SqlException ex) when (ex.Number == 1205)  // 死锁错误号

&#x20;   {

&#x20;       retryCount++;

&#x20;       Thread.Sleep(100 \* retryCount);  // 指数退避

&#x20;   }

}

#### 6. 常见死锁误区与真相

| 误区 | 真相 |

|------|------|

| 死锁只发生在多个表之间 | 同一张表的不同索引页也可形成死锁 |

| 索引越多越容易死锁 | 合理的覆盖索引能减少书签查找,反而降低死锁 |

| NOLOCK能完全避免死锁 | NOLOCK读不申请锁,但写操作仍可能死锁 |

| 死锁和阻塞是一回事 | 阻塞是等待锁释放;死锁是循环等待,且会被自动处理 |

| 增加索引能解决所有死锁 | 索引能减少扫描,但访问顺序错误仍会死锁 |

#### 7. 死锁排查检查清单


□ 捕获死锁图(启用1222跟踪标志)

□ 分析死锁图,找出参与的事务和资源

□ 查看两个事务的SQL语句和执行计划

□ 检查事务的隔离级别(是否使用了SERIALIZABLE?)

□ 检查访问顺序是否一致(表顺序、行顺序)

□ 检查是否有长事务未提交

□ 检查索引设计(是否扫描过多?)

□ 考虑开启RCSI(如果读多写少)

□ 检查是否使用了非聚集索引书签查找

□ 在应用层实现重试逻辑

---

**第六期小结**

死锁由四个必要条件共同形成,SQL Server通过锁监视器检测并选择牺牲品自动解决。死锁图(TF 1222)是分析的关键工具。消除死锁最有效的方法是统一访问顺序、缩短事务长度和开启行版本控制(RCSI)。理解锁的粒度和等待关系,才能在设计中避免死锁,或在发生后快速定位根因。

**下期预告**:查询优化器与执行计划 —— 如何读懂执行计划?为什么统计信息如此重要?何时应该干预优化器的选择?