SQL Server 死锁排查实录:MES 高并发采集的锁优化实战
背景:MES 高并发采集下的死锁噩梦
上周产线反馈:采集服务每隔几小时就报一次 SqlException,错误信息是 "事务(进程 ID xx)在资源上被另一个进程死锁"。产线看板数据卡住不刷新,班组长大发雷霆。
这套 MES 采集服务部署在 Windows Server 上,后端是 .NET 8 + EF Core,数据库是 SQL Server 2019。产线 20 台设备每 5 秒上报一次生产数据,采集服务并发写入 UPH 表和报警表。单看 QPS 不高(约 400/s),但死锁偏偏在高产期扎堆出现。
这篇文章记录我从定位死锁 → 分析根因 → 优化锁粒度 → 彻底消除的完整排查路径,给遇到同样问题的同行参考。
第一步:抓取死锁日志
SQL Server 默认不保留死锁详情,得先开 Trace Flag 1222 或用 Extended Events 抓取。我选了后者,对性能影响更小:
-- 创建 Extended Events 会话抓死锁
CREATE EVENT SESSION [deadlock_capture] ON SERVER
ADD EVENT sqlserver.xml_deadlock_report(
ACTION(sqlserver.sql_text, sqlserver.client_app_name))
ADD TARGET package0.event_file(
SET filename = 'C:\Logs\deadlock.xel',
max_file_size_mb = 10, max_rollover_files = 5);
ALTER EVENT SESSION [deadlock_capture] ON SERVER STATE = START;
等了半天,果然抓到了。用以下查询读取 XEL 文件内容:
-- 读取死锁 XML 报告
SELECT
event_data.value('(event/@timestamp)[1]', 'datetime2') AS [time],
event_data.value('(event/data[@name="deadlock_xml"]/value)[1]', 'nvarchar(max)') AS [deadlock_xml]
FROM (
SELECT CAST(event_data AS XML) AS event_data
FROM sys.fn_xe_file_target_read_file('C:\Logs\deadlock*.xel', NULL, NULL, NULL)
) t
WHERE event_data.exist('(event[@name="xml_deadlock_report"])') = 1;
XML 里清清楚楚写明了两个进程各自持有什么锁、等什么锁。关键信息:两个 UPDATE 语句在同一行上互相等对方释放锁。
第二步:定位死锁根因
看了 XML 报告后,问题出在采集服务的这段 EF Core 代码:
// 问题代码:先查再改,中间有间隙
public async Task UpdateDeviceStatus(string deviceCode, int status)
{
var device = await _db.Devices
.Where(d => d.Code == deviceCode)
.FirstOrDefaultAsync();
device.Status = status;
device.LastReportTime = DateTime.Now;
await _db.SaveChangesAsync(); // 死锁高发点
}
根因有三个:
- 先查后改:SELECT 拿到 S 锁,UPDATE 升级为 X 锁,两个请求同时执行时互相卡死
- 事务范围太大:EF Core 默认在 SaveChanges 时才提交,但查询在事务内已持有锁
- 缺少合适的索引:
Code字段没有唯一索引,SQL Server 用了行级锁 + 键查找,锁粒度变大
第三步:优化锁粒度与事务结构
改造分三刀:
刀 1:用 UPDATE 直接改,不先 SELECT
// 直接 UPDATE,避免先查后改的锁升级窗口
public async Task UpdateDeviceStatus(string deviceCode, int status)
{
await _db.Devices
.Where(d => d.Code == deviceCode)
.ExecuteUpdateAsync(s => s
.SetProperty(d => d.Status, status)
.SetProperty(d => d.LastReportTime, DateTime.Now));
}
ExecuteUpdateAsync 直接翻译成一条 UPDATE 语句,不经过 EntityTracker,不持有中间 S 锁。
刀 2:加唯一索引,收窄锁范围
-- 给 Code 字段加唯一非聚集索引
CREATE UNIQUE NONCLUSTERED INDEX IX_Devices_Code
ON dbo.Devices(Code)
INCLUDE (Status, LastReportTime);
加了覆盖索引后,UPDATE 只需锁定索引行,不再通过 RID/键查找回表,锁粒度从页级缩到行级。
刀 3:批量写入合并,减少事务次数
// 把 20 台设备的上报合并成一次写入
public async Task BatchUpdateStatus(List reports)
{
// 用 DataTable 做参数化批量更新
var dt = new DataTable();
dt.Columns.Add("Code", typeof(string));
dt.Columns.Add("Status", typeof(int));
dt.Columns.Add("Time", typeof(DateTime));
foreach (var r in reports)
dt.Rows.Add(r.Code, r.Status, r.Time);
var param = new SqlParameter("@devices", SqlDbType.Structured)
{ TypeName = "dbo.DeviceReportTable", Value = dt };
await _db.Database.ExecuteSqlRawAsync(
"EXEC sp_UpdateDeviceBatch @devices", param);
}
用 Table-Valued Parameter 把 20 次 UPDATE 合并成 1 次存储过程调用,事务持有时间从 ~200ms 降到 ~15ms。
第四步:验证与监控
改完部署后,用以下方式验证效果:
-- 检查死锁是否消失(XE 会话还在运行)
SELECT COUNT(*) AS deadlock_count
FROM sys.fn_xe_file_target_read_file('C:\Logs\deadlock*.xel', NULL, NULL, NULL)
CROSS APPLY event_data.nodes('//event[@name="xml_deadlock_report"]') AS t(event)
WHERE event.exist('.') = 1
AND event.value('(@timestamp)[1]', 'datetime2') > DATEADD(hour, -24, GETDATE());
改造前 24 小时死锁 37 次,改造后 连续 72 小时零死锁。采集服务 CPU 从平均 45% 降到 12%。
踩坑记录
- 坑 1:ExecuteUpdateAsync 绕过了 SaveChanges——它不会触发 EF Core 的拦截器和审计日志。如果系统依赖 ChangeTracker 做审计,改完发现审计记录没了。解决:手动补一条日志记录。
- 坑 2:TVP 参数的列名大小写敏感——SQL Server 的 TVP 类型列名必须和 DataTable 列名完全匹配,少一个字母或大小写不对直接报 "Column 'xxx' not found"。排查了半小时才发现列名拼错。
- 坑 3:加唯一索引时数据有重复——建索引直接报错,因为历史数据里 Code 有重复(设备更换后旧记录没清)。解决:先清理脏数据再建索引。
- 坑 4:sp_UpdateDeviceBatch 存储过程的死锁监控——存储过程内部的死锁 XE 抓到的 sql_text 是存储过程名,不是具体语句。要在存储过程里加
SET DEADLOCK_PRIORITY LOW让批量更新优先被牺牲,保护单条采集请求。
总结
SQL Server 死锁排查的核心思路:先抓证据(XE/Trace Flag),再分析锁链(XML 报告),最后从"减少持锁时间"和"收窄锁范围"两个维度优化。
本次实战中三刀组合拳的效果:
ExecuteUpdateAsync消除先查后改的锁升级窗口- 覆盖索引把锁从页级收到行级
- 批量合并把事务持有时间从 200ms 降到 15ms
死锁从 37 次/天降到 0,采集服务稳定性显著提升。做 MES 采集系统,永远要记住:并发越高,事务越要短。