接口幂等性是指同一个请求无论被重复提交多少次,对系统产生的最终影响都和只执行一次完全相同。去年大促我们一个支付回调接口因为没做幂等,用户在弱网下连续点了两下,后端没拦住,直接造成两笔重复扣款——这就是最典型的「重复请求」事故。本文用真实踩坑讲清幂等性设计与 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 不问 value | Lua 校验 requestId 再删 |
| 异常时未回滚去重记录 | 失败请求占住键,后续全拦 | 先写去重后执行业务 | 业务失败回滚或键前置校验 |
八、如何测试幂等性
第一,用同一 Token 或 Idempotency-Key 连发 3 次,断言只有一次生效、余额只扣一次。第二,在接口处理中途杀掉进程,触发上游超时重试,验证去重表拦截。第三,模拟 Kafka rebalance,重复投递同一条消息,验证消费端丢弃。把这三步写进自动化用例,幂等才有保障。
九、总结
接口幂等性不是某个银弹,而是一套「前端防抖 + 防重 Token + 去重表兜底 + 分布式锁防并发 + 状态机自洽」的组合拳。强一致写(支付、下单)务必上「去重表 + 锁」双保险;开放 API 优先用 Idempotency-Key;消息消费用「业务键 + Redis 去重」。记住:重复请求一定会来,区别只是你有没有准备好。它与 Redis 缓存三大问题、数据库连接池耗尽排查 一样,都是高并发系统必须提前补的课。




