松林AI说头像
关注

后端接口的幂等设计演进:从数据库唯一约束到分布式锁的选型

后端接口的幂等设计演进:从数据库唯一约束到分布式锁的选型

一、用户点了两下支付按钮,扣了两次钱

这大概是每个后端开发者最熟悉的幂等问题。网速不好、前端去重没做好、用户手快——同一个请求以各种方式到达了后端两次。如果接口不做幂等保护,轻则创建两条重复订单,重则重复扣款。幂等性的定义是:对同一业务的同一操作,执行一次和执行多次的效果相同。

实现幂等性的方案有很多,从最简单的数据库唯一约束,到稍复杂的 Redis 分布式锁,再到状态机校验。方案之间不是优劣关系,而是适用场景的递进关系。根据接口的复杂度和一致性要求,选择合适的方案。具体而言,纯插入操作可直接利用数据库唯一索引,实现零代码侵入且天然幂等,但仅适用于插入场景;需要带查询的更新操作可采用版本号或乐观锁,无锁开销且冲突时重试,局限在于高并发下重试频繁;跨服务的复杂业务流程适合分布式锁加业务状态检查,通用性强但引入锁管理复杂度;有状态流转的场景则推荐状态机校验,天然防重且可追溯,但需明确状态定义。

二、方案一:数据库唯一约束

最朴素也最可靠的方案。在订单表上给"业务唯一标识"加唯一索引,插入时如果唯一索引冲突,说明是重复请求,直接返回已有结果。

/**
 * 基于数据库唯一索引的幂等插入
 * 
 * 适用条件:
 * 1. 操作是纯插入(INSERT 场景)
    1. 存在不重复的业务唯一标识(如订单号、请求 traceId)
    1. 不涉及复杂的关联更新
  • 为什么这是首选方案:

    • 数据库天然保证唯一性,不依赖外部组件
    • 实现简单,业务代码几乎感知不到幂等逻辑
    • 性能开销小(唯一索引的 B+ 树查找是 O(log n))
      */
      @Service
      public class IdempotentOrderService {

    @Resource
    private OrderMapper orderMapper;

    /**

    • 幂等创建订单
    • @param requestId 请求的唯一幂等键(由客户端生成或按业务规则派生)
      */
      public Order createOrder(String requestId, CreateOrderDTO dto) {
      try {
      // 尝试插入。唯一索引在 request_id 字段上
      // 如果已存在同 request_id 的记录,数据库抛出 DuplicateKeyException
      Order order = new Order();
      order.setRequestId(requestId);
      // ... 其他字段赋值 ...
      orderMapper.insert(order);
      return order;

      } catch (DuplicateKeyException e) {
      // 唯一索引冲突 → 说明是重复请求
      // 查询已创建的订单并返回
      Order existingOrder = orderMapper.selectByRequestId(requestId);
      if (existingOrder == null) {
      // 理论上不应走到这里:有唯一约束冲突但查不到数据
      // 可能是主从延迟。这时候:
      // 强制走主库查询(确保读到刚插入的数据)
      throw new RuntimeException("幂等冲突但未查到已有记录");
      }
      // 返回已有结果,调用方看到的和第一次请求一样
      return existingOrder;
      }
      }
      }

唯一约束方案的局限是:它只能防重复插入,不能防重复更新。如果一个接口的操作是 UPDATE(修改已有记录),唯一约束就派不上用场了。

## 三、方案二:版本号乐观锁

对于更新操作,在表上加一个 `version` 字段,每次更新时 `WHERE version = ?` 并 `SET version = version + 1`。如果同一个请求被执行两次,第二次执行时 version 已经被第一次更新升级了,WHERE 条件匹配不到行,更新影响行数为 0。

问题的关键在于:version 依赖的不是全局请求 ID,而是数据本身的状态。这适合"修改已有订单的状态"这类场景——订单已经存在,只需要修改它。

乐观锁的优势是不需要额外的锁组件,劣势是在高并发冲突时重试频繁,可能引起性能下降。

## 四、方案三:分布式锁 + 业务状态检查

当操作的幂等判断需要跨服务、跨数据源时,分布式锁是最通用的兜底方案。Redis 的 SETNX 或 Redisson 的 RLock 都能提供分布式互斥。

```java
/**
 * 分布式锁实现幂等性
 * 
 * 适用场景:操作逻辑复杂,涉及多个数据源的变更
 * 
 * 为什么不是首选:
 * 1. 引入外部依赖(Redis),增加故障点
 * 2. 锁的超时需要仔细设置,太短可能导致锁提前释放
 * 3. 性能不如唯一索引(锁竞争 vs 索引查找)
 * 
 * 分布式锁的幂等性基于"准入控制"思想:
 * 进入 → 检查状态 → 仅当状态允许时才执行
 */
public class DistributedLockIdempotent {

    private final RedissonClient redisson;

    /**
     * 带幂等保护的复杂业务操作
     */
    public Result executeIdempotently(String requestId, BizData data) {
        // 锁的 key 使用请求的唯一幂等键
        String lockKey = "biz:idempotent:" + requestId;
        RLock lock = redisson.getLock(lockKey);
        
        try {
            // 尝试获取锁,最多等待 3 秒,锁自动释放时间 30 秒
            // 为什么自动释放时间设为 30 秒?
            // 需要大于业务操作的最长执行时间,防止业务未完成锁就过期
            boolean acquired = lock.tryLock(3, 30, TimeUnit.SECONDS);
            
            if (!acquired) {
                // 等待 3 秒还获取不到锁,可能并发极高或业务卡住
                throw new BizException("系统繁忙,请稍后重试");
            }
            
            // 获取锁成功后,检查业务状态
            // 即使拿到了锁,也要检查是否已经被处理过
            // 因为锁的超时和业务的完成不是原子操作
            if (isAlreadyProcessed(requestId)) {
                return getExistingResult(requestId);
            }
            
            // 执行业务逻辑
            Result result = doBusinessLogic(data);
            
            // 记录处理完成(用于后续的去重判断)
            markAsProcessed(requestId);
            
            return result;
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BizException("操作被中断");
        } finally {
            // 释放锁时额外判断:确保锁还是当前线程持有的
            // 如果业务执行超过了 30 秒,锁可能已经被自动释放并被其他线程获取
            // 这时如果无条件释放,会把别人的锁释放掉
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

lock.isHeldByCurrentThread() 这个判断很重要。锁的自动释放时间设置了 30 秒,但如果业务逻辑由于某种原因超过了 30 秒,锁被 Redis 自动删除了。此时另一个线程获得了这把锁。如果当前线程在 finally 中不做判断直接释放,就把别人的锁给释放掉了。

五、总结

幂等方案的选择是一个从简到繁的演进过程。能单靠数据库唯一索引解决时,不引入 Redis;能用版本号乐观锁解决时,不引入分布式锁。分布式锁是最通用的方案,但也是复杂度最高的方案。选型的关键是理解每个方案的适用边界:唯一索引管插入、乐观锁管更新(低冲突场景)、分布式锁管跨服务复杂流程。把这个演进路径梳理清楚之后,接到幂等需求时就知道该从哪个方案开始,也知道什么时候需要升级方案。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/cannonjinx/article/details/163020662

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--