作者:谢益培

1 背景

**关键词:**并发、丢掉更新

预收款账户表上有个累计抵扣金额的字段,该字段的含义是计算商家预收款账户上累计用于抵扣结算成功的金额数。更新机遇是,账单结算完结时,更新累计抵扣金额=累计抵扣金额+账单金额。

2 问题及现象

发现当账单结算完结时,偶然会产生累计抵扣金额字段值更新不准确的现象。
比如,某商家账户上累计抵扣金额原本为0元,当产生两笔分别为10和8的账单结算完结后,理论上累计抵扣金额应该变为18元,但实践为10元。也就是说,第二次更新把前一次更新内容给掩盖掉了。

3 问题分析

该问题为典型的第二类丢掉更新问题。

3.1 概念解释

业务在并发状况下,常见如下问题:

  1. **脏读:**一个业务读取了已被另个一个业务修正但尚未提交的数据。当一个业务正在拜访数据,并且对数据进行了修正,而这种修正还没有提交到数据库中;这时别的一个业务也拜访这个数据,然后运用了这个未提交的数据。
  2. **不可重复读:**在一个业务内,屡次读同一数据,读到的成果不同。第一个业务还没有结束时,别的一个业务也拜访该同一数据。那么,在第一个业务中的两次读数据之间,由于第二个业务的修正,那么第一个业务两次读到的数据或许是不一样的。这样就产生了在一个业务内两次读到的数据是不一样的,因而称为是不可重复读。
  3. **幻读:**同一业务中,当同一个查询履行屡次的时候,由于其他业务进行了刺进操作并提交业务,导致每次回来不同的成果集。幻读是业务非独立履行时产生的一种现象。例如第一个业务对一个表中的数据进行了修正,这种修正涉及到表的全部数据行。同时,第二个业务也修正了这个表中的数据,这种修正是向表中刺进了一行新数据。那么,就会产生操作第一个业务的用户发现表中还有没有修正的数据行,就好像产生了错觉一样。
  4. **更新丢掉:**两个业务同时更新一行数据,一个业务对数据的更新把另一个业务对数据的更新掩盖了。这是由于系统没有履行任何的锁操作,因而并发业务并没有被阻隔开来。

高并发下丢失更新的解决方案

图1 SQL标准定义了4种数据库业务阻隔等级

第一类丢掉更新:A业务吊销时,把现已提交的B业务的更新数据掩盖了。SQL标准中未对此做定义,所有数据库都已处理了第一类丢掉更新的问题。

高并发下丢失更新的解决方案

图2 第一类丢掉更新

第二类丢掉更新:A业务掩盖B业务现已提交的数据,形成B业务所做操作丢掉。第二类丢掉更新,和不可重复读本质上是同一类并发问题,通常将它看成不可重复读的特例。当两个或多个业务查询相同的记载,然后各自根据查询的成果更新记载时会形成第二类丢掉更新问题。每个业务不知道其它业务的存在,最终一个业务对记载所做的更改将掩盖其它业务之前对该记载所做的更改。

高并发下丢失更新的解决方案

图3 第二类丢掉更新

3.2 疑问点

产生问题的代码:

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ)
public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {
    Account account = getAccount(customerCode, entityCode, currency);
    BigDecimal newValue = account.getCumulativeDeductionAmount().add(transaction.getTransactionAmount());
    account.setCumulativeDeductionAmount(newValue);
    //耐久化
    Account update = new Account();
    update.setId(account.getId());
    update.setCumulativeDeductionAmount(account.getCumulativeDeductionAmount());
    accountBalanceInfoMapper.update(update);
}

上述代码中能够看到,该办法已设置业务阻隔等级为可重复读(isolation = Isolation.REPEATABLE_READ,也是MySQL的默认阻隔等级)。按照之前对阻隔等级标准的理解,可重复读等级是应该能够避免第二类更新丢掉的问题的,但为啥仍是产生了呢?!于是上网查阅相关资料,得到的结论是:MySQL数据库,设置业务阻隔等级为可重复读无法避免产生“第二类丢掉更新”问题。从这个案例中也得到一个教训就是,标准标准和所选的产品(组件)实践完成状况,两者需求同时考虑,关于边缘性或存在争议的标准内容要尽或许避免直接运用,最好经过其他机制来保证。

4 处理方案

以下整理了针对该问题的常见处理方案并按处理思路进行了分类。

4.1 依赖数据库的思路

办法1:

将业务阻隔等级改为串行,能处理但并发功能低,还或许导致大量超时和锁竞争。

办法2:

调整SQL句子,将更新赋值逻辑改为“c=c+x”形式,其中c为要更新的字段,x为增量值。这种办法能确保累加值不会被掩盖。但这种办法需求额定编写特殊的SQL,而且严格意义上讲,存在业务逻辑走漏到耐久层的不标准问题。

4.2 失望锁思路

办法3:

办法履行时增加分布式锁,来控制同一账户同一时刻只有一个线程可对其进行操作。效果等同将业务等级改为串行,也是排队履行,并发功能也低,仅仅锁机制不是由数据库完成了罢了。

分布式锁的完成办法有多种,比如该项目中有封装好的根据Redis的分布式锁,其注解的运用办法如下:

@CbbSingle(key = "QF:finishDeductTransaction:customerCode", value = {"#{customerCode}"})
public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {

办法4:

经过SQL句子启用数据库排他锁,例如:select * from table where name=’xxx’ for update。经过此sql查询到的数据就会被数据库上排他锁,因而其他业务就无法对该数据及进行修正了。

该办法比上面两种在并发功能方面会好一些,但仍然或许存在锁等待和超时状况产生。

4.3 达观锁思路

办法5:

达观锁的思路是假定并发抵触产生概率较低,敞开业务时先不加锁,在更新数据时经过版别比对以及判别影响行数来判别是否更新成功。

其中,版别概念,可所以要更新记载的版别号,或者更新时刻等。也能够用旧值条件或校验和等办法;

该办法并发功能最好,但一旦产生并发抵触会导致办法履行失败,此时就需求调配额定的重试或自旋逻辑来闭环。
思路如下:

//先查询出来要更新的数据
select column1,id,version from table where id=1001;
//进行业务逻辑处理
//更新这条数据
update table set column1=xx where id=1001 and version=查询出来其时的version值
//判别影响行数
if (records < 1) 更新失败...

本案例中的问题就是选用这种办法处理的:

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ)
public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {
    Account account = getAccount(customerCode, entityCode, currency);
    BigDecimal newValue = account.getCumulativeDeductionAmount().add(transaction.getTransactionAmount());
    account.setCumulativeDeductionAmount(newValue);
    //耐久化
    Account update = new Account();
    update.setId(account.getId());
    update.setVersion(account.getVersion());
    update.setCumulativeDeductionAmount(account.getCumulativeDeductionAmount());
    int records = accountBalanceInfoMapper.updateByIdAndVersion(update);
    if (records < 1) {
        throw new SingleThreadException("更新时数据版别号已产生改动。原因:产生并发业务");
    }
}

5 总结

  1. 关于常见并发业务问题,需求将业务阻隔等级和锁机制结合起来一起运用;
  2. 需求对并发问题从业务场景上进行分析和辨认,关于并发抵触少的场景,首选达观锁思路;关于并发抵触高的场景选用失望锁思路;