一次奇怪的空指针问题分析:事务、死锁与隐式回滚

Posted by zhangtao on Friday, February 7, 2025

最近我们在排查一个诡异的 空指针异常,整个分析过程可以说是跌宕起伏,最终的结论也颇具隐蔽性。今天就把这个问题分享出来,希望对大家有所帮助。

问题现象

在系统中,我们有 单据 B,它通过一个 关联 ID 字段与 上级单据 A 关联。

但在某次操作中,我们发现:

  1. 单据 B 存在,并且存储了 A 的 ID
  2. 但查询 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();
}

代码逻辑很简单:

  1. 第一步 生成 A。
  2. 第二步 执行 业务方法 C
  3. 第三步 生成 B,并存储 A 的 ID。

由于 B 存储了 A 的 ID,说明 DB生成单据A 代码应该成功执行了。但为什么 A 最终没有出现在数据库中?

难道是 createA() 过程中发生了异常,导致 A 没有生成? 我们查询当时的日志,发现 业务方法 C 在执行时发生了 死锁异常

image-20250207224000545

业务方法 C 的代码如下:

@Transactional
public void doC() {
    try {
        DB生成C();
    } catch (Exception e) {
//        输出异常日志;
    }
}

DB生成C 这一步时,数据库发生了死锁异常,但代码使用了 try-catch,所以理论上不会影响事务的执行,A 和 C 都应该正常生成。

那么问题来了,A 为什么消失了?

问题的根本原因:MySQL 隐式回滚

最终,DBA 通过查询数据库的日志,发现了问题的真正原因——MySQL 发生了“隐式回滚”

image-20250207224130444

MySQL 死锁处理机制 在 MySQL 中,当多个事务发生死锁时,数据库会自动选择一个代价较低的事务进行回滚,以解除死锁。这一行为是 数据库层面的自动回滚不会受到 try-catch 代码的影响

在本例中,事务执行时发生了死锁,MySQL 自动回滚了整个事务 createA(),导致 A 被回滚,实际上根本没被写入数据库。

但由于 doC() 代码中使用了 try-catch,异常并没有往上抛,导致事务继续执行到了 DB生成单据B;。由于 B 在一个新的事务中生成,它最终成功入库,并存储了 已被回滚的 A 的 ID,从而导致数据不一致的问题。

完整过程如下:

  1. DB生成单据A;执行成功(暂时)
  2. DB生成C;发生死锁,MySQL 选择回滚事务 createA(),A 被回滚
  3. 由于 try-catch 捕获了异常,事务继续执行
  4. DB生成单据B;B 在新的事务中成功插入,并存储了已回滚的 A 的 ID

最终,导致 B 关联了一个不存在的 A,后续调用 A 的 get 方法时报空指针异常

如何避免类似问题?

通过这次分析,我们可以总结出几点避免类似问题的经验:

  1. 避免在事务中吞掉异常
  • try-catch 不能仅仅记录日志,如果异常影响了事务的完整性,应该显式回滚整个事务

  • 改进 doC()方法:

    @Transactional
    public void doC() {
        try {
            DB生成C();
        } catch (Exception e) {
            log.error("生成 C 失败", e);
            throw e; // 让事务感知异常,避免错误继续执行
        }
    }
    
  1. 尽量控制事务粒度,避免长时间持有锁
  • 业务方法 C 的执行时间过长,可能加剧死锁风险。

  • 可以考虑将 业务方法 C 放到事务外部执行,避免影响 AB 的创建:

    @Transactional
    public void createA() {
        DB生成单据A();
        DB生成单据B();
    }
    
    public void doC() {
        DB生成C();  // 独立事务,避免影响 A、B
    }
    
  1. 调整执行顺序,将c方法挪至最后

    @Transactional
    public void createA() {
        DB生成单据A();
        DB生成单据B();
      	执行业务方法C();
    }
    

因为B方法的trycatch逻辑因为业务原因没法改,所以我这边采用了3的方法,并且同时优化了B方法,降低了死锁发生的概率。希望这次排查经历,能给大家一些启发!🚀🚀🚀