问题背景
某验收系统,客户发起验收流程时,由于前端没有做防重点击的限制,导致申请按钮连续点击了多次,重复发起了多条流程
历史逻辑
后端为了保证接口幂等,在发起验收流程的代码中加了几层逻辑如下:
- 判断验收记录状态是否为待发起, 如果不是,则立刻返回失败
- 发起流程的入口加了一层用户维度的锁,可以保证同一用户无法同时进入流程处理逻辑,伪代码如下:
@Component public class ProcessManager {private final CopyOnWriteArraySet<String> userLock = new CopyOnWriteArraySet<>();/*** 流程操作*/public void doAction(入参) {// 拦截器会提前解析token并将用户信息存入上下文AuthUser currentUser = ContextUtil.getCurrentUser();try {// 这里将上下文中的用户id存入CopyOnWriteArraySet中,如果保存失败说明当前用户正在操作流程,返回失败if (!userLock.add(currentUser.getId())) {throw new BusinessException("正在处理中,请勿重复操作");}// 此处是流程处理代码入口...} finally {// 流程代码结束后释放锁,放在finally代码块中,防止死锁 userLock.remove(currentUser.getId());// 此处清理流程上下文信息... }}}
- 流程处理完后,更新数据库中的验收记录状态为已发起
以上逻辑梳理成总体的伪代码如下:
@Autowired privarte ProcessManager processManager;public void startApply(入参) {// 校验验收记录的状态Record record = getRecord(入参);if (!record.getStatus().equals(待发起)) {返回失败}前置业务流程...// 发起流程 processManager.doAction(入参);// 更新状态 record.setStatus(待审批);baseMapper.updateById(record); }
可以转化为如下的流程图
问题分析
当出现以下情况时,会出现重复发起的问题
首先,为了保证事务的原子性,整个方法是一个大事务
多个线程同时查询验收记录,会查询到同样的未被改变状态的数据,会同时通过状态校验,进入到以下的情况:
由于代码执行有快慢,线程1率先发起了流程,并且完成了
此时线程2还未尝试获取锁,线程1就已经释放了锁,这时线程2也能顺利进入发起流程的代码,再次发起流程
当线程1和线程2都执行完成后,数据库中就生成了两条流程,出现了幂等性问题
解决思路
数据库锁
最开始查询验收记录时,可以在SQL后增加“for update”,将该条记录锁住,并且for update是当前读,能够读取到最新的已提交的数据
优点:可以保证只有一个线程进入后续代码
缺点:排它锁太重了,其他线程需要等待事务结束才能获取到锁查询数据,会严重影响性能
分布式锁
优点:可以保证只有一个线程进入后续代码,性能好,不影响其他查询
缺点:引入redis分布式锁又会面临redis集群的其他问题,可能会死锁或锁失效
数据库唯一索引
在流程实例表增加唯一索引,唯一字段根据业务属性决定,创建流程实例时把insert语句的方法用try-catch包裹起来,检测DuplicateKeyException异常,如果发生了重复键冲突,则直接报错
优点:根本上确保了流程实例的唯一性
缺点:每个线程都会执行到前置的业务处理,这部分是多余的计算
结论
通过对比优缺点,最终选择了数据库唯一索引的办法解决问题
首先根据业务特征,在流程实例表b_approve_instance
增加了对应唯一索引
之后在insert的方法外包裹一层try-catch,代码如下
try {baseMapper.insert(instance); } catch (DuplicateKeyException e) {throw new BusinessException("已提交,请勿重复提交"); }