分布式事务场景(Distributed Transaction Scenarios)
当系统被拆成多个服务、每个服务有自己的数据库时,「一次业务操作要同时改多个库」就成了常态。这条路上有几条主流技术路线:XA/2PC、Saga、TCC、Outbox 本地消息表、CDC 变更捕获。它们没有银弹——分别用一致性强度换取可用性、复杂度或实时性。
本页讲清每种方案的原理、落地方式、适用边界,给出选型决策树,最后用「订单 + 库存」的经典例子演示最终一致方案的完整实现。
前置知识
阅读本节前,建议先了解:两阶段提交原理、PREPARE TRANSACTION / COMMIT PREPARED
基础概念
分布式事务的核心矛盾
先想清楚问题本质。跨库操作之所以难,是因为「本地事务的原子性」无法跨网络传递:
同一进程内的两个操作:
操作 A(改库 1) 成功
操作 B(改库 2) 成功
→ 如果中间崩了?A 已生效,B 没做 → 不一致
本地事务能解决这个,靠的是「一个原子提交点」。
但跨库时,库 1 和库 2 各自有自己的提交点,没有共同的原子点。CAP 定理告诉我们:网络分区(Partition)不可避免时,只能在一致性(Consistency)与可用性(Availability)之间取舍。分布式事务的各路方案,本质就是在回答「你愿意为一致性付多少代价」。
五条技术路线与其取舍
| 方案 | 一致性 | 可用性 | 实现复杂度 | 对数据库侵入 | 适用场景 |
|---|---|---|---|---|---|
| XA / 2PC | 强一致 | 低(阻塞式) | 中(依赖事务管理器) | 高(需开启 prepared 事务) | 短事务、强一致要求、同构数据库 |
| Saga | 最终一致 | 高 | 高(要写补偿) | 低 | 长流程、跨服务、可补偿业务 |
| TCC | 最终一致 | 高 | 很高(三阶段都需实现) | 低 | 资金类、需资源预留 |
| Outbox 本地消息表 | 最终一致 | 高 | 中 | 中(需额外表 + 投递器) | 事件驱动的服务解耦 |
| CDC(Debezium 等) | 最终一致 | 高 | 中 | 低(读 WAL) | 已有数据同步基础设施 |
「强一致」与「最终一致」的实际差异
强一致(2PC):
T 时刻提交 → T+ε 所有库立即一致
代价:任一参与者不可用,整个事务都提交不了;协调者挂则悬挂
最终一致(Saga/Outbox/CDC):
T 时刻提交 → T+Δ 才全部一致(Δ 可能是毫秒~秒级)
代价:中间窗口内数据不一致;需要处理补偿与幂等大多数互联网业务能接受毫秒到秒级的最终一致。真正需要 2PC 的场景其实很窄:同构数据库、短事务、强一致不可妥协(如金融核心的账务记账)。
PostgreSQL 在这条链路里的位置
PostgreSQL 只能做参与者,不做协调者。因此所有方案都要回答「谁来当协调者、协调者如何持久化决策」:
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 读取语法与用法
选择方案的伪代码决策
需要跨多个库/服务同时修改数据?
├─ 否 → 用普通 PostgreSQL 事务(BEGIN ... COMMIT),不要引入分布式方案
│
└─ 是 → 事务是否「短小、快速、同构数据库」?
│
├─ 是 → 强一致是否不可妥协?
│ ├─ 是 → XA / 2PC(PREPARE TRANSACTION)
│ └─ 否 → Outbox 或 CDC(更简单、可用性更高)
│
└─ 否(长流程、异构、跨服务)
│
├─ 每一步都能写出「反向操作」? → Saga
├─ 涉及资金、需要资源预占? → TCC
└─ 只是「改了数据后要通知别人」?→ Outbox / CDC统一约定:幂等键
无论哪种方案,幂等(Idempotency) 都是地基。推荐每张业务表都带上幂等键:
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);-- 幂等插入:重复请求不会产生第二行
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 事务的模板查询
SELECT gid, owner, database, prepared, now() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared; 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。
Spring JtaTransactionManager
├── 数据源 1(PostgreSQL,支持 XA)→ PREPARE / COMMIT PREPARED
├── 数据源 2(PostgreSQL,支持 XA)→ PREPARE / COMMIT PREPARED
└── 消息队列(如支持 XA 的 MQ)数据库配置:
# postgresql.conf —— XA 必需的参数
max_prepared_transactions = 200-- JDBC 连接串中要声明支持 XA
-- jdbc:postgresql://db1:5432/shop?prepareThreshold=5典型 Java 侧形态(伪代码,展示语义而非可运行片段):
@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)
}关键配置点:
1. 事务管理器必须持久化决策日志(否则它会成为新的单点)
- Narayana / Atomikos 默认持久化到文件或数据库
2. 默认事务超时(如 60s)要覆盖最慢参与者的响应时间
3. PostgreSQL 侧的 max_prepared_transactions 要足够大(>= 并发全局事务数)
4. 需要监控 prepared 事务数量与年龄,识别卡住的全局事务XA 的实践代价
代价 1:阻塞式协议
任一参与者在 prepare 后不可用 → 整个全局事务卡住
代价 2:悬挂事务运维
协调者崩溃/丢日志 → 参与者永久 prepared
→ 见「两阶段提交原理」中的悬挂事务章节
代价 3:性能
每个全局事务要多一轮 RTT(prepare 阶段)+ 一次额外 fsync
代价 4:连接占用
prepared 事务持有资源,连接池要预留余量
代价 5:异构兼容性
不同数据库的 XA 实现水平参差,跨厂商组合常出问题因此 XA 的适用面其实很窄:同厂商、短事务、强一致刚需。
Saga:把大事务拆成「一连串本地事务 + 补偿」
Saga 的核心思想:把一个长流程拆成 N 个本地事务 T1..Tn,同时为每个 Ti 准备一个补偿操作 Ci。任何一步失败,就反向执行已完成的补偿。
正向:T1 → T2 → T3 → T4
失败:T1 → T2 → T3 ✗
补偿: C2 ← C1 (C3 无需执行,T3 未生效)两种编排方式:
| 方式 | 谁来驱动 | 特点 |
|---|---|---|
| 编排(Choreography) | 各服务监听事件、自行决策 | 去中心化,但流程分散难追踪 |
| 协同(Orchestration) | 中心化编排器(状态机) | 流程集中可观测,但编排器是核心组件 |
PostgreSQL 里的 Saga 状态表:
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 的语义陷阱:补偿不是回滚
数据库回滚:完全抹掉,如同没发生过。
Saga 补偿:追加一个「逆操作」,历史痕迹仍在。
例:扣了 100 元,补偿是「退回 100 元」,而不是「让扣款不存在」。
→ 账本上会看到两条记录(扣款 + 退款),而不是一片空白。由此推出三个必须处理的问题:
1. 补偿本身可能失败 → 补偿也要重试、也要幂等、也要有中间态
2. 没有隔离性 → 中间状态对外可见,需要业务层用「预留/冻结」等手法遮蔽
3. 补偿不可逆时无解 → 已发出的短信、已存的纸质单据无法撤销
→ 这类步骤要么放到最后,要么设计成「可撤销的软动作」TCC:Try-Confirm-Cancel
TCC(Try-Confirm-Cancel) 是 Saga 的一种强化形态,把每个参与者的操作显式拆成三个阶段:
Try :预留资源(冻结 100 元,而非直接扣款)
Confirm :确认使用(把冻结转为实际扣款)
Cancel :释放预留(解冻 100 元)对比 Saga:
| 维度 | Saga | TCC |
|---|---|---|
| 隔离性 | 无(中间状态可见) | 有(Try 阶段就锁定了资源) |
| 补偿语义 | 逆操作 | 释放预留 |
| 实现工作量 | 中(写补偿) | 高(三阶段都要写) |
| 适用 | 长流程、弱隔离可接受 | 资金、库存等需严格预留 |
PostgreSQL 侧的 TCC 表设计:
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 阶段(冻结):
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 阶段(确认):
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 阶段(解冻):
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(本地消息表) 是工程上性价比最高的方案。核心洞察是:不要试图让「改业务数据」和「发消息」原子,而是把消息写进同一张库的一张表,用本地事务保证它们原子,再异步投递。
问题:
BEGIN; 更新订单; COMMIT; → 发送 MQ 消息 ✗ 中间崩溃 → 订单改了,消息没发
Outbox:
BEGIN; 更新订单; INSERT INTO outbox(...); COMMIT; ← 一个本地事务,原子
→ 后台投递器读 outbox,发 MQ,标记已发送(幂等)表结构:
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'));业务与事件同事务写入:
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 并发安全地取任务):
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;投递失败的重试与死信:
-- 失败:累加重试次数
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 日志。
PostgreSQL(logical decoding)→ Debezium → Kafka → 下游消费者前提条件:
# postgresql.conf
wal_level = logical # 必须为 logical
max_replication_slots = 10
max_wal_senders = 10-- 创建逻辑复制槽
SELECT * FROM pg_create_logical_replication_slot('dbz_slot', 'pgoutput');-- 查看复制槽的滞后情况(危险信号:不活跃的槽会导致 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; slot_name | plugin | slot_type | active | retained_wal
-----------+----------+-----------+--------+--------------
dbz_slot | pgoutput | logical | t | 48 MB
(1 row)CDC 与 Outbox 的取舍:
| 维度 | Outbox | CDC |
|---|---|---|
| 业务代码侵入 | 需要写 outbox 表 | 无(读 WAL) |
| 事件语义 | 业务语义清晰(自定义事件) | 表级变更(需下游还原业务语义) |
| 历史数据 | 无法获取(只发新事件) | 可配合快照获取全量 |
| 依赖 | 只需数据库 | 需 Debezium/Kafka 等基础设施 |
| 运维风险 | 低 | 复制槽不活跃会导致 WAL 堆积撑爆磁盘 |
| 事务一致性 | 本地事务保证 | 逻辑解码保证 |
逻辑复制与 2PC 的兼容性坑
如果用 CDC 处理来自 2PC 的事务,要注意官方提示的兼容性问题:
基础回调(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 最终一致方案
表结构(订单库)。
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';下单:业务与事件同事务写入。
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; id | request_id
----+------------
1 | req-1001
(1 row)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;INSERT 0 1
COMMIT库存服务消费并扣减(幂等)。
-- 库存服务侧
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 事件发出去。
-- 每轮取一批(并发安全)
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; id | event_type | payload
----+----------------+----------------------------------------
1 | order.created | {"qty": 2, "sku": "SKU-8891", "order_id": 1}
(1 row)库存不足时的补偿:发出取消事件。
-- 库存服务回写一个补偿事件(写它自己的 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'));-- 订单服务消费 stock.rejected,取消订单
BEGIN;
UPDATE orders SET status = 'cancelled'
WHERE id = 1 AND status = 'created';
COMMIT;端到端:用 2PC 实现的强一致转账(对照)
-- 库 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 方案的分工差异:
2PC 方案:
优点:强一致,T+ε 一致
缺点:阻塞、悬挂风险、需 max_prepared_transactions、运维复杂
Outbox 方案:
优点:高可用、实现简单、无阻塞
缺点:最终一致(延迟)、需幂等、需处理补偿监控:分布式事务的健康指标
-- 1. prepared 事务数量与最老年龄(2PC 健康度)
SELECT count(*) AS prepared_count,
coalesce(max(now() - prepared), '0'::interval) AS oldest_age
FROM pg_prepared_xacts; prepared_count | oldest_age
----------------+-------------
0 | 00:00:00
(1 row)-- 2. Outbox 积压(最终一致健康度)
SELECT status, count(*) AS cnt,
now() - min(created_at) AS oldest_wait
FROM outbox
GROUP BY status
ORDER BY status; status | cnt | oldest_wait
---------+-----+-------------
failed | 2 | 01:12:33.221
pending | 18 | 00:00:04.118
sent | 480 | 02:31:09.774
(3 rows)-- 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; slot_name | active | retained_wal
-----------+--------+--------------
dbz_slot | t | 48 MB
(1 row)-- 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';对比演示:同一业务在两种方案下的失败行为
场景:下单 + 扣库存,库存服务不可用。
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——从并发控制回到查询抽象,理解视图如何封装复杂查询逻辑。