支付回调收到第三次的时候,订单已经发了两遍货。上游说它「只回调一次」,但它的重试策略写得明明白白:15 秒、15 秒、30 秒、3 分钟……24 小时内重试 9 次。幂等这件事从来不是「上游保证不重复」,而是「我假定它一定会重复」。
下面这套东西来自一个真实的对账事故:用户付了一笔 199 元的订单,库存扣了两次,优惠券返还了两次。修复过程里踩的坑基本覆盖了幂等设计的全部雷区。
幂等键:90% 的幂等 bug 都出在这一步
先说结论:幂等键必须由发起方生成,并且在整个重试链路里保持不变。
最常见的错误写法长这样:
// 错误示范:服务端自己生成 ID,然后拿它当幂等键
@PostMapping("/pay/callback")
public String callback(@RequestBody PayCallbackReq req) {
String idempotentKey = UUID.randomUUID().toString(); // 每次调用都是新的
if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(idempotentKey, "1", 60, TimeUnit.SECONDS))) {
doBusiness(req);
}
return "SUCCESS";
}
这段代码单次请求永远能通过,因为每次生成的 key 都是新的,setIfAbsent 必然成功。它不是幂等,它只是给每次重复请求都发了一张新门票。
幂等键的几个硬性要求:
| 要求 | 说明 | 反例 |
|---|---|---|
| 由发起方生成 | 重试时携带同一个值 | 服务端自己 randomUUID |
| 全局唯一 | 跨用户、跨业务不冲突 | 拿 user_id 当 key |
| 与业务语义绑定 | 同一笔支付流水号天然就是幂等键 | 用请求时间戳 |
| 生命周期够长 | 覆盖上游最大重试窗口 | Redis 只 TTL 60 秒 |
支付场景里,微信/支付宝的 transaction_id、自家的订单号、客户端表单的 UUID,都是合格的幂等键。真正危险的是「看着唯一、其实是新生成的」那种,比如服务端 UUID、请求时间戳、自增主键。
唯一索引:最便宜的兜底,但异常处理有坑
幂等记录落库,靠唯一索引兜底,这是成本最低、最不容易出错的一招。
CREATE TABLE `idempotent_record` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型,如 PAY_CALLBACK',
`biz_key` VARCHAR(128) NOT NULL COMMENT '幂等键,如微信支付 transaction_id',
`req_digest` CHAR(64) NOT NULL COMMENT '请求体 SHA-256,防同键不同参',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz` (`biz_type`, `biz_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
先说一个很多团队踩过的错误写法:先查再插。
// 错误示范:check-then-act,并发下必炸
if (mapper.selectByBizKey(bizType, bizKey) == null) {
mapper.insert(...);
doBusiness();
}
两个线程同时查,都返回 null,都插入 —— 如果业务表上没唯一索引,就是两次扣款。这个模式在单机压测里跑不出来,一上生产就现形,因为它需要「两个请求几乎同时到达」,而这个条件恰好是上游重试和前端双击最容易创造的。
正确做法是让数据库来当裁判:
@Service
public class PayCallbackService {
@Autowired private JdbcTemplate jdbcTemplate;
/**
* 返回 true 表示本次是第一次处理,false 表示重复请求
* 注意:这里必须是独立事务(REQUIRES_NEW),不能和业务事务共用一个
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryAcquire(String bizType, String bizKey, String reqDigest) {
try {
jdbcTemplate.update(
"INSERT INTO idempotent_record(biz_type, biz_key, req_digest, status) VALUES (?,?,?,0)",
bizType, bizKey, reqDigest);
return true;
} catch (DuplicateKeyException e) {
return false; // 唯一键冲突,说明已经被处理过或正在处理
}
}
}
这里有个必须知道的坑:在 Spring 事务里 catch 掉 DuplicateKeyException 之后继续干活,提交时会抛 UnexpectedRollbackException。因为唯一键冲突会让当前事务被标记为 rollback-only,Spring 在 commit 时发现事务已标记回滚,直接抛异常,业务代码前面干的事全部白干。
两种解法,选一个:
-- 解法一:用不抛异常的语法,让冲突静默返回 0 行
INSERT IGNORE INTO idempotent_record(biz_type, biz_key, req_digest) VALUES (?,?,?);
-- 解法二:ON DUPLICATE KEY UPDATE
INSERT INTO idempotent_record(biz_type, biz_key, req_digest) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE updated_at = NOW();
INSERT IGNORE 会把所有错误都吞掉,包括字段超长、类型错误,线上排查问题时容易抓瞎;ON DUPLICATE KEY UPDATE 更可控,但它的受影响行数在插入时是 1、更新时是 2、值没变化时是 0,别拿 affected rows 当「是否首次处理」的判据。
Redis 挡在前面:快,但只能当第一道闸门
高频回调下不可能每次都去怼数据库,Redis 挡第一层是合理的。但要用对:
public boolean tryLock(String key, String requestId, Duration ttl) {
// 必须是原子的 SET NX EX,不要 SETNX + EXPIRE 两条命令分开写
Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, requestId, ttl);
return Boolean.TRUE.equals(ok);
}
三个真实的坑:
坑一:SETNX + EXPIRE 分开写。 两条命令之间服务宕机,key 永不过期,业务被永久阻塞。更糟的是这个 key 可能对应一笔真实支付,用户的钱扣了,订单永远卡在处理中。
坑二:把 Redis 当唯一保证。 主从异步复制,主节点写入后还没同步就宕机,从节点升主,锁丢了,重复请求照样进来。所以 Redis 只能降低概率,正确性还得靠数据库唯一索引兜底。
坑三:把「防并发」和「防重复」混成一个 key。 很多团队这么写:加锁 TTL 30 秒,处理完立刻删锁。结果 5 分钟后上游重试,key 早没了,又处理一遍。防并发需要短 TTL(避免死锁),防重复需要长存活(覆盖重试窗口),这是两个不同的东西:
- 防并发:短 TTL 的锁,或者干脆用数据库行锁/CAS 更新,不用 Redis。
- 防重复:幂等记录,TTL 至少 48 小时,或者直接落库。
如果一定要用 Redis 释放锁,记得校验持有者,用 Lua 保证原子:
-- KEYS[1] = 锁 key,ARGV[1] = requestId
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
状态机:真正的幂等靠状态迁移,不是锁
即使两次请求都进了业务逻辑(锁没挡住,或者压根没加锁),只要状态迁移是条件更新,就不会出问题。这是我认为最应该优先采用的一招,因为它不引入任何新组件。
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE order_no = ?
AND status = 'WAIT_PAY';
int affected = orderMapper.markPaid(orderNo);
if (affected == 0) {
// 状态已经不是 WAIT_PAY,说明已经处理过,或者订单状态非法
Order o = orderMapper.selectByOrderNo(orderNo);
if ("PAID".equals(o.getStatus())) {
return Result.ok("已处理"); // 幂等命中,必须返回成功,否则上游继续重试
}
throw new IllegalStateException("订单状态异常: " + o.getStatus());
}
// 只有 affected == 1 的那个线程才继续做后续动作
inventoryService.deduct(orderNo);
couponService.refundCoupon(orderNo);
把判断和更新放进同一条 SQL,由 InnoDB 的行锁保证原子性,这是最朴素也最可靠的做法。
代价是什么?CAS 成功之后的动作失败了,就没人再帮你重试了。 订单已经变成 PAID,下次回调进来走 affected == 0 分支直接返回成功,库存永远扣不掉。所以后续动作要么做成可重入 + 本地消息表驱动:
-- 状态 CAS 和消息落库放在同一个事务里
BEGIN;
UPDATE orders SET status = 'PAID' WHERE order_no = ? AND status = 'WAIT_PAY';
INSERT INTO local_message(biz_key, topic, payload, status)
VALUES (?, 'ORDER_PAID', ?, 0);
COMMIT;
然后定时任务扫 status = 0 的消息投递到 MQ,投递成功置 1。这里 biz_key 又是幂等键,消费端还要再做一次幂等。幂等是会传染的,从入口一路到最下游都得做,只是每一层用的手段不同。
重复提交:前端只算体验优化
按钮置灰、防抖 500ms、loading 遮罩,这些只能拦住用户手抖,拦不住:
- 用户刷新页面后重新提交
- 多标签页同时打开同一个表单
- 客户端 SDK 网络超时重试
- 爬虫、压测脚本、恶意请求
真正有效的是表单级幂等 token:
// 1. 进入下单页时申请 token,服务端生成并存 Redis
@GetMapping("/order/token")
public String token() {
String t = UUID.randomUUID().toString().replace("-", "");
redis.opsForValue().set("submit:token:" + t, "0", Duration.ofMinutes(30));
return t;
}
// 2. 提交时带上同一个 token
@PostMapping("/order/create")
public Result create(@RequestHeader("X-Submit-Token") String token, @RequestBody OrderReq req) {
Boolean first = redis.opsForValue()
.setIfAbsent("submit:used:" + token, "1", Duration.ofMinutes(30));
if (!Boolean.TRUE.equals(first)) {
throw new BizException("请勿重复提交");
}
return orderService.create(req);
}
最大的坑在这里:前端每次点击都调一次 /order/token 拿新 token 再提交,服务端看到的永远是「新 token 第一次使用」,幂等直接失效。token 必须在页面加载时生成一次,在整个表单生命周期内复用,提交成功或页面卸载后才丢弃。
第二个坑:token 校验和业务处理不在同一个原子操作里。校验通过 → 服务端进程挂了 → 用户重试 → token 已标记使用 → 用户再也提交不了。更稳的做法是把 token 直接当幂等键落库,重复请求时返回上一次的结果,而不是冷冰冰地报「重复提交」。
幂等记录留多久:给几个具体数字
幂等表会无限增长。按日均 500 万次写接口、每次一条记录、单行 200 字节算,一年约 365 GB,这还没算索引。留多久取决于上游重试窗口:
| 上游 | 最大重试窗口 | 幂等记录最少保留 |
|---|---|---|
| 微信支付回调 | 24 小时内 9 次(15s/15s/30s/3m/10m/20m/30m/30m/60m) | 48 小时 |
| 支付宝异步通知 | 25 小时内 8 次 | 48 小时 |
| RocketMQ 消费重试 | 默认 16 次,跨度约 4 小时 46 分 | 24 小时 |
| 客户端手动重试 | 不确定 | 别少于 7 天 |
清理策略按 QPS 选:
-- 低 QPS:定时分批删,别一次性删完
DELETE FROM idempotent_record
WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)
LIMIT 1000; -- 每批 1000 行,批间 sleep 50ms,避免主从延迟拉爆
- 按月分表:
idempotent_record_202405,一个月一张,到期直接DROP TABLE。适合高 QPS。 - Redis + 落库分离:Redis 存近期 48 小时,数据库存长期,查询先查 Redis。
1000 QPS 就是一天 8640 万行,单表扛不住;DELETE ... LIMIT 1000 每秒最多删几千行,清理速度根本追不上写入速度。这种量级必须分表或分区,别指望一个定时任务解决问题。
几个边界条件,出事了才知道疼
同一个幂等键,请求体不一样。 有人拿订单号当幂等键,第一次请求金额 199,第二次(被篡改的)请求金额 1 元。直接返回第一次的结果是安全的,重新处理就出事了。落库时存请求体摘要,重复请求先比对:
String digest = DigestUtils.sha256Hex(JSON.toJSONString(req));
IdempotentRecord r = mapper.selectByBizKey(bizType, bizKey);
if (r != null && !r.getReqDigest().equals(digest)) {
log.error("幂等键冲突且参数不一致, bizKey={}", bizKey);
throw new BizException("请求参数与首次不一致");
}
验签必须在幂等之前。 先查幂等再验签,等于给伪造请求提供「占坑」能力:攻击者拿一个不存在的订单号刷你的幂等表,真正的回调进来时反而被当成重复请求丢弃,这笔钱就永远确认不了。
幂等命中时要返回成功。 很多人返回「重复请求」错误码,上游认为失败继续重试,越重试越堵。幂等命中必须返回和第一次完全相同的成功响应。
唯一索引键别太长。 MySQL 5.6/5.7 在 innodb_large_prefix=OFF 时单列索引前缀 767 字节,utf8mb4 下最多 191 字符。用 VARCHAR(128) 存原始 key 是安全的,超长就存哈希值。
分布式锁不等于幂等。 锁保证的是「同一时刻只有一个线程执行」,不保证「同一请求只执行一次」。锁 TTL 一到,重复请求照样进来。锁是减少无效竞争的性能优化,唯一索引和状态机才是正确性保证。
一个可以直接抄的处理顺序
一个写接口的幂等处理顺序:
- 解析请求取出幂等键(上游流水号 / 客户端 token),取不到直接拒绝,不要用 UUID 兜底。
- 验签、鉴权。
- Redis
SET NX EX 300抢一个「处理中」标记,抢不到就查最终结果返回(短暂等待 + 重试查询)。 - 数据库唯一索引插入幂等记录,独立事务,捕获冲突。
- 同一个业务事务里做状态 CAS 更新 + 本地消息落库。
- 返回成功,并把结果写回 Redis(TTL 覆盖上游重试窗口)。
- 后续动作(扣库存、发券、发货)走消息异步执行,消费端自己做幂等。
注意第 3 步和第 4 步的顺序:Redis 只是加速层,就算它整个挂掉或者主从切换丢数据,第 4 步的唯一索引仍然能保证正确性。反过来把 Redis 当唯一防线,迟早会遇到那两个线程同时进来、同时扣库存的下午。