MySQL 两阶段提交:redo 和 binlog 到底谁先写?
一句话结论(30s)
MySQL 两阶段提交的本质是 Server 层协调 redo log 与 binlog 两套日志的内部 2PC(不是分布式 XA)——因为一个事务要在两处各写一份日志,任何一处失败都会造成主从数据不一致。关键设计是「先 redo 后 binlog、以 binlog 为准」:崩溃恢复时 redo PREPARE 只是候选,binlog 完整才是提交决定;核心权衡是用一次额外的 PREPARE 步骤换任意时刻宕机都不产生主从分裂,并用组提交把 N 个事务合并成一次 fsync 来抵消性能代价。
核心原理(2min)
提交分两阶段:Prepare 阶段先写 redo log 并标记 PREPARE(内含 XID)后 fsync;Commit 阶段写 binlog、fsync binlog、再写 redo 的 commit 标记,最后清理 undo 与释放锁。崩溃恢复时扫描 redo 找 PREPARE 状态事务,用 XID 去 binlog 对账:redo 连 PREPARE 都没有则直接回滚;有 PREPARE 但 binlog 没有(或 binlog 写一半被截断)则回滚;有 PREPARE 且 binlog 完整则提交。之所以先 redo 后 binlog,是因为若反过来,在「binlog 已写、redo 未写」时崩溃会陷入两日志互相矛盾、没有多数派可裁决的僵局;先 redo 后 binlog 则 binlog 成为唯一裁决依据。组提交让 T1/T2/T3 的 binlog 合并成一次 fsync,牺牲单个事务提交延迟换吞吐量。
底层深入(5-10min)
问题:双日志一致性
InnoDB 有两套日志:redo log(InnoDB 层,物理日志,循环写)和 binlog(Server 层,逻辑日志,顺序追加写)。一个事务在这两个地方各写一份日志。
如果其中一个写成功、另一个写失败——主库和从库的数据就不一致了。
这就是两阶段提交(2PC)要解决的核心问题。
💭 想一想:为什么不”只写一份日志”,非要两处各写一份再协调?——因为 redo 和 binlog 是两层各自的东西:redo 归 InnoDB、用于崩溃恢复,binlog 归 Server、用于主从复制和闪回。一个事务必须同时进两份日志,任何一处成功、另一处失败,主库和从库就分叉了。2PC 的本质就是给”两处写”加一套”要么都成、要么都滚”的协议。
不是分布式事务的 2PC
MySQL 的两阶段提交是内部双日志协调,不是 XA 分布式事务的 2PC。参与者只有两个:redo log(资源管理器)和 binlog(也是资源管理器),协调者是 Server 层的 MYSQL_BIN_LOG::write_transaction。
完整流程
Prepare 阶段(InnoDB 执行):
1. 写 redo log,状态标记为 PREPARE(内含事务 XID)
2. fsync redo log(innodb_flush_log_at_trx_commit=1 时)
Commit 阶段(Server 层执行):
3. 写 binlog(整笔事务的变更记录)
4. fsync binlog(sync_binlog=1 时)
5. 写 redo log commit 标记(事务正式提交)
Commit 阶段后的清理:
6. undo log purge(将 undo 段标记为可复用)
7. 释放锁
源码佐证:三阶段的真实实现
下面从 MySQL 8.0 真实源码摘出关键片段,逐段对应上面的 7 步流程,确认「先写 redo PREPARE → 再写 binlog → 最后写 redo COMMIT」的顺序及其崩溃恢复依据。
阶段一:redo PREPARE(InnoDB 层,trx0trx.cc)
/* storage/innobase/trx/trx0trx.cc */
static lsn_t trx_prepare_low(
trx_t *trx, trx_undo_ptr_t *undo_ptr, bool noredo_logging)
{
if (undo_ptr->insert_undo != nullptr || undo_ptr->update_undo != nullptr) {
mtr_t mtr;
trx_rseg_t *rseg = undo_ptr->rseg;
mtr_start_sync(&mtr);
if (noredo_logging) {
mtr_set_log_mode(&mtr, MTR_LOG_NO_REDO);
}
/* Change the undo log segment states from TRX_UNDO_ACTIVE to
TRX_UNDO_PREPARED: these modifications to the file data
structure define the transaction as prepared in the file-based
world, at the serialization point of lsn. */
rseg->latch();
if (undo_ptr->insert_undo != nullptr) {
trx_undo_set_state_at_prepare(trx, undo_ptr->insert_undo, false, &mtr);
}
if (undo_ptr->update_undo != nullptr) {
if (!noredo_logging) {
trx_undo_gtid_set(trx, undo_ptr->update_undo, true);
}
trx_undo_set_state_at_prepare(trx, undo_ptr->update_undo, false, &mtr);
}
rseg->unlatch();
/*--------------*/
/* This mtr commit makes the transaction prepared in
file-based world. */
mtr_commit(&mtr);
/*--------------*/
if (!noredo_logging) {
const lsn_t lsn = mtr.commit_lsn();
return lsn;
}
}
return 0;
}
redo 的 PREPARE 不是写一条独立的「PREPARE 日志」,而是把 undo 段状态从 TRX_UNDO_ACTIVE 改成 TRX_UNDO_PREPARED。这一步通过 mtr_commit(&mtr) 生成 redo 记录(内含 XID),事务在文件层面正式进入 PREPARED 状态;trx_prepare_low 只负责「改状态、产生 redo」,真正 fsync 落盘的是下面 trx_prepare 里的 trx_flush_logs:
/* storage/innobase/trx/trx0trx.cc */
static void trx_prepare(trx_t *trx) {
...
lsn_t lsn = 0;
...
if (trx->rsegs.m_redo.rseg != nullptr && trx_is_redo_rseg_updated(trx)) {
lsn = trx_prepare_low(trx, &trx->rsegs.m_redo, false);
}
...
trx->state.store(TRX_STATE_PREPARED, std::memory_order_relaxed);
trx_sys->n_prepared_trx++;
...
if (lsn > 0) {
trx_flush_logs(trx, lsn); /* fsync redo,把 PREPARE 落盘 */
}
}
trx_flush_logs 里还有一个和组提交强相关的细节:Server 层进入 binlog 组提交的 prepare 阶段时,会把事务 durability 设为 HA_IGNORE_DURABILITY,跳过逐事务的 redo fsync,改由 flush 阶段一次性把一组事务的 PREPARE 记录合并落盘:
/* storage/innobase/trx/trx0trx.cc */
static void trx_flush_logs(trx_t *trx, lsn_t lsn) {
if (lsn == 0) {
return;
}
switch (thd_requested_durability(trx->mysql_thd)) {
case HA_IGNORE_DURABILITY:
/* We set the HA_IGNORE_DURABILITY during prepare phase of
binlog group commit to not flush redo log for every transaction
here. So that we can flush prepared records of transactions to
redo log in a group right before writing them to binary log
during flush stage of binlog group commit. */
break;
case HA_REGULAR_DURABILITY:
...
trx_flush_log_if_needed(lsn, trx);
}
}
这段注释直接点明组提交下 redo 的落盘时机:redo PREPARE 不逐个 fsync,而是在写 binlog 之前「成组 flush」。它保证了一个不变式——binlog 落盘时,对应的 redo PREPARE 一定已经先于 binlog 持久化,这正是崩溃恢复能「以 binlog 为准」的前提。
💭 想一想:为什么这个”不变式”如此关键?——因为崩溃恢复的裁决规则是”redo 里有 PREPARE、就去看 binlog 有没有完整记录”;如果允许 binlog 先落盘而 redo PREPARE 还没落盘,就会撞上”binlog 有、redo 没有”的僵局,无法裁决。先保证 redo PREPARE 一定先于 binlog 持久化,binlog 才能稳稳当”唯一裁判”。
阶段二:binlog 的 FLUSH / SYNC / COMMIT(Server 层,binlog.cc)
/* sql/binlog.cc — MYSQL_BIN_LOG::ordered_commit 三阶段骨架 */
int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit) {
...
/*
Stage #1: flushing transactions to binary log
While flushing, we allow new threads to enter and will process
them in due time. ...
*/
if (change_stage(thd, Commit_stage_manager::BINLOG_FLUSH_STAGE, thd, nullptr,
get_log_lock())) {
return finish_commit(thd);
}
...
flush_error = m_tc_log_processing->process_flush_stage_queue(
this, &total_bytes, &wait_queue);
if (flush_error == 0 && total_bytes > 0)
flush_error = flush_cache_to_file(&flush_end_pos);
...
/*
Stage #2: Syncing binary log file to disk
*/
if (change_stage(thd, Commit_stage_manager::SYNC_STAGE, wait_queue,
get_log_lock(), get_sync_lock())) {
return finish_commit(thd);
}
...
if (flush_error == 0 && total_bytes > 0) {
std::pair<bool, bool> result = sync_binlog_file(false);
sync_error = result.first;
}
...
/*
Stage #3: Commit all transactions in order.
...
*/
commit_stage:
if ((opt_binlog_order_commits || Clone_handler::need_commit_order()) &&
(sync_error == 0 || binlog_error_action != ABORT_SERVER)) {
if (change_stage(thd, Commit_stage_manager::COMMIT_STAGE, final_queue,
leave_mutex_before_commit_stage, get_commit_lock())) {
return finish_commit(thd);
}
THD *commit_queue =
Commit_stage_manager::get_instance().fetch_queue_acquire_lock(
Commit_stage_manager::COMMIT_STAGE);
...
process_commit_stage_queue(thd, commit_queue);
...
}
...
}
ordered_commit 用 change_stage 在 BINLOG_FLUSH_STAGE → SYNC_STAGE → COMMIT_STAGE 三个队列间切换:FLUSH 阶段 process_flush_stage_queue 把各事务缓存写入 binlog 文件并刷到 page cache(flush_cache_to_file);SYNC 阶段 sync_binlog_file 才真正 fsync(sync_binlog=1 时);COMMIT 阶段才轮到 process_commit_stage_queue 去调用存储引擎 commit。注意 commit 阶段永远排在 binlog 落盘之后——这正是「binlog 完整 = 可以提交」在代码层面的体现。
/* sql/binlog.cc — COMMIT 阶段才调用各引擎真正提交 */
void MYSQL_BIN_LOG::process_commit_stage_queue(THD *thd, THD *first) {
...
for (THD *head = first; head; head = head->next_to_commit) {
...
/* If flush succeeded, attach to the session and commit it in the
engines. */
...
finish_transaction_in_engines(head, all, false);
...
}
...
gtid_state->update_commit_group(first);
for (THD *head = first; head; head = head->next_to_commit) {
...
// Mark transaction as prepared in TC, if applicable
trx_coordinator::set_prepared_in_tc_in_engines(head, all);
/*
Decrement the prepared XID counter after storage engine commit.
...
*/
if (head->get_transaction()->m_flags.xid_written) dec_prep_xids(head);
}
}
finish_transaction_in_engines 会依次回调 InnoDB 的 innobase_commit → trx_commit,即写 redo COMMIT 标记。所以 binlog 的三阶段顺序天然锁定了 2PC 的提交顺序:先 redo PREPARE(阶段开始前)→ 写并 fsync binlog(FLUSH+SYNC)→ 再 redo COMMIT(COMMIT 阶段);尾部 xid_written / dec_prep_xids 则在做 XA 事务 PREPARE 计数与 TC 状态清理。
阶段三:redo COMMIT(InnoDB 层,trx0trx.cc)
/* storage/innobase/trx/trx0trx.cc */
void trx_commit_low(trx_t *trx, mtr_t *mtr) {
...
if (mtr != nullptr) {
mtr->set_sync();
serialised = trx_write_serialisation_history(trx, mtr);
/* The following call commits the mini-transaction, making the
whole transaction committed in the file-based world, at this
log sequence number. The transaction becomes 'durable' when
we write the log to disk, but in the logical sense the commit
in the file-based data structures (undo logs etc.) happens
here. */
mtr_commit(mtr);
...
}
...
trx_commit_in_memory(trx, mtr, serialised);
}
redo COMMIT 与 redo PREPARE 对称:trx_write_serialisation_history 把 undo 段状态从 PREPARED 改成「finished/committed」,再由 mtr_commit 生成 redo 提交记录:
/* storage/innobase/trx/trx0trx.cc */
static bool trx_write_serialisation_history(trx_t *trx, mtr_t *mtr) {
/* Change the undo log segment states from TRX_UNDO_ACTIVE to some
other state: these modifications to the file data structure define
the transaction as committed in the file based domain, at the
serialization point of the log sequence number lsn obtained below. */
...
if (trx->rsegs.m_redo.update_undo != nullptr) {
...
trx_undo_set_state_at_finish(trx->rsegs.m_redo.update_undo, mtr);
...
}
...
}
redo COMMIT 记录同样经 log_write_up_to(log, end_lsn, flush_to_disk) 落盘,flush_to_disk=true(innodb_flush_log_at_trx_commit=1)时才是 FULLY_PERSISTED 的物理 fsync。三个阶段的代码合起来就是完整顺序:redo PREPARE 落盘 → binlog 落盘 → redo COMMIT 落盘,任意一步宕机,恢复时都能以「binlog 里有没有完整 XID」唯一裁决提交还是回滚。
崩溃恢复的决策逻辑
假设在以上 7 步的任意一步崩溃。恢复时 InnoDB 扫描 redo log:
情况 1:redo log 里连 PREPARE 都没有
→ 事务根本没开始或没有修改(只读事务)。回滚,不查 binlog。
情况 2:redo log PREPARE 有,但 binlog 还没写(崩溃在第 3 步之前)
恢复时:
扫描 redo → 找到 PREPARE 状态事务(XID = 100)
扫描 binlog → 找不到 XID = 100 的写入记录
→ 回滚(主库回滚,从库也不会有这条记录,一致)
从库从来没有收到这个事务——因为 binlog 没写出去,一劳永逸。
情况 3:redo log PREPARE 有,binlog 也已经 fsync 了(崩溃在第 5 步之前或之后)
恢复时:
扫描 redo → 找到 PREPARE 状态事务(XID = 100)
扫描 binlog → 找到 XID = 100 的完整记录
→ 提交(主库回放 redo 完成提交,从库通过 binlog 已经收到此事务,一致)
情况 4:binlog 写到一半崩溃(第 3 步中间)
恢复时:
扫描 redo → 找到 PREPARE 状态事务(XID = 100)
扫描 binlog → binlog 文件末尾是不完整的事件(truncated)
→ 回滚(从库也不会收到这个半截事务)
把 binlog 尾部不完整的事件截断,事务在主库回滚,从库也是一致的。
为什么先 redo 后 binlog 而不是反过来?
如果先 binlog 后 redo:
1. 写 binlog 成功
2. 写 redo log PREPARE 之前崩溃
→ binlog 有这个事务,但从库也是 InnoDB,从库回放 binlog 后主从都有
→ 但主库崩溃恢复时没有 redo PREPARE 记录,事务在主库相当于不存在
→ 主库数据 ≠ 从库数据
在这个时间点崩溃,就无法判断应该回滚还是提交——binlog 说有,redo 说没有,两种日志互相矛盾,没有”多数派”可以裁决。
先 redo 后 binlog 的巧妙之处:崩溃恢复时以 binlog 为准——binlog 有 = 提交,binlog 没有 = 回滚。redo PREPARE 只是”候选”,binlog 完整才是”决定”。
💭 想一想:为什么”先 redo 后 binlog”就能以 binlog 为准,反过来就不行?——因为顺序决定了”谁可能是多余的”:先 redo 后 binlog 时,最坏情况是 redo 多了个 PREPARE 而 binlog 没有,这种”redo 多”很好处理(回滚即可);反过来先 binlog 后 redo,会变成”binlog 有、redo 没有”,两份日志一个说有一个说没有、没有多数派裁决,就卡死了。选对顺序,等于把”僵局”降级成”可裁决的两种常态”。
XID 的作用
XID 是贯穿 redo log 和 binlog 的”事务身份证”:
XID = {server_uuid}:{transaction_seq}
redo log PREPARE 记录: "事务 {XID} 进入 PREPARE"
binlog GTID 事件: "事务 {XID} 的变更如下..."
崩溃恢复: 用 XID 把 redo PREPARE 和 binlog 记录对起来
→ XID 在 redo 里有 PREPARE、在 binlog 里也有 → 提交
→ XID 只在 redo 里有 PREPARE → 回滚
组提交(Group Commit)的优化
如果每个事务都单独 fsync,在高并发下 fsync 次数太多,磁盘 IOPS 是瓶颈。
MySQL 的组提交让多个事务的 binlog 合并到一次 fsync:
T1 的 binlog 写入了 → 不立即 fsync,等一等
T2 的 binlog 写入了 →
T3 的 binlog 写入了 →
→ 一次性 fsync T1+T2+T3 的 binlog
→ 然后 T1/T2/T3 各自写 redo commit 标记
三个队列长度参数:
binlog_group_commit_sync_delay(等待时间,默认 0,不等待)binlog_group_commit_sync_no_delay_count(凑够多少事务立即 fsync,不等了)
组提交让事务的 binlog 写入延迟从”一个事务一次 fsync”降到”N 个事务一次 fsync”,吞吐量大幅提升,代价是单个事务的提交延迟略微增加。
💭 想一想:组提交为什么能”无损”地提升吞吐?——因为它合并的是”落盘”这一步,而不是”日志内容”:每个事务的 binlog 内容都完整写了,只是 fsync 被攒成一次;崩溃时这一批要么都随 fsync 落盘、要么都在 page cache 里一起没落,一致性结论仍是”以 binlog 是否完整”来判断,没有被破坏。牺牲的只是单个事务多等一会儿才 fsync 的延迟。
与分布式事务 2PC 的对比
| MySQL 内部 2PC | 分布式事务 2PC | |
|---|---|---|
| 参与者 | redo log + binlog(同进程) | 多个独立的数据库节点(跨网络) |
| 协调者 | Server 层(同进程) | 独立的事务管理器(TM) |
| PREPARE 含义 | redo PREPARE 标记 | 所有参与者回复”可以提交” |
| COMMIT 含义 | binlog + redo COMMIT 标记 | 协调者发 commit 指令 |
| 崩溃恢复 | 扫描本地 redo + binlog | 需要协调者持久化事务日志 |
| 网络开销 | 无(同一进程内) | RPC 往返 + 超时重试 |
MySQL 内部 2PC 比分布式 2PC 简单很多——没有网络分区、没有超时重试、没有协调者故障后的挂起问题。
总结
MySQL 两阶段提交的核心原则很简单:binlog 是”提交决定书”。redo PREPARE 只是候选,崩溃恢复时发现有 PREPARE 就去 binlog 里找”决定”——有 binlog 则提交,无 binlog 则回滚。这个设计让双日志在任何时刻宕机都能保证主从一致。
章末提问
追问 1:为什么要两阶段提交?不做会怎样?
回答思路:结论先行——因为一个事务要同时写 redo 和 binlog 两份日志,不做协调,任一时刻宕机都可能主从不一致。因为 redo 归 InnoDB(崩溃恢复用)、binlog 归 Server(主从复制用),两处各写一份;若一处成功一处失败,主库和从库数据就分叉。2PC 用「PREPARE + COMMIT」两步把”两处写”变成”要么都成、要么都滚”。
追问 2:崩溃恢复时怎么决定提交还是回滚?依据是什么?
回答思路:结论先行——以”binlog 里有没有完整的 XID”为唯一依据。因为恢复时扫 redo 找 PREPARE 状态事务,再用 XID 去 binlog 对账:redo 连 PREPARE 都没有→回滚;有 PREPARE 但 binlog 没有(或写一半被截断)→回滚;有 PREPARE 且 binlog 完整→提交。XID 是贯穿两端的事务身份证,让两份日志能对得上。
追问 3:为什么是先 redo 后 binlog,而不是反过来?
回答思路:结论先行——反过来会在”binlog 已写、redo 未写”时崩溃,陷入两份日志互相矛盾、无多数派可裁决的僵局。因为先 redo 后 binlog 时,最坏情况是 redo 多了个 PREPARE 而 binlog 没有,这种”redo 多”回滚即可处理;先 binlog 后 redo 则会出现”binlog 有、redo 没有”,一个说有、一个说没有,无法裁决。选对顺序,binlog 才能当”提交决定书”。