2026-04-22 · architecture / fintech
【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放
从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。


























支付系统上线第一天,你一定会被运营追着问同一个问题:“这笔钱好像扣了两次,你查一下?” 第二天问题会升级成”钱扣了但订单没生成”,第三天是”退款退了两次”。再往后,就是 SRE 半夜被拉起来看一条告警:某个商户的对账差错金额突破百万。
这些故障的根因,九成以上落在三个词上:幂等(Idempotency)、事务(Transaction)、一致性(Consistency)。三者互相纠缠:网络不可靠导致重试,重试要求幂等;跨服务的写操作要求事务;事务在分布式场景下无法做到强一致,就需要定义一致性语义并通过补偿把它收敛回去。
这篇文章的读者画像是:
我们不会把笔墨花在”什么是幂等”这种名词解释上——而是把一个完整的支付下单链路拆开,逐个环节讲:哪一步必须幂等、用什么兜底、出错了怎么收敛。文末给出一份落地清单,和一张可以直接抄的 SAGA 正反向流程图。
上一篇《账务数据库设计》把底层存储选型讲完了,接下来我们关心的是上层:业务怎么正确地写入这些表。
计算机网络的一个工程公理是:一次请求要么到达、要么没到达、要么到达了但响应没回来,第三种情况对客户端而言无法与前两种区分。放到支付链路上:
00
成功码——发卡行已经落账,网关不知道。只要链路里有任何一跳”不知道对端状态”,客户端唯一的生存策略就是重试;而重试的前提是接收方能识别重复请求并返回同一结果。这个能力就是幂等。
下面三类事故,在各家支付机构内部复盘库里都能找到对应条目:
一条工程经验:支付系统里”幂等缺失”导致的事故,数量级上往往大于业务 Bug 导致的事故。原因是后者一般会在测试或灰度阶段被发现,而幂等问题只在极端时序下才触发,很难在测试环境复现。
重试不只来自网络层。把支付链路画出来,我们至少能看到四类不同来源的重试:
系统里只要包含这四类来源中的任何一类,接收方就必须假设”任意接口、任意时刻都可能收到重复请求”。这不是防御式编程,是底线。
幂等不是一个开关,而是一个需要在三层协作的系统属性。
接口层的目标是:对同一个逻辑请求,无论重试多少次,都返回同一结果(包括同样的副作用次数——恰好一次)。
最干净的做法是客户端生成一个全局唯一的
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"}
服务端收到后:
409 Conflict
或 422,提示”该 key 已用于不同请求”。409 并带
Retry-After,让客户端等。Stripe 的官方实现把
Idempotency-Key 的有效期定为 24
小时,覆盖了它们内部所有异步补单、手动复核场景。超过
24 小时的重试被视为新请求,由业务层(见 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,让客户端自查。
最后一层是数据库。即便接口和业务都漏了,数据库的唯一索引也应当拦住双写:
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)
);三层叠加的防御纵深,基本可以挡住所有真实世界的重复写入。任何一层被绕过都只是”多走一圈”,不会真的落成双账。
幂等表里除了存 key,还得存请求指纹和响应快照,否则遇到”同 key 不同 body”的异常请求就只能抛 500。指纹通常是对请求体按字段白名单做规范化后哈希(SHA-256),规范化要点:
100 与
100.00 等价);client_ip、timestamp
等环境字段;响应缓存则要把HTTP
status、响应头白名单、响应体都存下来,重放时原样返回。一个坑是:如果第一次响应是
202 Accepted(异步),重试时业务状态已经推进到
201 Created,你要么返回
202(严格幂等),要么返回 201(反映当前真实状态)。Stripe
的选择是前者:24
小时内完全复现第一次响应,哪怕中间状态已经变化;想拿最新状态请调
GET。
幂等的难点不是串行重试,而是并发重试。客户端超时后立即重试,两次请求可能同时到达两台不同的服务实例:
正确做法是在第一次查询就占位:
-- 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 并写入响应缓存。
客户端重试策略与服务端幂等共同决定了端到端成本。常见反模式是客户端用固定间隔 1s 重试 10 次:服务端还没处理完,客户端已经又打了 9 个 409,查幂等表的 QPS 直接飙到业务 QPS 的 10 倍。
工程上推荐:
min(2^n * 100ms + rand(100ms), 30s),最多重试 6
次。Retry-After
头显式告诉客户端该等多久,幂等占位记录里存一个
expected_duration_ms,Retry-After = max(0, expected_duration_ms - elapsed)。幂等表会迅速膨胀。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;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 --> [*]
几个细节:
0100 授权与 0220
请款是两条独立报文。授权只冻结额度,请款才真正扣款。这个设计源自
1970 年代的磁条卡时代,至今仍是主流。Refunded → Captured
不可达。状态机实现里要有白名单校验。不要把状态机实现成内存对象,必须落库。推荐结构:
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
就说明被别人先跃迁了,业务层要决定:重新读取状态后重试、报错、或者静默成功。
同一个 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)是工程上的妥协。下面把主流方案逐一展开。
2PC 的流程是教科书内容:Prepare、Commit。协调者先向所有参与者发 Prepare,全部 OK 后发 Commit,任一 Prepare 失败则发 Rollback。
工程落地里它的代表是 XA 协议(X/Open XA),被 Oracle、DB2、MySQL(InnoDB)、PostgreSQL 等数据库原生支持。银行核心长期在 IBM CICS + DB2 XA 上跑批扣款,数十年未变。
2PC 的硬伤:
结论:2PC 只在强可控、低并发、金融核心场景下可用,比如日终批扣、总账汇总。互联网支付链路几乎没人直接用 XA。
TCC 是蚂蚁金服、腾讯在分布式事务中的主力模式。每个参与者实现三个接口:
协调者收到业务请求后:
TCC 的优点:
代价:
开源实现:Seata 的 TCC 模式、DTM 的 TCC。蚂蚁内部的 XTS/DTX 框架是 TCC 的工业级代表,支撑了余额宝、网商银行、支付宝红包全链路的事务一致性。
SAGA 概念来自 1987 年 Garcia-Molina 的论文,核心思想是把长事务拆成一串短事务,每个短事务有对应的补偿事务。成功则顺序推进,失败则逆序补偿。
SAGA 有两种协调方式:
对比:
| 维度 | Orchestration | Choreography |
|---|---|---|
| 可观测性 | 强:整个流程一图看全 | 弱:链路分散在多服务 |
| 耦合度 | 协调器知道所有参与者 | 各服务只知道事件 schema |
| 改动成本 | 改协调器 | 改多个服务的事件监听 |
| 运行时开销 | 协调器是潜在瓶颈 | 无中心瓶颈 |
| 故障定位 | 一处回放即可复盘 | 需要跨服务串 trace_id |
工程上,SAGA 是互联网金融场景里最常见的选择。它不要求参与者实现冻结语义(这是 TCC 的痛点),也不要求全局锁(这是 2PC 的痛点),代价是只能做到最终一致性。对大多数业务来说这是可接受的——用户在”下单后 5 秒看到订单生成”和”下单时阻塞等待所有事务提交”之间,一定选前者。
这个方案的核心是:把本地事务与消息发送绑定,让下游通过消费消息触发自己的本地事务。代表:
| 方案 | 一致性 | 吞吐 | 侵入性 | 运维复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 2PC/XA | 强 | 低 | 低(数据库协议级) | 高(悬挂恢复) | 银行核心批扣 |
| TCC | 强(业务视角) | 中 | 高 | 中 | 蚂蚁/腾讯支付核心 |
| SAGA | 最终 | 高 | 中 | 中(协调器) | 互联网支付、电商下单 |
| 可靠消息 | 最终 | 高 | 低 | 低(依赖 MQ) | 异步通知、对账触发 |
一个常见的工程组合:同步主链路用 SAGA,异步边链路用可靠消息,少数强一致诉求点用 TCC。
可靠消息方案有一个经典坑:本地事务与消息发送不在同一个事务里。假设订单服务写订单 + 发 MQ:
tx BEGIN
INSERT INTO orders (...);
mqProducer.send("order.created", ...); // ← 这里失败怎么办?
tx COMMIT
两种失败都会出事:
RocketMQ 事务消息通过”半消息 + 回查”解决,但绑定了 RocketMQ。跨 MQ 可移植的方案是 Outbox Pattern。
把”要发的消息”作为一条记录插到与订单同一个数据库的 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
投递天然成立,消费者只需保证幂等即可。
Debezium 订阅 PostgreSQL 的 WAL 或 MySQL 的 binlog,把 outbox 表的变更直接转成 Kafka 消息,完全不需要业务代码主动发消息。好处:
代价:
incremental snapshot。工程上,Outbox + Debezium + Kafka 已经是 2020 年后微服务事件驱动的默认配方。LinkedIn、Shopify、荷兰 ING 银行都有公开分享。
SAGA 和 TCC 都要求补偿可自动执行,但”能自动”不等于”应当自动”。一条工程经验:
阿里内部有”差错户”的概念:一旦自动补偿三次失败,资金进入差错户,由清结算人员手工核查。这个设计避免了自动化系统在诡异异常下把问题放大。
重复补偿是真实存在的——SAGA 协调器重启、MQ 重投、人工按错按钮,都会导致同一个 Cancel 被调两次。补偿接口必须:
saga_id + step_id
只扣减/解冻一次;TCC 的两个经典问题必须显式处理:
status=NULL
占位,Cancel 看到”无 Try 记录”时写一条”已空回滚”标记,后续
Try
看到该标记则立即返回成功——这就叫防悬挂。可靠消息方案没有这两个问题(因为它不做预留),但多了消息积压和消息顺序问题,需要在消费端做好幂等 + 业务状态机。
2015 年春节,支付宝”咻一咻”活动某一轮因下游结算链路超时,SAGA 协调器判定失败并触发 Cancel,但被 Cancel 的 Try 并未真正失败——只是回执慢。结果:用户账上的红包既被记账又被 Cancel 解冻,部分用户短时间内看到红包金额翻倍。事后复盘结论是防悬挂表的版本号粒度过粗,Try 与 Cancel 的幂等键没对齐。修复后的方案把幂等键下沉到”业务单号 + 步骤号 + 版本”,并强制所有 TCC 参与者在同一张事务表里登记。这个事故在蚂蚁内部被作为 TCC 培训的 Day 1 案例。
某国有大行日终批跑使用 XA 事务,协调者在 Commit 阶段遇到 DB2 锁等待超时,参与者进入 in-doubt 状态。恢复程序没有及时清理悬挂事务,导致次日开业前部分账户仍被锁,分行柜面提示”账务忙”。事后监管要求该行在批处理中引入”可取消的短事务 + 对账补偿”替代部分 XA 场景,并建立独立的悬挂事务告警。这个案例在 CNAPS 运维规范更新中被间接引用。
某头部电商在大促后第二天集中触发退款,退款 SAGA
协调器的限流没做好,下游账务服务被打爆。更糟的是补偿链路也在同一个服务实例上,补偿本身也挂了,导致部分订单既不成功也不失败,停在
Refunding 状态。修复办法:
stuck_duration
告警,超过阈值强制挂工单。这条教训放之四海而皆准:补偿链路必须与正向链路做资源隔离。
Stripe 在 2015 年博客里第一次系统讲了
Idempotency-Key。早期实现是纯内存 + Redis 快照,遇到 Redis
故障会丢 key,触发客户端重试时被当成新请求。2017
年左右迁到基于 MySQL 的持久化方案,配合事务隔离,算是业界
Idempotency-Key 工程化的范本。现在 Stripe API 的每个 POST
都支持并强烈建议传 Idempotency-Key,SDK
在重试时自动附带同一 key。
下面给出一个最小可用的工程骨架。语言选 Go,因为它更贴近支付网关团队的主流技术栈;Java 的等价实现思路一致,只是换成 Spring Boot + MyBatis。
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 两步。
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
实例并行跑不重复;对使用 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。
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 投递在工程上不可实现(这已经被 Kafka 的工程团队在 2017 年的 Exactly-once semantics 一文中详细论证)。Kafka 提供的”exactly-once processing”本质是”at-least-once 投递 + 幂等消费 + 事务性写外部系统”三件套共同作用的结果。
放到支付语境下,所谓”exactly-once”其实是:
Idempotency-Key 恰好一次改变状态;承认这三件事是不同语义,系统设计就会清楚很多。别把 exactly-once 当一个总开关,而是要看在哪一层用什么手段逼近 exactly-once 的业务效果。
(source_system, external_id)
是否已处理。简单直接,适合写少。INSERT ... ON CONFLICT DO NOTHING,成功才处理。UPDATE ... WHERE version = ?,条件不满足即静默。五种里最常用的是状态机 + 条件更新。它的好处是去重与业务逻辑共用一张表,不需要额外同步。
再从架构视角俯视一次,支付链路里跨系统的一致性大致有这四种模式组合:
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]
这张图里每条线都可以对照前文的章节:
整套模式里唯一不可妥协的点是:任何一跳都要能被安全重试。这就是本文反复强调的”幂等是底座”。
高 QPS 下,幂等表可能成为瓶颈,尤其在单主库架构里。常见优化:
Stripe 公开过的数字是:幂等表的 QPS 峰值约为 API 请求峰值的 1.3 倍(考虑重试因子),设计容量时按这个系数预留。
一套生产级幂等/事务体系应当对外暴露至少以下指标:
idempotency.hit_rate:命中缓存的请求比例,反映重试频率;idempotency.conflict_rate:同 key
不同指纹的比例,反映客户端逻辑质量;saga.step_latency:每一步的延迟分布;saga.compensation_rate:正向失败触发补偿的比例;saga.stuck_count:处于中间态超过阈值的实例数;outbox.lag:outbox
表中未发布记录的最大年龄;cdc.lag:CDC
消费位点与主库当前位点的差值。这些指标应在事故复盘时作为第一档证据。没有它们,链路一旦出事你只能靠日志 grep。
幂等与事务的正确性最怕”在测试里永远不复现”。生产级团队应当定期在预发环境注入:
目标只有一个:混沌结束后,账务表总和与预期完全一致。这既是幂等的终极验收标准,也是第 23 篇对账系统要回答的问题。
幂等与分布式事务虽然是技术问题,但和合规交叉的点并不少:
合规这条线我们不展开,放到第 21 篇《反洗钱与 KYC》与第 23 篇《对账系统工程》里再讲细节。这里的要点是:幂等与事务设计必须在 Day 1 就考虑可审计性,否则补起来成本惊人。
下面这些坑几乎每个支付团队都踩过,按频次从高到低排:
client_ip、timestamp 算进指纹,同
key 不同时间重试被视为冲突。| 业务 | 推荐方案 | 理由 |
|---|---|---|
| 卡支付同步主链路 | Idempotency-Key + 状态机 + SAGA | 吞吐与一致性平衡 |
| 账务核心批扣 | XA / TCC | 强一致、可审计 |
| 异步通知、下游触发 | Outbox + CDC + Kafka | 解耦、可回放 |
| 跨机构转账 | TCC + 人工挂账 | 悬挂可控、合规可查 |
| 交易所撮合 → 清结算 | 本地事务 + 异步消息 | 延迟极低 |
| 商户分账 | SAGA Orchestration | 步骤多、可视化 |
Idempotency-Key;上一篇:《账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户》
下一篇:《支付系统全景:收单、发卡、聚合、跨境、B2B、C2C》
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-22 · architecture / fintech
从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。
2026-04-13 · architecture
Saga、TCC、本地消息表、事务发件箱——应用层一致性方案的选型依据是什么?本文深入每种模式的补偿机制设计,对比 Saga 编排与协调,剖析 Eventuate Tram 的实现原理。
2026-04-22 · architecture / fintech
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
2026-04-22 · architecture / fintech
SaaS 与云服务的订阅与用量计费工程实践:计费模型、计量(Metering)、订阅生命周期、账单与发票、dunning、出海税务(VAT/GST/sales tax/消费税)、定价引擎与数据模型。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。