接口幂等性设计与实战:支付、下单、重试场景怎么搞?

前言

用户点一次「提交订单」可能因为网络抖动、前端重复点击、网关重试而变成多次请求。如果接口不幂等,就会重复扣款、重复下单、重复发券。支付、下单、退款、发放权益这类接口,幂等是必选项,不是可选项。

本文从「什么是幂等」讲起,到常见实现方式(唯一键、token、状态机),再落到支付、下单、消息消费等实战场景,并给出 Java 示例和踩坑提醒,方便直接用到项目里。


1. 什么是幂等?

幂等:同一个请求执行一次和执行多次,对资源的影响是一样的;从结果上看,和只执行一次等价。

  • 查询、删除(按ID):天然幂等。查多次、删多次结果一致。
  • 新增、更新、支付、下单:不设计就不幂等。重复调用会多扣钱、多下单。

所以幂等设计主要针对「会改变状态、会产生副作用的写操作」。


2. 为什么会有重复请求?

来源 说明
用户/前端 连续点击、刷新提交、返回后再次提交
网络/网关 超时重试、负载均衡重试、HTTP 重试
消息队列 至少一次语义下的重复投递、消费失败重试
定时任务 重复触发、多实例同时跑

只要存在重试或重复调用,就要在业务层保证幂等,不能只依赖「只调一次」。


3. 常见实现思路

3.1 唯一键 + 数据库约束(最常用)

业务生成一个唯一请求号/幂等键(订单号、支付流水号、out_trade_no 等),写入时以该字段建唯一索引。第一次插入成功,后续相同键再插入会唯一冲突,直接视为重复请求,返回「已处理」或原结果。

优点 :实现简单、可靠、不依赖外部存储。
缺点:依赖 DB 唯一约束;键要业务自己能生成且唯一(雪花、号段、UUID 等)。

sql 复制代码
-- 订单表
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号,幂等键',
    user_id BIGINT NOT NULL,
    amount DECIMAL(10,2),
    status INT,
    created_at DATETIME
);
java 复制代码
// 伪代码
public Order createOrder(CreateOrderRequest req) {
    String orderNo = req.getOrderNo(); // 前端或上游传入唯一订单号
    try {
        return orderDao.insert(Order.builder().orderNo(orderNo).userId(req.getUserId())...build());
    } catch (DuplicateKeyException e) {
        return orderDao.getByOrderNo(orderNo); // 重复请求,返回原订单
    }
}

3.2 幂等 token(防重复提交)

流程:先向服务端申请一个一次性幂等 token,提交时把 token 带上;服务端校验 token 存在则执行业务并删除(或标记使用),同一 token 再次请求则拒绝。

优点 :适合前端「防重复点击」、无唯一业务单号时的表单提交。
缺点:需要存 token(Redis/DB),有过期和清理问题;高并发时要注意「校验-删除」的原子性。

java 复制代码
// 1. 申请 token(可放在打开页面或点击「提交」时调一次)
String token = UUID.randomUUID().toString();
redis.set("idempotent:order:" + token, "1", 5, MINUTES);

// 2. 提交时带 token
public Order createOrder(String idempotentToken, CreateOrderRequest req) {
    String key = "idempotent:order:" + idempotentToken;
    if (!redis.delete(key)) {  // 删除成功才继续,保证只用一次
        throw new BizException("请勿重复提交");
    }
    return doCreateOrder(req);
}

注意:get + delete 非原子,高并发下可能重复;Redis 可用 Lua 或「SET key 1 NX EX 5」配合删除,或直接用「存在则删除并返回是否删除成功」的语义。

3.3 状态机(防重复推进)

订单/支付单有明确状态(待支付→已支付→已发货...)。只有当前状态允许才执行操作,并在一次更新中「校验旧状态 + 更新新状态」。同一请求多次调用,第一次把状态改掉后,后续校验旧状态失败,不会重复推进。

优点 :和业务强绑定,避免重复支付、重复发货等。
缺点 :要设计清晰的状态和流转;更新时要带条件(如 WHERE status = 待支付),并判断影响行数。

sql 复制代码
-- 只有「待支付」才能变成「已支付」
UPDATE orders SET status = '已支付', pay_time = NOW()
WHERE order_no = ? AND status = '待支付';
-- 若 affected rows = 0,说明已支付或已取消,直接返回幂等结果
java 复制代码
int n = orderDao.updateStatusToPaid(orderNo);
if (n == 0) {
    Order existing = orderDao.getByOrderNo(orderNo);
    if (existing.getStatus().equals("已支付")) {
        return existing; // 幂等:已处理过
    }
    throw new BizException("订单状态不允许支付");
}

3.4 分布式锁

对「同一业务键」加锁,例如 Redis SET key NX EX,拿到锁的请求执行业务,执行完释放;同一键的并发请求只有一个能拿到锁。
注意:锁只能串行化并发,不能区分「同一次请求重试」和「另一次新请求」。通常和唯一键或 token 配合:先保证「同一请求」用同一个幂等键,再用锁保护「校验+执行」的原子性(可选)。


4. 场景一:支付回调/支付结果同步

支付渠道(微信/支付宝)可能多次回调或推送,必须幂等。

做法:

  • 以支付渠道的支付单号(或 out_trade_no + 渠道)为幂等键,建唯一索引。
  • 回调里:先按支付单号查本地是否已有「已支付」记录;没有则插入支付流水(唯一键),再更新订单状态(用状态机:仅当待支付→已支付)。
  • 插入唯一键冲突或状态机更新影响行数为 0,都视为重复,返回 success,避免渠道反复重试。
java 复制代码
@Transactional
public void onPayNotify(PayNotifyRequest req) {
    String payOrderNo = req.getPayOrderNo();
    if (payRecordDao.existsByPayOrderNo(payOrderNo)) {
        return; // 已处理,直接返回成功
    }
    payRecordDao.insert(PayRecord.builder().payOrderNo(payOrderNo)...build());
    orderDao.updateStatusToPaid(req.getOrderNo());
}

5. 场景二:下单接口

做法:

  • 前端或网关生成订单号(或用后端生成的唯一 requestId),下单请求带 orderNo。
  • 表上 order_no 唯一索引;insert 成功即创建,DuplicateKey 则 select 返回原订单(同一订单号视为重复下单)。

若不想让前端传订单号,可以用「用户 + 商品 + 短时间窗口」生成幂等键,或先生成订单号再调下单(先拿号、再提交)。


6. 场景三:消息队列消费

MQ 至少一次语义下,同一条消息可能被投递多次,消费端必须幂等。

做法:

  • 以消息业务主键(如订单号、支付单号、消息ID)为幂等键,在 DB 或 Redis 记录「已处理」。
  • 先查是否已处理;未处理则执行业务并写入「已处理」;已处理则直接 ack,不重复执行业务。
java 复制代码
public void consume(Message msg) {
    String idempotentKey = msg.getOrderNo();
    if (redis.setNX("consumed:" + idempotentKey, "1", 24, HOURS)) {
        try {
            doCreateOrder(msg);
        } finally {
            // 可延长 key 或落库,防止 MQ 重试时 key 已过期
        }
    }
    // 已处理过,直接 ack
}

7. 场景四:退款、取消、发放权益

思路一致:

  • 退款:以退款单号或「订单号+退款批次」为幂等键,唯一表 + 状态机(仅待退款→退款中/已退款)。
  • 取消订单:订单号 + 状态机(仅待支付/待发货→已取消)。
  • 发券/发积分:以「用户+活动+发放批次」或流水号做唯一键,重复则跳过。

8. 实战注意点

8.1 幂等键谁生成?

  • 订单号、支付单号:建议服务端生成(雪花/号段),前端只传「创建订单」意图;或前端传 requestId,服务端用 requestId 做幂等键。
  • 支付回调:以渠道的支付单号为准,不要只用自家订单号(同一订单可能多次支付尝试)。

8.2 返回什么?

重复请求时,建议返回与第一次一致的结果(如订单详情、支付结果),并 HTTP 200,这样前端和渠道都按成功处理,不会继续重试。不要返回 4xx 导致调用方误以为失败而重试。

8.3 过期与清理

  • token 幂等:设置合理过期时间,并避免 key 无限增长。
  • 流水表:可按时间分表或归档,唯一键在有效期内即可。

8.4 事务与顺序

幂等校验(如查是否已存在)和业务写要在同一事务里,避免并发下重复通过校验。状态机更新用「WHERE 旧状态」并检查影响行数,保证只推进一次。


9. 总结

方式 适用场景 要点
唯一键 + 约束 下单、支付流水、退款单 业务生成唯一号,DB 唯一索引,冲突即重复
幂等 token 前端防重复提交、无业务单号 一次性 token,用完即删,注意原子性
状态机 支付、退款、取消、发货 仅允许当前状态→下一状态,WHERE 条件 + 影响行数
分布式锁 配合上述使用 串行化「校验+执行」,不单独作为幂等方案

原则:会改状态、会扣钱、会发权益的接口,都设计成幂等;幂等键选好、状态机画清楚、重复请求返回首次结果,就能覆盖绝大多数后端场景。

相关推荐
system_error_x5 小时前
SpringBoot 集成 Thymeleaf 简洁入门
java·spring boot·后端
步行cgn6 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
码事漫谈7 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780518 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123458 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒8 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记8 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
Mk598788 小时前
P4:把 Agent 的记忆搬进 Redis —— AgentScope-Java 工程化实战
后端
雨中飘荡的记忆8 小时前
Claude Code 入门必会:@、!、Slash Commands,一篇讲清楚
人工智能·后端
用户8356290780518 小时前
如何使用 Python 在 Word 文档中创建和格式化列表
后端·python