请求可以重试,业务只能生效一次:我们的幂等架构实践
在订单、支付和 AI 生成平台中,我们遇到过一类非常相似的问题:
用户点击提交后,页面一直转圈。几秒后客户端提示"请求超时",于是用户又点了一次。与此同时,网关重试了一次,下游消息也因为 ACK 丢失被重新投递。
从用户视角看,这只是一次普通操作;从后端日志看,却可能已经出现了多次执行:
text
用户重复点击 -> 请求 1、请求 2
网关超时重试 -> 请求 3
MQ 消息重复投递 -> 消费 1、消费 2
第三方重复回调 -> 回调 1、回调 2
如果处理的是普通查询,多执行几次也许只有性能损耗。但如果处理的是创建订单、扣减库存、支付、发券或者调用大模型,结果就可能变成重复下单、重复扣款以及重复消耗 Token。
我们最初也尝试过按钮置灰、Redis 锁和"执行前先查询"等办法。随着系统从单体演进到微服务,再增加异步 AI 任务后,这些局部措施逐渐暴露出边界。
最终我们形成了一条设计原则:
不要求请求在整个分布式链路中只出现一次,而是允许它重复到达,同时保证同一个业务意图只产生一个有效结果。
本文结合经过简化和脱敏的项目链路,分享这套方案如何一步步落地。
一、一次超时为什么会变成多次业务执行?
假设创建订单接口已经在数据库中提交成功,但响应返回客户端之前网络断开了。
客户端看到的是"超时",服务端发生的却是"成功"。如果客户端重新发送请求,而服务端又把它当成一笔新业务,就会创建第二个订单。
MQ 中也有同样的问题:
- 消费者收到订单事件;
- 成功扣减库存并提交数据库事务;
- 消费者还没来得及向 MQ 返回 ACK 就发生重启;
- MQ 认为消息没有处理,再次投递;
- 消费者又扣了一次库存。
这不是 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);
在同一个本地事务中:
- 插入业务流水;
- 只有流水首次插入成功才更新余额;
- 重复业务号读取原流水并返回;
- 余额是当前结果,账本是审计依据。
库存也采用同样思路:原子条件扣减防止超卖,唯一库存流水防止同一订单重复扣减。
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 并发送消息。发送过程中即使发生重试,最多只是同一事件被发送多次,不会再出现订单已经提交、事件却永久丢失的窗口。
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)
);
消费时,在同一个本地事务中完成两件事:
- 写入 Inbox;
- 修改业务数据。
如果 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。本地超时后先按任务号查询,而不是马上创建一条新任务。
如果供应商既不支持幂等键,也不支持按请求号查询,本地系统就无法凭空保证远端副作用只执行一次。此时我们的处理方式是:
- 调用前持久化执行尝试和请求参数;
- 将超时任务标记为
UNKNOWN,而不是FAILED; - 通过供应商回调、账单或后台补偿任务确认结果;
- 只有业务明确接受重复执行风险时,才创建新的尝试;
- 对孤儿任务和费用差异提供对账入口。
承认分布式系统中的"不确定状态",比看到超时就盲目重试更安全。
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 断开时,断掉的是结果传输,不应该连带重启生成任务。
我们的处理方式是:
- 先创建持久化
job_id; - 生成分片使用递增的
sequence_no; - 客户端记录最后收到的事件序号;
- 重连时携带
Last-Event-ID; - 服务从断点继续推送已经生成的内容。
这样,客户端可以重复连接,但推理任务仍然只有一条。
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 重试和死信数量;
- 长时间停留在
PROCESSING、RUNNING或UNKNOWN的业务; - 支付渠道及 AI 供应商账单与本地账本的差异。
上线前的故障测试也不再只覆盖正常流程,而是主动模拟:
- 大量并发请求使用同一个幂等键;
- 相同幂等键携带不同参数;
- 数据库提交成功后响应丢失;
- 业务提交后、MQ ACK 前消费者退出;
- Outbox 消息发送成功但状态未更新;
- 消息重复、延迟和乱序;
- 第三方已经成功但本地调用超时;
- AI Worker 在模型调用前后分别退出;
- 流式传输中断后客户端重连;
- 补偿任务与正常回调并发执行。
我们真正验证的不是"某段代码有没有运行第二次",而是订单、金额、库存、额度和任务状态是否只产生了一次有效变化。
结语
回头看,幂等从来不是给接口加一个注解,也不是放一把 Redis 锁就结束了。它贯穿了业务标识、数据库约束、状态机、本地事务、消息投递、外部调用和对账体系。
单体阶段,本地事务和唯一约束可以解决大部分问题;进入微服务后,需要用 Outbox、Inbox 和 Saga 管理跨服务的不确定性;到了 AI 应用中,还要把异步任务、模型调用、流式传输和 Token 计费纳入同一套设计。
我们最终接受了一个事实:请求、消息和回调一定会重复,网络超时也永远无法完全消失。
系统真正需要具备的,不是拒绝重试的能力,而是安全重试的能力:
业务标识唯一,状态迁移有条件,关键变化有账本,本地操作进事务,跨服务事件可重放,外部结果可查询,异常状态可对账。
这样,即使一次请求在链路中走了很多遍,用户最终仍然只会得到一笔订单、一次扣款,以及一份可以解释和追溯的业务结果。