【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 上开启 死锁跟踪:
-- 开启全局死锁日志输出(写入 ERRORLOG)
DBCC TRACEON(1222, -1);
🔒 安全说明:该操作仅增加日志输出,无性能风险,且可随时关闭(
DBCC TRACEOFF(1222, -1))。
约 15 分钟后,再次触发死锁,成功在 ERRORLOG 中捕获完整死锁图:
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 侧)
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_timeFROM tb_steps_node
)
SELECT SUM(...)
FROM CTE
WHERE wo_steps_id = @P0;
3.3 锁冲突细节
- 两进程均操作
TopMES_FM.dbo.tb_steps_node表 - 锁类型:页级锁(PAGE LOCK)
- 进程 A 持有页
289677的SIX锁,等待页164136 - 进程 B 持有页
164136的SIX锁,等待页289677
- 进程 A 持有页
- 死锁成因:循环等待 + 锁顺序不一致
3.4 根本原因推断
为确认表结构与现有索引情况,执行以下 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 的索引支持。
由此推断:
- 查询
WHERE wo_steps_id = ?→ 全表扫描 - 全表扫描过程中申请大量 页级共享锁(S)
- 高并发下多个请求交叉扫描不同数据页 → 锁顺序冲突 → 死锁
✅ 结论:缺少关键业务索引 是本次事故的根本原因。
四、解决方案:双管齐下——应用层重试 + 数据库索引优化
4.1 临时缓解:C# 端增加死锁自动重试(上线热修复)
在索引方案验证期间,为避免用户持续报错,紧急在 C# 数据访问层增加死锁重试逻辑:
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 根本解决:创建覆盖索引,消除死锁根源
根据查询模式,创建复合非聚集索引:
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 执行与验证
-
低峰期执行建索引(使用
ONLINE = ON避免阻塞):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); -
验证执行计划:
- 原计划:
Clustered Index Scan(逻辑读 12,000+) - 新计划:
Index Seek(逻辑读 8)
- 原计划:
-
压测验证:
- 模拟 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 次,可避免绝大多数用户可见故障
🔜 后续改进项
- 建立索引评审机制:新表上线需评估高频查询路径
- 引入 Extended Events:替代
TRACEON(1222),实现死锁可视化监控 - 推广重试封装:将死锁重试逻辑抽象为通用数据访问中间件组件
七、附录:关键命令速查
-- 开启死锁跟踪
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日
