拿到数据库报错日志,看到“Deadlock found when trying to get lock”时,大部分开发者的第一反应是重启事务或者重试。这种做法虽然能临时止血,但如果不读懂死锁日志里的细节,下次流量高峰依然会被同样的坑绊倒。日志不是用来吓人的,它是一份精确的案发现场报告,告诉你两个事务是如何互相卡住脖子的。

以MySQL为例,典型的死锁日志由几个关键部分组成:发生时间、事务编号、持锁与等锁状态、被回滚的事务。执行SHOW ENGINE INNODB STATUS后,拉到最后面的“LATEST DETECTED DEADLOCK”段落,就能看到最近一次死锁的完整记录。日志里会明确写出“TRANSACTION 1”和“TRANSACTION 2”,以及它们各自执行了哪条SQL、持有哪些锁、正在等待哪些锁。读懂这些信息,是精准定位代码问题的前提。

拆解日志中的锁类型与等待关系

死锁日志中经常出现“lock_mode X locks rec but not gap”和“lock_mode X locks gap before rec”这类描述。前者是记录锁,锁住了具体的某一行;后者是间隙锁,锁住了索引记录之间的空隙。在可重复读隔离级别下,间隙锁的存在是为了防止幻读,但它也是死锁的高发地带。日志里还会看到“WAITING FOR THIS LOCK TO BE GRANTED”,紧接着会列出被等待的锁在哪个索引上、锁的是哪个值。比如“space id 32 page no 3 n bits 72 index PRIMARY of table test.t_order”就告诉你,事务正在等待主键索引上的某个锁。把两个事务的持锁和等锁信息对照着看,就能画出等待图,找到循环依赖的那个点。

从日志倒推SQL执行顺序

死锁日志里最容易被忽略但最有价值的部分,是事务执行过的语句列表。日志会列出每个事务“MySQL thread id”之后的一串SQL,有时甚至包含具体的参数值。通过对比两个事务的语句顺序,你会发现典型的死锁模式:事务A先更新主键为1的行,再更新主键为2的行;事务B先更新主键为2的行,再更新主键为1的行。这种交叉顺序就是死锁的根源。更隐蔽的情况是,两个事务操作的是不同表,但因为外键约束或二级索引的加锁顺序不同,也会产生死锁。日志中的“RECORD LOCKS”段落会精确告诉你锁是加在哪个索引上的,结合表结构就能还原出完整的加锁过程。

业务代码层面的规避策略

死锁的本质是资源竞争,数据库层面无法完全消除,但业务代码可以从几个维度大幅降低死锁概率。最直接有效的方法是统一加锁顺序。如果你的业务逻辑需要同时操作订单表和库存表,那就规定所有涉及这两张表的事务都先锁订单再锁库存。在代码中,这意味着把对这两张表的操作封装到同一个Service方法里,并且严格按照固定顺序调用DAO层方法。如果业务逻辑确实无法统一顺序,比如不同接口的调用路径天然相反,那就需要在更细的粒度上做文章,比如把批量更新操作拆分成按主键排序后的小批次,确保每次事务内部的行级锁获取顺序一致。

缩小事务范围与减少持锁时间

很多死锁是因为事务过大、持锁时间过长导致的。检查你的代码,如果事务里包含了远程RPC调用、文件读写、甚至发送消息等非数据库操作,这些操作会大幅延长事务的持续时间,让锁一直被占用。正确的做法是把这些非必要的操作移出事务边界,只在真正需要数据库原子性保证的那几行代码上开启事务。另外,批量操作时不要一次性处理上万条数据,可以分批次提交,每批几百条,这样单次事务持锁时间短,死锁概率自然下降。

索引优化对锁粒度的影响

日志中如果出现大量间隙锁等待,往往是因为SQL语句没有走合适的索引,导致数据库锁定了比预期更多的行。比如一条UPDATE语句的WHERE条件字段没有索引,数据库就会进行全表扫描,对扫描到的每一行都加锁,甚至在间隙上也加锁。这种大范围的锁持有很容易与其他事务产生冲突。给WHERE条件字段加上合适的索引,让数据库能够精确定位到目标行,锁的粒度从表级或大范围间隙锁缩小到行级记录锁,死锁发生的概率会大幅降低。查看日志中的“index”字段,如果发现锁是加在非预期的索引上,就要去检查SQL执行计划了。

利用唯一索引和主键避免间隙锁

在可重复读隔离级别下,如果WHERE条件命中的是唯一索引且条件是等值查询,数据库只会加记录锁而不会加间隙锁。这意味着如果你的业务场景允许,尽量使用主键或唯一索引进行精确更新,可以避免很多因间隙锁引发的死锁。比如把“UPDATE t_order SET status=2 WHERE order_no='xxx'”改成“UPDATE t_order SET status=2 WHERE id=12345”,前提是你能先通过order_no查到对应的主键id。这个小小的改动,在并发量高的场景下效果非常显著。

降低隔离级别作为备选方案

如果业务对幻读不敏感,可以考虑将数据库隔离级别从可重复读降为读已提交。在读已提交级别下,间隙锁不再生效,只保留记录锁,这能消除一大类因间隙锁竞争导致的死锁。但这不是银弹,降低隔离级别意味着你要在业务代码中处理可能出现的不可重复读和幻读问题,比如通过乐观锁版本号机制来保证数据一致性。这个决策需要架构师和业务负责人一起评估,不能盲目使用。

死锁重试机制的正确实现

即便做了上述优化,死锁仍然可能发生,这是分布式数据库的客观现实。业务代码必须具备死锁重试能力。关键点在于重试必须是幂等的,且重试次数和间隔要合理。Spring框架提供了@Retryable注解可以方便地实现重试,但更推荐在捕获死锁异常后,手动进行有限次数的重试,并在每次重试前加上一个随机毫秒级的休眠,避免两个事务再次同时碰撞。代码示例如下:

int retryCount = 0;
while (retryCount < 3) {
    try {
        // 执行数据库操作
        orderService.updateOrderStatus(orderId, status);
        break;
    } catch (DeadlockLoserDataAccessException e) {
        retryCount++;
        if (retryCount == 3) {
            throw new BusinessException("操作失败,请稍后重试");
        }
        Thread.sleep(new Random().nextInt(100));
    }
}

这段代码捕获了Spring包装后的死锁异常,最多重试3次,每次随机休眠0到100毫秒,有效降低了再次死锁的概率。注意重试逻辑要放在事务边界之外,否则重试时事务已经回滚,需要重新开启。

从监控角度主动发现死锁隐患

不要等到线上报错才去看日志。在测试环境或预发环境,可以通过压测工具模拟并发场景,主动触发并分析死锁日志。把死锁日志的关键信息接入公司的监控平台,当死锁发生频率超过阈值时自动告警。同时,定期分析慢查询日志和死锁日志的关联性,很多死锁的前兆就是某些SQL执行时间变长,持锁时间随之增加。把这些分析结果反馈到代码评审环节,形成正向循环。

死锁日志是一份精确的技术文档,它告诉你代码里哪两个操作在打架。把日志里的锁类型、索引名称、等待关系读透,回到代码层面去调整加锁顺序、优化索引、缩小事务范围,大部分死锁问题都能从根源上解决。剩下的,交给重试机制和监控体系兜底,系统在并发下的稳定性会提升一个量级。