二手财务系统在收到用户付款后,会做费用项明细拆分。即按照应收费用明细顺序,依次做金额填充,生成实收费用项明细。
然而,财务最近发生了一起奇怪的拆分,分别收取了金额¥2000 和¥3000的收入,最终拆分结果是:
费用一:¥2000、费用一:¥2000、费用二:¥1000
并不是预期结果:
费用一:¥2000、费用二:¥3000
这是怎么回事?
在业务实际场景中, 我们假定用户应该支付:
费用一:2000、费用二:3000,一共待支付5000。
在支付时,可以选择一次性支付5000,或者分多次支付。
当用户选择一次性付款5000时,系统会按照应收明细拆分成两笔收款,分别是¥2000 和¥3000。
当用户分两次完成支付时,系统同样是按照应收明细拆分成了两笔:
可以看出,正常情况下,不论用户怎样进行付款,最后收款都会按照应收明细拆分成一样的结果。
根据问题现象进行反推,我们猜测问题可能是这样产生的:
第二笔收入进来时,并没有按照¥3000的应收明细进行拆分,而是按照先前已经拆分完的第一笔应收明细进行拆分,再将余额按照第二笔应收明细进行拆分。简单讲,就是收入拆分重复了。
经过排查日志,发现对这两笔收入的拆分发生在了两个不同的进程中。我们简单梳理一下每笔收入的拆分逻辑,系统会从数据库中读取“待分配”和“已分配”的明细,来确定该对照着哪笔“待分配明细”进行拆分。
那么会不会是读取的“已分配明细”出错了呢?处理收入¥2000 和¥3000的时候,都认为自己是最新的收入,然后参照着同一份“待分配明细”去填充。为此我们找到两个进程的的日志去看,大致时间如下:
另外,我们还对比了第二步读取待分配和已分配明细中获得的数据,发现是一样的,很明显这是一个经典的脏读问题。
为了解决并发导致的数据错误,首先想到的方法是加锁,使同一笔订单,在同一时间,只能由一个线程处理。笔者使用Redis锁进行限制,并将合同号作为Redis锁的key。至于锁加在哪里,当然是加在拆分的方法之外了。
修改后的逻辑大致是这样的:
给出这个解决方案后,在窗口期就进行了上线修复。本以为事情告一段落了,谁知两周之后,叕出现了拆分重复的情况。
看来之前提出加锁的方案并没有解决问题,这个问题必定隐藏着深层次的原因。前面已经进行了分析,必定是引起的脏读导致的,可是为什么加锁解决不了呢?难道是锁没有生效?
在进一步探究之前,我们先来复习一些概念。
为了提高效率,数据库使用了多线程对数据进行读写,这也就可能导致数据读写存在问题。问题可以归为三类:
并发问题 | 描述 |
---|---|
脏读 | 事务1没有提交数据,事务B就读取未提交的数据并进行处理 |
不可重复读 | 事务B分两次读取数据,期间事务1提交了一次数据,导致事务B读取的数据不一致 |
幻读 | 事务B两次读取表格数据,此时事务1进行了增删数据的更新,导致事务B读取的数据条数不一致 |
为了应对上述数据读写存在的问题,MySQL设置了4种隔离级别,每种隔离级别可以避免不同的问题,如下表所示:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(RU, Read uncommitted) | 不可避免 | 不可避免 | 不可避免 |
读已提交(RC, Read committed) | 可避免 | 不可避免 | 不可避免 |
可重复读(RR, Repeatable read) | 可避免 | 可避免 | 不可避免 |
可串行化(Serializable) | 可避免 | 可避免 | 可避免 |
选取数据库的隔离级别时,一般需要考虑数据并发安全和效率两方面,取一个均衡的方案。
事实上,通过抓取事务执行的binlog日志可以看出(下图所示),拆分两笔收入的事务,其中拆分¥2000的事务(事务1)commit与拆分¥3000的事务(事务2)的begin是在20:18:02。
根据现象可以推测:事务2读取到了事务1没有提交的数据,所以都认为自己是新的收入,导致按照相同的待填充明细将收入进行拆分。
按照常理来说,Redis锁生效了,应该会锁到第一个事务提交完成。可是从日志上来看,这个锁似乎没有生效。问题一定是出在了这个地方。
通过研究这部分的代码,终于理清了处理逻辑。如下图所示,由于历史的设计原因,这个地方存在两个事务嵌套,事务2是事务1的子事务,而其中的Redis锁加在了父事务和子事务之间。
这种设计是存在问题的。
首先来复习一下基本知识—— Spring支持的事务传播机制,简单讲就是当两个事务存在包含和被包含关系的时候,事务应该怎样去执行。Spring支持7种传播机制。
传播机制 | 描述 |
---|---|
PROPAGATION_REQUIRED | 如果当前没有事务,就新建一个事务。如果已经存在于一个事务中,加入到这个事务中 |
PROPAGATION_SUPPORTS | 支持当前事务,如果当前没有事务,就以非事务方式执行 |
PROPAGATION_MANDATORY | 使用当前的事务,如果当前没有事务,就抛出异常 |
PROPAGATION_REQUIRES_NEW | 新建事务,如果当前存在事务,把当前事务挂起 |
PROPAGATION_NOT_SUPPORTED | 以非事务方式执行,如果当前存在事务,就把当前事务挂起 |
PROPAGATION_NEVER | 以非事务方式执行,如果当前存在事务,则抛出异常 |
PROPAGATION_NESTED | 如果当前存在事务,则在嵌套事务内执行;如果当前没有事务,则执行与 PROPAGATION_REQUIRED类似的操作 |
通过分析代码得出,二手财务系统使用的是Spring默认传播机制:
PROPAGATION_REQUIRED,这就意味着在收入的拆分逻辑中,事务2是不起作用的,也就意味着Redis锁是在事务1内部生效的。由于Redis锁是在事务1内部生效的,也就无法起到控制事务的作用。在高并发状态下,就会出现两个事务同时处理数据的情况。
根据MVCC原理,我们再来回顾一下开始发现的可重复读的问题:事务1开始后,事务2就会拷贝一份数据用作select,而这份数据就有可能是事务1未提交的。
对整个问题进行流程分析后,梳理结果如下图。由于没有设置锁,步骤4和步骤6返回的待分配明细和已分配明细是一模一样的,也就导致两个机器填充的是同一份带填充明细。
认识到了问题的本质,给出的修复方案就简单了。对此,我们针对事务和锁的处理,提出了两种解决方法。
既然内部的事务由于默认的传播机制没有生效,那可以将传播机制改为PROPAGATION_REQUIRES_NEW,来保证嵌套在内部的事务2可以正常执行,能够在Redis锁释放之前提交数据。
修改代码如下所示:
@Transactional("transactionManager")
public void consume(Long id) throws Exception {
lock.tryLock(LockKeyGenerator.gen("Receivable", item.getContractId()),
new ILockBiz<Object>() {
reconciliationSuccessWithTrans();
}
}
@Transactional(transactionManager = "transactionManager", propagation = Propagation.REQUIRES_NEW)
public void reconciliationSuccessWithTrans() {
//收入拆分逻辑
...
}
首先,事务2没有生效,可以直接删除;而第一次修复是将Redis锁加在了事务的内部,这本身就是会导致脏读的问题,因此将Redis锁放到事务的外部即可。
修复后的逻辑如下图所示:
修改代码如下所示:
@Transactional("transactionManager")
public void consume(Long id) throws Exception {
lock.tryLock(LockKeyGenerator.gen("Receivable", item.getContractId()),
new ILockBiz<Object>() {
//其他逻辑
...
reconciliationSuccessWithTrans();
}
}
public void reconciliationSuccessWithTrans() {
//收入拆分逻辑
...
}
当然,要根据具体的业务场景选择解决方案。由于财务系统在两个事务的差集部分仍存在数据的提交,将事务设置成PROPAGATION_REQUIRES_NEW的传播机制,虽然可以保证内嵌事务回滚引发外层事务回滚,但是外层事务的回滚不会影响内嵌事务,所以需要评估是否会导致数据不一致。此外通过对比逻辑的修改量,我们最终选择了第二种修复方法。
让我们再次回顾下问题原因和解决过程:
大家平时在对数据库进行写操作时,一定要注意事务的处理,这次问题就是由于历史逻辑设计不合理所导致的。
随着公司业务量的增加,这种高并发的问题会暴露得更多。因此在编程时,我们一定要锻炼出 良好的高并发思维,做到未雨绸缪彻桑土、御冬旨蓄备桃诸。
领取专属 10元无门槛券
私享最新 技术干货