简易情景复现

单据提交后,将单据数据封装后发送至 BPM 系统进行审批,BPM 系统审批完成后,回传单据,修改单据为审批后的状态。
简易状态图如下:

在实际测试中,部分单据提交后,BPM 回传单据后,BFM 修改单据状态时,会抛出乐观锁版本不一致的异常。

错误分析

日志分析

观察关键日志

1
2
3
4
5
6
2025-09-16 15:38:54.856 DEBUG ... [task-5] ==>  Preparing: UPDATE ...
2025-09-16 15:38:54.858 DEBUG ... [task-5] ==> Parameters: ...
2025-09-16 15:38:58.990 DEBUG ... [task-6] ==> Preparing: UPDATE ...
2025-09-16 15:38:58.996 DEBUG ... [task-6] ==> Parameters: ...
2025-09-16 15:38:59.005 DEBUG ... [task-6] <== Updates: 1
2025-09-16 15:38:59.042 DEBUG ... [task-5] ==> Updates: 0

发现线程 5 先提交了更新了,但是却要等到线程 6 的更新完成,线程 5 的更新被阻塞,而线程 6 更新后导致乐观锁字段版本变化,导致线程 5 更新失败。

经检查,发现线程 5 是 BPM 回传处理单据审批状态的线程,线程 6 是 BFM 修改单据状态的线程。检查日志后发现,线程 6 执行时间长达 3 分钟。

由于线程 6 先开始执行,该单据的行锁一直被线程 6 占用,导致线程 5 执行更新时被阻塞,而线程 5 获取锁后,版本号已经被线程 6 修改,导致更新失败。

代码分析

检查两个线程分别对应的方法,发现线程 6 发送请求 BPM 之后需要等待响应结果,而 BPM 应该发起了审批完成请求到线程 5,两者速度相差不大,导致线程 6 还没有执行完,线程 5 就接收到了请求,并开始处理。

而线程 5 只使用了 SELECT …,并且没有更新失败进行重试,而是直接抛出异常。

修复方案

临时修复方案

  1. 使用 SELECT … FOR UPDATE, 在读取时添加排他锁
    SELECT … FOR UPDATE 也会对数据行添加排它锁,获取不到锁时阻塞,这种方法基本可以保证成功修改。

  2. 添加重试机制
    在方法中尝试捕获乐观锁异常,捕获异常后进行重试。这种方案需要考虑重试的次数、重试的间隔,并且需要重新进行数据查询。

在此次的场景中,线程 6 执行时间太长,失败概率太大,所以采用方案 1

优化方案

  1. 可对长事务的流程进行优化,将事务进行拆分
    在这个场景中,单据提交本身并没有大并发量,该方案成本较高,且对业务逻辑有较大影响,所以不建议采用。
  2. 优化发送 BPM 流程
    在日志中看到,发送 BPM 接收响应的时间和审批处理的时间并不太长,可以考虑合并两流程,在发送 BPM 的响应中回传审批状态。