问题记录:并发访问导致的数据库修改异常

  • 问题描述:

产生问题的功能类似于,有一个固定数值的可分配量可以分配给某些用户,在下发的过程中需要校验当前剩余的可分配量是否足够本次分配。
我在代码中的校验逻辑是首先读取历史数量,然后与当前分配量进行对比,数量足够的情况下会进行数据库修改。
这种逻辑在不会重复多次快速点击,并且类似于线性请求的情况下,是不会有问题的。我也就把所有计算和校验都放在了内存中进行,然后写表。

万万没想到啊~ 前端兄弟他没做防抖... ... 然后就破防了

问题原因

首先贴上问题伪代码:
    @Transactional(rollbackFor = Exception.class)
    public void assigningRewardAmount(Long id, Long proId, BigDecimal workload, String dateStr) {
        // ...
        // 问题主要出现在了获取这个 file 类上 // 
        System.out.println(LocalDateTime.now()); // code1
        MwProjectDetail file = fileMapper.selectByPrimaryKey(proId); // code2
 
        // 在并发分配的时候,在这个校验中读取的数据都是一样的,所以校验都能通过 // 
        BigDecimal last = file.getReservedWorkload().subtract(file.getReservedWorkloadAssigned());
        if (last.compareTo(workload) < 0) { // code3
            throw new InternalHandlerException("剩余可用量不足,仅剩数量[%s]", last.toString());
        }
        file.setReservedWorkloadAssigned(workload);
        file.fillingModifyOperatorInfo(AdminUtil.getAdminUid(), AdminUtil.getAdmin().getUsername(), new Date());

        // 在进行数据库修改的时候,会覆写掉上一次修改的值 // 
        fileMapper.updateByPrimaryKeySelected(file); // code4
       
        // ...
    }
因为通过controller访问的时候,请求会并发进来所以产生以下问题:

在上面的代码中,当发生并发访问的时候所有线程会在同一时间开启事务并且读取到数据库中的 file 对象, code1 中打印时间后读取 file 的时候还没有
任何一个线程提交事务,所以所有线程 code2 读取的 file 都是一样的, 这就导致了后面的 code3 校验都会通过,并且 code4 会互相覆写掉上一次修改的值。
最后的结果就是只要手够快,就能分够多的数量... ...

解决思路

因为作者比较菜并且过度相信前端兄弟,所以讲累计形式的计算放到了内存中进行然后写表,并且没有做任何并发保护所以导致了这个问题。
那么解决思路也非常简单,将所有累计相关的修改和校验都尽量放到数据库中进行,并且通过 CAS 乐观锁的方式防止并发修改出现问题,

code3 中的校验是不变的,因为如果数量已经不够了可以快速反映给前端
code4 中的修改方式修改为新的修改方式,并且数量的校验交给数据库去做

// code4 的代码替换为
file.setExpectVersion(file.getVersion());
file.setVersion(file.getVersion() + 1);
int cas = fileMapper.updateByPrimaryKeyCasAndCheckAmount(file);
if (cas != 1) {
    throw new CustomizeBusinessException("CAS 修改失败");
}

在JDBC配置后面加上 useAffectedRows=true 可以开启 update | insert 后返回真正的影响条数,而不是 where 条件命中条数
可以通过返回的影响条数来判断CAS操作是否成功

  • update 操作进行CAS操作可以通过version字段或者最后修改时间等字段来控制,下面是一段伪代码
 update
  <include refid="tableSql"/>
  set a.`updated_user` = #{updatedUser}, a.`updated_user_id` = #{updatedUserId},
      a.`updated_date` = #{updatedDate, jdbcType=TIMESTAMP}, a.`version` = #{version},
      a.`reserved_workload_assigned` = (ifnull(a.`reserved_workload_assigned`, 0) + #{reservedWorkloadAssigned})
  where a.`id` = #{id} 
      <!-- 通过内存中的预期版本来做到 CAS -->
      and a.`version` = #{expectVersion}
      <!-- 通过数据库来进行数量控制 -->
      and (ifnull(a.`reserved_workload_assigned`, 0) + #{reservedWorkloadAssigned}) <= a.`reserved_workload`
  • 如果是 insert 操作需要防止重复插入的话,可以通过 insert 后加 where 条件来进行判断
insert `my_table`(`name`) select 'Shelby' from dual where [唯一判定条件];

那么.....

那么这次事故带给我的教训是什么呢~ 第一:不要将累计的计算和重要校验放到内存中。第二:当进行非定向修改的时候记得将并发问题考虑好。第三:希望我别这么菜了

posted @ 2021-07-01 22:17  thinkMIne  阅读(278)  评论(0)    收藏  举报
/*粒子线条,鼠标移动会以鼠标为中心吸附的特效*/