云计算百科
云计算领域专业知识百科平台

网络重试导致重复下单怎么办?clientRequestId 与数据库唯一键双保险

网络重试导致重复下单怎么办?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,服务端代码完全没变,会发生什么?怎么排查?

    答:幂等机制完全失效,且现象具有迷惑性——服务端代码是对的、唯一索引也在、测试用例也是通过的,但线上就是会出重复订单。

    排查路径:

  • 先看数据。查重复订单的 client_request_id:如果两张重复订单的 client_request_id 不同,说明幂等键没起到作用,问题在生成侧而不是消费侧;如果相同,说明唯一索引没建对或者压根没走唯一键。
  • 再看客户端日志。对比同一次用户操作前后的请求体,看 clientRequestId 是否变化。
  • 定位生成时机。常见写法是在 HTTP 拦截器 / 请求构造器里 UUID.randomUUID(),这个位置每次请求都会执行一次——正确的做法是把键绑定到「用户意图」这个生命周期上,放在业务层(点击事件)而不是网络层(拦截器)。
  • 修复后的自查清单:同一个用户意图的所有重试请求,clientRequestId 必须字节级相同。

    场景题 2:如果不用 clientRequestId,直接对订单表做「用户 + 商品 + 5 分钟内」的查重,能替代吗?

    答:不能,有三个层面的问题。

    业务语义层面:用户的真实购买行为可以被查重规则误伤。用户想买两箱水,拆成两单下,第二单会被误判为重复而「回放」成第一单——用户以为买了两箱,实际只有一箱,这是比重复下单更严重的错误(少发货)。

    判定可靠性层面:查重条件必须是「能唯一标识一次用户意图」的字段集合。用户 + 商品 + 时间窗口无法保证唯一性——同一用户在同一秒内买两次同样的商品是合法的,而一次意图的重试可能跨越几十秒。时间和内容都不能作为意图的身份标识,只有发起方生成的唯一 ID 可以。

    并发正确性层面:即使前两点都妥协了,「先查后插」在并发下依然无效——两个请求同时查到「不存在」,然后同时插入,照样双开单。要正确必须靠唯一索引,而唯一索引需要一个确定性的列,那就又回到了「必须有一个幂等键列」。

    结论:查重只能作为辅助手段(比如风控提示「您刚刚下过一笔相似订单」),不能作为幂等的正确性保证。正确性的地基永远是唯一约束。

    十、一句话总结

    幂等不是「防止重复」,而是「让重复变得无害」。下单入口的重复来自网络层,所以防护必须落在由发起方携带、在数据层裁决的位置——客户端生成 clientRequestId 表达意图,数据库唯一索引做最终裁决,补偿机制收口中间态。三者缺一,重复下单就会以某种形式漏出来。

    推荐标签:接口幂等、唯一索引、订单系统、Spring Boot、微服务

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 网络重试导致重复下单怎么办?clientRequestId 与数据库唯一键双保险
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!