接口幂等相关

防重

防重:是拦截绝大部分的重复请求

重复请求主要来自以下几点:用户多次提交、系统自动重试、网络重试

  • 用户多次点击支付按钮:在网络较差或系统过载情况下,用户由于不确定交易是否完成而重复点击。

  • 自动重试机制:系统在超时或失败时重试请求,可能导致同一支付多次尝试。

  • 异常恢复:在系统升级或崩溃后,未决事务需要根据已有记录恢复和完成。内部系统重发操作。

  • 网络数据包重复:数据包在网络传输过程中,复制出了多份,导致支付平台收到多次一模一样的请求。

幂等

接口幂等:无论调用多少次,对被调用方的系统造成的业务结果是一致的,不会产生副作用,但从调用方来看可能多次结果都是成功,也可能后边的请求拿到的重复请求(这种更明确,更友好)

查询具有幂等性。新增、修改、删除不具有幂等性

  • 新增操作不是幂等

  • update操作看情况

    1. 幂等:
      旧值覆盖,比如:update user set status=1 where id=1

    2. 非幂等:
      增量计算,比如累加:update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。

  • 删除操作也是看情况

    1. 如果类似按最近几天的时间进行删除数据,那么删除不是幂等
    2. 如果是根据唯一标识删除数据,是幂等的
    

幂等性

防重与幂等的关系

防重是盾牌,作为第一道防线,目的是把绝大多数的重复请求给拦截掉
幂等是内部保护,及时盾被击穿,也不会让系统受到额外的副作用

  • 防重解决的是用户体验与系统效率问题(“别让用户/系统乱点”)
  • 幂等性解决的是业务正确性问题(“做多次 = 做一次”)。

防重是“不让重复进来”,幂等是“进来也不怕重复”。

实际案例

单据审核后,然后记录相关流水记录

public void updateNoticeLock(Integer documentId) {
    // 1.查询
    DocumentBO documentBO = this.sysUserMapper.selectDocument(documentId);
    // 2.幂等校验
    if (documentBO.getStatus().equals("1")) {
        throw new RuntimeException("单据已审核");
    }
    // 3.记录流水
    this.sysUserMapper.approve(documentBO);

    documentBO.setCreateTime(LocalDateTime.now());
    // 4.插入流水记录
    this.sysUserMapper.insertDocumentFlows(documentBO);
}

解决方案一:数据库互斥锁

  • 依赖数据库本身的锁机制实现并发事务数据修改数据串行操作

select ... for update;

// 先锁住记录,不让别的事务操作,也是保证了查询与更新是原子性操作
select id,ware_name,num from table where id =1 for update;

update table set num = num -1 where id = 1;
  • 注意事项:
1. **这种加锁的方式要注意对where查询的条件加索引,防止锁表**。
2. for update要与事务配合使用才能生效,理由如下
3. 相对于乐观并发机制,这中适合场景是并发量较多的情况下,但是这种加锁的方式可能会造成系统线程阻塞过多,会影响接口性能。

配合事务一块使用的原因:

这种是依赖数据库本身的锁机制,在执行查询时候就把数据锁住,让其他操作这条记录的事务等待。要知道数据库的锁针对的对象是一个个事务,换句话说数据库提供的锁能保证多个事务对同一条数据时操作时是串行执行的。

当一个事务A中某一块逻辑对某条记录进行修改时,会对当前记录进行加锁,若这时有其他事务B处理该记录时,就会等待前面事务A执行完,当事务内全部所有操作`提交后`,唤醒事务B再对该数据进行修改,所以这种依赖数据库的锁是能够保证事务并行执行的

解决方案二:乐观锁

适用于更新操作,有拒绝旧token的能力,比如使用版本号、时间戳、状态机

口诀:查询、判断、失败延迟重试

在真正更新操作时候判断数据是否有冲突,即当前数据是否被其他操作已经篡改过,如果已被篡改,将重新检测是否冲突。

冲突检测方法一般使用单项递增的版本号、时间戳或者状态机

比如使用单项递增的版本号,当前操作先获取版本号,判断当前版本号与数据库中数据的版本号是否一致,如果一致说明此时没有其他操作正在执行,即可更新;如果不一致那么这次操作即失败,可以延迟重新调用当前方法,再次获取当前数据的版本号,再次进行冲突检测与更新数据。

适用场景及注意事项:

  1. 适用于并发量低的场景下,当并发量高说明冲突越严重,那么循环调用的次数就多,吞吐量就低。
  2. 失败重试时注意睡眠合适的sleep时间,再查询判断处理。目的是防止出现栈内存溢出异常
  3. 不能使用事务
    • 一是因为使用事务后,像MySQL默认的隔离级别是RR,这种隔离级别对于使用了事务中有重试机制读到的数据永远是一开始查询到的(MVCC)
    • 二是因为使用事务后,数据库连接只有固定大小,如果操作一直重试,迟迟不提交会导致变成长事务,导致其他操作获取长连接失败。

业务场景,扣减库存

案例1(失败一直重试,直至成功)
   /**
     * 乐观锁(一直重试直至所有的都成功)
     */
    public void checkAndLock() throws InterruptedException {

        // 先查询库存是否充足(5000)
        Stock stock = this.stockMapper.simpleQuery(1L);

        // 再减库存
        if (stock.getCount() <= 0) {
            return;
        }

        stock.setCount(stock.getCount() - 1);
        // 如果记录被更改过,则需要重新获取版本号,重试更新
        if (this.stockMapper.updateOpti(stock) == 0) {
            TimeUnit.MILLISECONDS.sleep(20);
            this.checkAndLock();
        }

    }
案例2(失败有限次数重试)

超最大次数还是失败,可以发mq异步补偿,或消息通知告警

   /**
     * 有限次数失败重试
     *
     * @throws InterruptedException
     */
    public void checkAndLock() throws InterruptedException {

        int maxRetryNum = 4;
        int currNum = 1;

        while (currNum <= maxRetryNum) {

            Stock stock = this.stockMapper.simpleQuery(1L);

            // 再减库存
            if (stock.getCount() <= 0) {
                return;
            }

            stock.setCount(stock.getCount() - 1);

            if (this.stockMapper.updateOpti(stock) == 1) {
                log.info("更新成功");
                return;
            }
            currNum++;

            if (currNum < maxRetryNum) {
                TimeUnit.MILLISECONDS.sleep(20);
            }

        }

        // TODO: 2026/7/4  发mq异步补偿,或消息通知告警
        
        throw new RuntimeException("扣减库存失败");

    }
<update id="updateById2">
    UPDATE `db_stock`
    SET `count` = #{count},version = version + 1
	WHERE `id` = #{id} AND version = #{version}
</update>

注意:

1. 并发量越低,吞吐量越高
适用于并发低的场景下,并发高时会导致冲突严重,循环调用消耗cpu,吞吐量低。
2. 不可加事务,由于当前事务尚未提交,很难读取到其他事务更新后的数据(RR根本读不到,RC能读到)

解决方案三:分布式锁

口诀:一锁、二幂等校验、三更新(新增)

  • 基于redis分布式锁
  • 基于zookeeper实现的分布式锁
能够保证查询与修改是原子性操作,不会出现数据竞争的问题

操作步骤

一锁、二判断幂等、三操作(更新、插入)

以redis实现的分布式锁为例


解决方案四:建防重表

比如可能出现消息重复消费的情况时。新增消费消费表,当业务处理完后同时记录消息到消息消费表中,保证业务处理与记录消费消息是一致性的。下次有重复消费,判断该消息是否已经存在,如果存在表示已经消费,否则正常执行业务

总结与思考

  1. 要保证幂等性,本质上其实要保证幂等校验与操作数据是原子逻辑,如果能依赖数据库本身锁机制实现事务串行执行是最好的了,比如乐观锁种使用递增的版本号,或者状态机判断。像java层面使用悲观锁性能消耗是线程的阻塞与唤醒,而基于数据库的乐观锁实现依赖于数据库层面的锁,多个事务对同一个数据进行修改,数据库的锁机制会让多个事务同步执行,也就是说一个事务对数据修改必须要等待另外一个对相同数据修改的事务提交后才可以执行。

    一般逻辑在实现更新插入数据时,要进行幂等判断(查询、判断条件是否符合,如果符合则直接中断程序)
    遵循一锁、二判、三更新、四释放原则
    能依赖数据库本身的锁机制,能存在状态机流转,一定要加上状态条件。
    
  2. 同时重试通常跟幂等组合使用

  3. 悲观锁、乐观锁是实现方式、接口幂等是目的

参考

大厂防止超卖的7种实现,很受用!

最系统的幂等性方案:一锁二判三更新

posted @ 2026-07-04 19:50  永无八哥  阅读(3)  评论(0)    收藏  举报