《接口幂等性设计的三种方案与实践》

0 阅读4分钟

《接口幂等性设计的三种方案与实践》


前言

大家好,我是liyuhh985。

上次面试阿里二面,面试官问了我一个问题:

"你们项目里是怎么处理接口幂等的?"

当时我答得不太好,面试结束后我仔细研究了一下,发现这玩意儿太重要了——几乎所有写接口都会遇到重复提交的问题

今天把学习成果整理成文章,顺便也帮大家避坑。


什么是幂等性?

先看定义:

幂等性:同一个操作执行多次,结果是一样的。

听起来有点抽象,我举个例子:

操作是否幂等原因
查询用户✅ 幂等查 100 次返回结果不变
扣款 100 元✅ 幂等只扣一次,重复不扣
支付 100 元❌ 不幂等每调用一次就扣一次钱

为什么需要幂等?

在实际项目中,导致接口重复调用的场景太多了:

  1. 用户手抖:点击提交按钮两次
  2. 网络超时:前端超时重试
  3. MQ 消息重复:消息队列重复投递
  4. 恶意刷单:接口被重复调用

如果不做好幂等处理,轻则数据重复,重则资金损失


三种幂等方案实战

下面结合我的项目经历,分别介绍三种常用的幂等方案。

方案一:Token 防重

核心思路:先获取 Token,提交时验证后删除。

1. 用户打开页面 → 后端生成 Token,存入 Redis
2. 用户提交请求 → 带上 Token
3. 后端验证:Token 存在?→ 删除 Token → 执行操作
               Token 不存在?→ 返回"重复提交"

代码实现

// 1. 获取 Token
@GetMapping("/token")
public Result<String> getToken(Long voucherId) {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("seckill:token:" + userId, token, 5, TimeUnit.MINUTES);
    return Result.ok(token);
}

// 2. 提交时验证
@PostMapping("/seckill/{id}")
public Result seckill(@PathVariable Long id, @RequestParam String token) {
    // 先删除 Token(原子操作)
    Boolean deleted = redisTemplate.delete("seckill:token:" + userId);
    if (!deleted) {
        return Result.fail("请勿重复提交");
    }
    // 执行秒杀逻辑...
    return Result.ok();
}

适用场景:前端防重复提交、秒杀抢购


方案二:去重表

核心思路:用唯一标识(如订单号)存入去重表,利用数据库唯一键约束防止重复。

-- 建表 SQL
CREATE TABLE idempotent_dedup (
    request_id VARCHAR(64) PRIMARY KEY,  -- 唯一标识
    status INT DEFAULT 0,                 -- 0=处理中,1=成功
    result VARCHAR(256),
    created_at TIMESTAMP
);

代码实现

public void pay(String orderId) {
    // 尝试插入去重表
    try {
        dedupMapper.insert(new Dedup(orderId, 0));
    } catch (Exception e) {
        // 唯一键冲突,说明已处理过
        throw new BusinessException("订单已处理");
    }
    
    // 执行支付逻辑...
    
    // 标记成功
    dedupMapper.updateStatus(orderId, 1);
}

适用场景:后端重试、MQ 消息去重


方案三:状态机

核心思路:用 SQL 的 WHERE 条件锁定当前状态,只有符合预期才能更新。

// 只有 status=0(待支付)才能改成 1(已支付)
UpdateWrapper<Order> wrapper = new UpdateWrapper<>();
wrapper.eq("id", orderId)
       .eq("status", 0)   // 锁定当前状态
       .set("status", 1);

int rows = orderMapper.update(null, wrapper);
if (rows == 0) {
    throw new BusinessException("订单状态已变化");
}

生成的 SQL

UPDATE tb_order SET status = 1 WHERE id = 123 AND status = 0;
  • rows = 1 → 更新成功 ✅
  • rows = 0 → 状态已被修改,拒绝 ❌

适用场景:订单状态流转、支付状态变更


三种方案对比

方案存储优点缺点
Token 防重Redis速度快,不增加 DB 压力需要两次请求
去重表数据库可靠,可查历史增加存储开销
状态机现有表最常用,直接利用 DB 特性需要状态字段配合

项目实战

在我的秒杀项目中,我是这样组合使用的:

  1. 秒杀下单:Token 防重 + 分布式锁
  2. 订单支付:状态机(只有"待支付"才能改成"已支付")
  3. 消息消费:去重表(防止 MQ 重复消费)

改造后,接口重复调用的问题基本杜绝,线上再也没有出现过重复下单的 BUG。


面试怎么答?

面试官问这个问题,你可以这样回答:

"我们项目主要用三种方案来保证幂等:

  1. Token 防重,用于前端防重复提交;
  2. 去重表,用于 MQ 消息消费防重;
  3. 状态机,用于订单状态流转,只有符合预期状态才能更新。

具体用哪种方案,要看业务场景。比如支付这种有明确状态流转的,就用状态机;MQ 消费这种需要持久化的,就用去重表。"


总结

  1. 幂等性是后端开发必备技能
  2. 三种方案各有适用场景
  3. 状态机是最常用的,推荐优先考虑
  4. 组合使用效果更好

参考资料

  • Seata 官方文档
  • MyBatis-Plus 乐观锁

EOF