半同步复制下MySQL的正确关闭姿势?
声明:个人笔记,本文主要参考 补充了一些验证过程。
对于 MySQL 来说,常见的关闭方式如下:
mysqladmin shutdown
service mysqld stop(CentOS 6 SysV style)
-
systemctl stop mysqld(CentOS 7 Systemd style) -
kill mysqld_pid -
kill -9 mysqld_pid
这里前面 4 种关闭姿势本质上都是同一种方式,最终都是通过 kill -15 mysqld_pid 来关闭(kill = kill -15)。而 kill -9 一般我们称之为强制关闭,会被认为是不安全的操作。在生产环境中一般我们都是通过 kill -15 来关闭,这在单实例模式下是没有问题的,但在半同步复制模式(after_commit or after_sync)下反而会导致 Master 节点不等待 Slave 节点是否收到二进制日志的 ACK 回包,直接完成事务,然后停止数据库服务,从而可能导致主从数据不一致。
下面来尝试从源码层面来分析一下,不然感觉很难梳理的非常明白。
Linux 信号(signal)
这里就要简单了解一下 Linux 信号(signal)。
信号是 Linux 编程中非常重要的部分,是 Linux 进程之间相互传递消息的一种方法,信号全称为软中断信号,也有人称作软中断。
软中断信号用来通知进程发生了事件。进程之间可以通过调用 kill 库函数发送软中断信号。Linux 内核也可能给进程发送信号,通知进程发生了某个事件(例如内存越界)。
注意,信号只是用来通知某进程发生了什么事件,无法给进程传递任何数据,进程对信号的处理方法有三种:
忽略某个信号,对该信号不做任何处理,就像未发生过一样。
设置中断的处理函数,收到信号后,由该函数来处理。
对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程。
发出信号的原因很多,这里按发出信号的原因简单分类,以了解各种信号:
信号名 | 信号值 | 默认处理动作 | 发出信号的原因 |
---|---|---|---|
SIGHUP | 1 | A | 当用户退出shell时,由该shell启动的所有进程将收到这个信号 |
SIGINT | 2 | A | 键盘中断<ctrl+c>,用户终端向正在运行中的由该终端启动的程序发出此信号 |
SIGQUIT | 3 | C | 组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号 |
SIGILL | 4 | C | CPU检测到某进程执行了非法指令 |
SIGABRT | 6 | C | 调用abort函数时产生该信号 |
SIGFPE | 8 | C | 在发生致命的运算错误时发出,不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误 |
SIGKILL | 9 | AEF | 采用“kill -9 进程编号”强制杀死程序 |
SIGSEGV | 11 | C | 指示进程进行了无效内存访问 |
SIGPIPE | 13 | A | Broken pipe向一个没有读端的管道写数据 |
SIGALRM | 14 | A | 定时器超时,超时的时间由系统调用alarm设置 |
SIGTERM | 15 | A | 采用“kill 进程编号”或“killall 程序名”通知程序 |
SIGUSR1 | 30,10,16 | A | 用户自定义信号1 |
SIGUSR2 | 31,12,17 | A | 用户自定义信号2 |
SIGCHLD | 20,17,18 | B | 子进程结束时,父进程会收到这个信号 |
SIGCONT | 19,18,25 | 进程继续(曾被停止的进程) | |
SIGTSTP | 18,20,24 | D | 暂停进程的运行,键盘中断<ctrl+z> |
SIGTTIN | 21,21,26 | D | 后台进程企图读终端控制台 |
SIGTTOU | 22,22,27 | D | 后台进程企图写终端控制台 |
处理动作一项中的字母含义如下:
A,缺省的动作是终止进程。
B,缺省的动作是忽略此信号,将该信号丢弃,不做处理。
C,缺省的动作是终止进程并进行内核映像转储(core dump),内核映像转储是指将进程数据在内存的映像和进程在内核结构中的部分内容以一定格式转储到文件系统,并且进程退出执行,这样做的好处是为程序员提供了方便,使得他们可以得到进程当时执行时的数据值,允许他们确定转储的原因,并且可以调试他们的程序。
D,缺省的动作是停止进程,进入停止状况以后还能重新进行下去。
E,信号不能被捕获。
F,信号不能被忽略。
回到 MySQL 服务关闭,我们知道关闭 MySQL 服务本质上就两种机制,kill -9 或 kill -15。这两种信号的区别就在于默认处理动作,其中 -9(SIGKILL)表示终止进程,但可以被忽略和捕获,而 -15(SIGTERM)也表示进程终止,但不可以被忽略和捕获。
这意味着如果对一个进程发出 SIGKILL 信号,此进程是可以使用系统调用 signal 函数捕获此信号,从而进行一些进程关闭前的资源释放等工作;但如果对一个进程发出 SIGTERM 信号,此进程无法捕获此信号,系统会直接对进程进行关闭操作。也就是说,我们写程序的时候只能捕获系统允许被捕获的信号,比如 SIGINT 和 SIGTERM,对于 SIGKILL 信号是无能为力的,只能由系统进行强制关闭。
Note
可通过调用系统提供的 signal 函数设置程序对信号的处理方式。函数声明:
sighandler_t signal(int signum, sighandler_t handler);
参数 signum 表示信号的编号。
参数 handler 表示信号的处理方式,有三种情况:
1. SIG_IGN:忽略参数 signum 所指的信号。
2. 一个自定义的处理信号的函数,信号的编号为这个自定义函数的参数。
3. SIG_DFL:恢复参数 signum 所指信号的处理方法为默认值。
开发者不关心 signal 的返回值。
下面写一段代码演示一下信号的使用,我希望我的程序在运行过程中能捕获 SIGINT 和 SIGTERM 信号,捕获到信号之后进行资源释放操作,然后安全退出,防止意外发生。
void sighandler(int sig)
{
printf("收到了信号%d,程序退出。\n", sig);
exit(0);
}
int main()
{
// 设置SIGINT和SIGTERM的处理函数
signal(SIGINT, sighandler); signal(SIGTERM, sighandler);
// 一个死循环
while (1)
{
sleep(10);
}
}
编译一下这段代码,这段程序在运行时,不管是用 Ctrl+c 还是 kill -15,程序都能体面的退出。
./signal
^C收到了信号2,程序退出。
$ ./signal &
[1] 8245
$ kill 8245
收到了信号15,程序退出。
明白了信号以及信号在我们编写代码中怎么使用后,再来看看 MySQL 是怎么处理 SIGTERM 信号。
MySQL 信号处理代码
mysqld.cc
/** This thread handles SIGTERM, SIGQUIT and SIGHUP signals. */
extern "C" void *signal_hand(void *arg MY_ATTRIBUTE((unused)))
{
my_thread_init();
// 这里设置sigset_t信号集合,并将SIGTERM/SIGQUIT/SIGHUP设置
sigset_t set;
(void) sigemptyset(&set);
(void) sigaddset(&set, SIGTERM);
(void) sigaddset(&set, SIGQUIT);
(void) sigaddset(&set, SIGHUP);
/*
向start_signal_handler发出信号,表示我们已经准备好了。
这个工作是通过等待start_signal_handler释放mutex。之后,我们向它发出信号说我们准备好了。
*/
mysql_mutex_lock(&LOCK_start_signal_handler);
mysql_cond_broadcast(&COND_start_signal_handler);
mysql_mutex_unlock(&LOCK_start_signal_handler);
/*
等到mysqld_server_started == true,以确保所有的服务组件已被成功初始化。
这个步骤是强制性,因为只有在所有服务组件都被初始化后,才能安全地进行信号处理。
*/
mysql_mutex_lock(&LOCK_server_started);
while (!mysqld_server_started)
mysql_cond_wait(&COND_server_started, &LOCK_server_started);
mysql_mutex_unlock(&LOCK_server_started);
for (;;)
{
int sig;
while (sigwait(&set, &sig) == EINTR) // 调用sigwait堵塞捕获信号
{}
if (cleanup_done)
{
my_thread_end();
my_thread_exit(0); // Safety
return NULL; // Avoid compiler warnings
}
switch (sig) {
case SIGTERM:
case SIGQUIT:
// Switch to the file log message processing.
query_logger.set_handlers((log_output_options != LOG_NONE) ?
LOG_FILE : LOG_NONE);
DBUG_PRINT("info", ("Got signal: %d abort_loop: %d", sig, abort_loop));
if (!abort_loop)
{
abort_loop= true; // 标记线程终止
// Delete the instrumentation for the signal thread.
PSI_THREAD_CALL(delete_current_thread)();
/*
关闭socket监听器
然后主线程将设置socket_listener_active=false, 并等待我们完成下面的所有清理工作
*/
mysql_mutex_lock(&LOCK_socket_listener_active);
while (socket_listener_active)
{
DBUG_PRINT("info",("Killing socket listener"));
if (pthread_kill(main_thread_id, SIGUSR1))
{
assert(false);
break;
}
mysql_cond_wait(&COND_socket_listener_active,
&LOCK_socket_listener_active);
}
mysql_mutex_unlock(&LOCK_socket_listener_active);
// 优雅关闭所有客户端线程和slave线程,向所有线程发出信号告诉它们可以正常死亡了
// 这将给线程一些时间来优雅地中止它们的语句,并通知它们的客户端,服务器即将死亡
close_connections();
}
my_thread_end();
my_thread_exit(0);
return NULL; // Avoid compiler warnings
break;
case SIGHUP:
if (!abort_loop)
{
int not_used;
mysql_print_status(); // Print some debug info
reload_acl_and_cache(NULL,
(REFRESH_LOG | REFRESH_TABLES | REFRESH_FAST |
REFRESH_GRANT | REFRESH_THREADS | REFRESH_HOSTS),
NULL, ¬_used); // Flush logs
// Reenable query logs after the options were reloaded.
query_logger.set_handlers(log_output_options);
}
break;
default:
break; /* purecov: tested */
}
}
return NULL; /* purecov: deadcode */
}
可以看到 MySQL 收到 SIGTERM 信号后(kill -15关闭方式),设置了 abort_loop 为 true,用来标记线程终止。然后关闭 socket 监听器不再接受新的连接,调用 close_connections 函数优雅关闭所有客户端线程和 slave 线程,向所有线程发出信号告诉它们可以正常死亡了,这将给线程一些时间来优雅地中止它们的语句,未完成的事务会回滚,并通知它们的客户端,服务器即将死亡。
在这个阶段,服务器会刷新表缓存并关闭所有打开的表。这里的最后会调用 innobase_shutdown_for_mysql 函数正常关闭 InnoDB,其将缓冲池刷新到磁盘(除非 innodb_fast_shutdown = 2),将当前 LSN 写入表空间,并终止其自己的内部线程。
到目前为止看下来,SIGTERM 信号就是一个安全的 MySQL 关闭操作。
但当 SIGTERM 信号遇到半同步复制时就出现问题了,由于上面设置 abort_loop 变量为 true 了,在半同步插件代码中,会判断 abort_loop 变量,当为 true 时,在半同步哪里就成了一个强制关闭操作,转为异步同步。
MySQL 半同步代码
看半同步代码片段,主要在 ReplSemiSyncMaster::commitTrx 函数中。当开启半同步插件后,after_sync 模式,这个函数会在组提交(Group Commit)的 sync 队列处理阶段中被调用,也就是在 binlog 刷新到磁盘之后被调用,用来确认 ack 或等待超时。
semisync_master.cc
int ReplSemiSyncMaster::commitTrx(const char* trx_wait_binlog_name,
my_off_t trx_wait_binlog_pos)
{
....
/* Acquire the mutex. */
lock();
// 通过binlog位点获取到在after flush阶段存储的entry
TranxNode* entry= active_tranxs_->find_active_tranx_node(trx_wait_binlog_name,
trx_wait_binlog_pos);
// 获取到该entry对应的条件变量,使用该条件变量让当前线程挂起
mysql_cond_t* thd_cond= &entry->cond;
bool is_semi_sync_trans= true;
if (getMasterEnabled() && trx_wait_binlog_name)
{
struct timespec start_ts;
struct timespec abstime;
int wait_result;
set_timespec(&start_ts, 0);
// 判断半同步复制参数和状态是开启
if (!getMasterEnabled() || !is_on())
goto l_end;
// 计算等待时间
abstime.tv.i64 = start_ts.tv.i64 + (__int64)wait_timeout_ * TIME_THOUSAND * 10;
abstime.max_timeout_msec= (long)wait_timeout_;
// 注意此处的循环
while (is_on())
{
// 如果记录当前已收到ack的位点,则进行判断该事务是不是已经发送到从库了
if (reply_file_name_inited_)
{
// 拿当前等待ack的事务位点和已收到从库回复的ack位点进行比较
int cmp = ActiveTranx::compare(reply_file_name_, reply_file_pos_,
trx_wait_binlog_name, trx_wait_binlog_pos);
// 如果记录的收到从库ack回复的位点比当前事务还要大
// 说明该事务的binlog已经发到从库了,在这里跳出等待循环
if (cmp >= 0)
{
/* 我们已经把相关的binlog发送给了slave,不需要再在这里等待 */
// 打印异常情况到错误信息里
if (trace_level_ & kTraceDetail)
sql_print_information("%s: Binlog reply is ahead (%s, %lu),",
kWho, reply_file_name_, (unsigned long)reply_file_pos_);
break;
}
}
// 更新当前已知的等待线程的最小的binlog等待位点
if (wait_file_name_inited_)
{
// 拿当前事务的等待位点和记录的最小等待位点比较
int cmp = ActiveTranx::compare(trx_wait_binlog_name, trx_wait_binlog_pos,
wait_file_name_, wait_file_pos_);
if (cmp <= 0)
{
// 如果当前等待位点比记录的位点小,更新记录的最小位点
strncpy(wait_file_name_, trx_wait_binlog_name, sizeof(wait_file_name_) - 1);
wait_file_name_[sizeof(wait_file_name_) - 1]= '\0';
wait_file_pos_ = trx_wait_binlog_pos;
rpl_semi_sync_master_wait_pos_backtraverse++;
if (trace_level_ & kTraceDetail)
sql_print_information("%s: move back wait position (%s, %lu),",
kWho, wait_file_name_, (unsigned long)wait_file_pos_);
}
}
else
{
// 如果已知的最小等待位点在收到ack后被重置了 则从新记录新的最小等待位点
strncpy(wait_file_name_, trx_wait_binlog_name, sizeof(wait_file_name_) - 1);
wait_file_name_[sizeof(wait_file_name_) - 1]= '\0';
wait_file_pos_ = trx_wait_binlog_pos;
wait_file_name_inited_ = true;
if (trace_level_ & kTraceDetail)
sql_print_information("%s: init wait position (%s, %lu),",
kWho, wait_file_name_, (unsigned long)wait_file_pos_);
}
// 判断MySQL是否处于正在关闭状态,如果MySQL关了,半同步也需要关闭
// 这里除了kill -9 mysql_pid之外的关闭操作都会导致abort_loop为true,这里称kill -9之外的关闭都为强制关闭,会导致一些更新在从库丢失
// 参数rpl_semi_sync_master_wait_for_slave_count最小值是1,rpl_semi_sync_master_clients表示开启半同步连接到主库的从库数量
if (abort_loop && (rpl_semi_sync_master_clients ==
rpl_semi_sync_master_wait_for_slave_count - 1) && is_on())
{
sql_print_warning("SEMISYNC: Forced shutdown. Some updates might "
"not be replicated.");
// 关闭半同步,关闭半同步会唤醒其他正在等待ack的事务线程
switch_off();
break;
}
// 增加正在等待的ack会话的状态值
rpl_semi_sync_master_wait_sessions++;
/* wait for the position to be ACK'ed back */
assert(entry);
entry->n_waiters++;
// 开始进行条件和超时等待,并返回等待结果
// 此时线程会阻塞进行等待,要么就被ack reciever线程唤醒(或其他信号,比如mysqld被关闭),要么就等待ack超时,关闭半同步
// 背后使用的是系统调用pthread_cond_timedwait挂起线程,线程等待一定的时间,如果超时或有信号触发,线程唤醒
wait_result= mysql_cond_timedwait(&entry->cond, &LOCK_binlog_, &abstime);
entry->n_waiters--;
// 线程等待结束,减少正在等待ack会话的状态值计数器
if (rpl_semi_sync_master_wait_sessions > 0)
rpl_semi_sync_master_wait_sessions--;
// 如果等待的返回值不为0,说明等待ack超时,超时返回60;被信号唤醒值为0
if (wait_result != 0)
{
/* This is a real wait timeout. */
// 打印超时的信息到错误日志里
sql_print_warning("Timeout waiting for reply of binlog (file: %s, pos: %lu), "
"semi-sync up to file %s, position %lu.",
trx_wait_binlog_name, (unsigned long)trx_wait_binlog_pos,
reply_file_name_, (unsigned long)reply_file_pos_);
// 增加超时的状态计数器值
rpl_semi_sync_master_wait_timeouts++;
/* switch semi-sync off */
// 超时的话,关闭半同步,关闭半同步会唤醒其他正在等待ack的事务线程
switch_off();
}
else
{
// 计算此次等待ack的时长
int wait_time = getWaitTime(start_ts);
// 增加半同步的等待次数和半同步的总等待时间
// 用于计算半同步的平均等待时间,可以通过show status命令看到
rpl_semi_sync_master_trx_wait_num++;
rpl_semi_sync_master_trx_wait_time += wait_time;
}
// 注意上面无论是超时还是等到ack,都没有用break跳出while循环
}
l_end:
// 增加通过半同步事务和没通过半同步事务的计数器,用于状态统计
if (is_on() && is_semi_sync_trans)
rpl_semi_sync_master_yes_transactions++;
else
rpl_semi_sync_master_no_transactions++;
}
// after_sync钩子结束标志着一次半同步复制闭环的结束
// 需要将之前在after flush插入到哈希表的事务entry清除
if (trx_wait_binlog_name && active_tranxs_
&& entry && entry->n_waiters == 0)
active_tranxs_->clear_active_tranx_nodes(trx_wait_binlog_name,
trx_wait_binlog_pos);
unlock();
return function_exit(kWho, 0);
}
可以看到主库上的半同步插件判断 abort_loop 为 true 时(其他条件正常也会为 true),就会打印一个 warning,告诉你当前发生了强制关闭,可能会在从库丢失一些更新。接着就是关闭半同步,此时变为异步模式,接着当前事务继续在引擎层提交事务并返回客户端成功状态。
这会带来一个主从数据不一致的问题对于半同步复制来说,我们知道 after_sync 半同步模式下 binlog 的发送是在 binlog 刷盘之后(sync_binlog=1),然后等待从库的 ack 回包。如果此时主库宕机,由于主库等待 ack 的事务还没返回给用户成功信息,此时发生主从切换,从库没有这个事务数据也是ok的。
假设主库事务 T1 发送 binlog 到从库时,从库 io 线程故障,此时主库事务 T1 就会执行 commitTrx 等待被 ack reciever 唤醒或超时,此时卡在 mysql_cond_timedwait 函数。这时候主库主线程收到 SIGTERM 信号后设置了 abort_loop 为 true,并且关闭客户端线程时会先给所有客户端线程发送信号,以便让客户端线程优雅关闭。此时 mysql_cond_timedwait 会被信号唤醒,返回值为 0,继续往下执行代码;这整个逻辑都被包含在 while 循环中,会重新执行到判断 abort_loop 变量的代码片段,会调用 switch_off 函数关闭半同步转为异步复制,后续 T1 事务继续在引擎层提交,会在数据库真正关闭之前返回给客户端成功消息。
现在的状态是主库有 T1 事务,但从库确没有 T1 事务,此时如果把从库提升为主库,那么就意味着 T1 事务丢失了。
Note
刚开始直接看 commitTrx 这段代码时有些疑惑,变量 abort_loop 的判断在条件等待函数 mysql_cond_timedwait 的前面,如果确认 ack 出现等待(网络问题导致从库没有接收到 binlog 事件),那么也是卡在 mysql_cond_timedwait 函数这里,虽然这一整个逻辑都是在一个 while 循环中,但什么时候会触发到重新判断 abort_loop 变量并关闭半同步复制呢?
后面看 mysql_cond_timedwait 函数代码时才明白,其内部使用的是系统调用 pthread_cond_timedwait 挂起当前线程,等待一定的时间,这里的时间是 ack 等待超时时间,如果超时或有信号触发,线程被唤醒。
在 signal_hand 函数部分,关闭客户端线程时会先给所有客户端线程发送信号,以便让客户端线程优雅关闭。此时 mysql_cond_timedwait 就会执行结束,返回值 0。再次循环会重新执行到 abort_loop 变量判断的代码片段。
所以,在 after_sync 半同步模式下,直接 kill -9 mysql_pid 是最正确的关闭方式。同样的场景,假设主库事务 T1 发送 binlog 到从库时,从库 io 线程故障,此时主库等待从库 ack,这时候主库被 kill -9 关闭了,对于 T1 客户端来说会收到返回失败信息,此时如果把从库提升为主库是可行的,从库也没有 T1 事务。
虽然事务 T1 在旧主库重新启动时仍然能被恢复回来,但可以人为 flashback,或者丢弃旧主库,重新搭建主从关系。
Note
需要注意,Linux 系统的 reboot 命令是给所有进程发送 SIGTERM 信号,会导致 mysqld 正常关闭,可能会导致半同步复制下数据不一致。
测试方式
SIGTERM
主库 | 从库 | 终端 |
set global rpl_semi_sync_master_timeout = 1000000; | stop slave; | |
insert into t1 select 1,'a'; --等待ack | ||
kill mysqld_pid | ||
Query OK, 1 row affected (24.04 sec) --执行成功 |
SIGKILL
主库 | 从库 | 终端 |
set global rpl_semi_sync_master_timeout = 1000000; | stop slave; | |
insert into t1 select 2,'a'; --等待ack | ||
kill -9 mysqld_pid | ||
ERROR 2013 (HY000): Lost connection to MySQL server during query --执行失败 |
<参考>