先看一个经典的翻车现场:用户在支付页面手抖点了两下提交,前端发了两个一模一样的 POST,两个请求都进了支付流程——订单重复,扣了两次款。这就是典型的重复提交,而背后是更普遍的问题:接口幂等。
幂等这词听着唬人,本质就一句话:同一个操作,执行一次和执行一百次,结果一样。支付回调、表单提交、消息消费,这三类场景天天跟它打交道。这篇把幂等的方案从简单到可靠排一遍:为什么唯一键够用、什么时候必须上状态机、消息重复消费怎么兜底。代码按 Spring Boot 落地,能实跑的我都实跑验证过。
一、先分清:哪些请求天生幂等,哪些不是
不是所有接口都需要幂等设计。先分清再动手,别一上来就套方案:
| 请求类型 | 天生幂等? | 例子 |
|---|---|---|
| 查询(GET) | ✅ 幂等 | 查订单、查用户 |
| 更新固定值(PUT) | ✅ 幂等 | UPDATE user SET name='张三' |
| 追加/累加(POST) | ❌ 非幂等 | 下单、扣款、发消息、记账 |
条件更新(UPDATE ... WHERE status=0) | ⚠️ 看实现 | 靠状态判断防重 |
真实翻车的都是第三类:POST 出去的网络请求。客户端超时重试、用户双击、消息队列重投,任何一个都会把同一个业务动作重复执行。
二、方案 v1:数据库唯一键兜底(最朴素,也最可靠)
先做最简单的一层:让数据库在物理上不允许重复。
比如"一个用户对一个商品只能下一单"(限购),在订单表建唯一索引:
-- 防重复下单:同一用户 + 同一商品 + 同一场次 只能有一条
ALTER TABLE `t_order`
ADD UNIQUE KEY `uk_user_sku` (`user_id`, `sku_id`, `seckill_id`);代码里插入时捕获 DuplicateKeyException:
@Transactional
public Order createOrder(CreateOrderCmd cmd) {
Order order = new Order();
order.setUserId(cmd.getUserId());
order.setSkuId(cmd.getSkuId());
order.setSeckillId(cmd.getSeckillId());
order.setStatus(OrderStatus.CREATED);
try {
orderMapper.insert(order);
return order; // 插入成功 = 第一次执行
} catch (DuplicateKeyException e) {
return orderMapper.selectByUk( // 撞唯一键 = 重复请求,返回已有结果
cmd.getUserId(), cmd.getSkuId(), cmd.getSeckillId());
}
}这个方案的特点:
- 优点:数据库唯一索引是最终防线,并发下也不会漏,不需要任何协调
- 缺点:① 唯一键是业务强约束,没法覆盖"任意请求都要幂等";② 只能防"重复插入",防不了"重复更新累计"(比如余额加两次)
所以唯一键是兜底,不是万能。
三、方案 v2:token + Redis 防重(表单防双击的首选)
业务方说:我们不限制用户下几单,只是防手抖双击。这种情况唯一键约束不住,用「预发 token 机制」:
后端就两个动作:发 token 存 Redis,收 token 时删除。关键在删除用 SETNX/DEL 的原子性——两个并发请求同时来,只有一个能删成功:
// 1. 发 token:页面打开时调用
public String issueToken() {
String token = UUID.randomUUID().toString();
stringRedisTemplate.opsForValue().set(TOKEN_PREFIX + token, "1", Duration.ofMinutes(2));
return token;
}
// 2. 校验 token:提交时调用,只允许一次成功
public boolean consumeToken(String token) {
// 原子删除:返回 true 说明删到了 = 首次;返回 false = 已被删过 = 重复
return Boolean.TRUE.equals(stringRedisTemplate.delete(TOKEN_PREFIX + token));
}@PostMapping("/order")
public Result<?> create(@RequestBody CreateOrderReq req) {
if (!idempotentService.consumeToken(req.getToken())) {
return Result.fail("请勿重复提交");
}
// token 校验通过,继续正常下单流程
return orderService.create(req);
}为什么删 token 能防并发:Redis 单线程执行 DEL,两个请求同时 DEL 同一个 key,必然一个返回 1 一个返回 0。不存在两个都成功。
这个方案的特点:
- 优点:业务代码零侵入(校验抽成注解就能全站通用);不限制真实业务次数
- 缺点:① 依赖前端配合(必须先从后端拿 token);② token 校验过了、业务却失败(如库存不足),用户刷新重来还得重新取 token——校验成功 ≠ 业务成功,重复提交还是可能落库
所以 token 方案解决"双击",解决不了"重试"。
四、方案 v3:幂等表 + 状态机(支付回调这类"外部重试"的解法)
支付回调是幂等需求最硬的场景:支付平台会一直重试回调(最多重试 24 小时,间隔递增),你的接口必须对同一个订单的无数次回调返回同一个结果。token 方案在这失灵——回调方不会先来取 token。
解法是幂等表 + 状态机:
-- 幂等表:记录每个业务动作的处理结果
CREATE TABLE `t_idempotent` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型:PAY_CALLBACK / REFUND_CALLBACK',
`biz_no` VARCHAR(64) NOT NULL COMMENT '业务单号:订单号 / 支付流水号',
`status` TINYINT NOT NULL COMMENT '0=处理中 1=成功 2=失败',
`result_json` VARCHAR(512) COMMENT '首次处理结果,重复请求直接返回它',
`create_time` DATETIME NOT NULL,
UNIQUE KEY `uk_biz` (`biz_type`, `biz_no`)
) COMMENT '幂等记录表';核心思路:先占位(插入处理中记录),处理完回写结果。重复回调来了,发现已有记录,直接返回记录里的结果,不再重复执行:
@Transactional
public PayResult handlePayCallback(PayCallbackReq req) {
// 1. 先占位:INSERT ... status=0,撞唯一键说明是重复回调
IdempotentRecord record = tryInsertPending(req.getBizType(), req.getBizNo());
if (record == null) {
// 重复回调:查已有结果直接返回(幂等命中)
return readPrevResult(req.getBizType(), req.getBizNo());
}
try {
// 2. 第一次执行真实业务:更新订单状态、加余额
PayResult result = doHandlePay(req);
// 3. 回写结果:status=1
updateResult(record.getId(), result);
return result;
} catch (Exception e) {
// 4. 业务失败:标记 status=2,允许外部重试(重试时重新占位)
updateFail(record.getId());
throw e;
}
}状态机在这里起什么作用:处理中 → 成功/失败 三个状态,让"占位失败"和"业务失败"能区分开——占位失败是重复请求(返回旧结果),业务失败是执行出错(允许重试)。没有状态机,这两件事分不清,要么重复执行、要么永远不重试。
配合条件更新,状态机还能做到"只处理一次"的强保证:
-- 只有 status=处理中 才能流转到成功,防止并发下两个线程都执行业务
UPDATE t_idempotent SET status = 1, result_json = ?
WHERE id = ? AND status = 0;五、消息队列的重复消费:用「消费幂等」兜住
消息场景的重复消费是隐性的:MQ 是至少一次投递,消费端处理完、回执前挂了,消息就会重投。所以消费者必须自己幂等。
做法就是前面方案的组合:用业务唯一键当消费幂等键(订单号、流水号),消费时先查幂等表,处理过就跳过:
@RabbitListener(queues = "order.pay.success")
public void onPaySuccess(PaySuccessMsg msg) {
String idemKey = "PAY:" + msg.getOrderNo();
// 借助幂等表(或 Redis SETNX)判断是否已处理
if (!idempotentService.tryMarkProcessed(idemKey)) {
log.info("重复消息,跳过: {}", msg.getOrderNo());
return;
}
// 真正的业务:积分、通知、库存
orderService.afterPaySuccess(msg);
}六、方案选型:一张表说清楚
| 方案 | 防什么 | 成本 | 适用场景 |
|---|---|---|---|
| 数据库唯一键 | 重复插入 | 低 | 业务本身有唯一约束(限购、领券) |
| Redis token 防重 | 双击/连点 | 低 | 表单提交、前端交互类 |
| 幂等表 + 状态机 | 外部重试/回调 | 中 | 支付回调、Webhook、MQ 消费 |
| 状态机条件更新 | 并发下只执行一次 | 中 | 对"只处理一次"有强要求 |
我的组合拳(生产常用):
- 前端交互类 → token 防重(体验好)
- 支付回调/Webhook → 幂等表 + 状态机(防重试)
- 数据库层 → 唯一索引兜底(最终防线)
- MQ 消费 → 消费幂等键(防重投)
小结
幂等设计的核心就两句话:用"唯一的业务标识"把重复请求识别出来,用"先占位后回写"让重复请求只返回结果不重复执行。方案从唯一键到状态机,一层比一层通用,也一层比一层重——按场景选,别一上来就上分布式锁。
和分布式锁的关系:有人用 Redis 锁做幂等(先加锁再处理),但锁只保证"并发互斥",不保证"重复请求返回同一结果"——防并发和防重复是两码事。想彻底分清,看《锁的分类与实现原理》和《Redis 分布式锁详解》。
验证记录:文中的 Redis token 防重逻辑(
SET+DEL原子性)、幂等表占位回写逻辑(唯一键 + 状态流转),已用真实 Redis + MySQL 语义验证。Redis 部分:SET token 1 EX 120后DEL两次,第一次返回 1、第二次返回 0,实测符合"只允许一次成功"的预期。
