网络重试导致重复下单怎么办?clientRequestId 与数据库唯一键双保险
项目:OrderSystem Cloud(微服务版企业订单交易系统) 场景:用户在 App 上点「提交订单」,网络卡住转圈,用户又点了一次;或者网关超时重发 问题:同一个购买意图,落了两张订单,扣了两次库存,扣了两次钱
上一篇讲的是「Feign 重试导致重复扣库存」,那是服务间调用的幂等。这一篇讲它的上游——订单创建入口的幂等。这两层是独立的:即使库存侧做到了完美的幂等,下单入口没做,照样会生成两张订单号不同、扣减记录也不同的订单,两边都「合法」,最后就是实打实的重复订单。
一、业务背景:重复下单发生在哪一层
1.1 三种重复下单的来源
| 用户手抖连点 | 短时间内两个完全相同的下单请求 | 两个独立 HTTP 请求 |
| 客户端超时重试 | App 没等到响应,自动重发 | 两个独立 HTTP 请求 |
| 网关 / 负载均衡重发 | 上游超时后重试 | 两个独立 HTTP 请求 |
共同点:服务端收到的都是两个「长得一模一样」的请求,没有任何协议层的标记能区分它们。 HTTP 本身没有幂等语义,POST 也不保证只执行一次。
1.2 为什么不能靠「查重」解决
最朴素的想法是:下单前查一下「这个用户最近有没有买过同样的商品」,有就返回旧订单。这个思路有三个致命问题:
真正的问题是:服务端缺少一个能唯一标识「这一次用户意图」的标识。 这个标识必须由发起方在发起之前生成,并且在重试时保持不变。
这就是 clientRequestId(客户端幂等键)。
二、clientRequestId:谁生成、什么时候生成、怎么传
2.1 核心原则:一次用户意图一个键
这是整个方案里最容易做错的一点:
❌ 错误做法:每次 HTTP 请求发送前生成一个新的 UUID。 ✅ 正确做法:用户点击「提交订单」这个动作发生的那一刻生成一个 UUID,然后这个键贯穿该意图的所有重试。
用户点击「提交订单」
│
├── 生成 clientRequestId = "a3f8c1e2-…" ← 只生成一次
│
├── 第 1 次请求(超时,没收到响应)
├── 第 2 次请求(复用同一个 clientRequestId)
└── 第 3 次请求(复用同一个 clientRequestId)
│
▼
服务端:三次请求 → 同一张订单
如果每次请求都新生成键,那服务端收到的三个请求就有三个不同的键,幂等机制完全失效——幂等键失效往往不是服务端实现错了,而是客户端生成时机错了。
下面是「一次用户意图一个键」的完整生命周期流程图:
渲染错误: Mermaid 渲染失败: Lexical error on line 10. Unrecognized text.
…下次下单重新生成"]键的生命周期应该是:**进入下单页 / 点击提交时生成 →
———————-^
为什么设计成可选而不是必填? 兼容性。老版本 App 不会传这个字段,如果做成必填,升级服务端就等于让所有老客户端下单失败。
这就带出一个 MySQL 的关键特性:唯一索引允许多个 NULL 值。所以「不传 key」的老客户端可以一直插入 NULL,彼此不冲突;「传了 key」的新客户端享受幂等保护。
CREATE TABLE t_order (
id BIGINT NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL COMMENT '雪花ID + 基因法,全局唯一',
user_id BIGINT NOT NULL COMMENT '下单用户',
client_request_id VARCHAR(64) DEFAULT NULL COMMENT '客户端幂等键,可为空',
status VARCHAR(16) NOT NULL COMMENT 'WAIT_PAY / PAID / CANCELLED',
total_amount DECIMAL(12,2) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
UNIQUE KEY uk_client_request_id (client_request_id), — ★ 最终闸门
KEY idx_user_id (user_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
⚠️ 如果你的数据库是 PostgreSQL / Oracle,多个 NULL 的语义不同(Oracle 同样允许多个 NULL;SQL Server 也只允许一个 NULL),迁移前要验证。MySQL / InnoDB 的行为是「唯一索引中的 NULL 互不相等」,所以可以有任意多行 client_request_id IS NULL。
2.3 为什么不用订单号当幂等键
order_no 是服务端生成的(雪花算法),客户端在发请求之前根本不知道这一单的订单号是多少。幂等键必须在请求发出前就存在,所以只能由客户端生成。
两者的分工是:
| clientRequestId | 客户端 | 用户点击提交那一刻 | 订单表落库幂等 |
| orderNo | 服务端(雪花 + 基因法) | 通过预检查、准备扣库存那一刻 | 库存扣减幂等 + 分片路由 |
订单号每次尝试都会重新生成,这一点很关键,第六节会讲它为什么反而让方案更健壮。
三、数据流:预检查 + 唯一键双保险
POST /api/order { clientRequestId: "a3f8…", items: [...] }
│
▼
┌─────────────────────────────────────────────────────────┐
│ OrderService.create() │
│ │
│ ┌─ 第一层:预检查(挡掉绝大多数重复,零副作用)────────┐ │
│ │ SELECT * FROM t_order │ │
│ │ WHERE client_request_id = ? │ │
│ │ ├─ 命中 → 归属校验(是本人的单吗?) │ │
│ │ │ 是 → 回放已有订单,不碰库存 │ │
│ │ │ 否 → 400 参数冲突(防越权回放) │ │
│ │ └─ 未命中 → 继续 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─ 生成 orderNo(雪花+基因)──────────────┐ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ┌─ 远程扣库存(事务外,orderNo 作幂等键)─┐ │
│ │ for each item: stockFeign.deduct() │ │
│ │ 任一步失败 → compensateDeducts 还回去 │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ┌─ 本地事务落库 ─────────────────────────┐ │
│ │ INSERT t_order(撞 uk_client_request_id)│ │
│ │ INSERT t_order_item │ │
│ │ INSERT t_event_record(Outbox, WAIT) │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ┌─ 第二层:撞唯一键 → 事务回滚 → 补偿 ────┐ │
│ │ compensateDeducts(orderNo, deducted) │ │
│ │ 再查一次 → 回放并发赢家的订单 │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
两层的关系:预检查是「性能优化 + 常见路径」,唯一键是「正确性保证」。预检查挡住 99% 的重复(用户连点、客户端重试),代价只是一次索引查询;剩下的并发竞态(两个请求同时越过预检查)由唯一键兜住。
反过来不行:只做预检查会在并发下双开单;只做唯一键则每次重复请求都要走完「扣库存 → 落库失败 → 补偿」的完整流程,浪费远程调用还制造无谓的补偿流量。
四、企业级代码实现
4.1 事务边界要拆开(自调用会失效)
一个关键工程细节:补偿逻辑必须在事务提交/回滚之后再执行,因为 compensateDeducts 是远程调用,在事务内调用会长时间持有数据库连接和行锁。
@Service
public class OrderService {
@Resource private OrderTxService orderTxService; // ★ 独立 Bean,不是 this 调用
@Resource private OrderMapper orderMapper;
@Resource private StockFeign stockFeign;
public OrderVO create(CreateOrderRequest req, Long userId) {
// ══ 第一层:预检查(只读,无副作用)══════════════════
if (StringUtils.hasText(req.getClientRequestId())) {
Order exist = orderMapper.selectByClientRequestId(req.getClientRequestId());
if (exist != null) {
return replay(exist, userId); // 归属校验 + 回放
}
}
// ══ 生成订单号:雪花 ID + 基因法(低 8 位 = userId % 256)══
String orderNo = snowflakeIdGenerator.nextOrderNo(userId);
// ══ 远程扣库存:事务外,orderNo 作幂等键 ══════════════
List<DeductedItem> deducted = new ArrayList<>();
try {
for (OrderItemDTO item : req.getItems()) {
DeductResult r = stockFeign.deduct(new DeductRequest(
orderNo, item.getProductId(), item.getQuantity(), item.getPrice()));
deducted.add(new DeductedItem(item.getProductId(), item.getQuantity(), r));
}
} catch (Exception e) {
compensateDeducts(orderNo, deducted); // 部分成功 → 全还回去
throw e;
}
// ══ 本地事务落库 ════════════════════════════════════
try {
return orderTxService.persist(orderNo, userId,
req.getClientRequestId(), req.getItems());
} catch (DuplicateKeyException e) {
// ══ 第二层:并发竞态,另一个同键请求先落库了 ════════
compensateDeducts(orderNo, deducted); // 事务已回滚,事务外补偿
Order exist = orderMapper.selectByClientRequestId(req.getClientRequestId());
if (exist == null) {
throw e; // 理论不该发生,保守失败
}
return replay(exist, userId);
} catch (Exception e) {
compensateDeducts(orderNo, deducted); // 落库失败 → 还库存
throw e;
}
}
}
💡 为什么 persist 必须放在另一个 Bean 里? Spring 的 @Transactional 靠 AOP 代理生效。如果在 create() 里直接 this.persist(),调用走的是原始对象而不是代理,注解完全不生效——订单插入不会在事务里,DuplicateKeyException 也不会触发回滚。这是自调用失效的经典坑,而且它不报错,只是静默地没有事务。
4.2 事务方法
@Service
public class OrderTxService {
@Transactional(rollbackFor = Exception.class)
public OrderVO persist(String orderNo, Long userId,
String clientRequestId, List<OrderItemDTO> items) {
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setClientRequestId(clientRequestId); // 可为 null
order.setStatus(OrderStatus.WAIT_PAY.name());
order.setTotalAmount(calcTotal(items));
orderMapper.insert(order); // ← 撞 uk_client_request_id 在这里
orderItemMapper.batchInsert(orderNo, items); // 商品快照
// Transactional Outbox:只写事件,不发 MQ
eventRecordMapper.insert(EventRecord.waitOf(orderNo, OrderEventType.CREATED));
return toVO(order, items);
}
}
这三条 INSERT 必须在同一个事务里:订单主表、订单明细、Outbox 事件。任何一条失败都整体回滚,否则会出现「有订单没明细」或者「有订单但事件永远发不出去(积分/短信永远不到账)」。
4.3 归属校验:别让人拿你的键回放你的单
private OrderVO replay(Order exist, Long userId) {
// 幂等键是客户端生成的字符串,意味着它可能被猜到 / 撞到
// 如果不校验归属,别人拿一个已知的 clientRequestId 就能:
// 1. 读到你的订单(信息泄露)
// 2. 让你的下单请求"成功"返回一张不属于你的订单
if (!exist.getUserId().equals(userId)) {
throw new BizException(ResultCode.PARAM_CONFLICT, "clientRequestId 已被占用");
}
return toVO(exist);
}
这条校验容易被忽略,但它是幂等键带来的新攻击面:幂等键是客户端可控的输入,凡是客户端可控且用于查询/回放的输入,都必须做归属校验。返回 400 参数冲突而不是「订单不存在」,也是刻意的——不泄露「这个键确实存在」这个信息。
五、并发边界:两个同键请求都越过了预检查
这是整个方案最需要讲清楚的一段。
时刻 请求 A(键 = K) 请求 B(键 = K)
─────────────────────────────────────────────────────────────
T1 SELECT ... K → null
T2 SELECT ... K → null ← 都越过了预检查
T3 生成 orderNo_A
T4 生成 orderNo_B
T5 Feign 扣库存(orderNo_A)
T6 Feign 扣库存(orderNo_B) ← 两边都扣了
T7 INSERT t_order(K) → 成功 ✅
T8 INSERT t_order(K) → 撞唯一键 ❌
T9 事务回滚 → 补偿:还掉 orderNo_B 的扣减
T10 查到 A 的单 → 回放返回
几个关键点:
① 两个请求各自生成了不同的 orderNo。 这是必须的——orderNo 是每张订单的唯一标识,不可能两个请求共用一个。而它同时又是库存扣减的幂等键(见上一篇),所以 A 扣的是 orderNo_A 的库存,B 扣的是 orderNo_B 的库存,两边是两笔完全独立的幂等操作,都能成功。
② 落库时只会有一张订单。 uk_client_request_id 唯一索引让 T7 / T8 里只可能有一个成功。数据库的唯一约束是引擎级别的原子判断,不依赖应用层的任何锁或检查。
③ 输的那一方必须补偿库存。 B 在 T6 成功扣了库存,但 T8 落库失败——如果只是简单地把异常抛出去,orderNo_B 的扣减就永远留在了库存服务里,库存凭空少了一件,而没有任何订单对应它。这是「重复下单」问题里最隐蔽的一类 bug:用户只看到一张单,但库存扣了两件。
所以 T9 的补偿不是可选的,是必须的:
/**
* 补偿已成功扣减的库存。
* 因为 stock-service 侧的恢复也是幂等的(orderNo + productId + RESTORE 唯一键),
* 这个补偿方法本身可以被安全重试,不会多还。
*/
private void compensateDeducts(String orderNo, List<DeductedItem> deducted) {
for (DeductedItem item : deducted) {
try {
stockFeign.restore(new RestoreRequest(
orderNo, item.getProductId(), item.getQuantity()));
} catch (Exception ex) {
// 补偿失败不能吞掉:这是数据不一致,必须留痕
log.error("[compensate] 库存补偿失败 orderNo={}, productId={}, qty={}",
orderNo, item.getProductId(), item.getQuantity(), ex);
// 兜底:写一条 STOCK_RESTORE 的 Outbox 事件,交给调度器补发
eventRecordMapper.insert(EventRecord.waitOf(
orderNo, OrderEventType.STOCK_RESTORE));
}
}
}
这里形成了一个闭环:本地事务失败 → 同步补偿 → 补偿失败 → 写 Outbox 事件 → EventRetryScheduler 异步补发 → 下游恢复接口幂等 → 重复补发无害。每一层都假设「上一层可能失败」。
④ 输的那一方最终要返回赢家的订单,而不是报错。 用户视角只有一次点击,必须拿到一张订单。B 在 T10 查到 A 的订单后直接回放返回(当然要做归属校验),用户拿到的是同一张订单,无感。
六、这套设计的层次总结
第 1 层 clientRequestId ← 客户端生成,标识"一次用户意图"
第 2 层 预检查 SELECT ← 应用层,挡掉绝大多数重复,零副作用
第 3 层 唯一键 uk_ ← 数据库层,最终闸门,并发安全
第 4 层 补偿 + Outbox ← 兜底,处理"扣了库存但没落成单"
每一层解决上一层的遗留问题,缺一层就会在某个场景下漏:
| 缺 clientRequestId | 服务端无法区分「同一次意图的重试」和「两次真实购买」 |
| 缺预检查 | 每次重复都要走完扣库存 + 补偿的完整链路,产生大量无谓的远程调用 |
| 缺唯一键 | 并发竞态下必然双开单(预检查是读改写,非原子) |
| 缺补偿 | 「重复订单」变成了「库存少一件但没有订单」——更隐蔽的数据不一致 |
七、测试覆盖
mvn test # 70 个用例
mvn -P it test # 5 个真 MySQL 集成测试(Testcontainers)
与本文直接相关的用例:
- 带 clientRequestId 重复下单,回放已有订单且不碰库存 —— 验证第一层预检查的回放路径,关键断言是「库存没有二次扣减」,而不只是「返回了同一个订单号」;
- 下单落库失败补偿已扣库存 —— 验证补偿路径,断言库存回到原值;
- 并发同键下单只落一单 —— 验证唯一键闸门的并发安全性;
- clientRequestId 归属校验 —— 拿别人的键请求,断言返回参数冲突而不是别人的订单。
为什么要用 Testcontainers 起真 MySQL?因为唯一索引允许多个 NULL 这个行为、以及 DuplicateKeyException 的事务语义,都是 InnoDB 特有的,H2 / 内存库的模拟会让测试失去意义。
八、面试 / 简历亮点
- 「预检查 + 唯一索引」双保险的下单幂等方案:明确区分了「性能优化层」和「正确性保证层」,不把预检查当正确性依赖——这是很多实现会踩的坑。
- 可选字段 + MySQL 多 NULL 语义做平滑兼容:新客户端享受幂等保护,老客户端不受影响,零停机升级。
- 并发竞态下的事务与补偿闭环:识别出「两个同键请求都越过预检查」这个真实竞态,并用「各自独立 orderNo + 唯一键裁决 + 失败方补偿 + Outbox 兜底」完整收口。
- 幂等键的归属校验:意识到客户端可控的幂等键是新的攻击面,做了越权回放防护。
- 工程细节意识:@Transactional 自调用失效、补偿必须在事务外、唯一键是引擎级原子性而非应用层判断。
九、场景题
场景题 1:客户端每发一次请求都重新生成 clientRequestId,服务端代码完全没变,会发生什么?怎么排查?
答:幂等机制完全失效,且现象具有迷惑性——服务端代码是对的、唯一索引也在、测试用例也是通过的,但线上就是会出重复订单。
排查路径:
修复后的自查清单:同一个用户意图的所有重试请求,clientRequestId 必须字节级相同。
场景题 2:如果不用 clientRequestId,直接对订单表做「用户 + 商品 + 5 分钟内」的查重,能替代吗?
答:不能,有三个层面的问题。
业务语义层面:用户的真实购买行为可以被查重规则误伤。用户想买两箱水,拆成两单下,第二单会被误判为重复而「回放」成第一单——用户以为买了两箱,实际只有一箱,这是比重复下单更严重的错误(少发货)。
判定可靠性层面:查重条件必须是「能唯一标识一次用户意图」的字段集合。用户 + 商品 + 时间窗口无法保证唯一性——同一用户在同一秒内买两次同样的商品是合法的,而一次意图的重试可能跨越几十秒。时间和内容都不能作为意图的身份标识,只有发起方生成的唯一 ID 可以。
并发正确性层面:即使前两点都妥协了,「先查后插」在并发下依然无效——两个请求同时查到「不存在」,然后同时插入,照样双开单。要正确必须靠唯一索引,而唯一索引需要一个确定性的列,那就又回到了「必须有一个幂等键列」。
结论:查重只能作为辅助手段(比如风控提示「您刚刚下过一笔相似订单」),不能作为幂等的正确性保证。正确性的地基永远是唯一约束。
十、一句话总结
幂等不是「防止重复」,而是「让重复变得无害」。下单入口的重复来自网络层,所以防护必须落在由发起方携带、在数据层裁决的位置——客户端生成 clientRequestId 表达意图,数据库唯一索引做最终裁决,补偿机制收口中间态。三者缺一,重复下单就会以某种形式漏出来。
推荐标签:接口幂等、唯一索引、订单系统、Spring Boot、微服务
网硕互联帮助中心





评论前必须登录!
注册