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

前言

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

本文从「什么是幂等」讲起,到常见实现方式(唯一键、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 条件 + 影响行数
分布式锁 配合上述使用 串行化「校验+执行」,不单独作为幂等方案

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

相关推荐
舒一笑1 小时前
IDEA 调试技巧:关联本地源码,告别反编译代码
后端
UrbanJazzerati1 小时前
PostgreSQL 完全实战指南:从小白到高手 DDL篇
后端·面试
UrbanJazzerati1 小时前
Python实现Salesforce Bulk API 2.0批量数据导入:从Excel到云端的高效方案
后端·面试
豆苗学前端1 小时前
彻底讲透医院移动端手持设备PDA离线同步架构:从"记账本"到"分布式共识",吊打面试官
前端·javascript·后端
用户298698530141 小时前
C#中如何创建目录(TOC):使用Spire.Doc for .NET实现Word TOC自动化
后端·c#·.net
大鹏19882 小时前
警惕 Python 的"甜蜜陷阱":Pickle 反序列化漏洞深度剖析
后端
鱼人2 小时前
PHP 入门指南:从零基础到掌握核心语法
后端
却尘2 小时前
一个 ERR_SSL_PROTOCOL_ERROR 让我们排查了三层问题,最后发现根本不是 SSL 的锅
前端·后端·网络协议
yhyyht2 小时前
Apache Camel 框架入门记录(二)
后端