第 58 章:幂等性与防重复提交
学习目标
- 理解幂等性的概念
- 学会用 token 防止重复提交
- 学会用 Redis 唯一键防重
一、什么是幂等
同一个请求,执行多次和执行一次效果相同。
转账 100 元:第一次成功,第二次不能再扣
创建订单:第一次成功,第二次不能重复二、token 方案(防止前端重复提交)
⚠️ 这个 token 和 55 章的 JWT 不是一回事。碰巧都叫 token,作用完全相反:
幂等 token(本节) JWT(55 章) 干嘛 防重复提交 证明身份(已登录) 生命周期 一次一领,用完即焚 一次签发,反复使用直到过期 类比 银行叫号票 身份证 丢了咋办 重新领一张 重新登录 怎么区分:55 章的是验证"你是谁",请求头是
Authorization: Bearer xxxx;58 章的是验证"这次请求是不是重复",请求头是Idempotent-Token: xxxx。
1. 前端进入页面 → 后端生成 token,放 Redis(setnx)
2. 提交时带 token → 后端校验 + 删 token
3. 第二次提交带同一个 token → 失败(已被删)@GetMapping("/token")
public Result<String> getToken() {
String token = UUID.randomUUID().toString();
redis.opsForValue().set("idempotent:" + token, "1", 5, TimeUnit.MINUTES);
return Result.ok(token);
}
@PostMapping("/submit")
public Result<Void> submit(@RequestHeader("Idempotent-Token") String token,
@RequestBody OrderDTO dto) {
Boolean deleted = redis.delete("idempotent:" + token);
if (Boolean.FALSE.equals(deleted)) {
throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
}
// 业务逻辑
return Result.ok();
}⚠️ 坑 1:用
setnx+ 校验,不是查值再删。并发情况下两个请求都查到 token 存在,然后都删,都通过。💡 为什么要"删"而不是"查":
redis.delete是原子操作——Redis 一次完成"删并告诉你有没有删到",并发两个请求只有一个人能删成功;如果先查后删,两个请求都查到"在",逻辑就乱了。下面流程图里Boolean deleted = redis.delete(...):返回true= 第一次提交(通过);返回false= token 已被别人删过(重复,拒掉)。完整流程(图):
前端进入下单页 ↓ GET /idempotent/token ↓ 后端:UUID 存 Redis(key="idempotent:abc123", val="1", 5min) ↓ 后端:把 token "abc123" 返回给前端 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 前端:用户点 "提交订单",把 token 放 header 里发请求 ↓ POST /orders, Header: Idempotent-Token=abc123 ↓ 后端:redis.delete("idempotent:abc123") ├─ 删得到(token 还在) → 第一次,处理订单,✓ 返回成功 └─ 删不到(token 已删) → 重复,抛 "DUPLICATE_SUBMIT" 拒掉一句话总结 token 的"一次性":像银行叫号票——领一张办一次业务,办完作废,下次重新领。
2.1 多业务隔离(同一个 token 不能跨业务复用)
⚠️ 坑 3:多个业务场景(下单/支付/评论)不能共用同一个 token,否则会误判"重复提交"。
反例:三个场景都用同一把钥匙
idempotent:token,用户先点"支付"用掉 token,再点"发表评论"——后端一看 token 已删,提示"重复提交",但实际是评论的第一次。java// ❌ 错:三个场景一个钥匙 下单 → idempotent:token:abc 支付 → idempotent:token:abc // 同一把,先到先得,后面的全报错 评论 → idempotent:token:abc // ✅ 对:每个业务一把钥匙,用前缀区分 下单 → idempotent:order:abc 支付 → idempotent:pay:abc 评论 → idempotent:comment:abc实现:
java@GetMapping("/token") public Result<String> getToken(@RequestParam String biz) { // ① 前端告诉后端:哪个业务(order/pay/comment) String token = UUID.randomUUID().toString(); redis.opsForValue().set( "idempotent:" + biz + ":" + token, // ② 拼上 biz 前缀,业务之间不打架 "1", 5, TimeUnit.MINUTES); return Result.ok(token); } @PostMapping("/submit") public Result<Void> submit(@RequestHeader("Idempotent-Token") String token, @RequestHeader("X-Biz") String biz, // ③ 提交时也带业务名 @RequestBody OrderDTO dto) { Boolean deleted = redis.delete("idempotent:" + biz + ":" + token); // ④ 用 biz 前缀拼回去删 if (Boolean.FALSE.equals(deleted)) { throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT); } return Result.ok(); }业务量级选择:
业务量 做法 1 个业务要防重 写死一把钥匙,简单粗暴 2~3 个业务 每个业务一个 Redis key 前缀,手动写死就行 10+ 个业务 抽成注解 @Idempotent(biz="order"),自动拼前缀(参考 48 章@ValidStatus)
三、Redis 唯一键方案(订单等)
@PostMapping("/orders")
public Result<Order> create(@RequestBody OrderDTO dto) {
// ① 拼 Redis key:哪个用户买哪个商品 = 唯一的一单
// userId=123 + skuId=999 → "order:dedup:123:999"
// 同一个用户对同一个商品,key 一样(去重粒度)
String key = "order:dedup:" + dto.getUserId() + ":" + dto.getSkuId();
// ② 抢锁/占位:setIfAbsent = "key 不存在才设"
// - 第一次请求(key 没人占过)→ 返回 true,抢成功
// - 并发第二次请求(key 已被自己占)→ 返回 false,拒掉
// 60 秒后自动过期,防止 key 永远占着(兜底)
Boolean success = redis.opsForValue().setIfAbsent(key, "1", 60, TimeUnit.SECONDS);
// ③ 没抢到 → 重复请求,直接抛错
if (Boolean.FALSE.equals(success)) {
// Boolean.FALSE.equals 写法比 !success 安全,避免空指针
throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
}
// ④ 抢到了 → 真下单
try {
return Result.ok(orderService.create(dto));
// ⑤ 下单失败(key 占着但事没办成)→ 删 key,允许前端重试
// 比如库存不足抛异常,客户调整数量后再点,得能重新下单
} catch (Exception e) {
redis.delete(key); // 失败释放,允许重试
throw e; // 异常继续往上抛,让全局 handler 处理
}
}💡 三个关键动作缺一不可:
行 干嘛 不写会怎样 ① 拼 key 定义"啥叫重复"——同一用户同一商品算重复 去重粒度乱 ② setIfAbsent原子抢锁(不是先查再设) 并发会两个都通过(类似坑 1) ⑤ catch里delete失败释放 失败后 60 秒内同请求全拒,客户炸毛 一句话总结:
setIfAbsent= 抢位子——同一个人对同一个商品,redis 里只有一个位子,先到先得;失败记得把位子让出来,别人能重试。
四、数据库唯一索引(最稳妥)
CREATE TABLE order (
id BIGINT PRIMARY KEY,
order_no VARCHAR(50) UNIQUE, -- 唯一索引
user_id BIGINT,
amount DECIMAL(10,2)
);@PostMapping("/orders")
public Result<Order> create(@RequestBody OrderDTO dto) {
dto.setOrderNo(generateOrderNo());
try {
orderMapper.insert(dto);
} catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.DUPLICATE_SUBMIT);
}
return Result.ok(dto);
}优点:
- 数据库兜底,绝对可靠
- 即使 Redis 挂了也不会出问题
⚠️ 坑 4:单
order_no UNIQUE不足以防"用户重复下单"——因为generateOrderNo()每次都是新的随机号,用户两次提交会生成两个不同的 order_no,DB 都让插入,结果还是两单。反例:
用户点 "立即购买" 两次(网慢重发): 第 1 次:order_no = "ORD20260824ABC123" → 插入成功 ✓ 第 2 次:order_no = "ORD20260824XYZ789" ← 新号,跟第一次不同 → 插入成功 ✓ 结果:同一个用户买了 2 单 ❌
order_no UNIQUE兜底的真实场景:
兜的场景 做法 程序生成重复单号(雪花算法时钟回拨等极小概率) order_no UNIQUE(默认,基本撞不上)同一业务对象不能重复(同用户同商品同天) UNIQUE(user_id, sku_id, date)联合唯一 ← 真防重真防重的写法:
sqlCREATE TABLE order ( id BIGINT PRIMARY KEY, order_no VARCHAR(50) UNIQUE, -- 防程序 bug user_id BIGINT, sku_id BIGINT, create_date DATE, UNIQUE KEY uk_user_sku_day (user_id, sku_id, create_date) -- ① 关键:联合唯一,业务级防重 );💡
UNIQUE KEY uk_user_sku_day (user_id, sku_id, create_date)翻译:把"用户+商品+日期"这三列的组合当成一个整体判重,组合全表只能出现 1 次。user_id | sku_id | create_date | 能插入? 123 | 999 | 2026-08-24 | ✓ 第一次 123 | 999 | 2026-08-24 | ❌ 三列完全一样,撞了 123 | 999 | 2026-08-25 | ✓(日期不同,不算同一单) 456 | 999 | 2026-08-24 | ✓(用户不同)单列允许重复,组合不允许——这就是"业务级防重"(防你用户重复下单),区别于
order_no UNIQUE防的"程序 bug 级撞号"。javatry { orderMapper.insert(dto); } catch (DuplicateKeyException e) { // ② (user_id=123, sku_id=999, date=今天) 撞了 → 真拦住"重复下单" throw new BusinessException("不能重复下单"); }💡 选型建议:
想防的场景 用啥 程序意外生成重复单号 order_no UNIQUE(兜底)用户不能同一天买同一个商品 UNIQUE(user_id, sku_id, date)联合唯一综合防重(防抖 + 防并发 + 业务规则) 联合唯一 + Redis 占位(本节 + 上节组合)
五、状态机(订单状态流转)
5.1 状态机本身
// 支付订单
@PostMapping("/orders/{id}/pay")
public Result<Void> pay(@PathVariable Long id) {
int rows = orderMapper.updateStatus(id, OrderStatus.PAID, OrderStatus.UNPAID);
if (rows == 0) {
throw new BusinessException("订单状态已变,不能重复支付");
}
return Result.ok();
}UPDATE order SET status = 'PAID'
WHERE id = #{id} AND status = 'UNPAID' -- 乐观锁 / 状态机5.2 状态机能防啥、不能防啥
⚠️ 坑 5:状态机只防"DB 状态重复置",不防"支付 SDK 被重复调用"——并发两个请求同时进,状态判断都会被绕过,可能扣两次钱。
反例:
请求 A:查订单 → UNPAID → 调支付 SDK → 扣钱 ✓ 请求 B:查订单 → UNPAID → 调支付 SDK → 扣钱 ✓ ← 重复扣! 两个都 UPDATE → 一个 rows=1,一个 rows=0 结果:钱扣了 2 次,但订单只置 1 次 PAID解决:在状态机外面再加一层防重,三种方案任选:
方案 1:前置判断(简单,有 race)
public Result<Void> pay(@PathVariable Long id) {
Order order = orderMapper.selectById(id);
if (order.getStatus() != OrderStatus.UNPAID) {
return Result.ok(); // 已经支付过,直接返回成功
}
paymentService.callPay(order.getOrderNo(), order.getAmount());
orderMapper.updateStatus(id, PAID, UNPAID);
return Result.ok();
}仍有 race——A、B 都查到 UNPAID 都通过。单线程/低并发勉强能用。
方案 2:事务 + SELECT FOR UPDATE 行锁(90% 支付场景的标准答案)
@Transactional // ① 整个流程包成事务
public Result<Void> pay(@PathVariable Long id) {
// ② FOR UPDATE:给这行加锁,并发请求会"排队等",不会两个都进来
Order order = orderMapper.selectByIdForUpdate(id);
if (order.getStatus() != OrderStatus.UNPAID) {
return Result.ok(); // ③ 排队等到的请求查到的是 PAID,直接返回成功
}
paymentService.callPay(order.getOrderNo(), order.getAmount()); // ④ 一个一个来,只调一次
orderMapper.updateStatus(id, PAID, UNPAID);
return Result.ok();
}这就是银弹——SELECT FOR UPDATE 把"查 → 调支付 → 改状态"压在一个事务里串行化,并发谁先到谁扣钱,后排队的查到 PAID 直接返回。
💡
selectByIdForUpdate到底是什么? = "按 ID 查并加排他锁"——SQL 末尾拼FOR UPDATE,事务里查,别的线程会阻塞等待直到你提交;**经典用于"扣库存、抢单、抢座位"**等防超卖场景,必须在@Transactional里用,索引还要建对(否则行锁升级成表锁)。
方案 3:Redis 幂等键防最外层(跨服务 / 外部 API / 长流程)
public Result<Void> pay(@PathVariable Long id,
@RequestHeader("Pay-Idempotent-Key") String requestId) {
String key = "pay:idem:" + requestId;
// ① 请求进来第一件事:抢 redis 占位
if (!redis.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.MINUTES)) {
return Result.ok(); // ② 同一个 requestId 已经处理过,直接返回成功(不重扣)
}
try {
paymentService.callPay(...); // ③ 调支付
orderMapper.updateStatus(id, PAID, UNPAID); // ④ 改状态
} catch (Exception e) {
redis.delete(key); // ⑤ 失败释放,允许重试
throw e;
}
return Result.ok();
}适用:跨多个微服务、调用外部 API、需要前端配合生成 requestId 的场景。
5.3 三种方案怎么选
| 方案 | 适用 | 优缺点 |
|---|---|---|
| 1. 前置判断 | 单体项目、低并发 | 简单但有 race |
| 2. 事务 + FOR UPDATE | 90% 支付场景的标准答案 | 稳,但需要 DB 事务 |
| 3. Redis 幂等键 | 跨服务、外部 API、长流程 | 灵活,前端配合 |
💡 一句话总结:状态机只管 DB 状态,业务执行靠外面的锁(事务 / Redis 幂等键)——58 章五的状态机 + 58 章三的 Redis 占位 = 完整的支付幂等。
六、MQ 消息幂等
@RabbitListener(queues = "order.queue")
public void handle(OrderMessage msg) {
String dedupKey = "msg:" + msg.getMessageId();
if (redis.opsForValue().setIfAbsent(dedupKey, "1", 7, TimeUnit.DAYS)) {
// 第一次消费,执行
process(msg);
} else {
log.info("消息已消费,忽略: {}", msg.getMessageId());
}
}七、方案对比
| 方案 | 适用 | 可靠度 |
|---|---|---|
| Token | 前端按钮 | 中 |
| Redis 唯一键 | 后端业务 | 高 |
| 数据库唯一索引 | 强一致场景 | 最高 |
| 状态机 | 状态流转 | 高 |
⚠️ 坑 2:金融场景必须用数据库唯一索引,Redis 不可靠。
八、本章小结
| 要点 | 关键 |
|---|---|
| Token | 防止前端重复提交 |
| 唯一键 | Redis setnx |
| 唯一索引 | 数据库兜底 |
| 状态机 | 状态流转控制 |
动手练习
- 实现一个 token 方案,防止按钮重复提交
- 给订单表加唯一索引,写一个并发测试用例
下一章:第 59 章:Sentinel 限流与熔断 →