一、死锁到底是个什么鬼

咱们先来打个比方。想象两个人在狭窄的走廊里迎面相遇,都坚持让对方先走,结果谁都不让谁,就这么僵持住了——这就是死锁的生动写照。在SqlServer里,当两个或多个事务互相等待对方释放锁资源时,就会发生这种"堵车"现象。

举个典型的死锁场景:

  • 事务A锁定了表1,然后请求表2的锁
  • 事务B同时锁定了表2,却请求表1的锁
  • 两个事务就像两个固执的人,大眼瞪小眼谁也不让步
-- 事务1执行顺序
BEGIN TRAN
UPDATE 商品表 SET 库存=库存-1 WHERE 商品ID=1  -- 获取商品1的排他锁
WAITFOR DELAY '00:00:05'  -- 模拟业务处理耗时
UPDATE 订单表 SET 状态='已付款' WHERE 订单ID=100  -- 尝试获取订单表的锁
COMMIT TRAN

-- 事务2执行顺序
BEGIN TRAN
UPDATE 订单表 SET 备注='加急' WHERE 订单ID=100  -- 获取订单表的排他锁
WAITFOR DELAY '00:00:03'  -- 模拟业务处理耗时
UPDATE 商品表 SET 库存=库存+1 WHERE 商品ID=1  -- 尝试获取商品表的锁
COMMIT TRAN

二、如何发现死锁的蛛丝马迹

SqlServer其实挺贴心的,给我们准备了几种监控死锁的工具:

  1. 最快捷的方式就是查看SqlServer错误日志,死锁发生时系统会自动记录
-- 查询最近的死锁信息
EXEC sp_readerrorlog 0, 1, 'deadlock'
  1. 使用扩展事件(XEvent)实时监控,就像给数据库装了个行车记录仪
-- 创建死锁监控会话
CREATE EVENT SESSION [Deadlock_Monitor] ON SERVER 
ADD EVENT sqlserver.xml_deadlock_report
ADD TARGET package0.event_file(SET filename=N'Deadlock_Monitor')
GO

-- 启动监控
ALTER EVENT SESSION [Deadlock_Monitor] ON SERVER STATE = START
GO
  1. 老牌工具SQL Profiler虽然已经退役,但在某些老版本上还是可以凑合用
-- 使用Trace Flag捕获死锁图
DBCC TRACEON (1222, -1)  -- 将死锁信息输出到错误日志
DBCC TRACEON (1204, -1)  -- 另一种死锁记录格式

三、破解死锁的十八般武艺

遇到死锁别慌张,咱们有全套解决方案:

方案1:调整事务隔离级别

就像调节座椅高度一样,合适的隔离级别能减少锁冲突

-- 使用读已提交快照,减少阻塞
ALTER DATABASE 你的数据库 
SET ALLOW_SNAPSHOT_ISOLATION ON

ALTER DATABASE 你的数据库 
SET READ_COMMITTED_SNAPSHOT ON

方案2:统一访问顺序

让所有事务按照固定顺序访问表,就像交通规则靠右行驶

-- 修改事务代码,确保总是先访问商品表再访问订单表
CREATE PROCEDURE 下单流程
AS
BEGIN
    BEGIN TRAN
    -- 总是先操作商品表
    UPDATE 商品表...
    -- 然后操作订单表
    UPDATE 订单表...
    COMMIT TRAN
END

方案3:添加索引减少锁范围

就像把大马路划分成多个车道

-- 为常用查询条件添加索引
CREATE INDEX IX_订单表_状态 ON 订单表(状态)
INCLUDE (订单金额, 客户ID)

-- 使用覆盖索引减少锁升级
CREATE INDEX IX_商品表_库存 ON 商品表(库存)
INCLUDE (商品名称, 价格)

方案4:设置死锁优先级

让不太重要的事务主动让路

-- 设置事务优先级
BEGIN TRAN
SET DEADLOCK_PRIORITY LOW  -- 本事务优先级较低
-- 业务逻辑...
COMMIT TRAN

四、实战中容易踩的坑

  1. 过度依赖NOLOCK提示:虽然能减少阻塞,但可能读到脏数据
-- 危险操作:可能读到未提交的数据
SELECT * FROM 订单表 WITH(NOLOCK) WHERE 订单ID=100
  1. 忽略锁升级:当锁数量超过阈值时,SqlServer会自动升级为表锁
-- 监控锁升级事件
DBCC TRACEON (1211, 1224, -1)  -- 禁用锁升级(谨慎使用)
  1. 长事务问题:事务持续时间越长,死锁风险越高
-- 错误示范:包含用户交互的长事务
BEGIN TRAN
UPDATE 商品表...
-- 等待用户确认...(千万别这么做!)
COMMIT TRAN
  1. 忽略死锁重试机制:有时候自动重试就能解决问题
// C#代码示例:死锁重试逻辑
int retryCount = 0;
while(retryCount < 3)
{
    try
    {
        using(var tran = connection.BeginTransaction())
        {
            // 执行SQL操作
            tran.Commit();
            break;
        }
    }
    catch(SqlException ex) when (ex.Number == 1205) // 死锁错误码
    {
        retryCount++;
        Thread.Sleep(100 * retryCount);
    }
}

五、从预防到治理的全套方案

  1. 预防阶段:
  • 定期检查缺失索引
  • 分析执行计划中的警告
  • 使用Database Tuning Advisor获取优化建议
  1. 监控阶段:
  • 配置实时告警
  • 建立基线性能指标
  • 定期检查锁等待统计
-- 查询当前锁等待情况
SELECT 
    t.text AS 执行语句,
    wt.wait_type AS 等待类型,
    wt.wait_duration_ms AS 等待时长,
    wt.resource_description AS 资源描述
FROM sys.dm_os_waiting_tasks wt
INNER JOIN sys.dm_exec_requests r ON wt.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE wt.wait_type LIKE 'LCK%'
  1. 治理阶段:
  • 优化事务设计
  • 重构问题查询
  • 考虑应用层缓存
  1. 应急处理:
  • 使用KILL命令终止阻塞源头
  • 临时调整隔离级别
  • 启用快照隔离
-- 终止阻塞会话
SELECT 阻塞会话ID = blocking_session_id, 被阻塞会话ID = session_id
FROM sys.dm_exec_requests
WHERE blocking_session_id != 0

KILL 55 -- 终止指定会话

六、总结与最佳实践

经过这一番折腾,咱们可以得出几个金科玉律:

  1. 事务要短小精悍,别搞马拉松式长事务
  2. 访问顺序要统一,就像进出电梯先出后进
  3. 索引不是越多越好,但要确保关键查询有合适索引
  4. 监控要常态化,别等用户投诉才发现问题
  5. 适当使用乐观并发控制,特别是读多写少的场景

最后记住,死锁不可怕,可怕的是对死锁视而不见。只要掌握正确的工具和方法,就能让数据库运行得像高速公路一样畅通无阻。