接口幂等设计:防重复提交与分布式去重实战

接口幂等性是指同一个请求无论被重复提交多少次,对系统产生的最终影响都和只执行一次完全相同。去年大促我们一个支付回调接口因为没做幂等,用户在弱网下连续点了两下,后端没拦住,直接造成两笔重复扣款——这就是最典型的「重复请求」事故。本文用真实踩坑讲清幂等性设计与 6 种可落地的防护方案。

一、什么是接口幂等性

幂等(Idempotent)本是数学概念:某运算执行一次和执行多次结果一致。放到接口层面,就是「客户端可能因为网络、重试、手抖发出同一个写请求 N 次,服务端无论收到几次,都只产生一次业务效果」。它不要求接口内部无副作用,而是要求对外表现与单次调用等价。

需要特别区分:幂等 ≠ 防并发。并发是「同一时刻多个请求同时到达」,靠锁解决;幂等是「同一个请求被投递多次」,靠去重解决。两者经常一起出现,但手段不同,下文会分别给方案。

二、哪些场景会触发重复请求

2.1 前端重复点击

用户提交表单后页面没及时跳转,连点「确认支付」三五下;或移动端按钮防抖没做,双击触发两次请求。这是最高频的重复来源。

2.2 网络超时与自动重试

网关、RPC 框架、HTTP 客户端在超时后往往会无脑重试。曾有一次我们 Nginx 出现偶发 504,上游 Spring Cloud 的超时重试把同一个下单请求发了 3 次,库存被多扣。这类问题在 Nginx 502/504 排查实录 里也有涉及,超时重试是把双刃剑。

2.3 消息队列重复消费

Kafka 在 rebalance、消费者重启、at-least-once 投递语义下,同一条消息极可能重复投递。如果消费逻辑直接改余额、发短信,就会重复。可结合 Kafka 消息积压与重复消费排查实录 一起看。

2.4 异步回调乱序

支付渠道的回调、第三方 Webhook 有时会重复通知,甚至后发的通知先到。此时仅靠「状态判断」还不够,需要幂等键兜底。

三、幂等性设计的 6 种实战方案

3.1 方案一:唯一 ID + 去重表(数据库唯一索引)

最稳的兜底方案。用一张幂等记录表,对业务幂等键建唯一索引,重复插入会抛唯一约束异常,捕获后判定为重复。建表可纳入 数据库迁移 Flyway 版本化管理 的变更脚本里统一维护。

3.2 方案二:防重 Token(前端令牌)

进入表单页时后端下发一次性 Token 存 Redis,提交时携带,用 Lua 保证「校验 + 删除」原子,杜绝并发下的竞态重复。

3.3 方案三:状态机 + 版本号

业务本身有状态流转(待支付 → 已支付 → 已退款)。重复回调到达时,若当前状态已不在「可处理」分支,直接忽略。这是业务层最自然的幂等。

3.4 方案四:乐观锁(CAS)

更新时带版本号或原值条件,例如 UPDATE account SET balance=balance-? WHERE id=? AND version=?,影响行数为 0 即说明已被处理,视为重复。

3.5 方案五:Redis 分布式锁 / SETNX

对同一个业务主键加锁,保证同一时刻只有一个请求能进入处理。锁的细节可参考 Redis 分布式锁与高级数据结构实战。注意它解决的是「并发」而非「跨进程重试去重」,要和去重表配合使用。

3.6 方案六:业务幂等键(Idempotency-Key)

这是 Stripe 等开放 API 的标准做法:客户端每次写请求带一个全局唯一 Idempotency-Key 头,服务端统一拦截,相同键直接返回首次结果。最通用、对业务侵入最小。

四、方案选型对照表

方案适用场景优点缺点推荐指数
唯一索引去重表支付、下单等强一致写最可靠、天然兜底多一次 DB 写入★★★★★
防重 Token表单提交、按钮防抖前端友好、无 DB 压力需前后端配合★★★★☆
状态机有明确状态流转的业务业务自洽、零额外存储仅适用于状态型业务★★★★☆
乐观锁 CAS单条记录更新实现简单高并发下重试多★★★☆☆
Redis 分布式锁防并发、防竞态性能好需处理锁过期与释放★★★★☆
Idempotency-Key开放 API、通用写接口对业务侵入最小需客户端配合生成★★★★★

五、代码实战:支付接口幂等

下面用 Spring Boot + Redis + 数据库唯一索引,组合「防重 Token + 去重表」实现支付接口幂等。第一步是进入下单页时下发一次性 Token,并用 Lua 保证校验与删除原子:

// 1. 进入下单页时,后端下发防重 Token(存 Redis,5 分钟过期)
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("idemp:token:" + userId, token, 5, TimeUnit.MINUTES);
return token;

// 2. 提交时携带 Token,用 Lua 保证「校验+删除」原子,杜绝并发重复
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
             "return redis.call('del', KEYS[1]) else return 0 end";
Long ok = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class),
        Collections.singletonList("idemp:token:" + userId), token);
if (ok == null || ok == 0) {
    throw new BizException("请勿重复提交");
}

第二步是数据库去重表作为最后兜底。即便 Token 被绕过,唯一索引也会拦住重复写入:

-- 幂等记录表,biz_key 唯一约束是最后的兜底
CREATE TABLE idempotency_log (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_key     VARCHAR(128) NOT NULL COMMENT '业务幂等键',
  status      TINYINT      NOT NULL DEFAULT 0,
  created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_biz_key (biz_key)
) ENGINE=InnoDB;

// 写入用捕获唯一约束异常,命中即判定为重复
try {
    idemMapper.insert(log);
} catch (DuplicateKeyException e) {
    throw new BizException("该请求已处理,请勿重复提交");
}

第三步,对订单主键加 Redis 分布式锁,处理并发进入的情况。锁值用唯一 requestId,释放时校验归属:

// SET key value NX EX 原子加锁;值用唯一 requestId 便于安全释放
String locked = redisTemplate.opsForValue()
        .setIfAbsent("lock:pay:" + orderId, requestId, 10, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(locked)) {
    throw new BizException("订单处理中,请稍候");
}
try {
    // 业务处理...
} finally {
    // 释放锁:仅当值仍是自己的 requestId(Lua 保证原子)
    String script = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList("lock:pay:" + orderId), requestId);
}

第四步,提供一个通用拦截器,统一拦截带 Idempotency-Key 的请求,对业务零侵入:

// 通用方案:客户端每次写请求带 Idempotency-Key 头,服务端统一拦截
@Component
public class IdempotencyInterceptor implements HandlerInterceptor {
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object h) {
        String key = req.getHeader("Idempotency-Key");
        if (key == null) return true; // 未带键的不强制
        if (idemStore.putIfAbsent(key, System.nanoTime())) return true;
        resp.setStatus(HttpStatus.CONFLICT.value()); // 409 重复
        return false;
    }
}

六、消息队列重复消费的幂等处理

Kafka 消费端用「业务唯一键 + Redis SETNX」去重,已处理的消息直接 ack 丢弃。注意去重键的 TTL 要覆盖消息可能的最大重投窗口:

// 用 Redis 记录已处理的消息 key(业务唯一标识,如 orderId+eventType)
@KafkaListener(topics = "pay-result")
public void onMessage(ConsumerRecord<String, String> record) {
    PayEvent e = JSON.parseObject(record.value(), PayEvent.class);
    String dedupeKey = e.getOrderId() + ":" + e.getEventType();
    // SETNX:已存在说明重复消费,直接 ack 丢弃
    if (!redis.setIfAbsent("kafka:dedupe:" + dedupeKey, "1", 7, TimeUnit.DAYS)) {
        log.warn("重复消息丢弃: {}", dedupeKey);
        return;
    }
    handle(e); // 真实业务处理
}

七、常见踩坑与避坑清单

现象根因对策
只加锁不去重重试请求在锁释放后再次生效锁只防并发,不防跨次重试锁 + 去重表双保险
去重键设计过宽正常不同请求被误判重复键未含足够业务维度键 = 用户+业务单号+动作
Redis 幂等键无 TTL内存无限增长忘了设过期按业务窗口设 7 天等
释放锁不校验归属误删他人锁致并发直接 del 不问 valueLua 校验 requestId 再删
异常时未回滚去重记录失败请求占住键,后续全拦先写去重后执行业务业务失败回滚或键前置校验

八、如何测试幂等性

第一,用同一 Token 或 Idempotency-Key 连发 3 次,断言只有一次生效、余额只扣一次。第二,在接口处理中途杀掉进程,触发上游超时重试,验证去重表拦截。第三,模拟 Kafka rebalance,重复投递同一条消息,验证消费端丢弃。把这三步写进自动化用例,幂等才有保障。

九、总结

接口幂等性不是某个银弹,而是一套「前端防抖 + 防重 Token + 去重表兜底 + 分布式锁防并发 + 状态机自洽」的组合拳。强一致写(支付、下单)务必上「去重表 + 锁」双保险;开放 API 优先用 Idempotency-Key;消息消费用「业务键 + Redis 去重」。记住:重复请求一定会来,区别只是你有没有准备好。它与 Redis 缓存三大问题数据库连接池耗尽排查 一样,都是高并发系统必须提前补的课。

上一篇 技术影响力建设:工程师如何用输出撬动职业杠杆
下一篇 限流算法实战:令牌桶与漏桶原理及 Guava/Sentinel 落地