MySQL Binlog 組提交實現

語言: CN / TW / HK

本文程式碼分析基於 MySQL 8.0.29

1.背景

MySQL 提交流程有兩個問題需要解決:

1.1. 提交寫兩份日誌的效能問題

為了保證事務的永續性和原子性,事務提交完成前,其日誌(WAL)必須持久化。對於 MySQL 來說,需要保證事務提交前,redo log 落盤。雖然日誌順序寫的效能,已經高於資料檔案隨機寫的效能,但是如果每次事務提交,都需將 redo log 刷盤,效率較低。同時 MySQL 還要寫 binlog,相當於每次事務提交需要兩次 IO,很容易成為效能瓶頸。

為了解決上述效能問題,經過 MySQL 5.6/5.7/8.0 的不斷優化,引入組提交技術和流水線技術。

1.2. redo log/binlog 的原子性和一致性

原子性比較好解決,MySQL 利用一個內部 2PC 機制實現 redo log 和 binlog 的原子提交,其中2PC 的協調者由 binlog 承擔。

// mysqld.cc, init_server_components
 if (total_ha_2pc > 1 || (1 == total_ha_2pc && opt_bin_log)) {
   if (opt_bin_log)
     // tc means transaction coordinator
     tc_log = &mysql_bin_log;
   else
     tc_log = &tc_log_mmap;
 }

內部兩階段提交的流程簡單描述為:

  • Prepare 階段 :(1)InnoDB 將回滾段上的事務狀態設定為 PREPARED;(2)將 redolog 寫檔案並刷盤;
  • Commit 階段 :(1)Binlog 寫入檔案;(2)binlog 刷盤;(3)InnoDB commit;

兩階段提交的 commit point 是 binlog 刷盤成功(因為此時兩個日誌都持久化成功了)。Recovery 流程會比較 binlog xid 和 redo xid,判斷事務是否達到 commit point,以此來決定提交還是回滾:

  • 如果 binlog 還未刷盤,即使 redo log 已經刷盤了也要回滾。
  • 如果 binlog 已經刷盤,即使 InnoDB commit 還未完成,也要重新寫入 commit 標誌,完成提交。

解決完原子性的問題,還有一致性問題。事務binlog 提交的順序應該和 redo log 保持一致,否則可能物理備份(不拷貝 binlog)丟資料的問題(可以參考該文章給出的例子 https:// blog.51cto.com/u_151276 00/3998295 )。但是 Xtrabackup 在這次提交後 https:// jira.percona.com/browse /PXB-1770 ,通過備份 binlog 避免了這種問題。

MySQL5.6以前,為保證 binlog 和 redo log 提交順序一致,MySQL 使用了prepare_commit_mutex 鎖,事務在兩階段提交流程中持有它,這樣確實可以保證兩份日誌順序一致,但它也會導致事務序列執行,效率很低。後來組提交和流水線提交的引入,不僅減少了 IO 次數,還提高了事務執行的併發度,減小了加鎖的粒度。

2. 提交流水線

為解決上節提到的兩個問題,經過 5.6/5.7/8.0 的逐步優化,兩階段提交的邏輯優化為:

  • Prepare 階段基本不變,只是寫 redolog 時並不刷盤。
  • Commit 階段按步驟做流水線批處理,將鎖粒度進一步拆細。

Commit 階段又拆為三個主要步驟:

  • flush stage:按事務進入的順序將 binlog 從 cache 寫入檔案(不刷盤),redo log 刷盤(多個事務 redo 合併刷盤)。
  • sync stage:對 binlog 檔案做 fsync 操作(多個事務的 binlog 合併刷盤)。
  • commit stage:各個執行緒按順序做 InnoDB commit 操作。

每個 stage 一個佇列,第一個進入該佇列的執行緒成為 leader,後續進入的執行緒會阻塞直至完成提交。leader 執行緒會領導佇列中的所有執行緒執行該 stage 的任務,並帶領所有 follower 進入到下一個 stage 去執行,當遇到下一個 stage 為非空佇列時,leader 會變成 follower 註冊到此佇列中。

而 redo log 刷盤從 Prepare 階段移動到 flush stage,這樣 leader 也可以將多個事務的 redo log 合併刷盤。同樣 sync stage 的 leader 可以將多個事務的 binlog 合併刷盤。

每一個 stage 都是加鎖的,保證 binlog 與 redo log 寫入順序是一致的。

總結下來,這套優化主要帶來了兩個好處:

  1. Commit 階段流水化作業,stage 內批處理,stage 之間可以併發,大大提升了寫的併發度,進而提高吞吐與資源利用率。
  2. redo log / binlog 合併刷盤,大幅減少 IO 次數。

3. 程式碼實現

3.1 Prepare

協調者的 Prepare 呼叫儲存引擎的 ha_prepare_low 即可,下面這段註釋說的很清楚,此時不持久化 InnoDB redo log。

int MYSQL_BIN_LOG::prepare(THD *thd, bool all) {
  /*
    Set HA_IGNORE_DURABILITY to not flush the prepared record of the
    transaction to the log of storage engine (for example, InnoDB
    redo log) during the prepare phase. So that we can flush prepared
    records of transactions to the log of storage engine in a group
    right before flushing them to binary log during binlog group
    commit flush stage. Reset to HA_REGULAR_DURABILITY at the
    beginning of parsing next command.
  */
  thd->durability_property = HA_IGNORE_DURABILITY;
  int error = ha_prepare_low(thd, all);
  return error;
}

3.2 Commit

組提交的程式碼主要位於 MYSQL_BIN_LOG::ordered_commit

MySQL 8.0.29 後將原來 slave 並行回放過程抽象成新的 stage0(原來這個流程也是有的,只是沒有抽象為 stage0),其工作是協調多個回放執行緒的回放順序,讓事務提交順序與主庫一致。以下程式碼只有備庫回放會走到。

/*
    Stage #0: ensure slave threads commit order as they appear in the slave's
              relay log for transactions flushing to binary log.

    This will make thread wait until its turn to commit.
    Commit_order_manager maintains it own queue and its own order for the
    commit. So Stage#0 doesn't maintain separate StageID.
  */
  if (Commit_order_manager::wait_for_its_turn_before_flush_stage(thd) ||
      ending_trans(thd, all) ||
      Commit_order_manager::get_rollback_status(thd)) {
    if (Commit_order_manager::wait(thd)) {
      return thd->commit_error;
    }
  }

stage 轉換函式

事務提交三個 stage 之間的轉換,都用的是 MYSQL_BIN_LOG::change_stage 函式,其主要邏輯是呼叫了 Commit_stage_manager::enroll_for。該函式在 8.0.29 版本里,加了很多WL#7846 處理邏輯,幫助備庫在不開 binlog,但是並行回放的情況下,依舊可以和主庫保持相同的提交序,這一部分我會從下面的核心程式碼裡刪除,感興趣的朋友可以看下WL#7846 。

enroll_for 主要做了以下幾件事:

1.判斷自己是不是入隊的第一個,如果是則為 leader,否則為 follower,enroll_for 的返回值為 true 則為 leader。

2.釋放上個階段持有的鎖,先入隊新的 stage,再釋放上一個 stage 的鎖,保證事務執行的順序在每個 stage 相同,保證事務的正確性。注意:BINLOG_FLUSH_STAGE 沒有上一個階段的鎖,入參 stage_mutex 為 nullptr。

3.follower 會阻塞等待在 m_stage_cond_binlog 條件變數上。

4.Leader 持有本階段的鎖(enter_mutex)。

bool MYSQL_BIN_LOG::change_stage(THD *thd [[maybe_unused]],
                                 Commit_stage_manager::StageID stage,
                                 THD *queue, mysql_mutex_t *leave_mutex,
                                 mysql_mutex_t *enter_mutex) {
  if (!Commit_stage_manager::get_instance().enroll_for(
          stage, queue, leave_mutex, enter_mutex)) {
    return true;
  }
  return false;
}

bool Commit_stage_manager::enroll_for(StageID stage, THD *thd,
                                      mysql_mutex_t *stage_mutex,
                                      mysql_mutex_t *enter_mutex) {
  
  // 1.判斷自己是不是入隊的第一個,如果是則為 leader,
  // 否則為 follower,enroll_for 的返回值為 true 則為 leader。
  lock_queue(stage);
  bool leader = m_queue[stage].append(thd);
  unlock_queue(stage);
  
  // 2.先入隊新的 stage,再釋放上一個 stage 的鎖,
  // 保證事務執行的順序在每個 stage 相同,保證事務的正確性。
  // 注意:BINLOG_FLUSH_STAGE 沒有上一個階段的鎖,入參 stage_mutex 為 nullptr。
  
  // 特殊情況:當前持有的是 LOCK_log,且正在進行 rotating,就不用釋放當前 stage 的鎖了
  // 因為 rotating 需要 LOCK_log
  bool need_unlock_stage_mutex =
      !(mysql_bin_log.is_rotating_caused_by_incident &&
        stage_mutex == mysql_bin_log.get_log_lock());

  if (stage_mutex && need_unlock_stage_mutex) mysql_mutex_unlock(stage_mutex);

  // 3.follower 會阻塞等待在 m_stage_cond_binlog 條件變數上。
  if (!leader) {
    mysql_mutex_lock(&m_lock_done);
    while (thd->tx_commit_pending) {
      mysql_cond_wait(&m_stage_cond_binlog, &m_lock_done);
    }
    mysql_mutex_unlock(&m_lock_done);
    return false;
  }

  // 4.leader 持有本階段的鎖(enter_mutex)。
  bool need_lock_enter_mutex = false;
  if (leader && enter_mutex != nullptr) {
    // 特殊情況:enter_mutex 是 LOCK_log,且正在進行 rotating,就不用再去加鎖了,
    // 因為已經加上了。
    need_lock_enter_mutex = !(mysql_bin_log.is_rotating_caused_by_incident &&
                              enter_mutex == mysql_bin_log.get_log_lock());
    if (need_lock_enter_mutex)
      mysql_mutex_lock(enter_mutex);
    else
      mysql_mutex_assert_owner(enter_mutex);
  }
  return leader;
}

Stage 1 -- BINLOG_FLUSH_STAGE

事務 flush 到 binlog (不 sync) ,程式碼中的解釋:

/*
    Stage #1: flushing transactions to binary log

    While flushing, we allow new threads to enter and will process
    them in due time. Once the queue was empty, we cannot reap
    anything more since it is possible that a thread entered and
    appointed itself leader for the flush phase.
  */

BINLOG_FLUSH_STAGE leader 的主要工作如下(程式碼依舊位於MYSQL_BIN_LOG::ordered_commit)

1.change_stage,進入 BINLOG_FLUSH_STAGE 狀態。

2.如果發現 binlog 被關了,直接跳到(goto)commit stage。

(3,4,5 在 process_flush_stage_queue 完成)

3.拿到 flush queue 的 head,清空 flush queue,以便新的執行緒進入作為 leader。呼叫 ha_flush_logs(true) 批量刷 redo log。

4.依次呼叫 MYSQL_BIN_LOG::flush_thread_caches 將每個事務快取在 binlog_cache_mngr 裡的資訊 flush 到 binlog(cache)。呼叫路徑:

MYSQL_BIN_LOG::flush_thread_caches
|--binlog_cache_mngr::flush
|----binlog_stmt_cache_data::flush
|----binlog_trx_cache_data::flush
|------binlog_cache_data::flush 
|--------MYSQL_BIN_LOG::write_transaction

5.判斷是否需要 rotate。

6.將 binlog 寫到 binlog 檔案(不 sync),flush_cache_to_file

// 1. change_stage,進入 BINLOG_FLUSH_STAGE 狀態.
if (change_stage(thd, Commit_stage_manager::BINLOG_FLUSH_STAGE, thd, nullptr,
                 &LOCK_log)) {
    return finish_commit(thd);
 }

// 2.如果 binlog 被關了,直接跳到(goto)COMMIT_STAGE。
// leave_mutex_before_commit_stage 表示需要在 COMMIT_STAGE 釋放的鎖。
if (unlikely(!is_open())) {
    final_queue = fetch_and_process_flush_stage_queue(true);
    leave_mutex_before_commit_stage = &LOCK_log;
    goto commit_stage;
}

// 3/4/5 步在該函式執行
flush_error = process_flush_stage_queue(&total_bytes, &do_rotate, &wait_queue);

if (flush_error == 0 && total_bytes > 0) {
// 6.將 binlog 寫到 binlog 檔案
    flush_error = flush_cache_to_file(&flush_end_pos);
}

process_flush_stage_queue 執行 3-5 步,事務 redo 刷盤,將事務的資訊寫到 binary log

int MYSQL_BIN_LOG::process_flush_stage_queue(my_off_t *total_bytes_var,
                                             bool *rotate_var,
                                             THD **out_queue_var) {

  // 3. 該函式會呼叫 ha_flush_logs 持久化 redo log
  THD *first_seen = fetch_and_process_flush_stage_queue(should_return, term, true);

  //  4.依次將所有所有事務從各自的 cache 裡 flush 到 binlog
  for (THD *head = first_seen; head; head = head->next_to_commit) {
    std::pair<int, my_off_t> result = flush_thread_caches(head);;
  }
  
  // 5.判斷是否需要 rotate。剛寫完 binlog,是判斷的恰當時期。
  if (total_bytes > 0 &&
      (m_binlog_file->get_real_file_size() >= (my_off_t)max_size ||
       DBUG_EVALUATE_IF("simulate_max_binlog_size", true, false)))
    *rotate_var = true;
  return flush_error;
}

Stage 2 -- SYNC_STAGE

BINLOG_FLUSH_STAGE 階段的 leader 帶著一個連結串列進入 SYNC_STAGE 階段,首先依舊呼叫 change_state 函式,可能成為該階段的 leader,也可能成為 follower,因為此時 LOCK_sync 可能正在被做 sync 的執行緒持有。多個 flush queue 會因為等待鎖而合併成一個 sync queue。

Sync 的後續流程:

1.判斷本次要不要 sync

一個 SYNC_STAGE 的 leader 通過引數判斷,本次是否需要 sync。sync_counter 變數代表進入 SYNC_STAGE 但是沒有真正 sync 的 leader 的個數。當 MySQL 配置引數 sync_binlog 設定大於 1 時,並不是每個 leader 執行到這裡都會 sync。

get_sync_period() 獲得的值,即是 sync_binlog 引數的值。

因此,判斷 sync_counter + 1 >= get_sync_period(),表示當前的 leader 可以 sync 了,那麼該執行緒繼續等一會兒,等待更多的執行緒進入 sync queue 在一起提交,具體等多久,由 opt_binlog_group_commit_sync_no_delay_count 和 opt_binlog_group_commit_sync_delay 決定。如果本 leader 不 sync,則不用等待。

注意,當 sync_binlog == 0 時,每個 leader 執行緒都要等待。當 sync_binlog == 1 時,同樣每個 leader 執行緒都要等待,因為每個 leader 都要 sync。當 sync_binlog > 1 時,一部分 leader 執行緒就不用等待,接著執行,反正也不會 sync。

2.呼叫 sync_binlog_file 去 sync binlog。sync_binlog_file 中實現只有當 sync_period > 0 && ++sync_counter >= sync_period 時才真正 sync。

/*
    Stage #2: Syncing binary log file to disk
  */

  if (change_stage(thd, Commit_stage_manager::SYNC_STAGE, wait_queue, &LOCK_log,
                   &LOCK_sync)) {
    return finish_commit(thd);
  }

  // 1.判斷本次要不要真正 sync,真正 sync 需要等一會,詳見上文的說明
  if (!flush_error && (sync_counter + 1 >= get_sync_period()))
    Commit_stage_manager::get_instance().wait_count_or_timeout(
        opt_binlog_group_commit_sync_no_delay_count,
        opt_binlog_group_commit_sync_delay, Commit_stage_manager::SYNC_STAGE);
  
  // 僅是後面更新 binlog end 位點用
  final_queue = Commit_stage_manager::get_instance().fetch_queue_acquire_lock(
      Commit_stage_manager::SYNC_STAGE);
  
  // 2. sync
  if (flush_error == 0 && total_bytes > 0) {
    std::pair<bool, bool> result = sync_binlog_file(false);
    sync_error = result.first;
  }

Stage 3 -- COMMIT_STAGE

依次將 redolog 中已經 prepare 的事務在引擎層提交,該階段不用刷盤,因為 flush 階段中的 redolog 刷盤已經足夠保證資料庫崩潰時的資料安全了。

COMMIT_STAGE 的主要工作包括:

1.達成多數派後,呼叫 ha_commit_low 提交,提交完成後還需減少 prepared XID counter

2.喚醒所有等待的 follower,完成提交。

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, &LOCK_commit)) {
      return finish_commit(thd);
    }
    THD *commit_queue =
        Commit_stage_manager::get_instance().fetch_queue_acquire_lock(
            Commit_stage_manager::COMMIT_STAGE);
    
    // 執行 after sync hook(如果有的話)
    if (flush_error == 0 && sync_error == 0)
      sync_error = call_after_sync_hook(commit_queue);

    // 1.在該函式內完成,呼叫 ha_commit_low 提交引擎
    process_commit_stage_queue(thd, commit_queue);
    mysql_mutex_unlock(&LOCK_commit);
    
    // 執行 after commit hook(如果有的話)
    process_after_commit_stage_queue(thd, commit_queue);
    final_queue = commit_queue;
  } else {
    // 如果因為 opt_binlog_order_commits 為 false 進入這裡。
    // 不 ordered commit,那麼就等 follower 被通知後,自己去提交
    if (leave_mutex_before_commit_stage)
      mysql_mutex_unlock(leave_mutex_before_commit_stage);
    if (flush_error == 0 && sync_error == 0)
      sync_error = call_after_sync_hook(final_queue);
  }
  
  // 3. 通知佇列中所有等待的執行緒
  // follower 如果發現事務沒有提交,會呼叫 ha_commit_low, 此時就不能保證 commit 的順序了。
  /* Commit done so signal all waiting threads */
  Commit_stage_manager::get_instance().signal_done(final_queue);