深度解析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(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日志就会乱序)

深度解析Binlog组提交过程_第1张图片

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 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。如下图所示
深度解析Binlog组提交过程_第2张图片

t3在等待Lock_log时,t4也准备进行事务提交,此时加入以t3为leader的flush队列中,如下图所示:
深度解析Binlog组提交过程_第3张图片

随后,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占据),如下图所示:
深度解析Binlog组提交过程_第4张图片

  • 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持有),如下图所示:
    深度解析Binlog组提交过程_第5张图片

你可能会发现,此时以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线程,进入等待,如下图所示:
深度解析Binlog组提交过程_第6张图片

此时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线程,开始进入等待,如下图所示:

深度解析Binlog组提交过程_第7张图片

从图中可以发现,最终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的逻辑自己去思考和想象,本文中略过。

你可能感兴趣的:(MySQL)