Skip to content
Go back

MySQL两阶段提交与崩溃恢复

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_commitchange_stageBINLOG_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_committrx_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=trueinnodb_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 写入延迟从”一个事务一次 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 才能当”提交决定书”。


Share this post on:

Previous Post
MySQL半同步复制——主库宕机后数据不丢失的保障
Next Post
MySQL联合索引——最左前缀与索引下推