XA 事务

XA 事务提供跨通道/跨库的原子提交能力(两阶段提交 + 崩溃恢复),解决普通事务在多通道场景下的部分提交问题。

本文依据框架源码 src/Database/Transaction/XaCoordinatorXaJournalXaRecovery)及 DbManager::startXaTransaction() 编写。

为什么需要 XA

普通事务(Db::startTransaction())在单通道内完全原子,但跨多个通道时无法保证原子性——commit 按连接加入顺序逐个提交:

text
Db::startTransaction(function () {
    Db::channel('store')->execute(...);    // 通道 A:建店
    Db::channel('number')->execute(...);   // 通道 B:占号
});

若通道 A 提交成功后、通道 B 提交前发生崩溃或提交失败,A 的数据已落库、B 的数据被回滚——两边数据永久不一致(店建了但号没占),且只能人工修复。

XA 事务把原子性边界从"逐连接提交"收窄为"数据库服务端":

php
Db::startXaTransaction(function () {
    Db::channel('store')->execute(...);    // 通道 A
    Db::channel('number')->execute(...);   // 通道 B —— 要么全部落库,要么全部回滚
});

使用

闭包形式(推荐)

php
use Viswoole\Database\Facade\Db;

// 成功自动提交(两阶段),异常自动回滚并重抛,返回闭包返回值
$storeId = Db::startXaTransaction(function () {
    $id = Db::channel('store')->table('store')->insertGetId(['name' => '分店']);
    Db::channel('number')->table('seq')->insert(['key' => "store_{$id}"]);
    return $id;
});

手动形式

php
Db::startXaTransaction();                  // 开启(首条 SQL 执行时才真正建立分支)
try {
    Db::channel('store')->execute(...);
    Db::channel('number')->execute(...);
    Db::commit();                          // 两阶段提交
} catch (\Throwable $e) {
    Db::rollBack();                        // 各分支 XA ROLLBACK
    throw $e;
}

使用前提

仅 MySQL 支持,其他数据库参与即整体失败

  • 仅 MySQL(InnoDB)实现了 XA 协议。PostgreSQL / SQLite / Oracle / SQL Server 均不支持 XA 协议(PG 的 PREPARE TRANSACTION 是另一套两阶段语法,与 XA 不通约);
  • 非 MySQL 的 PDO 通道参与 XA 事务时,框架在事务开启瞬间抛出明确的 DbException 并整体回滚(fail-fast,不会产生部分提交或连接泄漏);
  • journal 通道(见下文配置)须为 MySQL 通道——journal 表是崩溃恢复的决策依据。

两阶段提交原理

首层 commit() 时,框架按以下时序编排(XaCoordinator):

text
① 各分支 XA END            分支不再接受业务语句
② journal 写入 prepare_start 行   ← 提交意图先于任何 PREPARE 持久化
③ 各分支 XA PREPARE          任一失败 → 全体 XA ROLLBACK + 清理 journal(全量放弃)
④ journal 标记 prepared     ← 提交意图确立
⑤ 各分支 XA COMMIT           任一失败 → 保留 journal,事务停留未决等待恢复
⑥ 删除 journal 行            事务终结

xid 结构与分支标识(gtrid/bqual)

XA 规范的 xid 由 gtrid + bqual + formatID 组成,本框架的拆分规则如下(见 XaContext):

  • gtridvw + 16 位十六进制随机数(共 18 字节,远小于 MySQL 64 字节上限),事务开启时生成;
  • 分支 xid{gtrid}-b{序号}(如 vw83108a7746d5a892-b1),按连接加入顺序递增;
  • bqual 恒为空:分支标识直接编码在 gtrid 的 -bN 后缀内,而非 XA 的 bqual 段(XA RECOVER 返回 bqual_length = 0);
  • 不含通道名:分支与通道的对应关系不持久化——崩溃恢复时不需要它,因为 MySQL 中 prepared 事务由实例持有,XA COMMIT / XA ROLLBACK 以 xid 寻址,可通过该实例上任意连接执行;
  • 恢复时以 journal 行的 gtrid 为前缀({gtrid}-)匹配 XA RECOVER 返回的未决 xid,归属同一全局事务的分支一并终结。

同一实例的分支 xid 必须唯一

MySQL 同一实例上两个连接 XA START 同一 xid 会得到 XAER_DUPID 错误——两个通道可能指向同一实例的不同库,因此每连接一个分支后缀,而非所有连接共用一个 xid。

与隔离级别的关系

XA 不改变隔离级别——沿用数据库会话/全局的隔离设置。需要关注的是 PREPARE 之后、终结之前 的数据可见性:

  • prepared 分支持有的行锁与普通未提交事务一致:其他会话读写受影响的行会被锁阻塞
  • 其他会话的 MVCC 快照读(普通 SELECT)不受影响,仍读到旧版本;
  • 悬挂时间越长(如崩溃后等待恢复),锁持有越久——这既依赖恢复任务的及时性(Worker 启动即恢复),也建议参与 XA 的表避免承载高频热点行写入。

无需为 XA 场景单独调整隔离级别;若业务依赖特定隔离级别,在参与通道上按常规方式设置即可。

嵌套事务与 SAVEPOINT 的边界

XA 分支内的 SAVEPOINT / ROLLBACK TO / RELEASE 是分支内的普通 SQL 语句,语义与单库嵌套事务完全一致,且与 XA 状态机无交互:

  • 内层 rollBack() 仅回滚到保存点,不影响分支的 XA END / XA PREPARE / XA COMMIT 流程;
  • 内层 commit() 仅释放保存点,数据仍受最外层 XA 事务保护;
  • 保存点在最外层两阶段提交时随分支一并终结,无需(也不能)单独处理。

普通事务内无法开启 XA

XA START 必须是事务首条语句。普通事务进行中调用 Db::startXaTransaction() 会抛出 DbException,请在最外层使用或将操作移出当前事务。

崩溃恢复

journal 表(默认 viswoole_xa_journal,位于 journal 通道)记录每个 XA 事务的提交意图。进程在任何时点崩溃后:

journal 行状态崩溃时点恢复动作
prepare_start(1)PREPARE 前或中途对未决分支执行 XA ROLLBACK(放弃提交)
prepared(2)PREPARE 全部成功后对未决分支执行 XA COMMIT(完成提交)

自动恢复(需显式开启)

php
// config/database.php
'xa' => [
    'auto_recovery' => env('DATABASE_XA_AUTO_RECOVERY', false),
    ...
],

auto_recovery = true 时,每个 Worker 进程启动自动执行恢复(journal 表不存在或无残留行时仅一次探测查询,开销可忽略)。

默认关闭——未使用 XA 的项目零副作用(不建表、不探测、无任何启动开销)。使用 XA 的项目建议开启以获得无人值守收敛;未开启时,悬挂事务通过下述手动命令或外部定时任务调度 xa:recover 收敛。

sh
php viswoole xa:recover

恢复操作以 xid 寻址、容忍"事务不存在"(XAER_NOTA)响应,全程幂等——多 Worker 并发恢复、重复执行均安全。无 journal 记录的未决事务(非框架创建)只告警不处理,需人工介入。

journal 表结构

首次使用 XA 事务时自动建表(XaJournal),结构与索引如下:

字段类型说明
gtridvarchar(64)全局事务 ID,主键——一个 XA 事务一行
statetinyint1 = prepare_start(提交未确立),2 = prepared(提交意图确立)
branchestext分支 xid 列表 JSON(信息列,供运维排查;恢复以 XA RECOVER 实际结果为准)
created_attimestamp行写入时间,prepare_start 行 60 秒处置冷却期的判断依据

索引仅主键(gtrid):无 state 索引——表稳态为空、行数极小,全表扫描开销可忽略。无需人工清理:正常路径 commit 尾部删行,异常路径恢复任务在终结未决分支后删行;表长期非空(见下文监控)说明存在未收敛事务。

建表时机:仅在实际执行 XA 事务提交时(首次 startXaTransaction)自动建表——恢复任务/xa:recover 只探测不建表,因此未使用 XA 的项目永远不会出现这张表。

告警与监控

当前版本的恢复告警通过日志输出(标签 XA,经框架日志通道写入控制台与日志文件),触发场景:

  • 无 journal 记录的未决事务(他方创建或 journal 行丢失)——跳过并提示人工处理;
  • 通道扫描失败 / 终结语句失败——journal 行保留,待下次恢复;
  • 恢复任务整体失败(journal 不可用等)——Worker 启动不阻断,输出错误日志。

监控指标建议:定期检查 journal 行数——

text
SELECT COUNT(*) FROM viswoole_xa_journal;

稳态应为 0;持续非 0 说明存在未收敛的未决事务(通道宕机、恢复持续失败),接入告警后按 statecreated_at 定位处置。xa:recover 幂等,可在告警触发后安全重跑。

恢复命令(xa:recover)

sh
php viswoole xa:recover                        # 全量恢复
php viswoole xa:recover --xid=vw83108a77...    # 仅恢复指定 gtrid 的事务
php viswoole xa:recover --dry-run              # 预览处置计划,不做任何变更

运维操作要点:

  • –xid:仅处置指定 gtrid 的 journal 行与其未决分支(传 journal 行主键即全局事务 ID,非分支 xid;journal 中不存在该 gtrid 时提示退出),用于定点排查单个悬挂事务;
  • –dry-run:执行完整扫描(含各通道 XA RECOVER)并逐项报告将执行的 XA COMMIT / XA ROLLBACK 与将删除的 journal 行,但不实际终结分支、不删除行——恢复前预览的标准流程:先 --dry-run 确认计划,再正式执行;
  • 重跑安全:终结语句以 xid 寻址、XAER_NOTA 容忍,多次执行与 Worker 自动恢复并发均无副作用;
  • 可在线执行:无需重启服务,命令进程独立于常驻服务运行;
  • 执行内容:读取 journal 行 → 逐通道 XA RECOVER → 按 journal 意图对未决分支补 XA COMMITXA ROLLBACK → 清理已收敛的行;
  • 冷却期:60 秒内写入的 prepare_start 行会被跳过(防误处置活跃事务),命令输出会提示跳过详情;
  • 只探测不建表:journal 表不存在(从未使用过 XA)时直接返回,不会创建任何表。

配置

journal 相关配置位于 config/database.php(详见数据库配置):

php
'xa' => [
    // journal 表所在通道名,留空使用默认通道
    'journal_channel' => env('DATABASE_XA_JOURNAL_CHANNEL', ''),
    // journal 表名,首次使用时自动建表
    'journal_table' => env('DATABASE_XA_JOURNAL_TABLE', 'viswoole_xa_journal'),
],

journal 通道通常指向参与事务的库之一(如 default 通道)——不引入额外部署组件;journal 不可用时 XA 提交将失败(fail-fast),不会静默丢失提交意图。

性能开销(定性)

框架未提供权威压测数据,以下为按协议推导的开销构成,供选型参考(实际损耗取决于磁盘性能与网络拓扑):

开销项数量说明
XA PREPARE fsync每分支 1 次PREPARE 需将分支数据持久化(普通事务仅在 commit 时 fsync 一次)
journal 写入3 次独立落盘INSERT + UPDATE + DELETE,各为 autocommit 单语句事务
连接持有时间显著延长PREPARE → COMMIT 期间连接被占用且锁未释放
锁持有窗口两阶段全程分支行锁从业务写入持有到 XA COMMIT 完成,长于普通事务

量级判断:单次 XA 事务的绝对开销在毫秒到几十毫秒级(取决于磁盘),对低频运营操作(建店、换号)无感;差异主要在吞吐场景——高频路径上每个事务多两轮 fsync 会让单库 TPS 明显下降,且 PREPARE 阻塞拉长连接占用,加剧连接池压力。因此高频写路径请使用普通事务,跨库原子性通过业务设计(同库建模、异步补偿)解决。

选型建议

场景推荐
单通道事务(绝大多数业务)Db::startTransaction() —— 零额外开销
跨通道低频运营操作(建店、换号、迁移)Db::startXaTransaction()
高频跨通道写路径优先考虑合并到同一库;确需跨库再评估 XA 的两轮磁盘同步开销

XA 较普通事务多两轮磁盘同步与一次 journal 落盘,PREPARE 阶段会阻塞连接,请勿在高频路径滥用。