1. 线上死锁事故现场还原
那天凌晨2点15分,我正在家里睡得正香,突然被一阵急促的电话铃声惊醒。运维同事告诉我订单系统出现了大量死锁告警,用户提交的批量订单有近30%失败。我立刻打开电脑连上生产环境,在错误日志中看到了熟悉的提示:"Deadlock found when trying to get lock; try restarting transaction"。
这个电商平台的订单系统有个典型的高并发场景:每天凌晨会有大量商户批量更新订单状态。当时正好赶上促销活动,每秒并发量达到平时5倍。通过show engine innodb status命令查看最新死锁日志,发现两个事务在互相等待对方持有的锁:
code复制*** (1) TRANSACTION:
TRANSACTION 182335678, ACTIVE 0 sec updating rows
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 3 row lock(s)
UPDATE orders SET status = 'shipped' WHERE merchant_id = 10086 AND create_time > '2023-06-01'
*** (2) TRANSACTION:
TRANSACTION 182335679, ACTIVE 0 sec updating rows
mysql tables in use 1, locked 1
5 lock struct(s), heap size 1136, 3 row lock(s)
UPDATE orders SET tracking_no = 'SF123456' WHERE order_id IN (SELECT order_id FROM orders WHERE merchant_id = 10086 LIMIT 100)
2. MySQL锁机制深度解析
2.1 行锁的底层实现原理
很多人以为行锁就是直接锁住磁盘上的数据行,其实不然。InnoDB的行锁是通过锁索引实现的,这个认知非常重要。我们的订单表上有三个索引:
- 主键索引(聚簇索引):order_id
- 普通索引:merchant_id
- 联合索引:(merchant_id, create_time)
当执行UPDATE orders SET status = 'shipped' WHERE merchant_id = 10086时:
- 先通过merchant_id索引找到所有符合条件的记录
- 对这些merchant_id索引记录加X锁
- 通过merchant_id索引中存储的主键值回表查询完整记录
- 对主键索引记录加X锁
2.2 死锁产生的四大必要条件
这次事故完美符合死锁产生的所有条件:
- 互斥条件:X锁是排他的,一个资源每次只能被一个事务占用
- 请求与保持:事务1持有merchant_id索引锁,同时请求主键锁;事务2持有主键锁,同时请求merchant_id锁
- 不剥夺条件:已获得的锁不能被其他事务强制剥夺
- 环路等待:事务1等待事务2释放锁,事务2也在等待事务1释放锁
3. 死锁排查实战指南
3.1 利用系统表快速定位问题
遇到死锁不要慌,MySQL提供了完善的分析工具。我常用的排查步骤:
sql复制-- 查看当前所有事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看锁等待关系
SELECT * FROM performance_schema.events_waits_current;
-- 查看最近发生的死锁详情
SHOW ENGINE INNODB STATUS\G
重点关注LATEST DETECTED DEADLOCK段,它会显示:
- 参与死锁的事务SQL语句
- 每个事务持有的锁和等待的锁
- 最终被选为牺牲品(victim)的事务
3.2 解读执行计划中的锁隐患
通过EXPLAIN分析那两个冲突SQL的执行计划,发现了关键问题:
sql复制EXPLAIN UPDATE orders SET status = 'shipped' WHERE merchant_id = 10086;
结果显示使用了merchant_id索引,但需要回表查询。而批量更新订单号的那个SQL虽然用了主键,但子查询里又用到了merchant_id条件。这就形成了经典的"交叉加锁"场景。
4. 从根本解决死锁问题
4.1 索引优化方案
经过分析,我们对订单表做了以下优化:
- 将(merchant_id, create_time)联合索引改为(merchant_id, create_time, status)覆盖索引
- 为高频查询增加status字段的单独索引
- 调整索引顺序,使最常用的查询条件能命中索引最左前缀
优化后的SQL执行时不再需要回表,直接通过覆盖索引完成更新:
sql复制ALTER TABLE orders
DROP INDEX idx_merchant_time,
ADD INDEX idx_merchant_time_status (merchant_id, create_time, status);
4.2 事务拆分与重试机制
对于必须使用多语句事务的场景,我们制定了新的开发规范:
- 按照固定顺序访问表和索引(先主键后二级索引)
- 大事务拆分为小事务,每个事务处理不超过100条记录
- 实现自动重试逻辑:
java复制// 伪代码示例
int retries = 3;
while(retries-- > 0){
try {
executeTransaction();
break;
} catch(DeadlockException e){
Thread.sleep(100 * (3 - retries)); // 指数退避
}
}
5. 预防死锁的最佳实践
经过这次事故,我们总结了以下经验:
- 监控预警:在Zabbix中配置死锁次数监控,超过阈值自动告警
- SQL审核:所有上线SQL必须经过执行计划检查,禁止全表更新
- 索引规范:
- 更新语句必须使用主键或唯一索引
- 避免在条件中使用不确定的函数如
NOW() - 联合索引字段顺序遵循ARC原则(Access、Range、Cover)
- 压测验证:任何批量操作上线前必须通过JMeter模拟高并发测试
这次死锁排查经历让我深刻体会到,数据库优化不是一劳永逸的工作。随着业务量增长,需要持续监控和调整。现在我们的订单系统已经稳定运行了半年多,再没出现过类似问题。关键是要建立完善的预防机制,而不是等问题发生了再去救火。