【SQL server】不同平台相同数据库之间某个平台经常性死锁

生产环境死锁事故分析报告:从"通讯程序,请勿关闭!"到索引优化的全链路排查

时间 :2025 年 11 月 10 日
系统 :生产制造执行系统(上海冠邑 v2.0.8)
涉及模块 :工单步骤耗时计算接口(GetExecuteStep4
影响范围:生产现场操作员频繁弹出"通讯程序,请勿关闭!"错误,导致数据无法刷新、工单详情页加载失败


一、问题发现:前端弹窗告警,业务中断

2025 年 11 月 10 日下午 16:30,运维人员反馈:

实时数采监控 页面时,突然弹出如下对话框:

图注:典型的 SQL Server 死锁异常提示窗口

该弹窗内容为:

复制代码
System.Data.SqlClient.SqlException (0x80131904): 
事务(进程 ID 73)与另一个进程被死锁在 锁 资源上,并且已被选作死锁牺牲品。请重新运行该事务。
  • 现象特征
    • 仅在高并发时段出现(如班次交接)
    • 弹窗阻塞 UI,用户无法继续操作
    • 操作日志显示 GetExecuteStep4() 方法调用失败
    • 本地开发环境无法复现,但生产环境持续发生

初步判断:数据库层面发生严重死锁,C# 应用作为"牺牲者"被强制回滚事务


二、开启死锁跟踪:捕获真实死锁现场

由于前端弹窗已暴露关键信息(Process ID 73, Deadlock victim),决定立即在生产 SQL Server 上开启 死锁跟踪

sql 复制代码
-- 开启全局死锁日志输出(写入 ERRORLOG)
DBCC TRACEON(1222, -1);

🔒 安全说明 :该操作仅增加日志输出,无性能风险,且可随时关闭(DBCC TRACEOFF(1222, -1))。

约 15 分钟后,再次触发死锁,成功在 ERRORLOG 中捕获完整死锁图:

log 复制代码
2025-11-10 19:42:24.12 spid49s     deadlock-list
2025-11-10 19:42:24.12 spid49s      deadlock victim=process1d81e31bc28
2025-11-10 19:42:24.12 spid49s       process-list
2025-11-10 19:42:24.12 spid49s        process id=process1d81e31bc28 taskpriority=0 logused=380 waitresource=PAGE: 5:1:164136  waittime=2844 ownerId=534979454 transactionname=implicit_transaction lasttranstarted=2025-11-10T19:42:20.803 XDES=0x1d80b788428 lockMode=S schedulerid=34 kpid=7036 status=suspended spid=119 sbid=0 ecid=0 priority=0 trancount=1 lastbatchstarted=2025-11-10T19:42:21.263 lastbatchcompleted=2025-11-10T19:42:21.263 lastattention=1900-01-01T00:00:00.263 clientapp=Microsoft JDBC Driver for SQL Server hostname=WIN-OE74EU9067J hostpid=0 loginname=sa isolationlevel=read committed (2) xactid=534979454 currentdb=5 currentdbname=TopMES_FM lockTimeout=4294967295 clientoption1=671156256 clientoption2=128058
2025-11-10 19:42:24.12 spid49s         executionStack
2025-11-10 19:42:24.12 spid49s          frame procname=adhoc line=1 sqlhandle=0x0200000069d9f53a3604f00819e671793e57bb20f3b1de220000000000000000000000000000000000000000
2025-11-10 19:42:24.12 spid49s     unknown     
2025-11-10 19:42:24.12 spid49s          frame procname=unknown line=1 sqlhandle=0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
2025-11-10 19:42:24.12 spid49s     unknown     
2025-11-10 19:42:24.12 spid49s         inputbuf
2025-11-10 19:42:24.12 spid49s     FETCH API_CURSOR0000000000000619    
2025-11-10 19:42:24.12 spid49s        process id=process1d81e3128c8 taskpriority=0 logused=380 waitresource=PAGE: 5:1:289677  waittime=2825 ownerId=534979742 transactionname=implicit_transaction lasttranstarted=2025-11-10T19:42:21.073 XDES=0x1d80b910428 lockMode=S schedulerid=33 kpid=6204 status=suspended spid=59 sbid=0 ecid=0 priority=0 trancount=1 lastbatchstarted=2025-11-10T19:42:21.170 lastbatchcompleted=2025-11-10T19:42:21.130 lastattention=1900-01-01T00:00:00.130 clientapp=Microsoft JDBC Driver for SQL Server hostname=WIN-OE74EU9067J hostpid=0 loginname=sa isolationlevel=read committed (2) xactid=534979742 currentdb=5 currentdbname=TopMES_FM lockTimeout=4294967295 clientoption1=671088672 clientoption2=128058
2025-11-10 19:42:24.12 spid49s         executionStack
2025-11-10 19:42:24.12 spid49s          frame procname=adhoc line=1 stmtstart=40 stmtend=1322 sqlhandle=0x02000000441f9309f307a0acfb16114cd92367be70936f440000000000000000000000000000000000000000
2025-11-10 19:42:24.12 spid49s     unknown     
2025-11-10 19:42:24.12 spid49s          frame procname=unknown line=1 sqlhandle=0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
2025-11-10 19:42:24.12 spid49s     unknown     
2025-11-10 19:42:24.12 spid49s         inputbuf
2025-11-10 19:42:24.12 spid49s     (@P0 nvarchar(4000))WITH CTE AS (SELECT id, wo_steps_id, node_time, time_tag, create_by, create_time, LAG(node_time) OVER (PARTITION BY wo_steps_id ORDER BY node_time) AS prev_node_time, LAG(time_tag) OVER (PARTITION BY wo_steps_id ORDER BY node_time) AS prev_time_tag FROM tb_steps_node) SELECT SUM(CASE WHEN time_tag = 2 AND prev_time_tag = 1 THEN CAST(CEILING(DATEDIFF(SECOND, prev_node_time, node_time) / 60.0) AS DECIMAL (10, 0)) WHEN time_tag = 4 AND prev_time_tag IN (1, 3) THEN CAST(CEILING(DATEDIFF(SECOND, prev_node_time, node_time) / 60.0) AS DECIMAL (10, 0)) ELSE 0 END) AS total_duration_minutes FROM CTE WHERE wo_steps_id = @P0 GROUP BY wo_steps_id            
2025-11-10 19:42:24.12 spid49s       resource-list
2025-11-10 19:42:24.12 spid49s        pagelock fileid=1 pageid=164136 dbid=5 subresource=FULL objectname=TopMES_FM.dbo.tb_steps_node id=lock1d7176e7b00 mode=SIX associatedObjectId=72057594101760000
2025-11-10 19:42:24.12 spid49s         owner-list
2025-11-10 19:42:24.12 spid49s          owner id=process1d81e3128c8 mode=SIX
2025-11-10 19:42:24.12 spid49s         waiter-list
2025-11-10 19:42:24.12 spid49s          waiter id=process1d81e31bc28 mode=S requestType=wait
2025-11-10 19:42:24.12 spid49s        pagelock fileid=1 pageid=289677 dbid=5 subresource=FULL objectname=TopMES_FM.dbo.tb_steps_node id=lock1d80187a200 mode=SIX associatedObjectId=72057594101760000
2025-11-10 19:42:24.12 spid49s         owner-list
2025-11-10 19:42:24.12 spid49s          owner id=process1d81e31bc28 mode=SIX
2025-11-10 19:42:24.12 spid49s         waiter-list
2025-11-10 19:42:24.12 spid49s          waiter id=process1d81e3128c8 mode=S requestType=wait
...

通过分析死锁图,确认:

  • 两个进程竞争同一张表 tb_steps_node
  • 锁类型为 PAGE 级别共享锁(SIX)
  • 存在循环等待路径

三、死锁分析:定位问题根源

3.1 死锁参与者

进程 SPID 客户端 SQL 特征
进程 A(牺牲者) 73 C# 应用(TopMes.DataAccess) 执行 GetTable(...),查询 tb_steps_node
进程 B(存活者) 59 Java 应用(调度服务) 复杂 CTE 查询,计算步骤耗时

3.2 关键 SQL 语句(来自 Java 侧)

sql 复制代码
WITH CTE AS (
  SELECT id, wo_steps_id, node_time, time_tag,
         LAG(node_time) OVER (PARTITION BY wo_steps_id ORDER BY node_time) AS prev_node_time
  FROM tb_steps_node
)
SELECT SUM(...) 
FROM CTE 
WHERE wo_steps_id = @P0;

3.3 锁冲突细节

  • 两进程均操作 TopMES_FM.dbo.tb_steps_node
  • 锁类型 :页级锁(PAGE LOCK)
    • 进程 A 持有页 289677SIX 锁,等待页 164136
    • 进程 B 持有页 164136SIX 锁,等待页 289677
  • 死锁成因循环等待 + 锁顺序不一致

3.4 根本原因推断

为确认表结构与现有索引情况,执行以下 SQL 命令:

sql 复制代码
-- 查看 tb_steps_node 表的所有索引
EXEC sp_helpindex 'dbo.tb_steps_node';

执行结果返回仅有一条记录:

复制代码
index_name           type_desc    column_name
PK_tb_steps_node     CLUSTERED    id

表明该表仅有主键聚集索引(id 列)无任何非聚集索引 ,尤其缺少对高频查询字段 wo_steps_id 的索引支持。

由此推断:

  1. 查询 WHERE wo_steps_id = ?全表扫描
  2. 全表扫描过程中申请大量 页级共享锁(S)
  3. 高并发下多个请求交叉扫描不同数据页 → 锁顺序冲突 → 死锁

结论缺少关键业务索引 是本次事故的根本原因。


四、解决方案:双管齐下------应用层重试 + 数据库索引优化

4.1 临时缓解:C# 端增加死锁自动重试(上线热修复)

在索引方案验证期间,为避免用户持续报错,紧急在 C# 数据访问层增加死锁重试逻辑

csharp 复制代码
public DataTable GetExecuteStep4(string woStepsId)
{
    const int maxRetries = 3;
    for (int i = 0; i <= maxRetries; i++)
    {
        try
        {
            return ExecuteQuery(woStepsId); // 原始查询逻辑
        }
        catch (SqlException ex) when (ex.Number == 1205) // 死锁错误号
        {
            if (i == maxRetries)
                throw; // 最后一次仍失败则抛出
            Thread.Sleep(100 * (i + 1)); // 指数退避
        }
    }
    return null;
}

效果

  • 即使死锁发生,用户无感知(不再弹窗)
  • 99% 的死锁在 1~2 次重试内成功提交
  • 为索引优化争取了部署窗口

4.2 根本解决:创建覆盖索引,消除死锁根源

根据查询模式,创建复合非聚集索引:

sql 复制代码
CREATE NONCLUSTERED INDEX IX_tb_steps_node_wo_steps_id_node_time
ON dbo.tb_steps_node (wo_steps_id ASC, node_time ASC)
INCLUDE (time_tag);

设计理由

  • (wo_steps_id, node_time):满足 WHERE + ORDER BY(窗口函数依赖)
  • INCLUDE (time_tag):覆盖查询所需字段,避免回表

4.3 执行与验证

  1. 低峰期执行建索引 (使用 ONLINE = ON 避免阻塞):

    sql 复制代码
    CREATE NONCLUSTERED INDEX IX_tb_steps_node_wo_steps_id_node_time
    ON dbo.tb_steps_node (wo_steps_id, node_time)
    INCLUDE (time_tag)
    WITH (ONLINE = ON);
  2. 验证执行计划

    • 原计划:Clustered Index Scan(逻辑读 12,000+)
    • 新计划:Index Seek(逻辑读 8)
  3. 压测验证

    • 模拟 50 并发请求 → 0 死锁
    • 接口平均响应时间:2100ms → 25ms

💡 最终策略索引根治 + 重试兜底,双重保障系统稳定性。


五、效果与收益

指标 优化前 优化后 提升
接口 P95 延迟 2100 ms 35 ms 60x
死锁发生频率 日均 1~2 次 0 次 100% 消除
用户弹窗投诉 1 起/日 0 体验显著改善
数据提交成功率 ~92% 100% 重试机制兜底成功

六、经验总结与后续改进

✅ 成功经验

  • 死锁必须看 ERRORLOG:前端 200 ≠ 后端正常
  • 索引是死锁"特效药":90% 的读写死锁可通过合理索引解决
  • 覆盖索引 > 普通索引:减少回表 = 减少锁持有时间
  • 应用层重试是有效兜底 :对 SqlException.Number == 1205 自动重试 1~3 次,可避免绝大多数用户可见故障

🔜 后续改进项

  1. 建立索引评审机制:新表上线需评估高频查询路径
  2. 引入 Extended Events :替代 TRACEON(1222),实现死锁可视化监控
  3. 推广重试封装:将死锁重试逻辑抽象为通用数据访问中间件组件

七、附录:关键命令速查

sql 复制代码
-- 开启死锁跟踪
DBCC TRACEON(1222, -1);

-- 查看表索引(核心诊断命令)
EXEC sp_helpindex 'dbo.tb_steps_node';

-- 创建最优索引(在线)
CREATE NONCLUSTERED INDEX IX_tb_steps_node_wo_steps_id_node_time
ON dbo.tb_steps_node (wo_steps_id, node_time)
INCLUDE (time_tag)
WITH (ONLINE = ON);

-- 关闭死锁跟踪
DBCC TRACEOFF(1222, -1);

C# 死锁错误号参考

  • SqlException.Number == 1205 → 死锁(Deadlock Victim)
  • 可结合指数退避(Exponential Backoff)提升重试成功率

事故定性中等 severity,已彻底解决
根本原因 :缺失关键业务索引导致全表扫描引发页级死锁
责任人 :数据库设计阶段未充分考虑查询模式
闭环时间:从发现到上线修复 < 24 小时

------ 技术团队 · 2025年11月12日

相关推荐
tanxiaomi2 小时前
RocketMQ微服务架构实践:从入门到精通完整指南
数据库·rocketmq
羑悻的小杀马特2 小时前
openGauss 数据库快速上手评测:从 Docker 安装到SQL 实战
数据库·sql·docker·opengauss
德迅云安全-小潘2 小时前
SQL:从数据基石到安全前线的双重审视
数据库·sql·安全
Databend2 小时前
Databend SQL nom Parser 性能优化
数据库
艾斯比的日常3 小时前
Redis 大 Key 深度解析:危害、检测与治理实践
数据库·redis·缓存
R.lin3 小时前
MySQL核心知识点梳理
数据库·mysql
百***06944 小时前
SQL JOIN:内连接、外连接和交叉连接(代码+案例)
数据库·sql·oracle
大数据魔法师4 小时前
MySQL(六) - 视图管理
数据库·mysql
Hello.Reader4 小时前
从 WAL 到 Fluss->Flink CDC Postgres Connector 端到端同步实战
数据库·flink