从一次SQL Server死锁排查,聊聊我们项目里踩过的‘大事务’与读写混合的坑

张开发
2026/5/8 10:11:37 15 分钟阅读

分享文章

从一次SQL Server死锁排查,聊聊我们项目里踩过的‘大事务’与读写混合的坑
从SQL Server死锁案例剖析大事务与读写混合的设计陷阱那天凌晨三点值班手机突然响起刺耳的报警声。监控系统显示核心订单表出现大量死锁交易成功率断崖式下跌到62%。团队紧急回滚了当晚发布的代码但问题远比想象中复杂——这竟是由一个看似无害的查询优化改动引发的连锁反应。本文将还原这次事故的技术真相揭示大事务与读写混合操作背后的危险陷阱。1. 死锁现场当简单查询变成系统杀手我们的电商平台在促销活动期间订单服务出现了诡异的性能波动。DBA提供的死锁图显示两个会话正在争夺同一批数据页的锁资源-- 死锁涉及的会话A订单创建事务 BEGIN TRAN INSERT INTO orders(...) VALUES(...) -- 获取X锁 UPDATE inventory SET stockstock-1 WHERE product_id123 -- 请求U锁 SELECT * FROM user_coupons WHERE user_id456 -- 请求S锁 COMMIT -- 会话B库存盘点事务 BEGIN TRAN SELECT * FROM inventory WHERE warehouseeast -- 持有S锁 UPDATE inventory SET last_checkNOW() WHERE warehouseeast -- 请求U锁 COMMIT关键冲突点在于会话A的UPDATE需要将SELECT获得的共享锁(S锁)升级为排他锁(X锁)会话B的UPDATE同样在等待S锁升级两个会话互相阻塞形成经典的锁升级死锁注意SQL Server的锁升级机制会在单个语句获取超过5000个锁时自动将细粒度锁升级为表锁这常常成为死锁的催化剂。2. 大事务的四宗罪为什么你的性能优化适得其反2.1 锁持续时间过长我们原本将多个操作合并到单个事务中的优化实际上制造了更长的锁持有时间。测试数据显示事务模式平均锁持有时间(ms)死锁率(%)小事务(5ms)120.3大事务(150ms)21017.62.2 锁资源堆积当事务包含多个表的DML操作时锁数量呈指数级增长。一个包含10个操作的大事务可能持有数据行锁索引键锁页锁表意向锁2.3 锁升级风险SQL Server的锁升级阈值是-- 查看当前实例的锁升级阈值 SELECT name, value_in_use FROM sys.configurations WHERE name LIKE %lock%当锁数量超过该阈值时引擎会尝试将多个行锁升级为单个表锁这个过程极易引发阻塞。2.4 内存压力每个锁约占用100字节内存十万个锁就意味着10MB的纯锁内存消耗。我们的监控显示死锁发生时Memory Clerk: Lock Manager || Used(KB): 8,7423. 读写混合的隐蔽代价你以为的优化可能是性能黑洞那个引发事故的优化代码看起来人畜无害// 原始代码安全但低效 public Order createOrder(OrderDTO dto) { checkInventory(dto); // 独立查询 validateCoupons(dto); // 独立查询 return transactionTemplate.execute(status - { Order order insertOrder(dto); // INSERT updateInventory(dto); // UPDATE return order; }); } // 优化后代码危险 public Order createOrder(OrderDTO dto) { return transactionTemplate.execute(status - { checkInventory(dto); // 移到事务内 validateCoupons(dto); // 移到事务内 Order order insertOrder(dto); updateInventory(dto); return order; }); }混合操作的致命组合SELECT获取共享锁(S锁)UPDATE需要升级为排他锁(X锁)INSERT获取新的排他锁不同会话的锁请求形成环形依赖4. 实战解决方案从应急止血到架构免疫4.1 紧急止血方案我们首先启用了读提交快照隔离(RCSI)ALTER DATABASE ECommerce SET READ_COMMITTED_SNAPSHOT ON;效果对比指标RCSI关闭RCSI开启死锁/小时432平均响应时间287ms312msCPU使用率68%72%提示RCSI会增加tempdb的负担需确保tempdb有足够的IOPS和空间4.2 事务拆分黄金法则长期解决方案是重构事务边界查询与写操作分离// 正确的分层设计 public Order createOrder(OrderDTO dto) { // 只读查询放在事务外 checkInventory(dto); validateCoupons(dto); return transactionTemplate.execute(status - { // 只包含必要的写操作 Order order insertOrder(dto); updateInventory(dto); return order; }); }大事务拆分为小批次-- 原大事务 BEGIN TRAN UPDATE large_table SET col1val1 WHERE date2020-01-01 COMMIT -- 拆分后 DECLARE batch_size INT 1000 WHILE EXISTS(SELECT 1 FROM large_table WHERE date2020-01-01 AND processed0) BEGIN BEGIN TRAN UPDATE TOP (batch_size) large_table SET col1val1, processed1 WHERE date2020-01-01 AND processed0 COMMIT WAITFOR DELAY 00:00:00.1 -- 给其他会话喘息机会 END4.3 锁提示的谨慎使用在某些必须混合读写的场景可以有限度地使用NOLOCK-- 仅适用于可以容忍脏读的报表查询 SELECT * FROM inventory WITH(NOLOCK) WHERE warehouseeast锁提示风险矩阵提示适用场景风险NOLOCK报表查询脏读UPDLOCK防止丢失更新可能增加阻塞ROWLOCK避免锁升级内存消耗大5. 深度防御构建死锁免疫系统5.1 实时监控体系我们部署了以下监控策略# 死锁监控PowerShell脚本 $query SELECT event_timestamp, deadlock_xml.value((//victim-process/id)[1],VARCHAR(50)) as victim, deadlock_xml.value(count(//process),INT) as process_count FROM sys.fn_get_audit_file(C:\Audits\*.sqlaudit,DEFAULT,DEFAULT) CROSS APPLY (SELECT CAST(event_data AS XML) AS deadlock_xml) as x WHERE action_id DLK Invoke-Sqlcmd -Query $query -ServerInstance DBServer | Export-Csv -Path deadlocks.csv5.2 压力测试红蓝对抗在CI/CD管道中加入死锁测试// JUnit死锁测试用例 Test public void shouldNotDeadlockWhenConcurrentOrderAndInventory() { // 模拟100个并发用户 IntStream.range(0, 100).parallel().forEach(i - { orderService.createOrder(randomOrder()); inventoryService.checkStock(randomProduct()); }); assertThat(deadlockMonitor.getCount()).isZero(); }5.3 架构级解决方案最终我们采用了CQRS模式彻底分离读写写入服务 ──[事件]── 读模型更新器 ── 优化过的读库 ↑ └── 事件总线实施效果写库死锁降为0读库查询性能提升4倍订单创建吞吐量从120TPS提升到540TPS那次事故后我们建立了事务健康度评分机制每个代码提交都会自动评估事务持续时间 (200ms扣分)锁数量估计 (50扣分)混合操作检测 (SELECTDML扣分)这个自动化检查已经拦截了17次潜在的危险提交。有时候最好的优化不是让代码做更多而是让它少惹麻烦。

更多文章