需求背景
对系统内的物品做一个SN码追踪,我这边的设计是入库和出库操作都会将物品的SN码和相关单据信息记录到一张表中,由于存在小零件的物品,一包几百个都是同一个SN码,所以这里牵扯到一个数量。表结构的核心字段如下
字段 | 类型 |
---|---|
SN码 | varchar |
操作单号 | varchar |
操作数量 | int |
库内余数 | int |
… | … |
并发问题
由于属于日志表,因此这张表的操作只能插入。字段库内余数
需要先查询最近的一次操作记录,拿到上次的库内余数
,然后与本次操作数量
做计算,得到新的库内余数
,再进行插入。
这里就会产生一个并发问题,这里直接使用了Lock
来保证串行执行,伪代码未下:
@Transactional
void m1(parm){
try{
//前置处理
lock.lock()
//业务代码
// 1. 查询最近一次记录的 库内余数
// 2. 计算本次操作后的 库内余数
// 3. 插入记录
}finally{
lock.unlock()
}
}
想法很好,并发时获取不到锁就阻塞,然后拿到锁后再进行操作。
单元测试,10个线程,并发插入1000条记录,测试失败!
问题分析
数据有误,说明在计算新的库内余数
时,部分线程没有拿到最新的数据。因此经过分析后,我注意到了@Transactional
,由于ERP系统用户少,并发不高,但数据比较重要,所以我们的业务层基本都是跑在事物下的,而本次的并发问题,罪魁祸首就是这个事物。
在线程进入方法后,一旦与数据库进行交互,会正式开启一个事物,之后获取锁,获取失败就会进行阻塞。当线程醒过来时,会继续之前的事物进行执行。而由于事物的隔离性,此时当前线程拿到的数据都是它开启事物时的数据,但这个数据其实并不一定是最新的!因此错误就产生了!
解决方案
我这里是直接采用了TransactionTemplate进行手动事物提交,当然也可以将这部分代码提出来,然后进行方法调用(注意同对象方法内调用不走代理事物失效的问题)。总之保证事物开启必须要在获取锁之后再开启即可。
void m1(parm){
try{
//前置处理
lock.lock()
//业务代码
// 手动开启事物
// 1. 查询最近一次记录的 库内余数
// 2. 计算本次操作后的 库内余数
// 3. 插入记录
}finally{
lock.unlock()
}
}
再次测试,成功!
一波未平一波又起
经过上面的方法,我在自己的Controller层调用时没问题了,但情况是我这个服务不仅我自己调用,别的服务也会在Service层(含事物)调用我的服务,而在他们的Service层进行第一次数据库访问时,事物便开启了,从而导致后面的数据都是事物开启时间的数据,再次引发上面所述的问题。
最终解决方案
问题的核心就是在我们获取锁后进行数据库查询时,必须要拿到最新的数据,因此我新开了一个事物,如下:
void m1(parm){
try{
//前置处理
lock.lock()
context.getBean(MyServiceImpl.class).doInsert(args)
}finally{
lock.unlock()
}
}
// 事物传播新开一个事物
@Transactional(propagation=Propagation.REQUIRES_NEW)
void doInsert(args){
// 1. 查询最近一次记录的 库内余数
// 2. 计算本次操作后的 库内余数
// 3. 插入记录
}
最终结果正常。
锁的优化
后期优化将锁换成了redis的锁,jdk的方法锁开销还是蛮大的,我们的数据只会计算同一个批次的余数,因此使用redis的锁对批次进行锁,可以有效提高并发量(虽然用不着,但学习还是需要的O(∩_∩)O)