• Welcome to HiddenMerit - Clyde's Blog
  • Welcome to try the game Torn: Referral Link
  • If you are my relative, friend, or netizen, quickly press Ctrl+D to bookmark Clyde's Blog
  • This site has a like feature. If you read any article, please hit the like button so I know someone has visited
  • Email: hiddenmeritATgmail.com (replace AT with @)

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

DBA Clyde Jin 3周前 (04-25) 90次浏览 0个评论

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

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)。理解锁的粒度和等待关系,才能在设计中避免死锁,或在发生后快速定位根因。

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


绩隐金 , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:第六期:并发控制(下)—— 死锁检测、分析与消除
喜欢 (0)
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址