SQL事务死锁排查:Web API批量处理引发触发器死锁问题
看起来你遇到了典型的「低负载稳定运行,高负载触发死锁」的场景,结合你提供的代码、死锁图和排查过程,咱们来一步步拆解问题并给出修复方案:
问题背景回顾
你的PurchaseController原本处理100条以内的采购数据毫无压力,当数据量突增至600条后出现超时,于是你修改代码将请求拆分为可配置批次,并调整事务粒度,但修改后频繁触发SQL死锁:
Transaction (Process ID 62) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.
将事务移至foreach循环内后死锁频率降低,但并未彻底解决,最终通过死锁图定位到Event_Registration表的触发器是核心问题。
死锁根源分析
从你提供的死锁图可以清晰看到:两个进程都卡在触发器的更新语句上,形成了循环等待:
- 进程54持有
PAGE: 6:1:84432的U锁,等待进程90持有的PAGE: 6:1:1848653的U锁 - 进程90持有
PAGE: 6:1:1848653的IX锁,等待进程54持有的PAGE: 6:1:84432的U锁
原触发器的逻辑存在致命缺陷:
UPDATE [Event_Registration] SET event_number = UPPER(event_number) WHERE event_number in (select event_number from inserted);
这条语句会扫描整个Event_Registration表(如果event_number没有覆盖索引),并锁定所有匹配该event_number的行所在的数据页面。在低数据量时,扫描范围小,锁冲突概率极低;但当数据量暴增后,每次触发器执行都会锁定大量页面,高并发下多个事务交叉锁定不同页面,就会形成循环等待,触发死锁。
具体修复方案
1. 重构触发器逻辑(核心修复)
原触发器的问题是更新范围过大,我们应该只针对本次插入/更新的具体行进行处理,而不是所有匹配event_number的行。修改后的触发器如下:
ALTER Trigger [dbo].[tr_event_registration] on [dbo].[Event_Registration] AFTER insert, update as if ((trigger_nestlevel() > 1) or (@@rowcount = 0)) return; -- 仅更新本次操作涉及的行,缩小锁范围 UPDATE er SET er.event_number = UPPER(er.event_number) FROM [Event_Registration] er INNER JOIN inserted i ON er.entity_number = i.entity_number AND er.event_number = i.event_number;
这样修改后,触发器只会锁定当前事务操作的具体行,而不是扫描全表并锁定大量页面,从根源上消除了锁冲突的基础。
2. 补充索引优化
为Event_Registration表添加复合索引:
CREATE NONCLUSTERED INDEX IX_Event_Registration_Entity_Event ON [dbo].[Event_Registration] ( entity_number ASC, event_number ASC ) INCLUDE (-- 如果触发器或业务有其他需要返回的列,可以添加在这里);
这个索引能让触发器的UPDATE语句快速定位到目标行,避免全表扫描,进一步缩短锁的持有时间。
3. 事务与批量处理的兜底优化
- 保持事务在foreach循环内部的设计,确保每个采购项的事务粒度尽可能小,减少锁的持有时间
- 添加死锁重试逻辑,作为兜底方案,当捕获到死锁异常时自动重试:
int maxRetries = 3; int currentRetry = 0; bool success = false; while (currentRetry < maxRetries && !success) { try { using (var dbTransaction = Members.Database.BeginTransaction()) { // 处理单个Purchase的业务逻辑 response = base.RecordEventRegistrations(Purchase.EventRegistrations); if (!response.IsSuccess) throw new Exception(response.Error); // ... 其他RecordXXX方法调用 dbTransaction.Commit(); success = true; } } catch (SqlException ex) { // 死锁错误号为1205 if (ex.Number == 1205) { currentRetry++; // 指数退避等待,避免立即重试再次冲突 Thread.Sleep(100 * (int)Math.Pow(2, currentRetry - 1)); } else { // 非死锁异常,直接抛出 throw; } } } if (!success) { response.IsSuccess = false; response.Error = "多次重试后仍无法完成操作,请稍后再试"; }
验证建议
你已经验证了禁用触发器后死锁消失,现在应用上述优化后的触发器和索引,应该能彻底解决死锁问题,同时保留event_number转为大写的业务规则。可以先在测试环境模拟高并发场景验证,再部署到生产环境。
内容的提问来源于stack exchange,提问作者Aaron S

