java 技术随笔

接口幂等性设计实战:支付回调、MQ 重复消费与六种防重方案

支付回调重复通知、消息队列重复投递、用户狂点“提交”按钮、RPC 框架自动重试——这些场景下如果接口不做幂等处理,轻则产生脏数据,重则重复扣款引发资损。本文讲清幂等的本质,并给出生产可用的六种方案与选型建议。

一、什么是幂等

一个操作无论执行一次还是多次,产生的结果与状态都完全一致,就称它具有幂等性。注意区分两个概念:

  • 防重:第二次请求直接拒绝(如重复提交表单提示“请勿重复操作”);
  • 幂等:第二次请求正常“消化”掉但不再重复生效(如支付回调第 2 次到达,业务侧直接返回成功)。

真正的接口幂等要应对的是:同一个业务请求被以不确定的次数重复执行

二、哪些场景必须考虑幂等

场景为什么可能重复
第三方支付回调微信/支付宝会多次通知,直到你确认成功
消息队列消费MQ 至少一次投递 + 消费失败重投,天然可能重复
RPC/HTTP 超时重试客户端框架对超时请求自动重发,服务端可能已处理
表单提交用户双击、弱网下重复点击
定时任务重跑补偿任务与主任务可能处理同一批数据

三、幂等方案全家桶(从数据库到 Redis)

方案 1:数据库唯一约束(最硬核的兜底)

// 建唯一索引:同一笔业务流水号只能插入一次
// ALTER TABLE t_order ADD UNIQUE KEY uk_biz_no (biz_no);
OrderPO exist = orderDao.selectByBizNo(bizNo);   // 先查
if (exist != null) { return SUCCESS; }           // 已处理过:直接返回成功
try {
    orderDao.insert(orderPO);                    // 首次插入
} catch (DuplicateKeyException e) {
    // 并发下两个请求都查不到、都来插入:靠唯一索引兜住重复,重新查一次返回
    return SUCCESS;
}

唯一索引能扛住任意并发,因为冲突由数据库保证。这是其他所有方案都不具备的“最后防线”,强烈建议每个核心写入表都设计一个 biz_no(业务流水号)唯一键。

方案 2:状态机校验(业务状态单向流转)

// 订单状态只能向前:待支付 -> 已支付 -> 已发货 -> 已完成
// 回调重复时状态已是“已支付”,update 语句的 where 条件让重复请求无效
int rows = orderMapper.updateState(
        orderId,
        /* newState */ PAYED,
        /* expectOldState */ WAIT_PAY);   // update ... where state = #{expectOldState}
if (rows == 0) {
    // 说明状态已不是“待支付”,重复通知或并发更新,直接按成功返回
    return SUCCESS;
}

方案 3:Redis SETNX + 过期时间(防重窗口 + 分布式锁兜底)

// 以“业务幂等键”为 key,抢到锁才允许继续,抢不到说明正在处理/已处理
String idemKey = "idem:pay:" + bizNo;
Boolean got = redisTemplate.opsForValue()
        .setIfAbsent(idemKey, "1", 30, TimeUnit.SECONDS); // 注意设过期时间防死锁
if (!Boolean.TRUE.equals(got)) {
    return SUCCESS;                       // 已有请求在处理,本次直接返回(或重试)
}
try {
    doPayBusiness();
    // 业务完成后可将 value 置为 done,便于查询;key 由过期时间兜底清理
} finally {
    // 仅当业务失败需要允许重试时才主动删除;成功后保留直到过期
}

方案 4:token 令牌(“发一个、用一个”)

// ① 进入下单页时先向后端申请 token(写入 Redis,如有效 5 分钟)
String token = tokenService.acquire(uid);     // Redis SET token-xxx 1 EX 300

// ② 提交时校验并“原子消费”:拿得到才允许执行(Lua 保证取+删不被打断)
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
             "then return redis.call('del', KEYS[1]) else return 0 end";
Long used = redisTemplate.execute(new DefaultRedisScript<Long>(lua, Long.class),
        Collections.singletonList("token:" + token), token);
if (used == null || used == 0) {
    throw new BizException("请勿重复提交");
}
// ③ 真正执行业务

方案 5:乐观锁(版本号 CAS)

// UPDATE t_account SET balance = balance - #{amount}, version = version + 1
//  WHERE id = #{id} AND version = #{oldVersion}
// 影响行数为 0 说明版本已被别人改过,本次操作作废或重试
int rows = accountMapper.deductBalance(id, amount, oldVersion);
if (rows == 0) { throw new BizException("余额已被其他操作更新,请重试"); }

方案 6:消息队列消费者幂等(最常用组合)

// 消费者侧:以“业务唯一键”(如订单号/流水号)建幂等表或 Redis 标记,重复消息直接 ack
// 推荐做法:MQ 消息体里带业务流水号,消费时“先查后插+唯一键兜底”
public void onMessage(PayMsg msg) {
    String bizNo = msg.getBizNo();                 // 业务的天然幂等键
    boolean first = consumeLogService.markDoing(bizNo); // 幂等表插记录,主键= bizNo
    if (!first) {
        log.warn("重复消息,直接确认: {}", bizNo);
        return;                                     // 重复消费:直接 ack 不再处理
    }
    try {
        doBiz(msg);
        consumeLogService.markDone(bizNo);
    } catch (Exception e) {
        consumeLogService.remove(bizNo);            // 失败清除标记,允许重投重试
        throw e;
    }
}

四、方案选型对照表

方案并发兜底能力实现成本适用场景
数据库唯一索引最强(DB 级保证)一切核心写入表(首选)
状态机强(依赖 where 条件)有明确状态流转的业务(订单/审批)
Redis SETNX中(依赖 Redis)防重窗口、接口限流式防重
token 令牌表单/下单防重复提交
乐观锁 version强(CAS 语义)金额扣减、库存扣减等防超扣
幂等表/消息标记强(配合唯一键)MQ 消费者、异步任务

五、实战范式:支付回调幂等(综合运用)

// 完整链路:唯一索引(防并发) + 状态机(防乱序) + Redis(防重窗口) 三层配合
public PayResult handlePayCallback(PayNotify req) {
    String bizNo = req.getBizNo();
    // 第 1 层:查本地状态,已终态直接返回成功(天然幂等)
    OrderPO order = orderDao.selectByBizNo(bizNo);
    if (order == null || order.getState() == PAYED) {
        return PayResult.SUCCESS;                    // 未见过 / 已支付:都算成功
    }
    // 第 2 层:Redis 分布式锁防并发同时入账
    RLock lock = redisson.getLock("lock:pay:" + bizNo);
    if (!lock.tryLock(5, TimeUnit.SECONDS)) return PayResult.RETRY;
    try {
        // 第 3 层:状态机 CAS 更新,天然幂等
        int rows = orderMapper.payByState(order.getId(), order.getState(), PAYED);
        if (rows == 1) {
            balanceService.add(order.getUid(), order.getAmount()); // 只入账一次
        }
        return PayResult.SUCCESS;
    } finally {
        lock.unlock();
    }
}

六、高频问题速答

  • 幂等键怎么选? 选业务上天然唯一的字段(订单号、支付流水号、消息业务 ID),不要用每次都会变的随机值。
  • Redis 防重和唯一索引冲突吗? 不冲突,Redis 挡大部分重复流量减轻 DB 压力,唯一索引兜底极端并发。
  • 先写库还是先发 MQ? 先落库并标记发送状态,再投递,防止“消息发出去了库没写成”导致对端查不到。
  • 幂等失败要不要补偿? 需要:定时任务扫描“已发送未完成”的中间态做对账补偿。
  • GET 请求需要幂等吗? 查询天然幂等;但“GET 却干了写的事”的接口才需要额外防护。

一句话总结:幂等键要取自业务、唯一索引做兜底、Redis 挡重压、状态机防乱序——层层设防才能在高并发与网络抖动下保住核心数据。

标签
幂等高并发消息队列设计