【bug】重复请求的幂等问题

问题背景

某验收系统,客户发起验收流程时,由于前端没有做防重点击的限制,导致申请按钮连续点击了多次,重复发起了多条流程

历史逻辑

后端为了保证接口幂等,在发起验收流程的代码中加了几层逻辑如下:

  1. 判断验收记录状态是否为待发起, 如果不是,则立刻返回失败
  2. 发起流程的入口加了一层用户维度的锁,可以保证同一用户无法同时进入流程处理逻辑,伪代码如下:
@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());
            // 此处清理流程上下文信息...
        }
    }


}
  1. 流程处理完后,更新数据库中的验收记录状态为已发起

以上逻辑梳理成总体的伪代码如下:

@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("已提交,请勿重复提交");
}

 

posted @ 2024-12-16 17:17  蓝瓶的真好喝  阅读(8)  评论(0编辑  收藏  举报