记录一个数据库长事务bug
简易情景复现
单据提交后,将单据数据封装后发送至 BPM 系统进行审批,BPM 系统审批完成后,回传单据,修改单据为审批后的状态。
简易状态图如下:
sequenceDiagram
participant Client
participant BFM
participant BPM
Client->>BFM: 提交单据
BFM->>BFM: 处理单据
BFM->>BPM: 发送单据
BPM->>BPM: 审批单据
BPM->>BFM: 回传单据
BFM->>BFM: 修改单据状态
在实际测试中,部分单据提交后,BPM 回传单据后,BFM 修改单据状态时,会抛出乐观锁版本不一致的异常。
错误分析
日志分析
观察关键日志
1 | 2025-09-16 15:38:54.856 DEBUG ... [task-5] ==> Preparing: UPDATE ... |
发现线程 5 先提交了更新了,但是却要等到线程 6 的更新完成,线程 5 的更新被阻塞,而线程 6 更新后导致乐观锁字段版本变化,导致线程 5 更新失败。
经检查,发现线程 5 是 BPM 回传处理单据审批状态的线程,线程 6 是 BFM 修改单据状态的线程。检查日志后发现,线程 6 执行时间长达 3 分钟。
由于线程 6 先开始执行,该单据的行锁一直被线程 6 占用,导致线程 5 执行更新时被阻塞,而线程 5 获取锁后,版本号已经被线程 6 修改,导致更新失败。
代码分析
检查两个线程分别对应的方法,发现线程 6 发送请求 BPM 之后需要等待响应结果,而 BPM 应该发起了审批完成请求到线程 5,两者速度相差不大,导致线程 6 还没有执行完,线程 5 就接收到了请求,并开始处理。
而线程 5 只使用了 SELECT …,并且没有更新失败进行重试,而是直接抛出异常。
修复方案
临时修复方案
使用 SELECT … FOR UPDATE, 在读取时添加排他锁
SELECT … FOR UPDATE 也会对数据行添加排它锁,获取不到锁时阻塞,这种方法基本可以保证成功修改。添加重试机制
在方法中尝试捕获乐观锁异常,捕获异常后进行重试。这种方案需要考虑重试的次数、重试的间隔,并且需要重新进行数据查询。
在此次的场景中,线程 6 执行时间太长,失败概率太大,所以采用方案 1
优化方案
- 可对长事务的流程进行优化,将事务进行拆分
在这个场景中,单据提交本身并没有大并发量,该方案成本较高,且对业务逻辑有较大影响,所以不建议采用。 - 优化发送 BPM 流程
在日志中看到,发送 BPM 接收响应的时间和审批处理的时间并不太长,可以考虑合并两流程,在发送 BPM 的响应中回传审批状态。