Skip to content

分布式事务场景(Distributed Transaction Scenarios) ​

当系统被拆成多个服务、每个服务有自己的数据库时,「一次业务操作要同时改多个库」就成了常态。这条路上有几条主流技术路线:XA/2PC、Saga、TCC、Outbox 本地消息表、CDC 变更捕获。它们没有银弹——分别用一致性强度换取可用性、复杂度或实时性。

本页讲清每种方案的原理、落地方式、适用边界,给出选型决策树,最后用「订单 + 库存」的经典例子演示最终一致方案的完整实现。

前置知识

阅读本节前,建议先了解:两阶段提交原理、PREPARE TRANSACTION / COMMIT PREPARED

基础概念 ​

分布式事务的核心矛盾 ​

先想清楚问题本质。跨库操作之所以难,是因为「本地事务的原子性」无法跨网络传递:

text
同一进程内的两个操作:
  操作 A(改库 1)  成功
  操作 B(改库 2)  成功
  → 如果中间崩了?A 已生效,B 没做 → 不一致

本地事务能解决这个,靠的是「一个原子提交点」。
但跨库时,库 1 和库 2 各自有自己的提交点,没有共同的原子点。

CAP 定理告诉我们:网络分区(Partition)不可避免时,只能在一致性(Consistency)与可用性(Availability)之间取舍。分布式事务的各路方案,本质就是在回答「你愿意为一致性付多少代价」。

五条技术路线与其取舍 ​

方案一致性可用性实现复杂度对数据库侵入适用场景
XA / 2PC强一致低(阻塞式)中(依赖事务管理器)高(需开启 prepared 事务)短事务、强一致要求、同构数据库
Saga最终一致高高(要写补偿)低长流程、跨服务、可补偿业务
TCC最终一致高很高(三阶段都需实现)低资金类、需资源预留
Outbox 本地消息表最终一致高中中(需额外表 + 投递器)事件驱动的服务解耦
CDC(Debezium 等)最终一致高中低(读 WAL)已有数据同步基础设施

「强一致」与「最终一致」的实际差异 ​

text
强一致(2PC):
  T 时刻提交 → T+ε 所有库立即一致
  代价:任一参与者不可用,整个事务都提交不了;协调者挂则悬挂

最终一致(Saga/Outbox/CDC):
  T 时刻提交 → T+Δ 才全部一致(Δ 可能是毫秒~秒级)
  代价:中间窗口内数据不一致;需要处理补偿与幂等

大多数互联网业务能接受毫秒到秒级的最终一致。真正需要 2PC 的场景其实很窄:同构数据库、短事务、强一致不可妥协(如金融核心的账务记账)。

PostgreSQL 在这条链路里的位置 ​

PostgreSQL 只能做参与者,不做协调者。因此所有方案都要回答「谁来当协调者、协调者如何持久化决策」:

text
XA / 2PC:协调者 = 外部事务管理器(Narayana、Atomikos、Seata、Spring JtaTransactionManager)
          → 用 PostgreSQL 的 PREPARE TRANSACTION 作为参与者接口

Saga:   协调者 = 业务编排器(代码里的状态机)
          → PostgreSQL 只负责存状态与补偿数据

TCC:    协调者 = 业务代码(Try/Confirm/Cancel 三接口)
          → PostgreSQL 存预留资源

Outbox: 协调者 = 应用内的「投递器」(poller)
          → PostgreSQL 存 outbox 表,用本地事务保证事件与业务同生共死

CDC:    协调者 = 消息中间件 + 消费方
          → PostgreSQL 只负责产生 WAL,由 Debezium 读取

语法与用法 ​

选择方案的伪代码决策 ​

text
需要跨多个库/服务同时修改数据?

├─ 否 → 用普通 PostgreSQL 事务(BEGIN ... COMMIT),不要引入分布式方案
│
└─ 是 → 事务是否「短小、快速、同构数据库」?
        │
        ├─ 是 → 强一致是否不可妥协?
        │        ├─ 是 → XA / 2PC(PREPARE TRANSACTION)
        │        └─ 否 → Outbox 或 CDC(更简单、可用性更高)
        │
        └─ 否(长流程、异构、跨服务)
                 │
                 ├─ 每一步都能写出「反向操作」? → Saga
                 ├─ 涉及资金、需要资源预占?    → TCC
                 └─ 只是「改了数据后要通知别人」?→ Outbox / CDC

统一约定:幂等键 ​

无论哪种方案,幂等(Idempotency) 都是地基。推荐每张业务表都带上幂等键:

sql
CREATE TABLE orders (
    id           bigserial PRIMARY KEY,
    request_id   text NOT NULL,
    user_id      bigint NOT NULL,
    sku          text NOT NULL,
    qty          integer NOT NULL CHECK (qty > 0),
    status       text NOT NULL DEFAULT 'created',
    created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX uq_orders_request_id ON orders (request_id);
sql
-- 幂等插入:重复请求不会产生第二行
INSERT INTO orders(request_id, user_id, sku, qty)
VALUES ('req-abc-001', 8891, 'SKU-8891', 2)
ON CONFLICT (request_id) DO NOTHING
RETURNING id;

观察 prepared 事务的模板查询 ​

sql
SELECT gid, owner, database, prepared, now() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared;
text
      gid       | owner | database |           prepared            |     age
----------------+-------+----------+-------------------------------+--------------
 xa-8f31a2c4    | xa    | shop     | 2026-09-28 16:01:22.114+08    | 00:00:03.221
(1 row)

详细说明 ​

XA / JTA:Java 生态的标准方案 ​

JTA(Java Transaction API) 是 Java 里的事务管理器接口;XA 是数据库侧的协议。二者配合,让 Java 应用能跨多个数据源执行 2PC。

text
Spring JtaTransactionManager
   ├── 数据源 1(PostgreSQL,支持 XA)→ PREPARE / COMMIT PREPARED
   ├── 数据源 2(PostgreSQL,支持 XA)→ PREPARE / COMMIT PREPARED
   └── 消息队列(如支持 XA 的 MQ)

数据库配置:

ini
# postgresql.conf —— XA 必需的参数
max_prepared_transactions = 200
sql
-- JDBC 连接串中要声明支持 XA
-- jdbc:postgresql://db1:5432/shop?prepareThreshold=5

典型 Java 侧形态(伪代码,展示语义而非可运行片段):

text
@Transactional  // JtaTransactionManager 接管
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountRepoA.debit(fromId, amount);    // 数据源 A
    accountRepoB.credit(toId, amount);     // 数据源 B
    // 提交时:TM 先对 A、B 各发一个 prepare(等价 PREPARE TRANSACTION)
    //        全部就绪后,再对 A、B 各发 commit(等价 COMMIT PREPARED)
}

关键配置点:

text
1. 事务管理器必须持久化决策日志(否则它会成为新的单点)
   - Narayana / Atomikos 默认持久化到文件或数据库
2. 默认事务超时(如 60s)要覆盖最慢参与者的响应时间
3. PostgreSQL 侧的 max_prepared_transactions 要足够大(>= 并发全局事务数)
4. 需要监控 prepared 事务数量与年龄,识别卡住的全局事务

XA 的实践代价 ​

text
代价 1:阻塞式协议
  任一参与者在 prepare 后不可用 → 整个全局事务卡住

代价 2:悬挂事务运维
  协调者崩溃/丢日志 → 参与者永久 prepared
  → 见「两阶段提交原理」中的悬挂事务章节

代价 3:性能
  每个全局事务要多一轮 RTT(prepare 阶段)+ 一次额外 fsync

代价 4:连接占用
  prepared 事务持有资源,连接池要预留余量

代价 5:异构兼容性
  不同数据库的 XA 实现水平参差,跨厂商组合常出问题

因此 XA 的适用面其实很窄:同厂商、短事务、强一致刚需。

Saga:把大事务拆成「一连串本地事务 + 补偿」 ​

Saga 的核心思想:把一个长流程拆成 N 个本地事务 T1..Tn,同时为每个 Ti 准备一个补偿操作 Ci。任何一步失败,就反向执行已完成的补偿。

text
正向:T1 → T2 → T3 → T4
失败:T1 → T2 → T3 ✗
补偿:      C2 ← C1        (C3 无需执行,T3 未生效)

两种编排方式:

方式谁来驱动特点
编排(Choreography)各服务监听事件、自行决策去中心化,但流程分散难追踪
协同(Orchestration)中心化编排器(状态机)流程集中可观测,但编排器是核心组件

PostgreSQL 里的 Saga 状态表:

sql
CREATE TABLE saga_instances (
    saga_id      text PRIMARY KEY,
    saga_type    text NOT NULL,
    status       text NOT NULL,          -- running / completed / compensating / failed
    current_step text,
    payload      jsonb NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE saga_steps (
    saga_id     text NOT NULL REFERENCES saga_instances(saga_id),
    step_name   text NOT NULL,
    status      text NOT NULL,           -- done / compensated
    result      jsonb,
    executed_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (saga_id, step_name)
);

Saga 的语义陷阱:补偿不是回滚 ​

text
数据库回滚:完全抹掉,如同没发生过。
Saga 补偿:追加一个「逆操作」,历史痕迹仍在。

例:扣了 100 元,补偿是「退回 100 元」,而不是「让扣款不存在」。
    → 账本上会看到两条记录(扣款 + 退款),而不是一片空白。

由此推出三个必须处理的问题:

text
1. 补偿本身可能失败 → 补偿也要重试、也要幂等、也要有中间态
2. 没有隔离性 → 中间状态对外可见,需要业务层用「预留/冻结」等手法遮蔽
3. 补偿不可逆时无解 → 已发出的短信、已存的纸质单据无法撤销
   → 这类步骤要么放到最后,要么设计成「可撤销的软动作」

TCC:Try-Confirm-Cancel ​

TCC(Try-Confirm-Cancel) 是 Saga 的一种强化形态,把每个参与者的操作显式拆成三个阶段:

text
Try     :预留资源(冻结 100 元,而非直接扣款)
Confirm :确认使用(把冻结转为实际扣款)
Cancel  :释放预留(解冻 100 元)

对比 Saga:

维度SagaTCC
隔离性无(中间状态可见)有(Try 阶段就锁定了资源)
补偿语义逆操作释放预留
实现工作量中(写补偿)高(三阶段都要写)
适用长流程、弱隔离可接受资金、库存等需严格预留

PostgreSQL 侧的 TCC 表设计:

sql
CREATE TABLE tcc_transactions (
    tx_id        text PRIMARY KEY,
    status       text NOT NULL,     -- trying / confirming / confirmed / cancelling / cancelled
    created_at   timestamptz NOT NULL DEFAULT now(),
    expires_at   timestamptz NOT NULL
);

CREATE TABLE account_freeze (
    tx_id       text NOT NULL,
    account_id  bigint NOT NULL,
    amount      numeric(14,2) NOT NULL CHECK (amount > 0),
    status      text NOT NULL DEFAULT 'frozen',   -- frozen / confirmed / cancelled
    PRIMARY KEY (tx_id, account_id)
);

Try 阶段(冻结):

sql
BEGIN;
-- 检查可用余额(余额 - 已冻结)
INSERT INTO account_freeze(tx_id, account_id, amount)
SELECT 'tx-001', 8891, 100
WHERE (SELECT balance FROM accounts WHERE id = 8891)
      - (SELECT coalesce(sum(amount),0) FROM account_freeze
          WHERE account_id = 8891 AND status = 'frozen') >= 100;
-- 若影响 0 行,说明余额不足,Try 失败
COMMIT;

Confirm 阶段(确认):

sql
BEGIN;
UPDATE account_freeze SET status = 'confirmed'
 WHERE tx_id = 'tx-001' AND status = 'frozen';

UPDATE accounts SET balance = balance - 100 WHERE id = 8891;

UPDATE tcc_transactions SET status = 'confirmed' WHERE tx_id = 'tx-001';
COMMIT;

Cancel 阶段(解冻):

sql
BEGIN;
UPDATE account_freeze SET status = 'cancelled'
 WHERE tx_id = 'tx-001' AND status = 'frozen';
UPDATE tcc_transactions SET status = 'cancelled' WHERE tx_id = 'tx-001';
COMMIT;

TCC 的隐藏难点:空回滚与悬挂

  • 空回滚:Cancel 比 Try 先到达(网络乱序)→ Cancel 找不到预留记录,必须记录「已取消」状态,否则后续迟到的 Try 会留下永久冻结。
  • 悬挂:Try 因超时被判失败、协调者发了 Cancel,但 Try 请求随后才到达并成功 → 资源被冻结却无人确认。 解法通常是「事务状态表 + 状态机校验」:Try 前先检查该 tx_id 是否已 cancelled。

Outbox 本地消息表:用本地事务保证「业务 + 事件」原子 ​

Outbox(本地消息表) 是工程上性价比最高的方案。核心洞察是:不要试图让「改业务数据」和「发消息」原子,而是把消息写进同一张库的一张表,用本地事务保证它们原子,再异步投递。

text
问题:
  BEGIN; 更新订单; COMMIT;  → 发送 MQ 消息   ✗ 中间崩溃 → 订单改了,消息没发

Outbox:
  BEGIN; 更新订单; INSERT INTO outbox(...); COMMIT;   ← 一个本地事务,原子
  → 后台投递器读 outbox,发 MQ,标记已发送(幂等)

表结构:

sql
CREATE TABLE outbox (
    id            bigserial PRIMARY KEY,
    aggregate_type text NOT NULL,
    aggregate_id   text NOT NULL,
    event_type     text NOT NULL,
    payload        jsonb NOT NULL,
    status         text NOT NULL DEFAULT 'pending',   -- pending / sent / failed
    retry_count    integer NOT NULL DEFAULT 0,
    created_at     timestamptz NOT NULL DEFAULT now(),
    sent_at        timestamptz
);

CREATE INDEX idx_outbox_pending ON outbox (created_at)
    WHERE status = 'pending';

-- 同一聚合的同一事件只产生一次(幂等键)
CREATE UNIQUE INDEX uq_outbox_dedup
    ON outbox (aggregate_type, aggregate_id, event_type, (payload->>'version'));

业务与事件同事务写入:

sql
BEGIN;
INSERT INTO orders(request_id, user_id, sku, qty, status)
VALUES ('req-abc-002', 8891, 'SKU-8891', 2, 'created');

INSERT INTO outbox(aggregate_type, aggregate_id, event_type, payload)
VALUES ('order',
        (SELECT id::text FROM orders WHERE request_id = 'req-abc-002'),
        'order.created',
        jsonb_build_object('sku', 'SKU-8891', 'qty', 2, 'version', 1));
COMMIT;

投递器(用 FOR UPDATE SKIP LOCKED 并发安全地取任务):

sql
WITH batch AS (
    SELECT id FROM outbox
     WHERE status = 'pending'
     ORDER BY id
     LIMIT 100
     FOR UPDATE SKIP LOCKED
)
UPDATE outbox o
   SET status = 'sent', sent_at = now()
  FROM batch b
 WHERE o.id = b.id
RETURNING o.id, o.aggregate_type, o.aggregate_id, o.event_type, o.payload;

投递失败的重试与死信:

sql
-- 失败:累加重试次数
UPDATE outbox
   SET retry_count = retry_count + 1,
       status = CASE WHEN retry_count + 1 >= 5 THEN 'failed' ELSE 'pending' END
 WHERE id = 12345;

为什么 Outbox 优于 2PC

它把「分布式一致性」降维成了「本地事务 + 异步重试」。没有 prepared 事务、没有悬挂、没有阻塞式协议,可用性高得多。代价是最终一致(存在投递延迟)和至少一次投递(消费方必须幂等)。

CDC:从 WAL 里直接读变更 ​

CDC(Change Data Capture,变更数据捕获) 比 Outbox 更「无侵入」——它不要求业务写 outbox 表,而是直接读 PostgreSQL 的 WAL 日志。

text
PostgreSQL(logical decoding)→ Debezium → Kafka → 下游消费者

前提条件:

ini
# postgresql.conf
wal_level = logical                     # 必须为 logical
max_replication_slots = 10
max_wal_senders = 10
sql
-- 创建逻辑复制槽
SELECT * FROM pg_create_logical_replication_slot('dbz_slot', 'pgoutput');
sql
-- 查看复制槽的滞后情况(危险信号:不活跃的槽会导致 WAL 无限堆积)
SELECT slot_name, plugin, slot_type, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
text
 slot_name |  plugin  | slot_type | active | retained_wal
-----------+----------+-----------+--------+--------------
 dbz_slot  | pgoutput | logical   | t      | 48 MB
(1 row)

CDC 与 Outbox 的取舍:

维度OutboxCDC
业务代码侵入需要写 outbox 表无(读 WAL)
事件语义业务语义清晰(自定义事件)表级变更(需下游还原业务语义)
历史数据无法获取(只发新事件)可配合快照获取全量
依赖只需数据库需 Debezium/Kafka 等基础设施
运维风险低复制槽不活跃会导致 WAL 堆积撑爆磁盘
事务一致性本地事务保证逻辑解码保证

逻辑复制与 2PC 的兼容性坑 ​

如果用 CDC 处理来自 2PC 的事务,要注意官方提示的兼容性问题:

text
基础回调(base callbacks)下:
  PREPARE TRANSACTION  → 被忽略
  COMMIT PREPARED      → 解码为普通 COMMIT
  ROLLBACK PREPARED    → 解码为普通 ROLLBACK

这意味着默认情况下,CDC 无法正确表达 2PC 的两阶段语义。要让输出插件正确解码,需要实现 begin_prepare_cb、prepare_cb、commit_prepared_cb、rollback_prepared_cb、stream_prepare_cb 及可选的 filter_prepare_cb 回调。

另有官方提示的死锁风险:如果 prepared 事务排他锁定了用户目录表(user catalog tables),解码 prepare 可能阻塞到主事务提交,甚至可能死锁。

方案对比总结 ​

需求特征推荐方案关键理由
单库操作普通事务不要过度设计
同构数据库、短事务、强一致XA / 2PC唯一能提供强一致的方案
长流程、跨服务、可补偿Saga高可用,补偿实现相对直接
资金/库存、需资源预留TCC隔离性最好,但要写三阶段
事件驱动、服务解耦Outbox实现简单、可用性高
已有 CDC 基础设施CDC对业务零侵入
只需「最终一致」,且能接受秒级延迟Outbox / CDC多数互联网业务的最优选

实战示例 ​

端到端:订单 + 库存的 Outbox 最终一致方案 ​

表结构(订单库)。

sql
CREATE TABLE orders (
    id         bigserial PRIMARY KEY,
    request_id text NOT NULL UNIQUE,
    user_id    bigint NOT NULL,
    sku        text NOT NULL,
    qty        integer NOT NULL CHECK (qty > 0),
    status     text NOT NULL DEFAULT 'created',
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id             bigserial PRIMARY KEY,
    aggregate_type text NOT NULL,
    aggregate_id   text NOT NULL,
    event_type     text NOT NULL,
    payload        jsonb NOT NULL,
    status         text NOT NULL DEFAULT 'pending',
    retry_count    integer NOT NULL DEFAULT 0,
    created_at     timestamptz NOT NULL DEFAULT now(),
    sent_at        timestamptz
);

CREATE INDEX idx_outbox_pending ON outbox (created_at) WHERE status = 'pending';

下单:业务与事件同事务写入。

sql
BEGIN;

INSERT INTO orders(request_id, user_id, sku, qty)
VALUES ('req-1001', 8891, 'SKU-8891', 2)
ON CONFLICT (request_id) DO NOTHING
RETURNING id, request_id;
text
 id | request_id
----+------------
  1 | req-1001
(1 row)
sql
INSERT INTO outbox(aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', '1', 'order.created',
        jsonb_build_object('order_id', 1, 'sku', 'SKU-8891', 'qty', 2));

COMMIT;
text
INSERT 0 1
COMMIT

库存服务消费并扣减(幂等)。

sql
-- 库存服务侧
CREATE TABLE processed_events (
    event_id   bigint PRIMARY KEY,
    processed_at timestamptz NOT NULL DEFAULT now()
);

BEGIN;

-- 幂等门:同一事件只处理一次
INSERT INTO processed_events(event_id) VALUES (1)
ON CONFLICT (event_id) DO NOTHING;

-- 若上面影响了 0 行,说明已处理过;用 DO 块做条件判断
DO $$
DECLARE
    affected integer;
BEGIN
    UPDATE stock SET qty = qty - 2 WHERE sku = 'SKU-8891' AND qty >= 2;
    GET DIAGNOSTICS affected = ROW_COUNT;
    IF affected = 0 THEN
        RAISE EXCEPTION 'insufficient stock for SKU-8891';
    END IF;
END
$$;

COMMIT;

投递器:持续把 pending 事件发出去。

sql
-- 每轮取一批(并发安全)
WITH batch AS (
    SELECT id FROM outbox
     WHERE status = 'pending'
     ORDER BY id
     LIMIT 100
     FOR UPDATE SKIP LOCKED
)
UPDATE outbox o
   SET status = 'sent', sent_at = now()
  FROM batch b
 WHERE o.id = b.id
RETURNING o.id, o.event_type, o.payload;
text
 id |   event_type   |                payload
----+----------------+----------------------------------------
  1 | order.created  | {"qty": 2, "sku": "SKU-8891", "order_id": 1}
(1 row)

库存不足时的补偿:发出取消事件。

sql
-- 库存服务回写一个补偿事件(写它自己的 outbox)
INSERT INTO outbox(aggregate_type, aggregate_id, event_type, payload)
VALUES ('order', '1', 'stock.rejected',
        jsonb_build_object('order_id', 1, 'reason', 'insufficient stock'));
sql
-- 订单服务消费 stock.rejected,取消订单
BEGIN;
UPDATE orders SET status = 'cancelled'
 WHERE id = 1 AND status = 'created';
COMMIT;

端到端:用 2PC 实现的强一致转账(对照) ​

sql
-- 库 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100;
PREPARE TRANSACTION 'xa-transfer-0001-A';

-- 库 B
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
PREPARE TRANSACTION 'xa-transfer-0001-B';

-- 两个都就绪 → 提交
COMMIT PREPARED 'xa-transfer-0001-A';
COMMIT PREPARED 'xa-transfer-0001-B';

对比 Outbox 方案的分工差异:

text
2PC 方案:
  优点:强一致,T+ε 一致
  缺点:阻塞、悬挂风险、需 max_prepared_transactions、运维复杂

Outbox 方案:
  优点:高可用、实现简单、无阻塞
  缺点:最终一致(延迟)、需幂等、需处理补偿

监控:分布式事务的健康指标 ​

sql
-- 1. prepared 事务数量与最老年龄(2PC 健康度)
SELECT count(*) AS prepared_count,
       coalesce(max(now() - prepared), '0'::interval) AS oldest_age
FROM pg_prepared_xacts;
text
 prepared_count | oldest_age
----------------+-------------
              0 | 00:00:00
(1 row)
sql
-- 2. Outbox 积压(最终一致健康度)
SELECT status, count(*) AS cnt,
       now() - min(created_at) AS oldest_wait
FROM outbox
GROUP BY status
ORDER BY status;
text
 status  | cnt | oldest_wait
---------+-----+-------------
 failed  |   2 | 01:12:33.221
 pending |  18 | 00:00:04.118
 sent    | 480 | 02:31:09.774
(3 rows)
sql
-- 3. 逻辑复制槽滞后(CDC 健康度)
SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
text
 slot_name | active | retained_wal
-----------+--------+--------------
 dbz_slot  | t      | 48 MB
(1 row)
sql
-- 4. Saga 卡住实例
SELECT saga_id, saga_type, status, current_step, now() - updated_at AS stuck_for
FROM saga_instances
WHERE status = 'running'
  AND now() - updated_at > interval '5 minutes';

对比演示:同一业务在两种方案下的失败行为 ​

text
场景:下单 + 扣库存,库存服务不可用。

2PC:
  订单库 PREPARE 成功 → 库存库 PREPARE 失败(服务不可用)
  → 协调者 ROLLBACK PREPARED 订单库
  → 业务收到失败,用户需重试
  → 结果:强一致,但整个下单不可用

Outbox:
  订单库本地事务成功(订单 created + outbox pending)→ 立刻返回成功
  → 投递器稍后投递,库存服务不可用时重试
  → 若最终库存不足 → 发 stock.rejected → 订单转 cancelled
  → 结果:下单始终可用,但存在不一致窗口

注意事项 ​

  • 错误!把 2PC(XA)当作所有分布式事务的默认答案。 它是阻塞式协议,可用性与运维成本高,仅在强一致刚需时使用。
  • 错误!用 2PC 覆盖长流程。 prepared 事务必须短命,长流程应用 Saga/TCC。
  • 不推荐在 Saga 里假设「补偿等于回滚」。补偿是追加逆操作,历史痕迹仍在,且补偿本身也可能失败。
  • 错误!TCC 只实现 Confirm/Cancel 而忽略空回滚与悬挂。 必须用事务状态表做状态机校验。
  • 错误!Outbox 投递器不用 FOR UPDATE SKIP LOCKED。 多实例并发拉取会重复投递,且互相阻塞。
  • 不推荐假设消息只投递一次。Outbox/CDC 都是至少一次语义,消费方必须幂等。
  • 错误!忽略逻辑复制槽的活跃状态。 不活跃的槽会让 WAL 无限堆积,最终撑满磁盘。
  • 错误!以为 CDC 默认能正确解码 2PC。 基础回调下 PREPARE TRANSACTION 被忽略,需要专门的输出插件回调。
  • 不推荐让协调者不持久化决策日志。协调者一崩,参与者就悬挂,无人知道该提交还是回滚。
  • 错误!跳过幂等键设计。 幂等是所有最终一致方案的地基,没有它重试必然产生重复数据。

最佳实践 ​

  • 先判断是否真需要分布式事务:能通过业务边界划分、避免跨库写,就不要引入分布式事务。
  • 默认选最终一致:多数场景用 Outbox 起步,实现简单、可用性高、运维负担小。
  • 强一致才上 2PC:同构数据库 + 短事务 + 不可妥协的一致性,才值得付出 XA 的代价。
  • 幂等设计前置:所有入站接口都用唯一业务键(如 request_id)+ 唯一索引兜底。
  • 投递器并发安全:FOR UPDATE SKIP LOCKED 拉取任务,保证多实例不重复不阻塞。
  • 补偿要有重试与死信:补偿失败要能重试、能告警、能人工介入;设置最大重试次数与死信表。
  • 可观测性三件套:prepared 事务年龄、Outbox 积压量与最老等待时长、复制槽滞后字节数。
  • Saga 状态机显式化:把当前步骤与状态持久化到表里,让流程可查询、可恢复、可人工干预。
  • 谨慎选择 CDC:确认团队有能力运维复制槽与 Kafka,别让「零侵入」变成「零可控」。
  • 文档化方案选择:把「为什么选它、不选另一个」写进架构文档,避免后人盲目替换。

下一节

继续学习:CREATE / DROP VIEW——从并发控制回到查询抽象,理解视图如何封装复杂查询逻辑。

参考链接 ​