请求可以重试,业务只能生效一次:我们的幂等架构实践

请求可以重试,业务只能生效一次:我们的幂等架构实践

在订单、支付和 AI 生成平台中,我们遇到过一类非常相似的问题:

用户点击提交后,页面一直转圈。几秒后客户端提示"请求超时",于是用户又点了一次。与此同时,网关重试了一次,下游消息也因为 ACK 丢失被重新投递。

从用户视角看,这只是一次普通操作;从后端日志看,却可能已经出现了多次执行:

text 复制代码
用户重复点击       -> 请求 1、请求 2
网关超时重试       -> 请求 3
MQ 消息重复投递    -> 消费 1、消费 2
第三方重复回调     -> 回调 1、回调 2

如果处理的是普通查询,多执行几次也许只有性能损耗。但如果处理的是创建订单、扣减库存、支付、发券或者调用大模型,结果就可能变成重复下单、重复扣款以及重复消耗 Token。

我们最初也尝试过按钮置灰、Redis 锁和"执行前先查询"等办法。随着系统从单体演进到微服务,再增加异步 AI 任务后,这些局部措施逐渐暴露出边界。

最终我们形成了一条设计原则:

不要求请求在整个分布式链路中只出现一次,而是允许它重复到达,同时保证同一个业务意图只产生一个有效结果。

本文结合经过简化和脱敏的项目链路,分享这套方案如何一步步落地。


一、一次超时为什么会变成多次业务执行?

假设创建订单接口已经在数据库中提交成功,但响应返回客户端之前网络断开了。

客户端看到的是"超时",服务端发生的却是"成功"。如果客户端重新发送请求,而服务端又把它当成一笔新业务,就会创建第二个订单。

MQ 中也有同样的问题:

  1. 消费者收到订单事件;
  2. 成功扣减库存并提交数据库事务;
  3. 消费者还没来得及向 MQ 返回 ACK 就发生重启;
  4. MQ 认为消息没有处理,再次投递;
  5. 消费者又扣了一次库存。

这不是 MQ 的缺陷。为了避免消息丢失,大多数消息系统默认提供的是 At Least Once,即一条消息至少被送达一次。出现网络分区或进程崩溃时,生产者、消费者和 MQ 都无法仅凭本地信息准确判断对方是否已经成功。

第三方支付和 AI 模型调用也是如此:

text 复制代码
本地发起调用 -> 第三方执行成功 -> 返回结果时网络超时

此时"调用超时"只说明本地没有拿到结果,并不代表远端没有执行。立即换一个单号再次调用,反而可能把一次结果未知变成两次真实扣款或两次模型推理。

所以我们没有继续追求物理意义上的 Exactly Once,而是把目标改成了:

text 复制代码
至少一次传输 + 业务幂等 = 对用户呈现一次有效结果

二、第一步不是加锁,而是定义"同一次业务"

实现幂等之前,最重要的问题是:哪些请求应该被认为是同一次操作?

请求参数相同,不一定代表同一次业务。用户完全可能购买两件相同商品,也可能用同一个 Prompt 连续生成两张图片。

我们为每次业务意图分配一个稳定的幂等标识:

场景 幂等标识
创建订单 tenant_id + client_order_no
发起支付 merchant_id + payment_request_no
支付回调 payment_channel + channel_transaction_no + event_type
MQ 消费 consumer_name + event_id
AI 生成任务 tenant_id + generation_request_id
AI 费用结算 account_id + usage_event_id + charge_type

以创建订单为例,客户端第一次提交时生成 Idempotency-Key,后续超时重试继续使用同一个值:

http 复制代码
POST /api/orders
Idempotency-Key: order_req_01JAZ4Y7N8D6M5
Content-Type: application/json

{
  "sku_id": 1001,
  "quantity": 2,
  "amount": 19900
}

服务端除了保存幂等键,还会保存请求摘要 request_hash,防止调用方复用了幂等键,却修改了商品数量或金额。

我们约定:

  • 相同幂等键、相同参数:返回第一次执行的资源和结果;
  • 相同幂等键、不同参数:返回 409 Conflict
  • 第一次仍在处理中:返回 202 Accepted 和业务单号;
  • 第一次已经成功:不重新执行,直接返回原结果。

这里还有一个实践细节:Trace ID 不能充当幂等键。Trace ID 标识一次技术调用链,同一次业务重试可能产生多个 Trace;幂等键标识业务意图,重试时必须保持稳定。


三、单体阶段:本地事务和唯一约束解决大部分问题

系统还是单体服务时,订单、库存和业务流水共用一个数据库。这个阶段最可靠的方案并不复杂:

业务唯一键 + 数据库唯一约束 + 本地事务 + 状态机。

1. 不再使用"先查再插"

早期代码通常是这样:

text 复制代码
查询订单是否存在
不存在 -> 创建订单

这个逻辑在单请求下没有问题,并发时却会出现竞态:

text 复制代码
请求 A:查询,不存在
请求 B:查询,不存在
请求 A:插入成功
请求 B:插入成功

后来我们直接把业务规则落实成数据库唯一约束:

sql 复制代码
CREATE UNIQUE INDEX uk_order_tenant_client_no
ON orders (tenant_id, client_order_no);

应用先尝试插入。插入成功说明自己赢得了本次业务执行权;发生唯一键冲突,则查询已经存在的订单,校验请求摘要后返回它。

go 复制代码
func (s *OrderService) CreateOrder(
    ctx context.Context,
    cmd CreateOrderCommand,
) (OrderResult, error) {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
       return OrderResult{}, err
    }

    order, err := s.orders.Insert(ctx, tx, cmd)
    if err != nil {
       _ = tx.Rollback()

       // 唯一键冲突后,必须在原事务之外读取已存在的订单。
       if errors.Is(err, repository.ErrDuplicateKey) {
          return s.loadIdempotentResult(ctx, cmd)
       }
       return OrderResult{}, err
    }

    if err := s.stock.FreezeOnce(ctx, tx, order.ID, cmd.Items); err != nil {
       _ = tx.Rollback()
       return OrderResult{}, err
    }

    if err := tx.Commit(); err != nil {
       return OrderResult{}, err
    }
    return NewOrderResult(order), nil
}

func (s *OrderService) loadIdempotentResult(
    ctx context.Context,
    cmd CreateOrderCommand,
) (OrderResult, error) {
    existing, err := s.orders.FindByBizKey(
       ctx,
       cmd.TenantID,
       cmd.ClientOrderNo,
    )
    if err != nil {
       return OrderResult{}, err
    }
    if existing.RequestHash != hashCommand(cmd) {
       return OrderResult{}, ErrIdempotencyKeyReused
    }
    return NewOrderResult(existing), nil
}

示例中的 repository.ErrDuplicateKey 是数据访问层对 MySQL、PostgreSQL 等数据库唯一键错误的统一封装。唯一键冲突可能让当前事务进入回滚状态,因此代码先回滚事务,再在事务外读取已存在的订单。

2. 用状态机约束"只能成功一次"

唯一约束可以防止创建两条订单,但不能阻止同一条订单被重复支付、重复关闭或重复发货。

我们把订单和支付设计成显式状态机:

text 复制代码
订单:CREATED -> CONFIRMED -> PAID -> FULFILLED
支付:INIT -> PROCESSING -> SUCCEEDED
                    -----> FAILED

每次状态迁移都带上前置状态:

sql 复制代码
UPDATE payment_orders
SET status = 'SUCCEEDED',
    channel_transaction_no = :channel_no,
    paid_at = NOW()
WHERE payment_no = :payment_no
  AND status = 'PROCESSING';

受影响行数为 1,表示当前请求完成了状态迁移;受影响行数为 0,表示其他请求已经处理过,本次不得重复记账或通知发货。

这种条件更新消除了"先查询状态、再更新状态"之间的并发窗口。

3. 金额、库存和额度必须有业务流水

对于余额等关键数据,我们不直接依靠下面这条语句防重复:

sql 复制代码
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

我们先写入带有唯一业务号的账本:

sql 复制代码
CREATE UNIQUE INDEX uk_ledger_biz
ON account_ledger (account_id, biz_type, biz_no);

在同一个本地事务中:

  1. 插入业务流水;
  2. 只有流水首次插入成功才更新余额;
  3. 重复业务号读取原流水并返回;
  4. 余额是当前结果,账本是审计依据。

库存也采用同样思路:原子条件扣减防止超卖,唯一库存流水防止同一订单重复扣减。

4. 为什么没有把 Redis 锁作为最终方案?

我们使用过 Redis SETNX 拦截短时间内的重复请求,但没有让它承担最终一致性:

  • Redis 锁可能过期,而业务仍未完成;
  • 获取锁成功不等于数据库事务一定提交;
  • Redis 与业务数据库之间没有原子事务;
  • 进程内锁在多实例部署后直接失效。

Redis 可以挡住一部分重复流量,数据库唯一约束才是最接近业务数据的最后防线。锁解决的是一段时间内的互斥,幂等解决的是一个业务结果在整个生命周期内只能生效一次,两者并不等价。


四、微服务阶段:本地成功不等于全链路成功

拆分成微服务后,一次下单会跨越订单、库存、支付和营销服务。每个服务都有自己的数据库,本地事务无法覆盖完整链路。

我们遇到的典型问题变成了:

text 复制代码
订单已提交 -> 发送 MQ 前服务崩溃 -> 库存服务永远收不到订单事件

如果先发消息再提交订单,则又会产生相反的问题:

text 复制代码
消息已发送 -> 订单事务回滚 -> 下游处理了一个不存在的订单

1. Outbox:让业务数据和事件一起落库

订单服务在同一个本地事务中写入订单和 Outbox:

sql 复制代码
BEGIN;

INSERT INTO orders (...);

INSERT INTO outbox_events (
  event_id,
  aggregate_type,
  aggregate_id,
  event_type,
  payload,
  status,
  created_at
) VALUES (...);

COMMIT;

独立发布器扫描 Outbox 并发送消息。发送过程中即使发生重试,最多只是同一事件被发送多次,不会再出现订单已经提交、事件却永久丢失的窗口。

sequenceDiagram participant C as 客户端 participant O as 订单服务 participant DB as 订单库 participant P as Outbox 发布器 participant MQ as 消息队列 participant S as 库存服务 C->>O: 创建订单(Idempotency-Key) O->>DB: 同一事务写订单和 Outbox DB-->>O: Commit O-->>C: 返回订单 P->>DB: 扫描未发布事件 P->>MQ: 发布 OrderCreated MQ->>S: 消息可能重复到达 S->>S: Inbox 去重并冻结库存 S-->>MQ: ACK

2. Inbox:让重复消息只产生一次业务结果

每个消费者在自己的数据库中保存已处理事件:

sql 复制代码
CREATE TABLE consumer_inbox (
  consumer_name VARCHAR(64) NOT NULL,
  event_id      VARCHAR(64) NOT NULL,
  processed_at  TIMESTAMP NOT NULL,
  PRIMARY KEY (consumer_name, event_id)
);

消费时,在同一个本地事务中完成两件事:

  1. 写入 Inbox;
  2. 修改业务数据。

如果 Inbox 唯一键冲突,说明这个消费者已经处理过该事件,直接 ACK 即可。

我们没有采用"先在 Redis 中 SETNX,再更新数据库"的方式。如果 SETNX 成功后进程崩溃,消息再次到达时会被误判为已处理,而真正的业务事务可能根本没有提交。

3. Saga:每个正向步骤和补偿步骤都要幂等

订单全链路最终形成了一套 Saga 状态机:

text 复制代码
创建订单
  -> 冻结库存
  -> 创建支付
  -> 支付成功
  -> 确认库存

中间失败时执行补偿:

text 复制代码
支付失败 -> 释放库存 -> 关闭订单

补偿不等于数据库回滚。它也是一次可能超时、失败和重复执行的远程业务操作。因此,我们为每个动作设置独立业务号:

text 复制代码
order_id + FREEZE_STOCK
order_id + RELEASE_STOCK
order_id + CONFIRM_STOCK

正常链路和补偿任务并发到达时,服务依靠状态机与唯一流水决定哪个动作可以生效。

4. 去重还不够,消息可能乱序

去重只能拦截同一个事件,不能阻止旧状态覆盖新状态:

text 复制代码
PaymentSucceeded(version=3)
PaymentProcessing(version=2)  // 更晚到达

消费者需要同时校验聚合版本:

sql 复制代码
UPDATE payment_view
SET status = :new_status,
    version = :new_version
WHERE payment_id = :payment_id
  AND version < :new_version;

状态机也会拒绝支付从 SUCCEEDED 回退为 PROCESSING。至此,我们处理的已经不只是幂等,还包括消息顺序与状态单调性。


五、AI 平台阶段:重复执行开始直接产生推理成本

AI 生成平台的链路更长:

text 复制代码
用户提交生成请求
-> 创建任务
-> MQ 调度
-> Worker 调用模型供应商
-> 保存生成资产
-> 统计 Token 或 GPU 用量
-> 扣减账户额度
-> 通知用户

这里的重复执行不仅会产生重复数据,还可能重复占用 GPU、重复调用收费模型以及重复扣减用户额度。

1. 同一个 Prompt 不代表同一次请求

我们没有按照 Prompt 内容去重,因为用户可能确实想用同一个 Prompt 再生成一次,而且模型、随机种子、参数和知识库版本都会影响结果。

任务模型中区分两个概念:

  • generation_request_id:用户的一次生成意图;
  • attempt_id:后台的一次执行尝试。

用户点击"重新生成"时,创建新的 generation_request_id;同一任务因超时或 Worker 故障重试时,保留原 generation_request_id,只增加新的 attempt_id

创建任务时使用唯一约束:

sql 复制代码
CREATE UNIQUE INDEX uk_generation_request
ON generation_jobs (tenant_id, generation_request_id);

重复提交不会再创建一条 MQ 任务,而是返回同一个 job_id

json 复制代码
{
  "job_id": "gen_123",
  "status": "QUEUED"
}

2. Worker 抢占任务,但不把租约误当成幂等

Worker 使用条件更新和租约获取任务执行权:

sql 复制代码
UPDATE generation_jobs
SET status = 'RUNNING',
    worker_id = :worker_id,
    lease_until = :lease_until,
    attempt_no = attempt_no + 1
WHERE id = :job_id
  AND (
    status IN ('QUEUED', 'FAILED_RETRYABLE')
    OR (status = 'RUNNING' AND lease_until < NOW())
  );

只有影响行数为 1 的 Worker 可以继续执行。Worker 崩溃后,其他实例可以在租约到期后接管。

但租约只能控制本地任务所有权。如果旧 Worker 在暂停后恢复,新旧 Worker 仍可能同时调用远端模型,因此远端调用还需要单独设计。

3. 第三方模型超时后,优先查询而不是立即重跑

如果模型供应商支持幂等键,我们会透传稳定的供应商请求号:

text 复制代码
provider_request_key = tenant_id + generation_request_id

如果供应商提供异步任务号,则在本地持久化 provider_job_id。本地超时后先按任务号查询,而不是马上创建一条新任务。

如果供应商既不支持幂等键,也不支持按请求号查询,本地系统就无法凭空保证远端副作用只执行一次。此时我们的处理方式是:

  1. 调用前持久化执行尝试和请求参数;
  2. 将超时任务标记为 UNKNOWN,而不是 FAILED
  3. 通过供应商回调、账单或后台补偿任务确认结果;
  4. 只有业务明确接受重复执行风险时,才创建新的尝试;
  5. 对孤儿任务和费用差异提供对账入口。

承认分布式系统中的"不确定状态",比看到超时就盲目重试更安全。

4. 用量事实和计费流水分开

我们将 AI 费用拆成两层:

  • usage_event:模型实际产生的输入 Token、输出 Token、图片张数或 GPU 秒;
  • billing_ledger:根据用量和计价规则生成的账户流水。

分别建立唯一约束:

text 复制代码
usage_event:
provider + provider_request_id + usage_type

billing_ledger:
account_id + usage_event_id + charge_type

额度处理也拆分为预占、结算和释放:

text 复制代码
gen_123 + RESERVE  -> 预占 1.00 元
gen_123 + CAPTURE  -> 实际结算 0.72 元
gen_123 + RELEASE  -> 释放剩余 0.28 元

即使模型完成回调重复到达,CAPTURE 流水的唯一约束也会阻止二次扣费。

5. 流式输出断线,不重新启动推理

SSE 或 WebSocket 断开时,断掉的是结果传输,不应该连带重启生成任务。

我们的处理方式是:

  1. 先创建持久化 job_id
  2. 生成分片使用递增的 sequence_no
  3. 客户端记录最后收到的事件序号;
  4. 重连时携带 Last-Event-ID
  5. 服务从断点继续推送已经生成的内容。

这样,客户端可以重复连接,但推理任务仍然只有一条。

6. Agent 的工具调用必须由工具层兜底

Agent 恢复运行时,模型可能再次调用已经执行过的工具。我们不会依赖模型"记住自己调用过",而是给工具调用分配稳定标识:

text 复制代码
tool_call_id = run_id + step_id + tool_name

执行结果持久化后,重复工具调用直接读取原结果。涉及下单、发消息、转账等真实副作用时,工具服务还必须使用自己的业务幂等键。

也就是说,Agent 编排层的幂等不能替代支付、订单等领域服务的幂等,两层都要存在。


六、我们踩过的几个坑

坑一:前端按钮置灰就算完成防重

按钮置灰只能改善体验,无法防止客户端重试、网关重试、MQ 重投和第三方回调。

坑二:先查询,再决定是否执行

查询和写入之间存在并发窗口。最终必须通过唯一约束或带条件的原子更新裁决。

坑三:Redis SETNX 成功就认为业务成功

Redis 与数据库之间没有原子事务。去重标记成功后进程崩溃,可能导致业务永远不再执行。

坑四:接口超时就把订单标记为失败

超时只代表结果未知。支付和模型调用应优先查询远端状态,无法确认时进入 UNKNOWN 和对账流程。

坑五:消费完成后先 ACK,再提交数据库

如果 ACK 成功后数据库事务失败,消息不会再来,业务却没有落库。正确顺序是先提交本地事务,再 ACK。

坑六:数据库提交后直接发送 MQ

服务可能在提交后、发送消息前崩溃。Outbox 的价值就是填补这个一致性窗口。

坑七:只处理重复消息,没有处理乱序消息

事件 ID 解决重复,聚合版本和状态机解决乱序,两者不能互相替代。


七、现在使用的设计基线

系统演进之后,我们把下列规则作为核心业务的默认设计基线:

场景 默认方案 最终兜底
单体创建订单 幂等键 + 请求摘要 + 本地事务 数据库唯一约束
余额、库存、额度 唯一业务流水 + 条件更新 账本与状态机
微服务事件发布 业务数据与 Outbox 同事务 补偿扫描或 CDC
MQ 消费 Inbox 与业务更新同事务 唯一事件 ID
跨服务业务流程 Saga + 幂等补偿 对账与人工修复
第三方支付 固定商户单号 + 查询优先 渠道流水与对账
AI 生成任务 生成请求 ID + 状态机 + Worker 租约 Job 唯一约束
AI 模型调用 供应商幂等键或远端任务号 用量与账单对账
AI 费用结算 用量事件 + 计费账本 流水唯一约束
Agent 工具调用 Run/Step 级调用 ID 工具自身业务幂等

除此之外,我们还会监控:

  • 幂等键命中次数和参数冲突次数;
  • 数据库唯一键冲突率;
  • Outbox 堆积时间;
  • Inbox 去重数量;
  • MQ 重试和死信数量;
  • 长时间停留在 PROCESSINGRUNNINGUNKNOWN 的业务;
  • 支付渠道及 AI 供应商账单与本地账本的差异。

上线前的故障测试也不再只覆盖正常流程,而是主动模拟:

  • 大量并发请求使用同一个幂等键;
  • 相同幂等键携带不同参数;
  • 数据库提交成功后响应丢失;
  • 业务提交后、MQ ACK 前消费者退出;
  • Outbox 消息发送成功但状态未更新;
  • 消息重复、延迟和乱序;
  • 第三方已经成功但本地调用超时;
  • AI Worker 在模型调用前后分别退出;
  • 流式传输中断后客户端重连;
  • 补偿任务与正常回调并发执行。

我们真正验证的不是"某段代码有没有运行第二次",而是订单、金额、库存、额度和任务状态是否只产生了一次有效变化。


结语

回头看,幂等从来不是给接口加一个注解,也不是放一把 Redis 锁就结束了。它贯穿了业务标识、数据库约束、状态机、本地事务、消息投递、外部调用和对账体系。

单体阶段,本地事务和唯一约束可以解决大部分问题;进入微服务后,需要用 Outbox、Inbox 和 Saga 管理跨服务的不确定性;到了 AI 应用中,还要把异步任务、模型调用、流式传输和 Token 计费纳入同一套设计。

我们最终接受了一个事实:请求、消息和回调一定会重复,网络超时也永远无法完全消失。

系统真正需要具备的,不是拒绝重试的能力,而是安全重试的能力:

业务标识唯一,状态迁移有条件,关键变化有账本,本地操作进事务,跨服务事件可重放,外部结果可查询,异常状态可对账。

这样,即使一次请求在链路中走了很多遍,用户最终仍然只会得到一笔订单、一次扣款,以及一份可以解释和追溯的业务结果。

相关推荐
Ai拆代码的曹操1 小时前
K8s 调度器 predicate 阶段揭秘:为什么你的 Pod 总是堆在同一台机器
后端·容器
Ai拆代码的曹操1 小时前
手摸手排查:Pod CrashLoopBackOff 日志丢失问题,3 层兜底方案
后端·容器
程序员包打听1 小时前
从 npx 到 moonx,moonbit 的野心与展望
前端·后端
Conan在掘金2 小时前
ArkTS 进阶之道(8):@Prop/@Link 父子传值——单向 vs 双向数据流根因
后端
JoyT2 小时前
面向Agent系统的Java后端知识总览(上)
后端
AI编程实验室2 小时前
用 npm + Three.js 做一颗西瓜:把夏天的清凉感放进浏览器
前端·后端·ai编程
SimonKing2 小时前
别再盲目跑测试了,用 JaCoCo 告诉你哪些代码根本没被覆盖
java·后端·程序员
渣波2 小时前
基于 Milvus 构建小说知识库 RAG,实现图书智能问答(天龙八部实战)
前端·后端