最近我们在排查一个诡异的 空指针异常,整个分析过程可以说是跌宕起伏,最终的结论也颇具隐蔽性。今天就把这个问题分享出来,希望对大家有所帮助。
问题现象
在系统中,我们有 单据 B,它通过一个 关联 ID
字段与 上级单据 A 关联。
但在某次操作中,我们发现:
- 单据 B 存在,并且存储了 A 的 ID。
- 但查询 A 时,却查不到数据,导致后续代码调用 A 的
get
方法时报空指针异常。
理论上,B 既然存储了 A 的 ID,A 就应该存在,否则 A 的 ID 是怎么来的?
难道 A 被删除了?
然而,代码中并没有删除 A 的逻辑,而且 DBA 查询了数据库日志,确认 A 从未被删除。那么,这就只剩下一种可能:A 从未生成。
代码分析
我们回溯代码,A 和 B 是在同一个事务内生成的,具体逻辑如下:
@Transactional
public void createA() {DB生成单据A;执行业务方法C;DB生成单据B;
}
代码逻辑很简单:
- 第一步 生成 A。
- 第二步 执行 业务方法 C。
- 第三步 生成 B,并存储 A 的 ID。
由于 B 存储了 A 的 ID,说明 DB生成单据A
代码应该成功执行了。但为什么 A 最终没有出现在数据库中?
难道是 createA()
过程中发生了异常,导致 A 没有生成?
我们查询当时的日志,发现 业务方法 C 在执行时发生了 死锁异常!
业务方法 C 的代码如下:
@Transactional
public void doC() {try {DB生成C;} catch (Exception e) {输出异常日志;}
}
在 DB生成C
这一步时,数据库发生了死锁异常,但代码使用了 try-catch
,所以理论上不会影响事务的执行,A 和 C 都应该正常生成。
那么问题来了,A 为什么消失了?
问题的根本原因:MySQL 隐式回滚
最终,DBA 通过查询数据库的日志,发现了问题的真正原因——MySQL 发生了“隐式回滚”。
MySQL 死锁处理机制
在 MySQL 中,当多个事务发生死锁时,数据库会自动选择一个代价较低的事务进行回滚,以解除死锁。这一行为是 数据库层面的自动回滚,不会受到 try-catch
代码的影响。
在本例中,事务执行时发生了死锁,MySQL 自动回滚了整个事务 createA()
,导致 A 被回滚,实际上根本没被写入数据库。
但由于 doC()
代码中使用了 try-catch
,异常并没有往上抛,导致事务继续执行到了 DB生成单据B;
。由于 B 在一个新的事务中生成,它最终成功入库,并存储了 已被回滚的 A 的 ID,从而导致数据不一致的问题。
完整过程如下:
DB生成单据A;
→ 执行成功(暂时)DB生成C;
→ 发生死锁,MySQL 选择回滚事务createA()
,A 被回滚- 由于
try-catch
捕获了异常,事务继续执行 DB生成单据B;
→ B 在新的事务中成功插入,并存储了已回滚的 A 的 ID
最终,导致 B 关联了一个不存在的 A,后续调用 A 的 get
方法时报空指针异常。
如何避免类似问题?
通过这次分析,我们可以总结出几点避免类似问题的经验:
- 避免在事务中吞掉异常
-
try-catch
不能仅仅记录日志,如果异常影响了事务的完整性,应该显式回滚整个事务。 -
改进 doC()方法:
@Transactional public void doC() {try {DB生成C;} catch (Exception e) {log.error("生成 C 失败", e);throw e; // 让事务感知异常,避免错误继续执行} }
- 尽量控制事务粒度,避免长时间持有锁
-
业务方法 C
的执行时间过长,可能加剧死锁风险。 -
可以考虑将
业务方法 C
放到事务外部执行,避免影响A
和B
的创建:@Transactional public void createA() {DB生成单据A;DB生成单据B; }public void doC() {DB生成C; // 独立事务,避免影响 A、B }
-
调整执行顺序,将c方法挪至最后
@Transactional public void createA() {DB生成单据A;DB生成单据B;执行业务方法C; }
因为B方法的trycatch逻辑因为业务原因没法改,所以我这边采用了3的方法,并且同时优化了B方法,降低了死锁发生的概率。希望这次排查经历,能给大家一些启发!🚀🚀🚀