XA 事务
XA 事务提供跨通道/跨库的原子提交能力(两阶段提交 + 崩溃恢复),解决普通事务在多通道场景下的部分提交问题。
本文依据框架源码
src/Database/Transaction/(XaCoordinator、XaJournal、XaRecovery)及DbManager::startXaTransaction()编写。
为什么需要 XA
普通事务(Db::startTransaction())在单通道内完全原子,但跨多个通道时无法保证原子性——commit 按连接加入顺序逐个提交:
Db::startTransaction(function () {
Db::channel('store')->execute(...); // 通道 A:建店
Db::channel('number')->execute(...); // 通道 B:占号
});若通道 A 提交成功后、通道 B 提交前发生崩溃或提交失败,A 的数据已落库、B 的数据被回滚——两边数据永久不一致(店建了但号没占),且只能人工修复。
XA 事务把原子性边界从"逐连接提交"收窄为"数据库服务端":
Db::startXaTransaction(function () {
Db::channel('store')->execute(...); // 通道 A
Db::channel('number')->execute(...); // 通道 B —— 要么全部落库,要么全部回滚
});使用
闭包形式(推荐)
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;
});手动形式
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):
① 各分支 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):
- gtrid:
vw+ 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(完成提交) |
自动恢复(需显式开启)
// config/database.php
'xa' => [
'auto_recovery' => env('DATABASE_XA_AUTO_RECOVERY', false),
...
],auto_recovery = true 时,每个 Worker 进程启动自动执行恢复(journal 表不存在或无残留行时仅一次探测查询,开销可忽略)。
默认关闭——未使用 XA 的项目零副作用(不建表、不探测、无任何启动开销)。使用 XA 的项目建议开启以获得无人值守收敛;未开启时,悬挂事务通过下述手动命令或外部定时任务调度 xa:recover 收敛。
php viswoole xa:recover恢复操作以 xid 寻址、容忍"事务不存在"(XAER_NOTA)响应,全程幂等——多 Worker 并发恢复、重复执行均安全。无 journal 记录的未决事务(非框架创建)只告警不处理,需人工介入。
journal 表结构
首次使用 XA 事务时自动建表(XaJournal),结构与索引如下:
| 字段 | 类型 | 说明 |
|---|---|---|
gtrid | varchar(64) | 全局事务 ID,主键——一个 XA 事务一行 |
state | tinyint | 1 = prepare_start(提交未确立),2 = prepared(提交意图确立) |
branches | text | 分支 xid 列表 JSON(信息列,供运维排查;恢复以 XA RECOVER 实际结果为准) |
created_at | timestamp | 行写入时间,prepare_start 行 60 秒处置冷却期的判断依据 |
索引仅主键(gtrid):无 state 索引——表稳态为空、行数极小,全表扫描开销可忽略。无需人工清理:正常路径 commit 尾部删行,异常路径恢复任务在终结未决分支后删行;表长期非空(见下文监控)说明存在未收敛事务。
建表时机:仅在实际执行 XA 事务提交时(首次 startXaTransaction)自动建表——恢复任务/xa:recover 只探测不建表,因此未使用 XA 的项目永远不会出现这张表。
告警与监控
当前版本的恢复告警通过日志输出(标签 XA,经框架日志通道写入控制台与日志文件),触发场景:
- 无 journal 记录的未决事务(他方创建或 journal 行丢失)——跳过并提示人工处理;
- 通道扫描失败 / 终结语句失败——journal 行保留,待下次恢复;
- 恢复任务整体失败(journal 不可用等)——Worker 启动不阻断,输出错误日志。
监控指标建议:定期检查 journal 行数——
SELECT COUNT(*) FROM viswoole_xa_journal;稳态应为 0;持续非 0 说明存在未收敛的未决事务(通道宕机、恢复持续失败),接入告警后按 state 与 created_at 定位处置。xa:recover 幂等,可在告警触发后安全重跑。
恢复命令(xa:recover)
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 COMMIT或XA ROLLBACK→ 清理已收敛的行; - 冷却期:60 秒内写入的
prepare_start行会被跳过(防误处置活跃事务),命令输出会提示跳过详情; - 只探测不建表:journal 表不存在(从未使用过 XA)时直接返回,不会创建任何表。
配置
journal 相关配置位于 config/database.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 阶段会阻塞连接,请勿在高频路径滥用。
