一、问题描述
新开发的系统,往往可能需要将旧版的系统中的历史数据,用脚本的方式在新系统中跑一遍业务流程,其实可能是用Java代码自动调用一些业务流程接口。
在执行过程中发现报错:
2021-01-27 19:32:46.300 [http-nio-5090-exec-4] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet]:182 - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
[Request processing failed;
nested exception is org.springframework.orm.ObjectOptimisticLockingFailureException:
Object of class [com.etc.trade.domain.ProcessBill] with identifier [378]:
optimistic locking failed;
nested exception is org.hibernate.StaleObjectStateException:
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.etc.trade.domain.ProcessBill#378]] with root cause
报错显示,id=378的ProcessBill对象的乐观锁失败,该记录被另外一个事务修改或删除。
二、排查定位
这种异常一般是由于,数据库里的某一条记录的某一个版本对应的信息,同时被两个事务读取到并试图进行写操作(包括更新和删除);这种情况下第一个写成功的事务不会有影响,第二个事务再对同一版本的同一记录进行写操作时,抛出乐观锁异常ObjectOptimisticLockingFailureException
。
2.1 乐观锁
同悲观锁一样,乐观锁一样也是为了一定程度上解决并发问题。
乐观锁的实现是CAS
机制(compare and swap),但需注意ABA
问题。
在数据库上,乐观锁的实现一般是采用类似的版本号机制
,相比悲观锁,它开销小,效率高,适用于冲突不多的多读场景;即保持最乐观的态度,赌你不会遇到多读少写下很小几率的并发问题。
如果运气不好遇到了并发问题,乐观锁的处理是只会抛出一个这样的异常,进一步处理的权力交由程序员。
乐观锁版本号机制示例如下:
UPDATE `process_bill`
SET `status` = 'new status',
`version` = `version` + 1
WHERE
`bill_no` = 'B2021013016266789' AND `version` = 2
2.2 分析推测
- 条件: 本项目中使用了JPA操作数据库,基于
@Version
的乐观锁机制,
对每一个流程单processBill,会有多次不同接口调用(即多次业务流程推进),上一个接口A返回时会触发一个异步任务T,在拿到这个接口A的返回后,脚本会触发调用下一个接口B。 - 结果:
此时异步任务T和下一个接口B可能取到了同一个版本的processBill,而异步任务T先将version由2变为3,之后下一个接口B再试图将version由2变为3时就必然失败。
三、问题处理
乐观锁异常有多种解决方案,根据不同项目的实际业务场景采取各自适合的方式。
对本文中的场景,由于异步任务T实际上不是耗时任务,所以最终将异步任务T改为在接口A中同步处理。
另外,常见的处理方式有:
- 对此异常添加重试机制
@Query
原生写操作,避免JPA的@Version
乐观锁写操作的机制- 写操作使用MySQL行锁的悲观锁(
for update
),在数据库层面最大程度避免了并发问题,但数据库锁时间长,开销大,更应该在应用层做处理 - 必要时在应用层对关键对象,使用
zookeeper
等分布式锁