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阶段拆分为三个阶段,分别为
其中不同的阶段会对应着不同的队列m_queue,并且通过队列内部的互斥锁m_lock保证队列并发访问(入队,出队)的正确性,事务提交的三个阶段通过三把互斥锁来进行保护,分别为:
值得一提的时,这三把锁分别保护的临界区是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*)来完成,具体过程如下:
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对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)
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。如下图所示
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*),详细如下:
/*
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占据),如下图所示:
mysql_mutex_lock(enter_mutex);
你可能会发现,此时以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阶段的操作,具体步骤如下:
由于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的逻辑自己去思考和想象,本文中略过。