PREPARE TRANSACTION / COMMIT PREPARED
上一页讲了 2PC 的协议原理,本页进入实操:PREPARE TRANSACTION、COMMIT PREPARED、ROLLBACK PREPARED 三条命令的完整语法、gid 的规则、prepared 事务的全部限制清单、报错信息与含义、悬挂事务的排查与清理流程,并以「双库转账」做一次完整的端到端演示。
前置知识
阅读本节前,建议先了解:两阶段提交原理、BEGIN / COMMIT / ROLLBACK
基础概念
三条命令在事务生命周期中的位置
普通事务:
BEGIN ──→ SQL ──→ COMMIT / ROLLBACK (一条路径走到头)
两阶段提交:
BEGIN ──→ SQL ──→ PREPARE TRANSACTION 'gid' (阶段一:冻结)
│
├─→ COMMIT PREPARED 'gid' (阶段二:提交)
└─→ ROLLBACK PREPARED 'gid' (阶段二:回滚)PREPARE TRANSACTION 是唯一能让事务脱离会话而存活的语句。执行之后,事务的所有效果都已经持久化、所有锁都还持有,就等最后那道指令。
gid:全局事务标识符
gid 是全局事务标识符(Global Transaction Identifier),它是 prepared 事务的唯一句柄,等于一把「钥匙」。
规则(官方):
1. 必须是字符串字面量(string literal)
2. 长度必须小于 200 字节
3. 不能与现有任何未结束的 prepared 事务重名
4. 由外部事务管理器负责保证全局唯一推荐命名结构:
{业务前缀}-{日期}-{序列}-{参与者标识}
例:
xfer-2026-09-28-0001-A 转账业务,2026-09-28,第 1 笔,参与者 A
xfer-2026-09-28-0001-B 同一笔转账的参与者 B
jta-8f31a2c4-a1 外部事务管理器分配的事务 ID好的 gid 有三个作用:可读(运维看 pg_prepared_xacts 就知道是什么业务)、可分组(同一笔转账通过前缀关联)、可追溯(日期与序列能定位到日志)。
谁执行哪条命令
| 动作 | 谁可以执行 |
|---|---|
PREPARE TRANSACTION | 任何能在该库开启事务并拥有相应对象权限的用户 |
COMMIT PREPARED | 原执行 prepare 的用户,或超级用户 |
ROLLBACK PREPARED | 同上 |
| 执行位置 | 必须在 prepare 时所在的那个数据库 |
| 执行会话 | 不必是原会话(这正是 2PC 的意义所在) |
会话 A(库 shop):
BEGIN; ...; PREPARE TRANSACTION 'g1';
会话 B(库 shop,另一个连接):
COMMIT PREPARED 'g1'; ✓ 允许这条「不必同会话」的规则是协调者能在崩溃后收尾的基础——它只要重连即可,不需要还原原来的连接。
与普通事务结束对照:
| 维度 | COMMIT | COMMIT PREPARED |
|---|---|---|
| 作用对象 | 当前会话的活动事务 | 一个 prepared 事务(按 gid 指定) |
| 能否在事务块内 | 能(本身就是结束语句) | 不能(它是独立命令) |
| 所属会话 | 必须是发起事务的会话 | 任意会话 |
| 权限 | 发起者自己 | 原用户或超级用户 |
| 数据库 | 当前库 | 必须是 prepare 时的那个库 |
| 报错后的状态 | 事务可能 aborted | prepared 条目仍在,可重试 |
参数前提:max_prepared_transactions
SHOW max_prepared_transactions; max_prepared_transactions
---------------------------
0
(1 row)默认 0,表示禁用该特性。任何 2PC 命令都会失败:
ERROR: prepared transactions are disabled
HINT: Set max_prepared_transactions to a nonzero value.该参数只能在服务器启动时设置(postgresql.conf + 重启,或 ALTER SYSTEM SET + 重启):
# postgresql.conf
max_prepared_transactions = 100# 方式一:直接改配置文件后重启
pg_ctl restart -D $PGDATA
# 方式二:ALTER SYSTEM 后重启
psql -c "ALTER SYSTEM SET max_prepared_transactions = 100;"
pg_ctl restart -D $PGDATAstandby 必须 >= primary
官方要求:standby 服务器上的 max_prepared_transactions 必须大于等于 primary 上的值,否则 standby 将不允许执行查询。这是复制环境中一个容易遗漏的配置项。
语法与用法
完整语法
PREPARE TRANSACTION transaction_id
COMMIT PREPARED transaction_id
ROLLBACK PREPARED transaction_id其中 transaction_id 是字符串字面量(String Literal):
PREPARE TRANSACTION 'gid-0001'; -- 正确:单引号字面量常见错误:写成标识符(不带引号)会直接语法报错。
PREPARE TRANSACTION gid_0001;ERROR: syntax error at or near "gid_0001"
LINE 1: PREPARE TRANSACTION gid_0001;
^在 PL/pgSQL 里也不能把变量直接塞进去,因为 PREPARE TRANSACTION 不接受参数,必须用 EXECUTE 动态拼成字面量:
DO $$
DECLARE
g text := 'gid-' || to_char(now(), 'YYYYMMDDHH24MISS');
BEGIN
-- 注意:EXECUTE 在事务块内执行,PREPARE TRANSACTION 会终结该事务
EXECUTE format('PREPARE TRANSACTION %L', g);
END $$;最小示例
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'demo-cmd-0001';BEGIN
UPDATE 1
PREPARE TRANSACTION-- 查看
SELECT gid, owner, database, prepared FROM pg_prepared_xacts; gid | owner | database | prepared
------------------+-------+----------+-------------------------------
demo-cmd-0001 | app | shop | 2026-09-28 15:02:11.447+08
(1 row)-- 提交(改为 ROLLBACK PREPARED 'demo-cmd-0001' 即回滚)
COMMIT PREPARED 'demo-cmd-0001';COMMIT PREPARED报错信息速查表
| 报错 | 含义 | 处理 |
|---|---|---|
prepared transactions are disabled | max_prepared_transactions = 0 | 改参数并重启 |
transaction identifier 'x' is already in use | gid 重复 | 换一个唯一的 gid |
prepared transaction with identifier 'x' is busy | 该 gid 正被其他会话处理 | 稍后重试 |
prepared transaction belongs to another database | 连到了错误的库 | 回到 prepare 时的库 |
permission denied to finish prepared transaction | 非原用户且非超级用户 | 用原用户或超级用户 |
cannot PREPARE a transaction that has operated on temporary objects | 用过临时对象 | 去掉临时表/临时命名空间 |
cannot PREPARE a read-only transaction | 只读事务 | 只读参与者无需 2PC |
cannot PREPARE a transaction that has executed LISTEN, UNLISTEN, or NOTIFY | 用过通知语句 | 移出 2PC 范围 |
详细说明
限制清单:五类不能 prepared 的事务
官方对 PREPARE TRANSACTION 的禁区列得很清楚,触发时都会直接报错:
不可 prepare 的清单(官方):
1. 只读事务(READ ONLY)
2. 操作过临时表(temporary tables)或临时命名空间(pg_temp_n)
3. 声明了 WITH HOLD 的游标(cursors declared WITH HOLD)
4. 执行过 LISTEN / UNLISTEN / NOTIFY
5. 无实际写入的事务(PREPARE 要求事务真正改动了数据)限制一:只读事务。
BEGIN READ ONLY;
SELECT count(*) FROM accounts;
PREPARE TRANSACTION 'ro-demo';ERROR: cannot PREPARE a read-only transaction设计层面很好理解:2PC 的意义是「保证多个写操作原子生效」,只读事务不改变任何状态,让它持有 prepared 状态只会白占资源。协调者应只对写入方发 prepare。
限制二:临时对象。
CREATE TEMP TABLE tmp_calc(x int);
BEGIN;
INSERT INTO tmp_calc VALUES (1);
PREPARE TRANSACTION 'temp-demo';ERROR: cannot PREPARE a transaction that has operated on temporary objects原因:临时表、临时命名空间与会话强绑定;prepared 事务脱离会话存在,若允许 prepare,一个没有会话的临时表既无人能访问也无人能清理。
ROLLBACK;
DROP TABLE IF EXISTS tmp_calc;限制三:WITH HOLD 游标。 它在事务提交后仍然存活,同样是与事务生命周期矛盾的会话状态,因此被禁止:
BEGIN;
DECLARE c_hold CURSOR WITH HOLD FOR SELECT * FROM accounts;
PREPARE TRANSACTION 'cursor-demo';ERROR: cannot PREPARE a transaction that has operated on temporary objects限制四:LISTEN / UNLISTEN / NOTIFY。
BEGIN;
LISTEN channel_a;
PREPARE TRANSACTION 'listen-demo';ERROR: cannot PREPARE a transaction that has executed LISTEN, UNLISTEN, or NOTIFYLISTEN 注册的订阅、NOTIFY 排队的通知都是会话级状态,prepared 事务没有会话可以承接。
ROLLBACK;报错信息有重叠
限制二、三触发时,报错文案都指向 temporary objects(WITH HOLD 游标在内部也关联到会话级资源)。排障时不要只看文案字面,要看事务里实际做了什么。
限制五:PREPARE 之后不能再「接着写」。 PREPARE TRANSACTION 必须在事务块内执行,但它终结了该事务块:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'after-demo-0001';
-- 下面这条已经是全新的隐式事务,与 prepared 事务无关!
UPDATE accounts SET balance = balance + 100 WHERE id = 2;BEGIN
UPDATE 1
PREPARE TRANSACTION
UPDATE 1如果业务代码以为「它们是一体的」,就会产生逻辑错乱。正确姿势是让 PREPARE TRANSACTION 成为事务块的最后一条语句:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'after-demo-0002'; -- 到此为止SET 的持久化行为
BEGIN;
SET LOCAL lock_timeout = '5s'; -- LOCAL:随事务消失,无副作用
SET search_path = app_schema; -- 非 LOCAL:会残留!
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'set-demo';-- 事务已 prepared,但 search_path 还改着
SHOW search_path; search_path
----------------
app_schema
(1 row)官方明确:使用 SET(非 LOCAL)修改的运行时会话参数会持久到 PREPARE TRANSACTION 之后。
规范:在 2PC 路径上,所有 GUC 修改一律用 SET LOCAL。实在需要非 LOCAL 的,在 prepare 前手工 RESET:
RESET search_path;gid 重复与并发
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'dup-demo';PREPARE TRANSACTIONBEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'dup-demo';ERROR: transaction identifier "dup-demo" is already in use这是保护机制而非缺陷——如果允许重名,协调者后来的 COMMIT PREPARED 'dup-demo' 就不知道该提交哪个了。
若某个 gid 正被处理(例如另一个会话正在执行 COMMIT PREPARED):
ERROR: prepared transaction with identifier "dup-demo" is busy这表示存在并发访问,重试即可。
跨库与权限限制
COMMIT PREPARED 只能在prepare 时所在的那个数据库里执行:
psql -d bank_a -c "BEGIN; UPDATE accounts SET balance=balance-1 WHERE id=1; PREPARE TRANSACTION 'x-db';"
psql -d bank_b -c "COMMIT PREPARED 'x-db';"ERROR: prepared transaction belongs to another database
HINT: Connect to the database where the transaction was prepared to finish it.这个错误来自 LockGXact()(src/backend/access/transam/twophase.c)。运维含义:清理悬挂事务无法「一个连接搞定所有库」,必须逐库连接、逐库检查:
# 错误的做法:以为在一个库就能看到全部
psql -d bank_a -c "SELECT gid FROM pg_prepared_xacts;" # 只看到 bank_a 的
# 正确的做法:逐库查询
for db in bank_a bank_b shop; do
echo "--- $db"
psql -d "$db" -At -c "SELECT gid, prepared FROM pg_prepared_xacts;"
done权限方面,只有原执行 prepare 的用户或超级用户能收尾:
-- 以普通用户 bob 尝试收尾 alice 创建的 prepared 事务
COMMIT PREPARED 'alice-gid-1';ERROR: permission denied to finish prepared transaction
HINT: Must be superuser or the user that prepared the transaction.\c - postgres -- 切换到超级用户后即可
COMMIT PREPARED 'alice-gid-1';COMMIT PREPARED实践建议:协调者应使用固定的专用账户(如 xa_coordinator)在所有参与库上执行 2PC,这样权限与归属都一致,排障时不用在多个用户之间切换。
悬挂事务的完整清理流程
第一步:发现。
SELECT
gid,
owner,
database,
prepared,
now() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared; gid | owner | database | prepared | age
-------------------+-------+----------+-------------------------------+--------------
xfer-8f31a2-A | xa | bank_a | 2026-09-28 09:12:03.771+08 | 06:02:11.882
(1 row)第二步:评估它持有哪些锁。
SELECT
l.locktype,
l.relation::regclass AS relation,
l.mode,
l.virtualtransaction
FROM pg_locks l
WHERE l.virtualtransaction = '-1/' || (
SELECT transaction FROM pg_prepared_xacts WHERE gid = 'xfer-8f31a2-A'
); locktype | relation | mode | virtualtransaction
----------+------------+------------------+--------------------
relation | accounts | RowExclusiveLock | -1/759160
transactionid| | ExclusiveLock | -1/759160
(2 rows)第三步:确认全局决策。 这一步无法用 SQL 完成,必须依赖外部信息:
检查清单:
□ 协调者日志里这笔全局事务的最终决定是什么?
□ 其他参与者的状态如何(已提交 / 已回滚 / 也悬挂)?
□ 是否有业务侧的对账数据可以佐证?第四步:执行清理。
-- 情况 A:确认全局应提交
COMMIT PREPARED 'xfer-8f31a2-A';-- 情况 B:确认全局应回滚,或信息不足(偏保守)
ROLLBACK PREPARED 'xfer-8f31a2-A';第五步:验证。
SELECT count(*) AS remaining FROM pg_prepared_xacts; remaining
-----------
0
(1 row)监控与告警 SQL
例行巡检需要三个层次的信号:总量、超期条目、影响面。
-- 1. 总览:数量与最老年龄
SELECT
count(*) AS prepared_count,
coalesce(max(now() - prepared), '0'::interval) AS oldest_age
FROM pg_prepared_xacts; prepared_count | oldest_age
----------------+----------------------
2 | 00:12:44.331219
(1 row)-- 2. 超阈值告警
SELECT gid, owner, database, prepared, now() - prepared AS age
FROM pg_prepared_xacts
WHERE now() - prepared > interval '5 minutes'
ORDER BY prepared;-- 3. 每个 prepared 事务持有的锁数量(评估阻塞影响面)
SELECT ppx.gid, ppx.owner, ppx.database,
count(l.locktype) AS held_locks,
now() - ppx.prepared AS age
FROM pg_prepared_xacts ppx
LEFT JOIN pg_locks l ON l.virtualtransaction = '-1/' || ppx.transaction
GROUP BY ppx.gid, ppx.owner, ppx.database, ppx.prepared
ORDER BY held_locks DESC, age DESC; gid | owner | database | held_locks | age
----------------+-------+----------+------------+--------------
xfer-8f31a2-A | xa | bank_a | 14 | 00:12:44.331
(1 row)防止悬挂:看门狗只告警,不自动回滚
PostgreSQL 本身不提供 prepared 事务超时。可以在协调者侧或运维侧实现「看门狗」:
-- 看门狗逻辑示意(谨慎使用!回滚前必须先确认全局状态)
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT gid FROM pg_prepared_xacts
WHERE now() - prepared > interval '30 minutes'
LOOP
RAISE WARNING 'found stale prepared xact: %', r.gid;
-- 生产环境不要自动 ROLLBACK,应先告警,人工确认
-- EXECUTE format('ROLLBACK PREPARED %L', r.gid);
END LOOP;
END
$$;不要盲目自动回滚
自动回滚看似省事,但如果其他参与者已经提交,回滚这一个库会造成数据不一致——原本 2PC 要防的正是这个。看门狗应当只告警不擅自动手,或者只在有充分的外部决策依据时才自动执行。
实战示例
端到端:双库转账完整演示
准备。
-- bank_a 与 bank_b 各建表
CREATE TABLE IF NOT EXISTS accounts (
id integer PRIMARY KEY,
name text NOT NULL,
balance numeric(14,2) NOT NULL CHECK (balance >= 0)
);-- bank_a 初始化
INSERT INTO accounts(id, name, balance) VALUES
(1, 'alice', 1000.00), (2, 'bob', 500.00)
ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance;-- bank_b 初始化(初始余额与 bank_a 不同)
INSERT INTO accounts(id, name, balance) VALUES
(1, 'alice', 200.00), (2, 'bob', 300.00)
ON CONFLICT (id) DO UPDATE SET balance = EXCLUDED.balance;INSERT 0 2 ← 两个库分别返回一次阶段一:两库分别 prepare。
-- 库 bank_a:从 alice 扣 100
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-2026-09-28-0002-A';BEGIN
UPDATE 1
PREPARE TRANSACTION-- 库 bank_b:给 alice 加 100
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-2026-09-28-0002-B';BEGIN
UPDATE 1
PREPARE TRANSACTION检查两边都就绪。
-- bank_a
SELECT gid, owner, database, prepared FROM pg_prepared_xacts; gid | owner | database | prepared
--------------------------+-------+----------+-------------------------------
xfer-2026-09-28-0002-A | xa | bank_a | 2026-09-28 15:20:01.114+08
(1 row)-- bank_b
SELECT gid, owner, database, prepared FROM pg_prepared_xacts; gid | owner | database | prepared
--------------------------+-------+----------+-------------------------------
xfer-2026-09-28-0002-B | xa | bank_b | 2026-09-28 15:20:01.773+08
(1 row)阶段二:两边提交,然后验证。
-- bank_a
COMMIT PREPARED 'xfer-2026-09-28-0002-A';COMMIT PREPARED-- bank_b
COMMIT PREPARED 'xfer-2026-09-28-0002-B';COMMIT PREPARED-- 验证:bank_a 扣了 100,bank_b 加了 100
-- 在 bank_a
SELECT id, name, balance FROM accounts WHERE id = 1; -- 900.00
-- 在 bank_b
SELECT id, name, balance FROM accounts WHERE id = 1; -- 300.00演示:prepare 成功后回滚,且改动不可见
-- 阶段一
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'xfer-cancel-0001-A';PREPARE TRANSACTION此时在另一个会话里查询,看到的仍是旧值——prepared 事务的改动对其他人不可见,直到 COMMIT PREPARED:
SELECT balance FROM accounts WHERE id = 1; balance
---------
900.00
(1 row)阶段二决定回滚,数据完全未变:
ROLLBACK PREPARED 'xfer-cancel-0001-A';ROLLBACK PREPAREDSELECT balance FROM accounts WHERE id = 1; -- 仍是 900.00演示:不可见,但仍持锁
「不可见」的另一面是锁真实持有——同一行不能被别人改:
-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 500 → 450
PREPARE TRANSACTION 'invisible-demo';-- 会话 B(独立连接):读到旧值 500,看不到 450
SELECT balance FROM accounts WHERE id = 2; balance
---------
500.00
(1 row)-- 会话 B 想改同一行 → 被 prepared 事务的锁阻塞
BEGIN;
SET LOCAL lock_timeout = '2s';
UPDATE accounts SET balance = balance + 1 WHERE id = 2;ERROR: canceling statement due to lock timeout结论:prepared 事务「不可见但仍持锁」,这是它既安全(不泄漏中间状态)又危险(阻塞他人)的双重性。
ROLLBACK PREPARED 'invisible-demo'; -- 收尾演示:gid 长度限制与并发收尾
gid 官方要求 less than 200 bytes,超长直接报错:
BEGIN;
UPDATE accounts SET balance = balance - 1 WHERE id = 1;
PREPARE TRANSACTION 'xxx...(此处省略,实际输入 250 个 x)...';ERROR: transaction identifier too longROLLBACK;实践中建议把 gid 控制在 64 字节以内,便于日志与监控展示。
并发收尾同一 gid 时,只有一个会话能成功,另一个会得到 busy:
-- 会话 B 与 会话 C 同时执行
COMMIT PREPARED 'busy-demo';-- 其中一个成功:
COMMIT PREPARED
-- 另一个可能得到:
ERROR: prepared transaction with identifier "busy-demo" is busy这不是致命问题——说明有并发收尾。协调者应在收到 busy 后重试(通常重试时会发现条目已消失,即视为成功)。
排查脚本:一键汇总所有库的悬挂事务
#!/usr/bin/env bash
set -uo pipefail
THRESHOLD="${1:-5 minutes}"
for db in $(psql -At -c \
"SELECT datname FROM pg_database
WHERE datallowconn AND NOT datistemplate ORDER BY datname"); do
cnt=$(psql -d "$db" -At -c "SELECT count(*) FROM pg_prepared_xacts;")
[ "$cnt" = "0" ] && continue
echo "=== database: $db (prepared: $cnt) ==="
psql -d "$db" -P pager=off -c "
SELECT gid,
owner,
prepared,
now() - prepared AS age,
(SELECT count(*) FROM pg_locks l
WHERE l.virtualtransaction = '-1/' || ppx.transaction) AS held_locks
FROM pg_prepared_xacts ppx
WHERE now() - prepared > interval '$THRESHOLD'
ORDER BY prepared;"
done=== database: bank_a (prepared: 1) ===
gid | owner | prepared | age | held_locks
-------------------+-------+-------------------------------+--------------+------------
xfer-8f31a2-A | xa | 2026-09-28 09:12:03.771+08 | 06:22:11.882 | 14
(1 row)注意事项
- 错误!在未修改参数的情况下使用 2PC。 默认
max_prepared_transactions = 0,会直接报prepared transactions are disabled。 - 错误!把
PREPARE TRANSACTION写在事务块的中间。 它是终结语句,其后的语句属于新事务,会导致逻辑错乱。 - 错误!在只读事务上 prepare。 报
cannot PREPARE a read-only transaction;只读参与者无需参与 2PC。 - 错误!在含有临时表、
WITH HOLD游标、LISTEN/NOTIFY的事务上 prepare。 这些会被明确拒绝。 - 错误!在错误的数据库执行
COMMIT PREPARED。 报prepared transaction belongs to another database,必须回到 prepare 时的库。 - 错误!用非原用户、非超级用户收尾他人的 prepared 事务。 报
permission denied to finish prepared transaction。 - 不推荐用非
LOCAL的SET修改 GUC 后 prepare。设置会残留在会话上,污染后续语句。 - 错误!让 prepared 事务长期悬挂。 会阻碍 VACUUM、持续持锁,极端情况可能因事务 ID 回绕导致数据库关闭。
- 不推荐写自动回滚悬挂事务的脚本。其他参与者可能已提交,盲目回滚会造成不一致。
- 错误!gid 超过 200 字节。 报
transaction identifier too long,且必须是字符串字面量。
最佳实践
- gid 规范命名:
{业务}-{日期}-{序列}-{参与者},并保留在协调者日志中,便于关联排查。 - 专用账户执行 2PC:所有参与库使用同一协调账户,避免权限与归属混乱。
- prepared 窗口极短:prepare 与最终裁决之间不插入任何网络调用或耗时操作。
- 只用
SET LOCAL:2PC 路径上的 GUC 修改一律用SET LOCAL,杜绝会话污染。 - 逐库监控:因为 prepared 事务仅在本库可见,监控必须遍历所有数据库。
- 告警优先于自动化:对
age超阈值的 prepared 事务告警,人工评估后再COMMIT/ROLLBACK PREPARED。 held_locks一并监控:用它衡量悬挂事务的影响面,held_locks大意味着阻塞面广。- 复制环境核对参数:确认 standby 的
max_prepared_transactions >= primary,否则 standby 拒绝查询。 - 优先考虑替代方案:能用 Outbox / Saga 实现的最终一致,就不要引入 2PC 的运维负担。
下一节
继续学习:分布式事务场景——XA/JTA 集成、Saga、TCC、Outbox 本地消息表与 CDC 的选型决策。