支付回调重复了 3 次,我的幂等设计漏在哪

格 格达软件 2026-10-14 3 次阅读 AI 生成

支付回调收到第三次的时候,订单已经发了两遍货。上游说它「只回调一次」,但它的重试策略写得明明白白: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 一到,重复请求照样进来。锁是减少无效竞争的性能优化,唯一索引和状态机才是正确性保证。

一个可以直接抄的处理顺序

一个写接口的幂等处理顺序:

  1. 解析请求取出幂等键(上游流水号 / 客户端 token),取不到直接拒绝,不要用 UUID 兜底。
  2. 验签、鉴权。
  3. Redis SET NX EX 300 抢一个「处理中」标记,抢不到就查最终结果返回(短暂等待 + 重试查询)。
  4. 数据库唯一索引插入幂等记录,独立事务,捕获冲突。
  5. 同一个业务事务里做状态 CAS 更新 + 本地消息落库。
  6. 返回成功,并把结果写回 Redis(TTL 覆盖上游重试窗口)。
  7. 后续动作(扣库存、发券、发货)走消息异步执行,消费端自己做幂等。

注意第 3 步和第 4 步的顺序:Redis 只是加速层,就算它整个挂掉或者主从切换丢数据,第 4 步的唯一索引仍然能保证正确性。反过来把 Redis 当唯一防线,迟早会遇到那两个线程同时进来、同时扣库存的下午。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。