土法炼钢 · 系统与基础设施

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

文章导航

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

金融科技系列导航

按系列顺序继续阅读,而不是停在单篇。

系列目录上一篇:【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户下一篇:【金融科技工程】支付系统全景:收单、发卡、聚合、跨境、B2B、C2C

源码下载

本文相关源码已整理,共 1 个文件。

打开下载目录 →

目录

“这笔钱好像扣了两次”“钱扣了但订单没生成”“退款退了两次”——支付系统的很多工单最后都落到三个词上:幂等(Idempotency)、事务(Transaction)、一致性(Consistency)。网络不可靠导致重试,重试要求幂等;跨服务写要求事务;分布式事务做不到廉价的强一致,就要定义一致性语义,再用补偿把状态收敛回去。

本文面向设计支付、账务、订单系统的后端工程师,不展开”什么是幂等”的名词解释,而是沿一条支付下单链路逐跳回答:哪一步必须幂等、用什么兜底、出错了怎么收敛。不展开的话题:共识协议本身、跨境清算报文细节。

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


一、为什么支付必须幂等

1.1 网络永远不可靠

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

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

1.2 不幂等的真实损失

缺少幂等时,重复请求会落成三类典型资金问题:

这类问题难缠的原因不在代码量,而在触发条件:它们只在超时、并发重试、进程崩溃恰好落在某个窗口时出现,常规功能测试几乎覆盖不到。Warszawski 与 Bailis 在 2017 年的 ACIDRain 研究中对 12 个电商应用做并发攻击,找到 22 个可利用的并发异常,说明”应用层判重 + 弱隔离级别”在真实代码里很常见。

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"}

服务端收到后:

这两个状态码取自 IETF HTTPAPI 工作组的 The Idempotency-Key HTTP Header Field 草案(draft-07 第 2.7 节)。该草案目前仍是 Internet-Draft,没有成为 RFC,引用时应视为”业界共识的整理”而非强制标准。

Stripe 的官方文档写明:它保存某个 key 第一次请求的状态码和响应体,无论成功失败,包括 500;key 在至少 24 小时后可能被清理,清理后再用同一 key 会被当成新请求;参数与原请求不一致会报错;参数校验失败或与并发中的同 key 请求冲突时不保存结果,客户端可以重试。超过保留期的重试由业务层(见 2.2)兜底。

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

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

当业务系统收到一个已经存在的 out_trade_no 时,如果请求参数与第一次一致,应返回和第一次一致的结果,而不是报错”订单已存在”;如果同一 out_trade_no 带着不同金额再来,就不是重试而是客户端错误,应拒绝并保留第一次的订单不动。

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(异步),重试时业务状态已经推进,你要么原样返回 202(严格重放),要么返回当前状态。Stripe 文档描述的是前者:同一 key 返回第一次保存的结果;想拿最新状态应调用 GET。

下图把服务端一个 key 的生命周期画成状态机。PROCESSING 带租约(lease),持有者崩溃后租约过期,后来的重复请求可以接管;接管存在”旧持有者其实还活着”的僵尸风险,因此业务表的唯一约束必须保留。

Idempotency-Key 服务端状态机:ABSENT、PROCESSING、COMPLETED 三态,并发重复返回 409,指纹不一致返回 422,过期清理后由业务唯一键兜底

3.2 并发与竞态

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

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

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

-- PostgreSQL:INSERT ... ON CONFLICT(MySQL 用 INSERT IGNORE 后检查 affected rows)
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 key_hash;

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

3.3 与重试 backoff 的互动

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

工程上推荐:

3.4 Idempotency-Key 的 TTL 与清理

幂等表会迅速膨胀。保留期至少要覆盖客户端的最长重试窗口;Stripe 文档给出的是”至少 24 小时”,自建系统常按对账周期取更长。清理方式:

TiDB 从 v6.5 起支持行级 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,
    event_id        VARCHAR(64) NOT NULL,   -- 上游事件 ID
    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_id)
);

跃迁用乐观锁保护:

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_id) 的作用。事件插入与 payment_state 的条件更新放在同一个本地事务里,重复事件触发唯一冲突,整笔回滚后直接丢弃。

把状态机与 Idempotency-Key 并列理解:前者保护跨事件的一致性,后者保护同一事件的幂等性,两者是正交的。


五、分布式事务方案与谱系

单机事务的 ACID 在跨服务、跨数据库时要付出协调成本。CAP 定理(Gilbert & Lynch 2002 给出证明)说的是:发生网络分区时,系统无法同时保证线性一致和每个请求都得到非错误响应;它不是”平时三选二”,而是分区期间的取舍。工程上更常用的是按业务需要选择一致性强度,BASE(Basically Available, Soft state, Eventually consistent)是其中偏可用的一端。

今天支付系统里的几种模式并非凭空出现,它们分别来自三条研究线:原子提交(2PC/XA)、补偿(Sagas/TCC)、幂等消息与 API。

支付一致性模式谱系:1978 Gray 2PC、1987 Sagas、1991 XA、2007 Helland、2014 TCC for REST、2017 ACIDRain 与 Stripe、2019 Debezium outbox、2022 Seata tcc_fence_log、2025 IETF draft-07

下面把主流方案逐一展开。

5.1 两阶段提交(2PC / XA)

2PC 的流程最早由 Gray 在 1978 年的 Notes on Data Base Operating Systems 中系统描述:协调者先向所有参与者发 Prepare,全部 OK 后发 Commit,任一 Prepare 失败则发 Rollback。

工程落地里它的代表是 XA 协议(X/Open 1991 年发布的 XA 规范),Oracle、DB2、MySQL(InnoDB)、PostgreSQL(PREPARE TRANSACTION)等数据库都支持。

2PC 的硬伤:

结论:2PC 适合参与者少、网络可控、对强一致要求高的场景,比如同一机房内的批处理;面向互联网的跨服务支付链路较少直接使用 XA。

5.2 TCC(Try-Confirm-Cancel)

TCC 把”预留—确认—取消”的业务协议显式化;Pardon 与 Pautasso 在 2014 年把它整理为面向 REST 服务的 Atomic Distributed Transactions: a RESTful Design。每个参与者实现三个接口:

协调者收到业务请求后:

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

TCC 的优点:

代价:

开源实现:Seata 的 TCC 模式、DTM 的 TCC。

5.3 SAGA

SAGA 概念来自 Garcia-Molina 与 Salem 1987 年的 SIGMOD 论文 Sagas,核心思想是把长事务拆成一串短事务,每个短事务有对应的补偿事务。成功则顺序推进,失败则逆序补偿。论文同时指出代价:Saga 之间没有隔离,中间状态对外可见。

SAGA 有两种协调方式:

对比:

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

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

5.4 可靠消息最终一致性

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

5.5 四种方案的选型矩阵

方案 一致性 吞吐 侵入性 运维复杂度 典型场景
2PC/XA 强 低 低(数据库协议级) 高(悬挂恢复) 银行核心批扣
TCC 最终(Try 预留提供业务级隔离) 中 高 中 需要资源冻结的扣款、额度占用
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 消息,完全不需要业务代码主动发消息。好处:

代价:

Debezium 为此提供了 Outbox Event Router 单消息转换,按 aggregatetype 路由 topic、以 aggregateid 作为消息 key,同一聚合的事件因此落在同一分区、保持顺序。


七、补偿策略

7.1 自动补偿 vs 人工介入

SAGA 和 TCC 都要求补偿可自动执行,但”能自动”不等于”应当自动”。一个可执行的分流规则:

自动补偿重试达到上限后,应把资金挂到独立的差错/暂挂科目,由清结算人员核查,而不是无限重试。这样做把”未知”显式记账,避免自动化在异常输入下把问题放大。

7.2 补偿也要幂等

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

7.3 空回滚、悬挂与防悬挂

TCC 的两个经典问题必须显式处理,下图对比了有无防悬挂记录时的时序:

TCC 空回滚与悬挂:(a) 无 fence 时迟到的 Try 冻结资金且永不释放;(b) Seata tcc_fence_log 下 Cancel 先插入 SUSPENDED 记录,迟到的 Try 插入时主键冲突被拒绝

Seata 从 1.5.1 起提供 tcc_fence_log 表实现这套逻辑(见 Seata TCC 文档):以 (xid, branch_id) 为主键,Cancel 发现无记录时插入状态为 SUSPENDED 的行并跳过业务撤销;迟到的 Try 插入 TRIED 行时撞主键而失败;同一行还让重复的 Confirm/Cancel 看到已完成状态后直接返回。关键前提是:fence 记录与业务变更在同一个本地事务里提交,否则两者之间仍有窗口。

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

7.4 补偿链路的资源隔离

大促后集中退款这类流量尖峰,最容易暴露的问题是补偿链路与正向链路共用线程池和连接池:正向请求打满下游后,补偿请求也排不上队,订单停在 Refunding 这类中间态。可执行的隔离手段:


八、代码示例

下面给出一个最小可用的工程骨架。语言选 Go,因为它更贴近支付网关团队的主流技术栈;Java 的等价实现思路一致,只是换成 Spring Boot + MyBatis。

8.1 Idempotency-Key 中间件(Go)

package idem

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/json"
    "errors"
    "io"
    "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, err := hashBody(r)
        if err != nil {
            http.Error(w, "cannot read body", http.StatusBadRequest)
            return
        }
        keyHash := sha256.Sum256([]byte(key))
        acquired, cached, err := m.store.Acquire(r.Context(), keyHash[:], bodyHash, m.ttl)
        if errors.Is(err, ErrConflict) {
            http.Error(w, "idempotency key reused with different payload", http.StatusUnprocessableEntity)
            return
        }
        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())
    })
}

// hashBody 读出 body 计算 SHA-256 后放回,供后续 handler 再读。
// 生产实现应先按字段白名单做 JSON 规范化(见 3.1),并用 http.MaxBytesReader 限制大小。
func hashBody(r *http.Request) ([]byte, error) {
    b, err := io.ReadAll(r.Body)
    if err != nil {
        return nil, err
    }
    r.Body = io.NopCloser(bytes.NewReader(b))
    sum := sha256.Sum256(b)
    return sum[:], 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(为聚焦逻辑,这里假设 key_hash 已由 tenant、endpoint 与原始 key 共同哈希得到,表中省略了 tenant_id、endpoint 两列):

func (p *PgStore) Acquire(ctx context.Context, key, reqHash []byte, ttl time.Duration) (bool, *Response, error) {
    // 第一步:尝试占位。RETURNING 只有在本次真正插入时才返回行。
    var inserted []byte
    err := p.db.QueryRowContext(ctx, `
        INSERT INTO idempotency_keys (key_hash, request_hash, status, expires_at)
        VALUES ($1, $2, 0, now() + make_interval(secs => $3))
        ON CONFLICT (key_hash) DO NOTHING
        RETURNING key_hash
    `, key, reqHash, ttl.Seconds()).Scan(&inserted)
    if err == nil {
        return true, nil, nil
    }
    if !errors.Is(err, sql.ErrNoRows) {
        return false, nil, err
    }

    // 第二步:已存在,读出原记录判断。
    var (
        storedHash []byte
        status     int
        body       sql.NullString
    )
    err = p.db.QueryRowContext(ctx, `
        SELECT request_hash, status, response_body::text
        FROM idempotency_keys WHERE key_hash = $1
    `, key).Scan(&storedHash, &status, &body)
    if errors.Is(err, sql.ErrNoRows) {
        return false, nil, nil // 刚被清理,让客户端重试
    }
    if err != nil {
        return false, nil, err
    }
    if !bytes.Equal(storedHash, reqHash) {
        return false, nil, ErrConflict
    }
    if status == 0 {
        return false, nil, nil // processing
    }
    var resp Response
    if err := json.Unmarshal([]byte(body.String), &resp); err != nil {
        return false, nil, err
    }
    return false, &resp, nil // done 或 failed,都重放保存的响应
}

两步法的要点是用 RETURNING 是否有行来区分”本实例刚插入”与”已有记录”。曾经流行的写法 ON CONFLICT DO UPDATE SET key_hash = EXCLUDED.key_hash RETURNING ... 在冲突时也会返回行,若再用”status=0 且指纹相同”推断”是我插入的”,就会把另一个实例正在处理的请求误判成自己抢到了锁,导致同一请求执行两次。这段代码没有实现 3.2 节提到的租约接管,持有者崩溃后的 status=0 记录要靠 locked_until 字段或后台任务回收。

8.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
}

关键点:

8.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。

8.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 已经把这一层做好了,自己写只建议在教学或极小规模场景。

8.5 完整流程图

下单 → 扣库存 → 扣账户 → 通知 的 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”(恰好一次)几乎是所有工程师都希望获得却又容易误解的承诺。我们先把它讲清楚再继续。

9.1 三种语义

要区分”投递”与”处理效果”。在发送方无法确认对端是否收到的网络里,单靠传输层无法保证每条消息恰好送达一次;能做到的是让重复投递不产生重复效果。Confluent 2017 年的 Exactly-once Semantics Are Possible: Here’s How Kafka Does It 介绍的 Kafka 0.11 方案正是如此:幂等生产者(producer id + 序号去重)+ 跨分区事务 + read_committed 消费,保证的是 Kafka 内部”读—处理—写”的恰好一次效果;一旦副作用写到 Kafka 之外(数据库、银行接口),仍要靠下游自己幂等。Helland 2007 年在 Life beyond Distributed Transactions 中把这一点总结为:跨实体的消息至少投递一次,接收方必须记住处理过什么。

9.2 支付系统的等价表述

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

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

9.3 消费端幂等的五种实现

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

9.4 跨系统一致性模式图

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

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]

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

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


十、性能、观测与合规

10.1 幂等表的热点问题

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

幂等表的访问量至少等于带 key 的写请求量,再乘上重试放大系数;这个系数取决于客户端退避策略,应从 idempotency.hit_rate 指标实测,而不是套用别人的经验值。

10.2 观测指标

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

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

10.3 混沌测试

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

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

10.4 与合规的接口

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

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


十一、工程坑点与落地清单

11.1 十个常见坑

下面这些坑都能直接对应到前文某一节的机制:

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

11.2 场景匹配表

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

11.3 落地清单

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

11.4 与其他篇章的交叉引用

架构层(通用幂等模式) 与 金融科技层(支付语义) 应分开读、合在一起落地:


十二、争论与开放问题

2PC 真的该被抛弃吗? 微服务社区常说”不要用分布式事务”,但 Laigner 等人 2021 年对开源微服务应用的实证研究(VLDB)发现,开发者回避 2PC 后往往用临时拼凑的应用层逻辑处理一致性,正确性没有保障。反过来,Gray 与 Lamport 的 Paxos Commit、以及 Spanner 这类把 2PC 建在复制组之上的系统,说明阻塞问题可以在基础设施层解决。对支付团队而言,真正的问题不是”2PC 好不好”,而是协调成本应该由数据库承担还是由业务代码承担。

Saga 缺乏隔离怎么办? Garcia-Molina 与 Salem 当年就承认 Saga 不提供隔离;Frank 与 Zahle 1998 年提出语义锁、可交换更新、悲观视图等”对策”(countermeasures),但这些对策都要求业务方逐个分析读写冲突。ACIDRain 一类研究表明,应用层自己实现的隔离恰恰是并发漏洞的高发区。目前没有通用、自动化的方法验证一组 Saga 补偿在并发下仍保持业务不变量。

Idempotency-Key 会标准化到什么程度? IETF 草案在 2025 年推进到 draft-07 后已过期,失败请求是否保存结果、保留期多长、并发重复返回什么,各家仍有分歧——Stripe v1 连 500 也重放,而 v2 API 对同 key 的失败请求会重新执行且不产生重复副作用。自建系统应把这些选择写进 API 文档,而不是假设客户端”知道”。

exactly-once 的边界在哪里? Kafka 事务只覆盖 Kafka 内部的读写;把副作用延伸到外部数据库或银行接口时,仍要回到 Helland 的结论:至少一次投递 + 接收方去重。如何在跨组织、无共享事务日志的链路上做端到端去重,目前只能靠业务单号与对账兜底。


参考资料

规范与官方文档(A 级)

  1. Stripe API Reference, Idempotent requests
  2. Jena, J. & Dalal, S., The Idempotency-Key HTTP Header Field, IETF HTTPAPI Internet-Draft, draft-07
  3. X/Open, Distributed Transaction Processing: The XA Specification, 1991
  4. Apache Seata, TCC 模式(含 tcc_fence_log 防悬挂)
  5. Apache RocketMQ, Transaction Message
  6. Debezium Documentation, Outbox Event Router
  7. PingCAP, Periodically Delete Data Using TTL
  8. Temporal Documentation, Saga pattern

论文(A 级)

  1. Gray, J., Notes on Data Base Operating Systems, in Operating Systems: An Advanced Course, LNCS 60, Springer, 1978
  2. Garcia-Molina, H. & Salem, K., Sagas, ACM SIGMOD 1987
  3. Frank, L. & Zahle, T. U., Semantic ACID Properties in Multidatabases Using Remote Procedure Calls and Update Propagations, Software: Practice and Experience, 1998
  4. Gilbert, S. & Lynch, N., Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services, ACM SIGACT News, 2002
  5. Gray, J. & Lamport, L., Consensus on Transaction Commit, ACM TODS, 2006
  6. Helland, P., Life beyond Distributed Transactions: an Apostate’s Opinion, CIDR 2007(ACM Queue 2016 年重刊)
  7. Pardon, G. & Pautasso, C., Atomic Distributed Transactions: a RESTful Design, WWW 2014 Companion
  8. Warszawski, T. & Bailis, P., ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications, ACM SIGMOD 2017
  9. Laigner, R. et al., Data Management in Microservices: State of the Practice, Challenges, and Research Directions, PVLDB 14(13), 2021

工程博客(B 级)

  1. Leach, B., Designing robust and predictable APIs with idempotency, Stripe Blog, 2017
  2. Narkhede, N., Exactly-once Semantics Are Possible: Here’s How Kafka Does It, Confluent Blog, 2017

站内

  1. 系统架构 · 幂等性设计、支付网关设计、支付系统全景

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

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

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-04-22 · architecture / fintech

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

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

2026-04-22 · architecture / fintech

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

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


By .