土法炼钢兴趣小组的算法知识备份

【金融科技工程】幂等、事务与一致性:SAGA、TCC、对账补偿

文章导航

分类入口
architecturefintech
标签入口
#idempotency#transaction#saga#tcc#2pc#outbox#cdc#distributed-transaction#stripe

目录

引子

支付系统上线第一天,你一定会被运营追着问同一个问题:“这笔钱好像扣了两次,你查一下?” 第二天问题会升级成”钱扣了但订单没生成”,第三天是”退款退了两次”。再往后,就是 SRE 半夜被拉起来看一条告警:某个商户的对账差错金额突破百万。

这些故障的根因,九成以上落在三个词上:幂等(Idempotency)、事务(Transaction)、一致性(Consistency)。三者互相纠缠:网络不可靠导致重试,重试要求幂等;跨服务的写操作要求事务;事务在分布式场景下无法做到强一致,就需要定义一致性语义并通过补偿把它收敛回去。

这篇文章的读者画像是:

我们不会把笔墨花在”什么是幂等”这种名词解释上——而是把一个完整的支付下单链路拆开,逐个环节讲:哪一步必须幂等、用什么兜底、出错了怎么收敛。文末给出一份落地清单,和一张可以直接抄的 SAGA 正反向流程图。

上一篇《账务数据库设计》把底层存储选型讲完了,接下来我们关心的是上层:业务怎么正确地写入这些表


一、为什么支付必须幂等

1.1 网络永远不可靠

计算机网络的一个工程公理是:一次请求要么到达、要么没到达、要么到达了但响应没回来,第三种情况对客户端而言无法与前两种区分。放到支付链路上:

只要链路里有任何一跳”不知道对端状态”,客户端唯一的生存策略就是重试;而重试的前提是接收方能识别重复请求并返回同一结果。这个能力就是幂等。

1.2 不幂等的真实损失

下面三类事故,在各家支付机构内部复盘库里都能找到对应条目:

一条工程经验:支付系统里”幂等缺失”导致的事故,数量级上往往大于业务 Bug 导致的事故。原因是后者一般会在测试或灰度阶段被发现,而幂等问题只在极端时序下才触发,很难在测试环境复现。

1.3 重试的四种来源

重试不只来自网络层。把支付链路画出来,我们至少能看到四类不同来源的重试:

  1. 客户端重试:用户连点、App 自动重发、SDK 内置 backoff。
  2. 网关/中间件重试:HTTP 客户端在 502/504 上的自动 retry、Service Mesh 的 retry budget。
  3. 回调重发:支付平台无法确认商户收到通知,会按 0s、30s、1min、5min、…、24h 的指数退避持续重推,直到商户返回成功或达到上限。
  4. 消息中间件重投: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"}

服务端收到后:

Stripe 的官方实现Idempotency-Key 的有效期定为 24 小时,覆盖了它们内部所有异步补单、手动复核场景。超过 24 小时的重试被视为新请求,由业务层(见 2.2)兜底。

2.2 业务层:唯一业务单号与状态机

接口层只能挡住”同一 key 的重试”,挡不住”同一逻辑、不同 key 的重试”(例如客户端为每次重试都生成新 key)。业务层的兜底是业务唯一约束

当业务系统收到一个已经存在的 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),规范化要点:

响应缓存则要把HTTP status、响应头白名单、响应体都存下来,重放时原样返回。一个坑是:如果第一次响应是 202 Accepted(异步),重试时业务状态已经推进到 201 Created,你要么返回 202(严格幂等),要么返回 201(反映当前真实状态)。Stripe 的选择是前者:24 小时内完全复现第一次响应,哪怕中间状态已经变化;想拿最新状态请调 GET。

3.2 并发与竞态

幂等的难点不是串行重试,而是并发重试。客户端超时后立即重试,两次请求可能同时到达两台不同的服务实例:

  1. 实例 A 查幂等表,key 不存在;
  2. 实例 B 查幂等表,key 也不存在;
  3. A 开始处理,B 也开始处理;
  4. 两次都写了数据,才发现冲突。

正确做法是在第一次查询就占位

-- 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;

“占位—处理—回填”三步一定要分开事务,否则长事务会把幂等表锁成热点。占位那条 INSERT 立即提交,处理完业务再开一个短事务更新 status=1 并写入响应缓存。

3.3 与重试 backoff 的互动

客户端重试策略与服务端幂等共同决定了端到端成本。常见反模式是客户端用固定间隔 1s 重试 10 次:服务端还没处理完,客户端已经又打了 9 个 409,查幂等表的 QPS 直接飙到业务 QPS 的 10 倍。

工程上推荐:

3.4 Idempotency-Key 的 TTL 与清理

幂等表会迅速膨胀。Stripe 选 24h,国内主流支付机构多数选择 7 天(与对账周期对齐)。清理方式:

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 --> [*]

几个细节:

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_eventsUNIQUE (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 的硬伤:

结论:2PC 只在强可控、低并发、金融核心场景下可用,比如日终批扣、总账汇总。互联网支付链路几乎没人直接用 XA。

5.2 TCC(Try-Confirm-Cancel)

TCC 是蚂蚁金服、腾讯在分布式事务中的主力模式。每个参与者实现三个接口:

协调者收到业务请求后:

  1. 所有参与者先调 Try;
  2. 全部 OK 则依次调 Confirm;
  3. 任一 Try 失败则对已 Try 成功的调 Cancel。

TCC 的优点:

代价:

开源实现:Seata 的 TCC 模式、DTM 的 TCC。蚂蚁内部的 XTS/DTX 框架是 TCC 的工业级代表,支撑了余额宝、网商银行、支付宝红包全链路的事务一致性

5.3 SAGA

SAGA 概念来自 1987 年 Garcia-Molina 的论文,核心思想是把长事务拆成一串短事务,每个短事务有对应的补偿事务。成功则顺序推进,失败则逆序补偿。

SAGA 有两种协调方式:

对比:

维度 Orchestration Choreography
可观测性 强:整个流程一图看全 弱:链路分散在多服务
耦合度 协调器知道所有参与者 各服务只知道事件 schema
改动成本 改协调器 改多个服务的事件监听
运行时开销 协调器是潜在瓶颈 无中心瓶颈
故障定位 一处回放即可复盘 需要跨服务串 trace_id

工程上,SAGA 是互联网金融场景里最常见的选择。它不要求参与者实现冻结语义(这是 TCC 的痛点),也不要求全局锁(这是 2PC 的痛点),代价是只能做到最终一致性。对大多数业务来说这是可接受的——用户在”下单后 5 秒看到订单生成”和”下单时阻塞等待所有事务提交”之间,一定选前者。

5.4 可靠消息最终一致性

这个方案的核心是:把本地事务与消息发送绑定,让下游通过消费消息触发自己的本地事务。代表:

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

两种失败都会出事:

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 消息,完全不需要业务代码主动发消息。好处:

代价:

工程上,Outbox + Debezium + Kafka 已经是 2020 年后微服务事件驱动的默认配方。LinkedIn、Shopify、荷兰 ING 银行都有公开分享。


七、补偿策略

7.1 自动补偿 vs 人工介入

SAGA 和 TCC 都要求补偿可自动执行,但”能自动”不等于”应当自动”。一条工程经验:

阿里内部有”差错户”的概念:一旦自动补偿三次失败,资金进入差错户,由清结算人员手工核查。这个设计避免了自动化系统在诡异异常下把问题放大。

7.2 补偿也要幂等

重复补偿是真实存在的——SAGA 协调器重启、MQ 重投、人工按错按钮,都会导致同一个 Cancel 被调两次。补偿接口必须:

7.3 悬挂与空补偿

TCC 的两个经典问题必须显式处理:

可靠消息方案没有这两个问题(因为它不做预留),但多了消息积压消息顺序问题,需要在消费端做好幂等 + 业务状态机。


八、真实案例

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 状态。修复办法:

这条教训放之四海而皆准:补偿链路必须与正向链路做资源隔离

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 NOTHINGSELECT 两步。

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
}

关键点:

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 三种语义

在分布式系统中,纯粹的 exactly-once 投递在工程上不可实现(这已经被 Kafka 的工程团队在 2017 年的 Exactly-once semantics 一文中详细论证)。Kafka 提供的”exactly-once processing”本质是”at-least-once 投递 + 幂等消费 + 事务性写外部系统”三件套共同作用的结果。

11.2 支付系统的等价表述

放到支付语境下,所谓”exactly-once”其实是:

承认这三件事是不同语义,系统设计就会清楚很多。别把 exactly-once 当一个总开关,而是要看在哪一层用什么手段逼近 exactly-once 的业务效果

11.3 消费端幂等的五种实现

五种里最常用的是状态机 + 条件更新。它的好处是去重与业务逻辑共用一张表,不需要额外同步


十二、跨系统一致性模式图

再从架构视角俯视一次,支付链路里跨系统的一致性大致有这四种模式组合:

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]

这张图里每条线都可以对照前文的章节:

整套模式里唯一不可妥协的点是:任何一跳都要能被安全重试。这就是本文反复强调的”幂等是底座”。


十三、性能与观测

13.1 幂等表的热点问题

高 QPS 下,幂等表可能成为瓶颈,尤其在单主库架构里。常见优化:

Stripe 公开过的数字是:幂等表的 QPS 峰值约为 API 请求峰值的 1.3 倍(考虑重试因子),设计容量时按这个系数预留。

13.2 观测指标

一套生产级幂等/事务体系应当对外暴露至少以下指标:

这些指标应在事故复盘时作为第一档证据。没有它们,链路一旦出事你只能靠日志 grep。

13.3 混沌测试

幂等与事务的正确性最怕”在测试里永远不复现”。生产级团队应当定期在预发环境注入:

目标只有一个:混沌结束后,账务表总和与预期完全一致。这既是幂等的终极验收标准,也是第 23 篇对账系统要回答的问题。


十四、与合规的接口

幂等与分布式事务虽然是技术问题,但和合规交叉的点并不少:

合规这条线我们不展开,放到第 21 篇《反洗钱与 KYC》与第 23 篇《对账系统工程》里再讲细节。这里的要点是:幂等与事务设计必须在 Day 1 就考虑可审计性,否则补起来成本惊人。


十五、工程坑点

下面这些坑几乎每个支付团队都踩过,按频次从高到低排:

  1. 幂等表没有唯一索引:只有主键 + 应用层判重,被并发请求绕过。
  2. 幂等响应没缓存 status:第一次返回 202,第二次返回 201,客户端状态机错乱。
  3. 请求指纹算错:把 client_iptimestamp 算进指纹,同 key 不同时间重试被视为冲突。
  4. 状态机用枚举字符串:上下游版本不一致时产生”未知状态”。枚举务必用数字 + 注释文档。
  5. Outbox 不带顺序:下游乱序消费导致”退款先于下单”的事件到达。用 aggregate_id 做 partition key。
  6. CDC 不做 DDL 对齐:业务加字段没通知下游,消费者反序列化挂。Debezium 有 Schema Registry 兜底,务必开启。
  7. SAGA 协调器没有恢复:重启后事件丢失,部分订单永远挂着。必须把状态写库。
  8. 补偿没做限流:补偿风暴打爆下游,扩大故障面。补偿链路用独立线程池 + 独立 MQ 分区。
  9. TCC 没防悬挂:空回滚、悬挂同时出现,业务表出现”冻结却无订单”的记录。
  10. 回调没签名 + 没防重放:伪造回调被当成真实结果,这属于《支付网关设计》的范畴,但在这一层就要留好验签位。

十六、选型建议与落地清单

16.1 场景匹配表

业务 推荐方案 理由
卡支付同步主链路 Idempotency-Key + 状态机 + SAGA 吞吐与一致性平衡
账务核心批扣 XA / TCC 强一致、可审计
异步通知、下游触发 Outbox + CDC + Kafka 解耦、可回放
跨机构转账 TCC + 人工挂账 悬挂可控、合规可查
交易所撮合 → 清结算 本地事务 + 异步消息 延迟极低
商户分账 SAGA Orchestration 步骤多、可视化

16.2 落地清单

  1. 所有写接口必须接受且持久化 Idempotency-Key
  2. 所有业务实体必须有跨租户唯一的业务单号;
  3. 所有状态流转必须走状态机白名单并带乐观锁;
  4. 所有事件发布走 Outbox + CDC,禁止事务内直接发 MQ;
  5. 所有跨服务写优先选 SAGA,次选 TCC,慎选 2PC;
  6. 所有补偿接口与正向接口同等级幂等;
  7. 所有中间态都设置 stuck 告警(通常 1–5 分钟阈值);
  8. 所有自动补偿失败三次挂人工工单,不再自动重试;
  9. 所有对账差错进差错户,不进正常账户(接《对账系统工程》);
  10. 所有事故复盘回填到 Idempotency-Key / 状态机白名单 / 补偿策略,形成闭环。

十七、与其他篇章的交叉引用


参考资料

  1. Stripe Engineering Blog, Designing robust and predictable APIs with idempotency
  2. Stripe API Reference, Idempotent requests
  3. Hector Garcia-Molina, Kenneth Salem, SAGAS, ACM SIGMOD 1987
  4. Pat Helland, Life beyond Distributed Transactions: an Apostate’s Opinion, ACM Queue 2016
  5. Apache RocketMQ, Transaction Message
  6. Debezium Documentation, Outbox Event Router
  7. Temporal Documentation, Sagas
  8. Seata 官方文档,TCC 模式
  9. DTM 文档,分布式事务实践
  10. X/Open, Distributed Transaction Processing: The XA Specification

上一篇《账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户》

下一篇《支付系统全景:收单、发卡、聚合、跨境、B2B、C2C》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放

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

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。


By .