前言
用户点一次「提交订单」可能因为网络抖动、前端重复点击、网关重试而变成多次请求。如果接口不幂等,就会重复扣款、重复下单、重复发券。支付、下单、退款、发放权益这类接口,幂等是必选项,不是可选项。
本文从「什么是幂等」讲起,到常见实现方式(唯一键、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 条件 + 影响行数 |
| 分布式锁 | 配合上述使用 | 串行化「校验+执行」,不单独作为幂等方案 |
原则:会改状态、会扣钱、会发权益的接口,都设计成幂等;幂等键选好、状态机画清楚、重复请求返回首次结果,就能覆盖绝大多数后端场景。