引子
支付系统上线第一天,你一定会被运营追着问同一个问题:“这笔钱好像扣了两次,你查一下?” 第二天问题会升级成”钱扣了但订单没生成”,第三天是”退款退了两次”。再往后,就是 SRE 半夜被拉起来看一条告警:某个商户的对账差错金额突破百万。
这些故障的根因,九成以上落在三个词上:幂等(Idempotency)、事务(Transaction)、一致性(Consistency)。三者互相纠缠:网络不可靠导致重试,重试要求幂等;跨服务的写操作要求事务;事务在分布式场景下无法做到强一致,就需要定义一致性语义并通过补偿把它收敛回去。
这篇文章的读者画像是:
- 正在设计或维护支付、账务、订单、库存系统的后端工程师与架构师;
- 有一定分布式经验,知道 2PC、Saga 等名词,但在工程落地时仍会纠结”到底该选哪种”;
- 希望一次把 Idempotency-Key、状态机、Outbox、补偿串起来的读者。
我们不会把笔墨花在”什么是幂等”这种名词解释上——而是把一个完整的支付下单链路拆开,逐个环节讲:哪一步必须幂等、用什么兜底、出错了怎么收敛。文末给出一份落地清单,和一张可以直接抄的 SAGA 正反向流程图。
上一篇《账务数据库设计》把底层存储选型讲完了,接下来我们关心的是上层:业务怎么正确地写入这些表。
一、为什么支付必须幂等
1.1 网络永远不可靠
计算机网络的一个工程公理是:一次请求要么到达、要么没到达、要么到达了但响应没回来,第三种情况对客户端而言无法与前两种区分。放到支付链路上:
- 用户点”立即支付”,客户端向网关发起请求,网关超时——钱到底扣没扣?
- 网关向银行发 ISO
8583(卡组织收单报文)请求,银行返回前网络抖动,网关未收到
00成功码——发卡行已经落账,网关不知道。 - 商户服务器回调被调用,HTTP 连接被负载均衡踢掉,支付平台没收到 200——回调会被重推。
只要链路里有任何一跳”不知道对端状态”,客户端唯一的生存策略就是重试;而重试的前提是接收方能识别重复请求并返回同一结果。这个能力就是幂等。
1.2 不幂等的真实损失
下面三类事故,在各家支付机构内部复盘库里都能找到对应条目:
- 双花(Double Spend):同一张卡、同一笔订单被扣款两次。原因通常是网关超时后客户端带同样的订单号重试,后端把它当成新订单。2018 年某头部电商双 11 曾出现千万级双扣,客服后来一笔一笔反向退,直接损失是人工成本,间接损失是品牌。
- 少付/漏账:上游已经扣用户钱,下游入账失败,账务表没有对应贷方分录。资金跑进”在途户”(suspense account)堆积,一旦超过阈值,监管会要求写情况说明。
- 多扣(Over-Capture):同一张订单被 capture 两次(比如用户连点”确认付款”触发两次调用),虽然每笔都成功,但合计金额超过订单金额。这类问题通常在月末对账时才被发现。
一条工程经验:支付系统里”幂等缺失”导致的事故,数量级上往往大于业务 Bug 导致的事故。原因是后者一般会在测试或灰度阶段被发现,而幂等问题只在极端时序下才触发,很难在测试环境复现。
1.3 重试的四种来源
重试不只来自网络层。把支付链路画出来,我们至少能看到四类不同来源的重试:
- 客户端重试:用户连点、App 自动重发、SDK 内置 backoff。
- 网关/中间件重试:HTTP 客户端在 502/504 上的自动 retry、Service Mesh 的 retry budget。
- 回调重发:支付平台无法确认商户收到通知,会按 0s、30s、1min、5min、…、24h 的指数退避持续重推,直到商户返回成功或达到上限。
- 消息中间件重投:Kafka 的 at-least-once、RocketMQ 的重试队列、SQS 的 visibility timeout 过期都会导致同一消息多次投递。
系统里只要包含这四类来源中的任何一类,接收方就必须假设”任意接口、任意时刻都可能收到重复请求”。这不是防御式编程,是底线。
二、幂等的三层设计
幂等不是一个开关,而是一个需要在三层协作的系统属性。
2.1 接口层:Idempotency-Key 与请求指纹
接口层的目标是:对同一个逻辑请求,无论重试多少次,都返回同一结果(包括同样的副作用次数——恰好一次)。
最干净的做法是客户端生成一个全局唯一的
Idempotency-Key(UUID v4 或 ULID),随 HTTP
头发送:
POST /v1/charges HTTP/1.1
Host: api.example.com
Idempotency-Key: 9c4e4c1e-3b8e-4d7d-9f7e-2f4a6c7d8e9f
Content-Type: application/json
Authorization: Bearer ***
{"amount": 12000, "currency": "CNY", "source": "card_xxx"}
服务端收到后:
- 若 key 不存在:正常处理,把 key、请求指纹(Request Fingerprint)、响应体、状态码一并写入幂等表。
- 若 key 存在且指纹匹配:直接返回缓存的响应体与状态码(包括 4xx)。
- 若 key 存在但指纹不匹配:返回
409 Conflict或422,提示”该 key 已用于不同请求”。 - 若 key 存在但处理中:返回
409并带Retry-After,让客户端等。
Stripe 的官方实现把
Idempotency-Key 的有效期定为 24
小时,覆盖了它们内部所有异步补单、手动复核场景。超过
24 小时的重试被视为新请求,由业务层(见 2.2)兜底。
2.2 业务层:唯一业务单号与状态机
接口层只能挡住”同一 key 的重试”,挡不住”同一逻辑、不同 key 的重试”(例如客户端为每次重试都生成新 key)。业务层的兜底是业务唯一约束:
- 每笔支付必须带商户订单号
out_trade_no,在支付机构侧与商户维度组成唯一索引(merchant_id, out_trade_no)。 - 每次退款必须带退款单号
out_refund_no,在(charge_id, out_refund_no)上唯一。 - 每笔转账带
transfer_id,在(tenant_id, transfer_id)上唯一。
当业务系统收到一个已经存在的 out_trade_no
时,它应返回和第一次完全一致的结果,而不是报错”订单已存在”。这里一个常见的反例是:第一次请求创建了订单但还没支付,第二次请求带同样
out_trade_no
和不同金额——此时应当以第一次为准返回,同时报
400,让客户端自查。
2.3 数据层:去重表与唯一索引
最后一层是数据库。即便接口和业务都漏了,数据库的唯一索引也应当拦住双写:
CREATE TABLE idempotency_keys (
key_hash BYTEA PRIMARY KEY,
tenant_id BIGINT NOT NULL,
endpoint TEXT NOT NULL,
request_hash BYTEA NOT NULL,
response_code SMALLINT,
response_body JSONB,
status SMALLINT NOT NULL, -- 0=processing,1=done,2=failed
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_idem_expire ON idempotency_keys (expires_at);配合业务表:
CREATE TABLE charges (
id BIGINT PRIMARY KEY,
merchant_id BIGINT NOT NULL,
out_trade_no VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status SMALLINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (merchant_id, out_trade_no)
);三层叠加的防御纵深,基本可以挡住所有真实世界的重复写入。任何一层被绕过都只是”多走一圈”,不会真的落成双账。
三、Idempotency-Key 的工程细节
3.1 请求指纹与响应缓存
幂等表里除了存 key,还得存请求指纹和响应快照,否则遇到”同 key 不同 body”的异常请求就只能抛 500。指纹通常是对请求体按字段白名单做规范化后哈希(SHA-256),规范化要点:
- JSON 字段按字典序排序;
- 数值字段按 decimal 精度归一(
100与100.00等价); - 忽略
client_ip、timestamp等环境字段; - 二进制字段用 base64 编码。
响应缓存则要把HTTP
status、响应头白名单、响应体都存下来,重放时原样返回。一个坑是:如果第一次响应是
202 Accepted(异步),重试时业务状态已经推进到
201 Created,你要么返回
202(严格幂等),要么返回 201(反映当前真实状态)。Stripe
的选择是前者:24
小时内完全复现第一次响应,哪怕中间状态已经变化;想拿最新状态请调
GET。
3.2 并发与竞态
幂等的难点不是串行重试,而是并发重试。客户端超时后立即重试,两次请求可能同时到达两台不同的服务实例:
- 实例 A 查幂等表,key 不存在;
- 实例 B 查幂等表,key 也不存在;
- A 开始处理,B 也开始处理;
- 两次都写了数据,才发现冲突。
正确做法是在第一次查询就占位:
-- Postgres / MySQL 8+: 使用 INSERT ... ON CONFLICT
INSERT INTO idempotency_keys (key_hash, tenant_id, endpoint, request_hash, status, expires_at)
VALUES ($1, $2, $3, $4, 0, now() + interval '24 hours')
ON CONFLICT (key_hash) DO NOTHING
RETURNING id;- 如果
RETURNING有值:本实例抢到锁,继续处理。 - 如果无值:说明已存在,改为
SELECT读出原记录:status=1:直接返回缓存响应;status=0:返回409 Retry-After,让客户端稍后重试;status=2:返回记录里的失败响应。
“占位—处理—回填”三步一定要分开事务,否则长事务会把幂等表锁成热点。占位那条
INSERT 立即提交,处理完业务再开一个短事务更新
status=1 并写入响应缓存。
3.3 与重试 backoff 的互动
客户端重试策略与服务端幂等共同决定了端到端成本。常见反模式是客户端用固定间隔 1s 重试 10 次:服务端还没处理完,客户端已经又打了 9 个 409,查幂等表的 QPS 直接飙到业务 QPS 的 10 倍。
工程上推荐:
- 客户端:指数退避 + jitter,例如
min(2^n * 100ms + rand(100ms), 30s),最多重试 6 次。 - 服务端:
Retry-After头显式告诉客户端该等多久,幂等占位记录里存一个expected_duration_ms,Retry-After = max(0, expected_duration_ms - elapsed)。 - 熔断:客户端连续 N 次 409 后熔断对同一 key 的查询,直接走 GET 查最终状态。
3.4 Idempotency-Key 的 TTL 与清理
幂等表会迅速膨胀。Stripe 选 24h,国内主流支付机构多数选择 7 天(与对账周期对齐)。清理方式:
expires_at上建索引 + 定时DELETE WHERE expires_at < now()小批量清理;- 或者按天分表/分区,过期直接
DROP PARTITION,避免大事务。
TiDB 和 OceanBase 都支持 TTL
属性,可以把手动清理省掉:
CREATE TABLE idempotency_keys (...) TTL = `expires_at` + INTERVAL 0 DAY;四、订单状态机
4.1 一笔支付的生命周期
Idempotency-Key 保证的是”同一请求不会执行两次”,但支付本身是个跨越多步骤的流程,需要显式建模成状态机。一个典型的卡支付状态机:
stateDiagram-v2
[*] --> Created
Created --> Authorizing: submit
Authorizing --> Authorized: auth_ok
Authorizing --> Failed: auth_fail
Authorized --> Capturing: capture
Capturing --> Captured: capture_ok
Capturing --> Authorized: capture_retry
Captured --> Refunding: refund
Refunding --> Refunded: refund_ok
Refunding --> Captured: refund_fail
Authorized --> Reversed: void
Created --> Canceled: cancel
Failed --> [*]
Refunded --> [*]
Reversed --> [*]
Canceled --> [*]
几个细节:
- Authorize 与 Capture 分离:卡支付 ISO
8583 体系里,
0100授权与0220请款是两条独立报文。授权只冻结额度,请款才真正扣款。这个设计源自 1970 年代的磁条卡时代,至今仍是主流。 - Capturing、Refunding 是中间态:区分”请求已发出、等待网络回执”与”确认成功”非常关键。没有中间态的状态机一旦超时就只能回滚,容易造成资金悬挂。
- 非法跃迁必须拒绝:比如
Refunded → Captured不可达。状态机实现里要有白名单校验。
4.2 状态机持久化
不要把状态机实现成内存对象,必须落库。推荐结构:
CREATE TABLE payment_state (
charge_id BIGINT PRIMARY KEY,
state SMALLINT NOT NULL,
version INT NOT NULL DEFAULT 0,
last_event_id BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE payment_events (
id BIGSERIAL PRIMARY KEY,
charge_id BIGINT NOT NULL,
from_state SMALLINT NOT NULL,
to_state SMALLINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (charge_id, event_type, payload)
);跃迁用乐观锁保护:
UPDATE payment_state
SET state = $new_state, version = version + 1, last_event_id = $event_id, updated_at = now()
WHERE charge_id = $id AND state = $expected_state AND version = $expected_version;UPDATE 影响行数为 0
就说明被别人先跃迁了,业务层要决定:重新读取状态后重试、报错、或者静默成功。
4.3 状态机与幂等的协同
同一个 event_id(上游传来的事件 ID)对同一
charge_id 只允许消费一次——这就是
payment_events 上
UNIQUE (charge_id, event_type, payload)
的作用。重复事件会触发唯一冲突,直接丢弃。
把状态机与 Idempotency-Key 并列理解:前者保护跨事件的一致性,后者保护同一事件的幂等性,两者是正交的。
五、分布式事务方案对比
单机事务的 ACID 在分布式环境下必然要打折扣。CAP 定理告诉我们一致性(C)与可用性(A)在分区(P)下二选一;BASE(Basically Available, Soft state, Eventually consistent)是工程上的妥协。下面把主流方案逐一展开。
5.1 两阶段提交(2PC / XA)
2PC 的流程是教科书内容:Prepare、Commit。协调者先向所有参与者发 Prepare,全部 OK 后发 Commit,任一 Prepare 失败则发 Rollback。
工程落地里它的代表是 XA 协议(X/Open XA),被 Oracle、DB2、MySQL(InnoDB)、PostgreSQL 等数据库原生支持。银行核心长期在 IBM CICS + DB2 XA 上跑批扣款,数十年未变。
2PC 的硬伤:
- 协调者单点:一旦协调者在 Commit 阶段宕机,参与者会进入”悬挂”状态——锁还占着、事务既不能提交也不能回滚。需要人工介入或独立的恢复服务。
- 同步阻塞:Prepare 阶段参与者的锁必须持有到 Commit 结束,高并发下吞吐骤降。
- 跨公网不适用:握手次数多,任一网络抖动都会放大成超时。
结论:2PC 只在强可控、低并发、金融核心场景下可用,比如日终批扣、总账汇总。互联网支付链路几乎没人直接用 XA。
5.2 TCC(Try-Confirm-Cancel)
TCC 是蚂蚁金服、腾讯在分布式事务中的主力模式。每个参与者实现三个接口:
- Try:做预留/冻结,不实际生效。例如扣款的 Try 是”冻结 100 元”。
- Confirm:真正生效。扣款的 Confirm 是”从冻结账户实际扣除”。
- Cancel:释放预留。扣款的 Cancel 是”解冻 100 元”。
协调者收到业务请求后:
- 所有参与者先调 Try;
- 全部 OK 则依次调 Confirm;
- 任一 Try 失败则对已 Try 成功的调 Cancel。
TCC 的优点:
- 无锁:Try 阶段只冻结业务资源,不持有数据库行锁,吞吐远高于 2PC;
- 跨语言、跨系统:只要实现三个 HTTP 接口即可;
- 补偿语义清晰:Cancel 显式定义,不靠日志回放。
代价:
- 业务侵入强:每个参与者都要多写两倍代码;
- 悬挂问题:Cancel 先到、Try 后到会造成”空回滚后再冻结”,需要全局防悬挂表;
- 幂等要求三倍:Try/Confirm/Cancel 都必须幂等。
开源实现:Seata 的 TCC 模式、DTM 的 TCC。蚂蚁内部的 XTS/DTX 框架是 TCC 的工业级代表,支撑了余额宝、网商银行、支付宝红包全链路的事务一致性。
5.3 SAGA
SAGA 概念来自 1987 年 Garcia-Molina 的论文,核心思想是把长事务拆成一串短事务,每个短事务有对应的补偿事务。成功则顺序推进,失败则逆序补偿。
SAGA 有两种协调方式:
- Orchestration(编排):有一个中央协调器(Saga Coordinator),显式调度每一步。代表实现:Temporal、AWS Step Functions、DTM 的 Saga 模式、阿里云 Seata-Saga。
- Choreography(编舞):没有中心,每个服务订阅上一步的事件并决定自己干什么。代表实现:基于 Kafka/RocketMQ 事件驱动的微服务链。
对比:
| 维度 | Orchestration | Choreography |
|---|---|---|
| 可观测性 | 强:整个流程一图看全 | 弱:链路分散在多服务 |
| 耦合度 | 协调器知道所有参与者 | 各服务只知道事件 schema |
| 改动成本 | 改协调器 | 改多个服务的事件监听 |
| 运行时开销 | 协调器是潜在瓶颈 | 无中心瓶颈 |
| 故障定位 | 一处回放即可复盘 | 需要跨服务串 trace_id |
工程上,SAGA 是互联网金融场景里最常见的选择。它不要求参与者实现冻结语义(这是 TCC 的痛点),也不要求全局锁(这是 2PC 的痛点),代价是只能做到最终一致性。对大多数业务来说这是可接受的——用户在”下单后 5 秒看到订单生成”和”下单时阻塞等待所有事务提交”之间,一定选前者。
5.4 可靠消息最终一致性
这个方案的核心是:把本地事务与消息发送绑定,让下游通过消费消息触发自己的本地事务。代表:
- RocketMQ 事务消息:阿里开源,2017 年在双 11 支付链路投产。生产者先发”半消息”,本地事务成功后提交,失败则回滚;MQ 定期回查生产者判断半消息最终状态。
- Kafka Outbox Pattern:Kafka 本身没有事务消息,但可以通过 Outbox 表 + CDC(Change Data Capture)实现等价语义(见第六章)。
- Pulsar Transaction:Apache Pulsar 2.7+ 原生支持事务消息。
5.5 四种方案的选型矩阵
| 方案 | 一致性 | 吞吐 | 侵入性 | 运维复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 2PC/XA | 强 | 低 | 低(数据库协议级) | 高(悬挂恢复) | 银行核心批扣 |
| TCC | 强(业务视角) | 中 | 高 | 中 | 蚂蚁/腾讯支付核心 |
| SAGA | 最终 | 高 | 中 | 中(协调器) | 互联网支付、电商下单 |
| 可靠消息 | 最终 | 高 | 低 | 低(依赖 MQ) | 异步通知、对账触发 |
一个常见的工程组合:同步主链路用 SAGA,异步边链路用可靠消息,少数强一致诉求点用 TCC。
六、Outbox Pattern 与 CDC
6.1 问题的提出
可靠消息方案有一个经典坑:本地事务与消息发送不在同一个事务里。假设订单服务写订单 + 发 MQ:
tx BEGIN
INSERT INTO orders (...);
mqProducer.send("order.created", ...); // ← 这里失败怎么办?
tx COMMIT
两种失败都会出事:
- 订单写成功,MQ 发失败 → 下游永远收不到事件;
- MQ 发成功,订单回滚 → 下游按”幻订单”处理。
RocketMQ 事务消息通过”半消息 + 回查”解决,但绑定了 RocketMQ。跨 MQ 可移植的方案是 Outbox Pattern。
6.2 Outbox 表设计
把”要发的消息”作为一条记录插到与订单同一个数据库的 outbox 表里,这样它们天然共享事务:
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(32) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpub ON outbox (id) WHERE published_at IS NULL;事务里同时写业务表和 outbox:
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', '12345', 'OrderCreated', '{"amount":10000}');
COMMIT;然后由一个独立的 Relay 进程或
CDC 工具读 outbox 推到
MQ。读一条、推一条、标记
published_at,失败则下次重试。因为是读库推
MQ,at-least-once
投递天然成立,消费者只需保证幂等即可。
6.3 用 CDC 替代 Relay
Debezium 订阅 PostgreSQL 的 WAL 或 MySQL 的 binlog,把 outbox 表的变更直接转成 Kafka 消息,完全不需要业务代码主动发消息。好处:
- 零额外延迟:WAL 是主库必然要写的;
- 强顺序:按 LSN/GTID 顺序投递;
- 不占业务吞吐:CDC 是旁路。
代价:
- 需要运维一套 Kafka + Debezium;
- Schema 变更要和下游协调;
- 首次快照可能对主库有冲击,需要用
incremental snapshot。
工程上,Outbox + Debezium + Kafka 已经是 2020 年后微服务事件驱动的默认配方。LinkedIn、Shopify、荷兰 ING 银行都有公开分享。
七、补偿策略
7.1 自动补偿 vs 人工介入
SAGA 和 TCC 都要求补偿可自动执行,但”能自动”不等于”应当自动”。一条工程经验:
- 技术失败(网络超时、DB 短暂不可用)→ 自动补偿 + 重试;
- 业务失败(风控拒绝、账户不存在)→ 立即补偿,不重试;
- 资金异常(金额不匹配、货币不一致)→ 停止自动流程,挂人工工单。
阿里内部有”差错户”的概念:一旦自动补偿三次失败,资金进入差错户,由清结算人员手工核查。这个设计避免了自动化系统在诡异异常下把问题放大。
7.2 补偿也要幂等
重复补偿是真实存在的——SAGA 协调器重启、MQ 重投、人工按错按钮,都会导致同一个 Cancel 被调两次。补偿接口必须:
- 对同一
saga_id + step_id只扣减/解冻一次; - 带版本号,已补偿则静默返回成功;
- 补偿失败单独告警,不要掩盖在”最终成功”的统计里。
7.3 悬挂与空补偿
TCC 的两个经典问题必须显式处理:
- 空回滚(Empty Rollback):Cancel 先于
Try 到达。解决:在事务表里预插
status=NULL占位,Cancel 看到”无 Try 记录”时写一条”已空回滚”标记,后续 Try 看到该标记则立即返回成功——这就叫防悬挂。 - 悬挂(Hanging):Try 在 Cancel 之后到达。不处理会产生”冻结但永不释放”的资源。防悬挂表解决上述两种情况。
可靠消息方案没有这两个问题(因为它不做预留),但多了消息积压和消息顺序问题,需要在消费端做好幂等 + 业务状态机。
八、真实案例
8.1 支付宝红包超发(2015)
2015 年春节,支付宝”咻一咻”活动某一轮因下游结算链路超时,SAGA 协调器判定失败并触发 Cancel,但被 Cancel 的 Try 并未真正失败——只是回执慢。结果:用户账上的红包既被记账又被 Cancel 解冻,部分用户短时间内看到红包金额翻倍。事后复盘结论是防悬挂表的版本号粒度过粗,Try 与 Cancel 的幂等键没对齐。修复后的方案把幂等键下沉到”业务单号 + 步骤号 + 版本”,并强制所有 TCC 参与者在同一张事务表里登记。这个事故在蚂蚁内部被作为 TCC 培训的 Day 1 案例。
8.2 某国有大行 2PC 超时挂账(2019)
某国有大行日终批跑使用 XA 事务,协调者在 Commit 阶段遇到 DB2 锁等待超时,参与者进入 in-doubt 状态。恢复程序没有及时清理悬挂事务,导致次日开业前部分账户仍被锁,分行柜面提示”账务忙”。事后监管要求该行在批处理中引入”可取消的短事务 + 对账补偿”替代部分 XA 场景,并建立独立的悬挂事务告警。这个案例在 CNAPS 运维规范更新中被间接引用。
8.3 电商退款风暴(2023)
某头部电商在大促后第二天集中触发退款,退款 SAGA
协调器的限流没做好,下游账务服务被打爆。更糟的是补偿链路也在同一个服务实例上,补偿本身也挂了,导致部分订单既不成功也不失败,停在
Refunding 状态。修复办法:
- 协调器加令牌桶限流;
- 正向与补偿走不同的线程池与连接池;
- 补偿 MQ 设独立优先级队列,永远优先消费;
- 所有中间态设
stuck_duration告警,超过阈值强制挂工单。
这条教训放之四海而皆准:补偿链路必须与正向链路做资源隔离。
8.4 Stripe 的 Idempotency 演进
Stripe 在 2015 年博客里第一次系统讲了
Idempotency-Key。早期实现是纯内存 + Redis 快照,遇到 Redis
故障会丢 key,触发客户端重试时被当成新请求。2017
年左右迁到基于 MySQL 的持久化方案,配合事务隔离,算是业界
Idempotency-Key 工程化的范本。现在 Stripe API 的每个 POST
都支持并强烈建议传 Idempotency-Key,SDK
在重试时自动附带同一 key。
九、代码示例
下面给出一个最小可用的工程骨架。语言选 Go,因为它更贴近支付网关团队的主流技术栈;Java 的等价实现思路一致,只是换成 Spring Boot + MyBatis。
9.1 Idempotency-Key 中间件(Go)
package idem
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"net/http"
"time"
)
type Store interface {
Acquire(ctx context.Context, key []byte, reqHash []byte, ttl time.Duration) (acquired bool, cached *Response, err error)
Complete(ctx context.Context, key []byte, resp *Response) error
}
type Response struct {
Status int `json:"status"`
Header http.Header `json:"header"`
Body json.RawMessage `json:"body"`
}
type Middleware struct {
store Store
ttl time.Duration
}
func New(store Store, ttl time.Duration) *Middleware {
return &Middleware{store: store, ttl: ttl}
}
func (m *Middleware) Wrap(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" || r.Method != http.MethodPost {
h.ServeHTTP(w, r)
return
}
bodyHash := hashBody(r)
keyHash := sha256.Sum256([]byte(key))
acquired, cached, err := m.store.Acquire(r.Context(), keyHash[:], bodyHash, m.ttl)
if err != nil {
http.Error(w, "idempotency store error", http.StatusServiceUnavailable)
return
}
if !acquired && cached != nil {
writeCached(w, cached)
return
}
if !acquired && cached == nil {
w.Header().Set("Retry-After", "2")
http.Error(w, "request in progress", http.StatusConflict)
return
}
rec := &recorder{ResponseWriter: w, status: 200}
h.ServeHTTP(rec, r)
_ = m.store.Complete(r.Context(), keyHash[:], rec.snapshot())
})
}
func hashBody(r *http.Request) []byte {
// 真实实现里要把 body 读到 buffer 并 reset,这里省略。
return nil
}
type recorder struct {
http.ResponseWriter
status int
buf []byte
}
func (r *recorder) WriteHeader(code int) { r.status = code; r.ResponseWriter.WriteHeader(code) }
func (r *recorder) Write(p []byte) (int, error) {
r.buf = append(r.buf, p...)
return r.ResponseWriter.Write(p)
}
func (r *recorder) snapshot() *Response {
return &Response{Status: r.status, Header: r.Header().Clone(), Body: r.buf}
}
func writeCached(w http.ResponseWriter, c *Response) {
for k, vs := range c.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(c.Status)
_, _ = w.Write(c.Body)
}
var ErrConflict = errors.New("idempotency key conflict")PostgreSQL 实现 Store:
func (p *PgStore) Acquire(ctx context.Context, key, reqHash []byte, ttl time.Duration) (bool, *Response, error) {
var existing struct {
reqHash []byte
status int
body []byte
}
row := p.db.QueryRowContext(ctx, `
INSERT INTO idempotency_keys (key_hash, request_hash, status, expires_at)
VALUES ($1, $2, 0, now() + $3::interval)
ON CONFLICT (key_hash) DO UPDATE SET key_hash = EXCLUDED.key_hash
RETURNING request_hash, status, coalesce(response_body, ''::jsonb)::text
`, key, reqHash, ttl.String())
if err := row.Scan(&existing.reqHash, &existing.status, &existing.body); err != nil {
return false, nil, err
}
// 新插入:request_hash 与传入一致且 status=0 → 本实例 acquire 成功
if bytesEqual(existing.reqHash, reqHash) && existing.status == 0 && len(existing.body) == 0 {
return true, nil, nil
}
if !bytesEqual(existing.reqHash, reqHash) {
return false, nil, ErrConflict
}
if existing.status == 1 {
var resp Response
_ = json.Unmarshal([]byte(existing.body), &resp)
return false, &resp, nil
}
return false, nil, nil // processing
}注意这里用
ON CONFLICT DO UPDATE SET key_hash = EXCLUDED.key_hash
只是为了让 RETURNING
在冲突时也返回现有行,生产上可以拆成先
INSERT ON CONFLICT DO NOTHING 再
SELECT 两步。
9.2 Outbox 发布器(Go)
func publish(ctx context.Context, db *sql.DB, mq MQ) error {
rows, err := db.QueryContext(ctx, `
SELECT id, event_type, payload FROM outbox
WHERE published_at IS NULL
ORDER BY id ASC
LIMIT 500
FOR UPDATE SKIP LOCKED`)
if err != nil { return err }
defer rows.Close()
type msg struct{ id int64; topic string; body []byte }
var batch []msg
for rows.Next() {
var m msg
if err := rows.Scan(&m.id, &m.topic, &m.body); err != nil { return err }
batch = append(batch, m)
}
for _, m := range batch {
if err := mq.Send(ctx, m.topic, m.body); err != nil {
return err // 下次重试
}
_, _ = db.ExecContext(ctx, `UPDATE outbox SET published_at = now() WHERE id = $1`, m.id)
}
return nil
}关键点:
FOR UPDATE SKIP LOCKED让多个 Relay 实例并行跑不重复;- 批量读、逐条发、逐条标;
- 发送失败立即 return,依赖外层循环 backoff。
9.3 Java 版 Idempotency 中间件(Spring Boot)
对使用 Spring 生态的团队,上面的 Go
例子可以直接翻译为一个 HandlerInterceptor:
@Component
public class IdempotencyInterceptor implements HandlerInterceptor {
private final IdempotencyStore store;
private final Duration ttl;
public IdempotencyInterceptor(IdempotencyStore store,
@Value("${idem.ttl:PT24H}") Duration ttl) {
this.store = store;
this.ttl = ttl;
}
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler)
throws Exception {
if (!"POST".equals(req.getMethod())) return true;
String key = req.getHeader("Idempotency-Key");
if (key == null || key.isBlank()) return true;
byte[] body = ((ContentCachingRequestWrapper) req).getContentAsByteArray();
byte[] reqHash = DigestUtils.sha256(body);
byte[] keyHash = DigestUtils.sha256(key.getBytes(StandardCharsets.UTF_8));
IdempotencyStore.AcquireResult r = store.acquire(keyHash, reqHash, ttl);
if (r.acquired()) {
req.setAttribute("idem.keyHash", keyHash);
return true;
}
if (r.cachedResponse() != null) {
resp.setStatus(r.cachedResponse().status());
r.cachedResponse().headers().forEach(resp::setHeader);
resp.getOutputStream().write(r.cachedResponse().body());
return false;
}
resp.setHeader("Retry-After", "2");
resp.sendError(409, "request in progress");
return false;
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) {
byte[] keyHash = (byte[]) req.getAttribute("idem.keyHash");
if (keyHash == null) return;
ContentCachingResponseWrapper w = (ContentCachingResponseWrapper) resp;
store.complete(keyHash, new IdempotencyStore.Snapshot(
w.getStatus(),
Map.of("Content-Type", w.getContentType() == null ? "application/json" : w.getContentType()),
w.getContentAsByteArray()));
}
}关键点和 Go 版一致:先占位再处理、响应缓存要带 status 与 header、同 key 不同指纹必须报 409。
9.4 SAGA 协调器骨架(Go)
type Step struct {
Name string
Forward func(ctx context.Context) error
Compensate func(ctx context.Context) error
}
type Saga struct {
ID string
Steps []Step
}
func (s *Saga) Run(ctx context.Context, log Log) error {
completed := []int{}
for i, step := range s.Steps {
if err := withRetry(ctx, step.Forward); err != nil {
log.Failed(s.ID, step.Name, err)
for j := len(completed) - 1; j >= 0; j-- {
k := completed[j]
if cerr := withRetry(ctx, s.Steps[k].Compensate); cerr != nil {
log.CompensationFailed(s.ID, s.Steps[k].Name, cerr)
return cerr // 停在这里,挂人工
}
}
return err
}
completed = append(completed, i)
log.Success(s.ID, step.Name)
}
return nil
}真实生产里 Saga
必须可持久化、可恢复:每一步的开始与结束写事件表,协调器重启后能根据事件表继续执行。Temporal、DTM
已经把这一层做好了,自己写只建议在教学或极小规模场景。
十、完整流程图
下单 → 扣库存 → 扣账户 → 通知 的 SAGA 正向 + 补偿路径:
sequenceDiagram
participant C as Client
participant O as Order Service
participant I as Inventory Service
participant A as Account Service
participant N as Notification Service
participant S as Saga Coordinator
C->>O: POST /orders (Idempotency-Key)
O->>S: start saga(order_id)
S->>I: reserve(stock)
I-->>S: ok
S->>A: debit(amount)
A-->>S: FAILED (insufficient)
Note over S: 进入补偿
S->>I: release(stock)
I-->>S: ok
S->>O: mark canceled
O-->>C: 402 Payment Required
Note over C,N: 正向路径(成功)
C->>O: POST /orders (retry / new)
O->>S: start saga
S->>I: reserve(stock)
I-->>S: ok
S->>A: debit(amount)
A-->>S: ok
S->>N: send(notify)
N-->>S: ok
S->>O: mark paid
O-->>C: 201 Created
两条路径都经过同一个协调器,正向与补偿的每一步都要求幂等;协调器自身通过事件表做检查点。
十一、Exactly-Once 的幻觉与现实
在消息系统、流处理、支付链路里,“exactly-once”(恰好一次)几乎是所有工程师都希望获得却又容易误解的承诺。我们先把它讲清楚再继续。
11.1 三种语义
- At-most-once(至多一次):可能丢消息,绝不重复。适用于审计日志(丢一点可接受)或高频心跳。
- At-least-once(至少一次):不会丢,但可能重复。绝大多数 MQ、HTTP 重试都是这一档。
- Exactly-once(恰好一次):既不丢也不重。成本最高。
在分布式系统中,纯粹的 exactly-once 投递在工程上不可实现(这已经被 Kafka 的工程团队在 2017 年的 Exactly-once semantics 一文中详细论证)。Kafka 提供的”exactly-once processing”本质是”at-least-once 投递 + 幂等消费 + 事务性写外部系统”三件套共同作用的结果。
11.2 支付系统的等价表述
放到支付语境下,所谓”exactly-once”其实是:
- 对外 API:同一
Idempotency-Key恰好一次改变状态; - 对内 MQ:at-least-once 投递 + 消费端幂等 = 业务侧 exactly-once;
- 对账:日终审计时账务总和与外部对账文件一致,差异额低于阈值。
承认这三件事是不同语义,系统设计就会清楚很多。别把 exactly-once 当一个总开关,而是要看在哪一层用什么手段逼近 exactly-once 的业务效果。
11.3 消费端幂等的五种实现
- 业务唯一键去重:消费前查
(source_system, external_id)是否已处理。简单直接,适合写少。 - Token 去重表:每条消息带一个 token(MQ
的 msgId 或业务事件 ID),消费前
INSERT ... ON CONFLICT DO NOTHING,成功才处理。 - 状态机 guard:消费后只在合法状态下跃迁,重复消费自然无效。
- 版本号/条件更新:
UPDATE ... WHERE version = ?,条件不满足即静默。 - 幂等宿主(Idempotent Consumer):Camel、Spring Integration 都有内置模式,把去重表封装成消费者装饰器。
五种里最常用的是状态机 + 条件更新。它的好处是去重与业务逻辑共用一张表,不需要额外同步。
十二、跨系统一致性模式图
再从架构视角俯视一次,支付链路里跨系统的一致性大致有这四种模式组合:
flowchart LR
A[Client] -->|Idempotency-Key| B[API Gateway]
B -->|状态机跃迁| C[Order Service]
C -->|本地事务 + Outbox| D[(DB)]
D -->|CDC| E[Kafka]
E -->|at-least-once| F[Account Service]
E --> G[Inventory Service]
E --> H[Notification Service]
F -->|幂等消费| I[(Ledger DB)]
G -->|幂等消费| J[(Stock DB)]
F -->|失败事件| K[Saga Coordinator]
K -->|补偿| G
K -->|补偿| F
K -->|挂单| L[Ops Console]
这张图里每条线都可以对照前文的章节:
- A → B 的 Idempotency-Key 由第三章的中间件实现;
- C → D 的状态机由第四章落库;
- D → E 的 Outbox/CDC 在第六章;
- E → F/G/H 的 at-least-once 由消费端幂等(第十一章)兜底;
- K 的补偿与挂单由第七章与第八章的事故经验驱动。
整套模式里唯一不可妥协的点是:任何一跳都要能被安全重试。这就是本文反复强调的”幂等是底座”。
十三、性能与观测
13.1 幂等表的热点问题
高 QPS 下,幂等表可能成为瓶颈,尤其在单主库架构里。常见优化:
- 按 key_hash 分片:将幂等表水平拆分到 8 / 16 / 64 个分表;
- 写缓冲:把幂等占位先写 Redis,异步落库。但要小心 Redis 故障时 fallback 到 DB 的幂等是否生效;
- 行级 TTL:避免单张表无限膨胀;
- 只保留哈希:指纹和响应体大时,可以把响应单独放对象存储(S3/OSS),幂等表只存指针。
Stripe 公开过的数字是:幂等表的 QPS 峰值约为 API 请求峰值的 1.3 倍(考虑重试因子),设计容量时按这个系数预留。
13.2 观测指标
一套生产级幂等/事务体系应当对外暴露至少以下指标:
idempotency.hit_rate:命中缓存的请求比例,反映重试频率;idempotency.conflict_rate:同 key 不同指纹的比例,反映客户端逻辑质量;saga.step_latency:每一步的延迟分布;saga.compensation_rate:正向失败触发补偿的比例;saga.stuck_count:处于中间态超过阈值的实例数;outbox.lag:outbox 表中未发布记录的最大年龄;cdc.lag:CDC 消费位点与主库当前位点的差值。
这些指标应在事故复盘时作为第一档证据。没有它们,链路一旦出事你只能靠日志 grep。
13.3 混沌测试
幂等与事务的正确性最怕”在测试里永远不复现”。生产级团队应当定期在预发环境注入:
- 网络抖动:随机丢包、延迟、DNS 抖动;
- 中间件故障:kill MQ broker、Redis、DB 主库;
- 时钟偏移:把协调器时钟往前/后拨几分钟;
- 重复投递:强制 MQ 对指定 topic 重投 N 遍;
- 协调器重启:在 SAGA 执行中途 SIGKILL 一次。
目标只有一个:混沌结束后,账务表总和与预期完全一致。这既是幂等的终极验收标准,也是第 23 篇对账系统要回答的问题。
十四、与合规的接口
幂等与分布式事务虽然是技术问题,但和合规交叉的点并不少:
- 可审计:幂等表、状态机事件、Outbox 都必须保留足够时长(国内《支付机构预付卡业务管理办法》与银行业同类规则通常要求 5–10 年);
- 可追溯:一笔支付的所有中间态必须能串成完整时间线,供监管检查;
- 差错处理流程:自动补偿失败进人工工单,这一步的 SOP 必须文档化、可演练;
- 不可篡改:事件表只追加不更新,与《复式记账工程化》里 postings 表的 append-only 策略一致。
合规这条线我们不展开,放到第 21 篇《反洗钱与 KYC》与第 23 篇《对账系统工程》里再讲细节。这里的要点是:幂等与事务设计必须在 Day 1 就考虑可审计性,否则补起来成本惊人。
十五、工程坑点
下面这些坑几乎每个支付团队都踩过,按频次从高到低排:
- 幂等表没有唯一索引:只有主键 + 应用层判重,被并发请求绕过。
- 幂等响应没缓存 status:第一次返回 202,第二次返回 201,客户端状态机错乱。
- 请求指纹算错:把
client_ip、timestamp算进指纹,同 key 不同时间重试被视为冲突。 - 状态机用枚举字符串:上下游版本不一致时产生”未知状态”。枚举务必用数字 + 注释文档。
- Outbox 不带顺序:下游乱序消费导致”退款先于下单”的事件到达。用 aggregate_id 做 partition key。
- CDC 不做 DDL 对齐:业务加字段没通知下游,消费者反序列化挂。Debezium 有 Schema Registry 兜底,务必开启。
- SAGA 协调器没有恢复:重启后事件丢失,部分订单永远挂着。必须把状态写库。
- 补偿没做限流:补偿风暴打爆下游,扩大故障面。补偿链路用独立线程池 + 独立 MQ 分区。
- TCC 没防悬挂:空回滚、悬挂同时出现,业务表出现”冻结却无订单”的记录。
- 回调没签名 + 没防重放:伪造回调被当成真实结果,这属于《支付网关设计》的范畴,但在这一层就要留好验签位。
十六、选型建议与落地清单
16.1 场景匹配表
| 业务 | 推荐方案 | 理由 |
|---|---|---|
| 卡支付同步主链路 | Idempotency-Key + 状态机 + SAGA | 吞吐与一致性平衡 |
| 账务核心批扣 | XA / TCC | 强一致、可审计 |
| 异步通知、下游触发 | Outbox + CDC + Kafka | 解耦、可回放 |
| 跨机构转账 | TCC + 人工挂账 | 悬挂可控、合规可查 |
| 交易所撮合 → 清结算 | 本地事务 + 异步消息 | 延迟极低 |
| 商户分账 | SAGA Orchestration | 步骤多、可视化 |
16.2 落地清单
- 所有写接口必须接受且持久化
Idempotency-Key; - 所有业务实体必须有跨租户唯一的业务单号;
- 所有状态流转必须走状态机白名单并带乐观锁;
- 所有事件发布走 Outbox + CDC,禁止事务内直接发 MQ;
- 所有跨服务写优先选 SAGA,次选 TCC,慎选 2PC;
- 所有补偿接口与正向接口同等级幂等;
- 所有中间态都设置 stuck 告警(通常 1–5 分钟阈值);
- 所有自动补偿失败三次挂人工工单,不再自动重试;
- 所有对账差错进差错户,不进正常账户(接《对账系统工程》);
- 所有事故复盘回填到 Idempotency-Key / 状态机白名单 / 补偿策略,形成闭环。
十七、与其他篇章的交叉引用
- 《复式记账工程化》给出了本文状态机落地时的账务侧视角;
- 《账务数据库设计》为幂等表、Outbox 表、状态表提供物理承载;
- 《支付网关设计》会详细讲签名、防重放、补单;
- 《对账系统工程》承接本文未尽的对账补偿;
- 《金融级可靠性》讨论两地三中心下的事务恢复策略。
参考资料
- Stripe Engineering Blog, Designing robust and predictable APIs with idempotency
- Stripe API Reference, Idempotent requests
- Hector Garcia-Molina, Kenneth Salem, SAGAS, ACM SIGMOD 1987
- Pat Helland, Life beyond Distributed Transactions: an Apostate’s Opinion, ACM Queue 2016
- Apache RocketMQ, Transaction Message
- Debezium Documentation, Outbox Event Router
- Temporal Documentation, Sagas
- Seata 官方文档,TCC 模式
- DTM 文档,分布式事务实践
- X/Open, Distributed Transaction Processing: The XA Specification
上一篇:《账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户》
下一篇:《支付系统全景:收单、发卡、聚合、跨境、B2B、C2C》
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放
从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。
【系统架构设计百科】应用层数据一致性模式:在正确性与性能之间走钢丝
Saga、TCC、本地消息表、事务发件箱——应用层一致性方案的选型依据是什么?本文深入每种模式的补偿机制设计,对比 Saga 编排与协调,剖析 Eventuate Tram 的实现原理。
【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
【金融科技工程】钱的建模:金额精度、币种、会计单位、多语言金额
在代码里正确地表示"一笔钱"远比看起来难。本文系统梳理金额的数值建模(浮点、定点、Decimal、最小单位)、币种标准(ISO 4217)、本地化显示、汇率换算与数据库存储,并给出 Go、Python、Java、Rust 的工程化示例。