深度解析Binlog組提交過程

MySQL引入binlog來實現主從實例之間的數據同步,提高數據庫系統的可用性,但同時也增加了事務整體的資源消耗,需要額外的磁盤空間和IO處理能力。尤其是爲了保證本地事務的持久性,必須將binlog刷盤控制參數sync_binlog設置爲1,設想如果每一次事務提交,都強制進行一次刷盤操作,數據庫整體的性能會受到極大的影響。由此也引出了本節的主要內容:binlog組提交。

大多數MySQL DBA對組提交應該是不會陌生,MySQL引入組提交的目的是爲了在高併發下合併多個線程的刷盤操作,降低日誌刷盤次數,提高數據庫的整體性能。這裏的日誌不單指二進制日誌binlog,還包括了innodb的事務日誌redo log。

在binlog的刷盤過程中,MySQL根據不同操作系統的特性,會盡量的去調用fdatasync而不是fsync,但是對於追加式的日誌寫入來講,fdatasync並不會比fsync的效率高太多。

在歷史版本中MySQL對組提交先後進行過多次優化,先是支持了redo log的組提交,後續又開始支持binlog的提交,但是卻出現了在binlog組提交下無法對redo log進行組提交的尷尬情況,本文對於這些內容不再進行過多描述,直接來看在MySQL-5.7版本中的組提交實現邏輯。

下面來看組提交的具體實現。 MySQL把2pc的commit階段拆分爲三個階段,分別爲

  • flush
  • sync
  • commit

其中不同的階段會對應着不同的隊列m_queue,並且通過隊列內部的互斥鎖m_lock保證隊列併發訪問(入隊,出隊)的正確性,事務提交的三個階段通過三把互斥鎖來進行保護,分別爲:

  • LOCK_log
  • LOCK_sync
  • LOCK_commit

值得一提的時,這三把鎖分別保護的臨界區是flush,sync,commit這三個事務提交的過程,並不是隊列,隊列由隊列內部的互斥鎖來進行保護。

組提交的核心思想就是在進行不同節點的處理時,都由隊首線程來完成本階段的工作,非隊首線程進入等待,直到事務提交完成。這部分邏輯主要在文件binlog.cc中的函數int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit)。本節結合源代碼以及6個客戶端線程的事務提交過程來闡述組提交的具體過程。

準備提交的線程t1在進行flush階段之前,如果當前線程爲slave實例的worker線程,並且開啓了參數slave_preserve_commit_order,需要檢測當前線程是否爲**的隊首線程。如果不是的話,需要進行等待,這也是爲了在多線程回放過程中,保證slave實例的事務提交順序和master實例一致。本文不對commit order相關的內容進行過多介紹。參照代碼如下:

#ifdef HAVE_REPLICATION
  if (has_commit_order_manager(thd))
  {
    Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
    Commit_order_manager *mngr= worker->get_commit_order_manager();

    if (mngr->wait_for_its_turn(worker, all))
    {
      thd->commit_error= THD::CE_COMMIT_ERROR;
      DBUG_RETURN(thd->commit_error);
    }

    if (change_stage(thd, Stage_manager::FLUSH_STAGE, thd, NULL, &LOCK_log))
      DBUG_RETURN(finish_commit(thd));
  }
  else
#endif

多線程複製分發:

    if (rli->get_commit_order_manager() != NULL && worker != NULL)
      rli->get_commit_order_manager()->register_trx(worker);

如果是master實例,t1線程可以直接通過函數MYSQL_BIN_LOG::change_stage進入flush隊列,並且由於它是進入隊列的第一個線程,順理成章的成爲flush 隊列的隊首元素,加入flush隊列的過程由函數Stage_manager::enroll_for(Stage_manager::StageID, THD*, st_mysql_mutex*)來完成,具體過程如下:

  • 調用函數Stage_manager::Mutex_queue::append(THD *first),判斷當前隊列是否爲空,如果爲空的話,則說明當前線程可以作爲隊首線程,這個過程由隊列中的互斥鎖m_lock來進行保護。代碼如下:
bool
Stage_manager::Mutex_queue::append(THD *first)
{
  DBUG_ENTER("Stage_manager::Mutex_queue::append");
  lock();
  DBUG_PRINT("enter", ("first: 0x%llx", (ulonglong) first));
  DBUG_PRINT("info", ("m_first: 0x%llx, &m_first: 0x%llx, m_last: 0x%llx",
                       (ulonglong) m_first, (ulonglong) &m_first,
                       (ulonglong) m_last));
  int32 count= 1;
  bool empty= (m_first == NULL);
  *m_last= first;
  DBUG_PRINT("info", ("m_first: 0x%llx, &m_first: 0x%llx, m_last: 0x%llx",
                       (ulonglong) m_first, (ulonglong) &m_first,
                       (ulonglong) m_last));
  /*
    Go to the last THD instance of the list. We expect lists to be
    moderately short. If they are not, we need to track the end of
    the queue as well.
  */

  while (first->next_to_commit)
  {
    count++;
    first= first->next_to_commit;
  }
  my_atomic_add32(&m_size, count);

  m_last= &first->next_to_commit;
  DBUG_PRINT("info", ("m_first: 0x%llx, &m_first: 0x%llx, m_last: 0x%llx",
                        (ulonglong) m_first, (ulonglong) &m_first,
                        (ulonglong) m_last));
  DBUG_ASSERT(m_first || m_last == &m_first);
  DBUG_PRINT("return", ("empty: %s", YESNO(empty)));
  unlock();
  DBUG_RETURN(empty);
}
  • 由於t1線程是flush隊列的leader,所以加入隊列後不需要進行等待(等待別人幫助自己進行事務提交操作)
  • 對互斥鎖LOCK_log加鎖,目的是爲了保證flush階段多個階段的順序性(因爲真正到了寫磁盤的時候,如果多個隊列的leader線程一起寫,記錄的binlog日誌就會亂序)

t1對LOCK_log加鎖後,不會進行任何等待,便要開始進行Flush binlog cache的操作,但是非常有可能的是,在t1等待獲取LOCK_log前後,清空flush隊列之前,t2也要進行事務提交操作,並且進行flush隊列的入隊動作,但是發現隊列非空,t1已經成爲了隊首,所以自己要進入等待狀態,讓t1帶領自己進行事務提交操作,等待的邏輯在函數Stage_manager::enroll_for(Stage_manager::StageID, THD*, st_mysql_mutex*)中,實現如下:

  if (!leader)
  {
    mysql_mutex_lock(&m_lock_done);
#ifndef DBUG_OFF
    /*
      Leader can be awaiting all-clear to preempt follower's execution.
      With setting the status the follower ensures it won't execute anything
      including thread-specific code.
    */
    thd->get_transaction()->m_flags.ready_preempt= 1;
    if (leader_await_preempt_status)
      mysql_cond_signal(&m_cond_preempt);
#endif
    while (thd->get_transaction()->m_flags.pending)
      mysql_cond_wait(&m_cond_done, &m_lock_done);
    mysql_mutex_unlock(&m_lock_done);
  }

隨後t1通過函數MYSQL_BIN_LOG::process_flush_stage_queue(unsigned long long*, bool*, THD**)來帶領t2進行binlog cache的刷新操作,這個過程如下所示:

  • 通過函數Stage_manager::fetch_queue_for(Stage_manager::StageID)清空flush 隊列,並且獲取隊首線程,如下:
Stage_manager::fetch_queue_for(Stage_manager::StageID)
    Stage_manager::Mutex_queue::fetch_and_empty()
    {
        lock();
        THD *result= m_first;
        m_first= NULL;
        m_last= &m_first;
        my_atomic_store32(&m_size, 0);
        unlock();
    }

可以看到,清空隊列的過程也需要進行mutex鎖保護。
注意:
這裏的清空隊列並不意味着之前形成的flush隊列不存在了

  • 調用函數ha_flush_logs刷新redo log,並且強制落盤

  • 調用函數assign_automatic_gtids_to_flush_group爲隊列中的每一個線程中的事務分配GTID

  • 在循環中刷新隊列中每一個線程的binlog 緩存,如下:

  /* Flush thread caches to binary log. */
  for (THD *head= first_seen ; head ; head = head->next_to_commit)
  {
    std::pair<int,my_off_t> result= flush_thread_caches(head);
    total_bytes+= result.second;
    if (flush_error == 1)
      flush_error= result.first;
#ifndef DBUG_OFF
    no_flushes++;
#endif
  }
  • 調用函數flush_cache_to_file將binlog緩存寫入物理文件

  • 判斷參數sync_binlog是否爲1,如果爲1的話,則立即通知dump線程進行binlog的發送工作,否則不進行通知。

    update_binlog_end_pos_after_sync= (get_sync_period() == 1);
    if (!update_binlog_end_pos_after_sync)
      update_binlog_end_pos();

在t1帶領t2進行flush階段的操作時,t3開始進行事務提交,t3沒有t2那麼幸運,沒有加入以t1爲首的隊列,t3自己成爲新的flush隊列的leader,但是現在卻無法執行flush操作,因爲t1尚未釋放Lock_log。如下圖所示

t3在等待Lock_log時,t4也準備進行事務提交,此時加入以t3爲leader的flush隊列中,如下圖所示:

隨後,t1線程結束了flush階段的操作,準備進入sync隊列,開始sync階段的操作,這個過程的入口函數同樣是MYSQL_BIN_LOG::change_stage(THD*, Stage_manager::StageID, THD*, st_mysql_mutex*, st_mysql_mutex*),詳細如下:

  • 通過函數Stage_manager::enroll_for(Stage_manager::StageID, THD*, st_mysql_mutex*),加入sync隊列,並且成爲sync隊列的leader。
  • t1在成功加入sync隊列後,要釋放爲了進行上一個階段而添加的互斥鎖Lock_log,如下:
  /*
    The stage mutex can be NULL if we are enrolling for the first
    stage.
  */
  if (stage_mutex)
    mysql_mutex_unlock(stage_mutex);

t1一旦釋放Lock_log,則t3可以獲取到Lock_log,並且開始執行flush階段的操作,假設正好在t3清空flush隊列後,t5加入了flush隊列中,並且成爲新的flush隊列的leader,等待Lock_log(Lock_log此時被t3佔據),如下圖所示:

  • t1添加sync階段的互斥鎖LOCK_sync
  mysql_mutex_lock(enter_mutex);
  • t1線程判斷在執行sync操作前是否要進行等待,這裏的等待分爲兩種情況
    • sync_binlog爲1或者0時,判斷是否符合binlog_group_commit_sync_delay以及binlog_group_commit_sync_no_delay_count的條件。
  • 清空當前sync隊列,這個過程和清空flush隊列的情況保持一致,但是可能的情況是在t1在釋放Lock_log之後,清空sync隊列之前,t3完成了flush階段的工作,並且加入到了以t1爲首的sync隊列中,但是由於自己不是leader線程,所以開始進入等待,於此同時,一旦t3進入sync階段,就會釋放Lock_log,則t5可以對Lock_log加鎖,並且開始清空flush隊列,執行flush階段的操作,但是t6沒有在t5清空隊列前加入flush隊列,它自己成爲了新的flush隊列的leader,等待互斥鎖Lock_log(此時被t5持有),如下圖所示:

你可能會發現,此時以t1爲首的sync隊列已經被拉長了,並且隊列中出了t1以外的其他線程都是處於等待狀態,不同的時,有的線程是在進入flush階段就開始等待的,比如t2/t3,有的是在進入sync階段時開始等待的,比如t3。讀者可能聽說過:隊列的leader可能成爲flower,但flower永遠都是flower這句話,就是這個意思。

  • 通過函數sync_binlog_file進行binlog文件的sync操作,在sync_binlog=1時,確保每一個事務的binlog日誌都是被固化存儲的。

  • 在sync_binlog爲1時,binlog日誌落盤後,通知dump線程進行binlog文件的發送。

此時t5執行完了flush階段的操作,但是沒有能夠加入以t1爲首的sync隊列中,此時sync隊列爲空(t1已經清空了sync隊列),t5成爲了新的sync隊列的leader,並且釋放Lock_log,由於t5釋放了Lock_log,則t6可以獲取到互斥鎖Lock_log,我們假設在t6獲取到Lock_log之前,t7加入了以t6爲首的flush隊列中,t7爲非leader線程,進入等待,如下圖所示:

此時t1執行完了sync階段的操作,進入commit隊列中,開始執行commit階段的操作,具體步驟如下:

  • 通過函數MYSQL_BIN_LOG::change_stage調用Stage_manager::enroll_for,加入commit隊列,這個邏輯和加入flush隊列,sync隊列的過程一樣,代碼省略。
  • 如果是SQL回放線程(並行回放),在Commit_order_manager中註銷自己。
  • 釋放爲了執行sync階段而持有的互斥鎖Lock_sync
  • 對LOCK_commit加鎖

由於t1釋放了Lock_sync,t5則可以獲取到此互斥鎖,等待條件觸發sync binlog,此時t6執行完了flush階段的操作,並且加入到了以t5爲首的sync隊列中,由於t6是非leader線程,開始進入等待,如下圖所示:

從圖中可以發現,最終t1在進行事務提交時,其實是帶領t2,t3,t4這三個線程一起來完成的。commit階段的主要操作在函數MYSQL_BIN_LOG::process_commit_stage_queue(THD *thd, THD *first)中完成,它主要的工作是在循環中去調用存儲引擎提供的commit接口函數,完成InnoDB層的事務提交(假設爲InnoDB存儲引擎)。

MYSQL_BIN_LOG::process_commit_stage_queue(THD*, THD*)
{
    for (THD *head= first ; head ; head = head->next_to_commit)
    {
        /*
        storage engine commit
        */
        if (ha_commit_low(head, all, false))
            head->commit_error= THD::CE_COMMIT_ERROR;
    }
    
}

在執行完commit操作之後,t1釋放LOCK_commit。隨後需要喚醒在進入各個隊列時由於自己是非leader線程而開始進入等待的線程,比如t2,t3,t4,代碼如下:

  /* Commit done so signal all waiting threads */
  stage_manager.signal_done(final_queue);

signal_done函數實現如下:

void Stage_manager::signal_done(THD *queue)
{
  mysql_mutex_lock(&m_lock_done);
  for (THD *thd= queue ; thd ; thd = thd->next_to_commit)
    thd->get_transaction()->m_flags.pending= false;
  mysql_mutex_unlock(&m_lock_done);
  mysql_cond_broadcast(&m_cond_done);
}

但其實t1不僅僅會喚醒這些線程,在本文的示例中,t1會將t6,t7一併喚醒,但是t6,t7會通過pending標誌進行自檢,是否真的已經完成了事務提交操作(signal_done函數中更新了t2,t3,t4的pending標誌爲false),如果不是的話,再次進入等待。這部分邏輯在函數Stage_manager::enroll_for中,如下:

  if (!leader)
  {
    mysql_mutex_lock(&m_lock_done);
    while (thd->get_transaction()->m_flags.pending)
      mysql_cond_wait(&m_cond_done, &m_lock_done);
    mysql_mutex_unlock(&m_lock_done);
  }

通過互斥鎖m_lock_done以及條件變量m_cond_done來實現被喚醒,同時在進入條件等待前,判斷一次是否已經被提交,防止由於沒有收到信號而錯過被喚醒的最快的一次機會。

在對組提交進行講解時,忽略了和多線程複製相關的邏輯時間戳操作,這部分內容會在後面進行描述。

對於其他線程的後續操作,讀者可以參考t1的邏輯自己去思考和想象,本文中略過。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章