一次 MySQL 客户端退出问题的排查笔记

16 阅读8分钟

声明:该问题我还未完全搞明白原因,文章纯属记录排查思路。

一、问题背景

日常工作中经常会写一些 shell 脚本,用来做数据迁移、历史数据归档之类的事情。

这类脚本有几个共同特点:数据量大、执行时间长、经常跑几个小时,甚至几天。

为了避免 SSH 断开影响执行,我一般都会用 nohup 把脚本扔到后台运行。

# 导入
nohup sh import.sh &
# 导出
nohup sh export.sh &

脚本的核心逻辑很简单,按天、按小时循环,调用 MySQL 客户端导入/导出 SQL 文件。

/usr/local/mysql/bin/mysql \
  -h 127.0.0.1 -P 3306 \
  -u'username' -p'password' \
  database_name < table_name.sql

这个方案平时一直在用。

但在一次导入过程中,脚本执行失败退出了。

二、问题现象

nohup.out 中的错误信息如下:

开始导入数据, table_name ...
mysql: [Warning] Using a password on the command line interface can be insecure.
Terminal close -- query aborted
操作失败, ret:1

关键信息只有一行:

Terminal close -- query aborted

意思就是:终端关闭、查询被中止。

但这个本身很矛盾,脚本是通过 nohup 启动的,SSH 会话已经退出,理论上不应该再受终端影响。

三、Terminal close 从哪里来的

这其实是 MySQL 客户端进程输出的日志。

我本地有 mysql 5.7.44 源码,于是乎我就在源码目录里搜了一把。

find . -type f | xargs grep "Terminal close"
./client/mysql.cc:  const char *reason= "Terminal close";

只命中了一个地方,在 mysql.cc 文件中的信号处理函数里面。

// 代码经过简化
void handle_quit_signal(int sig) {
    const char *reason = "Terminal close";
    kill_query(reason); // 在这里打印的
    mysql_end(sig);
}

继续搜这个函数,看到信号注册逻辑。

#ifndef _WIN32
  signal(SIGINT, handle_ctrlc_signal);
  signal(SIGQUIT, mysql_end);
  signal(SIGHUP, handle_quit_signal); // 这里
#endif

也就是说:只有当 MySQL 客户端进程收到了 SIGHUP 信号时,才会打印 Terminal close -- query aborted 日志。

没有其他路径,说明MySQL 客户端确实收到了 SIGHUP 信号。

那 SIGHUP 是谁触发的?

四、nohup 做了什么

既然现象和终端有关,就先回到 nohup。

nohup 的行为并不复杂。从它的源码实现上看,nohup 本质上只是将进程对 SIGHUP 的处理方式设置为忽略(SIG_IGN),目的是让进程在终端关闭时,不因为收到 SIGHUP 信号而退出。

同时,如果进程的标准输出或错误输出仍然指向终端,nohup 会将它们重定向到文件(默认是 nohup.out),以避免终端关闭后,还要往终端写,就写入失败了。

不过,nohup 并不会让进程脱离控制终端,也不会真正“切断”进程与终端的关系。 它只是在终端消失这一事件发生时,保证进程不会因此被信号杀死。

执行 nohup sh import.sh & 时,大致流程可以理解为:

  1. shell 解析命令,因存在 &,决定以后台作业方式启动该进程;
  2. shell fork 出子进程,在子进程中 exec nohup;
  3. nohup 在自身进程中:
    • 将 SIGHUP 的处理方式设置为 SIG_IGN(就是忽略 SIGHUP 信号);
    • 如果 stdin 仍然指向终端,则主动使其不可用(通过替换为写方式打开的 /dev/null);
    • 如果 stdout / stderr 仍然指向终端,则将其重定向到 nohup.out;
    • nohup 调用 exec,将自身替换为 sh import.sh;
  4. shell 不等待该进程结束,继续返回提示符。

在 nohup sh import.sh & 这种典型用法下,shell 会将命令作为后台作业启动。

对于后台作业,shell 在终端关闭时并不会向其发送 SIGHUP。

因此,后续启动的 sh 以及 MySQL 客户端,实际上并不会因为终端关闭收到 SIGHUP 信号。

所以,还是那个疑问,是谁,给 MySQL 客户端,发送了 SIGHUP 信号?

五、进程、会话和终端的关系

为了进一步缩小范围,需要把几个基础概念放到同一个坐标系里。

一次 SSH 登录,通常会发生这些事情:

  1. sshd fork 出子进程;
  2. 子进程调用 setsid 创建新会话,并成为会话首进程,也叫控制进程;
  3. 控制进程打开一个伪终端,如 /dev/pts/0;
  4. 该终端成为会话的控制终端;
  5. 启动 shell;

关系可以简化成:

会话
 ├─ 控制终端 pts/0
 ├─ 前台进程组
 └─ 后台进程组

一个会话最多只有一个控制终端。

终端关闭时,内核会向控制进程(一般就是 shell 进程)发送一个 SIGHUP 信号。

shell 再将信号转发给它管理的作业,nohup 的意义就在于提前忽略这个信号。

六、模拟验证:nohup 场景下是否真的会收到 SIGHUP

为了验证“nohup + exit 是否会触发 SIGHUP”,我还写了个简单的模拟程序。

程序逻辑直接、简单:

  • 注册 SIGHUP 处理函数
  • 如果收到信号,打印提示并退出
  • 否则 sleep 一会儿正常结束
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void handler(int sig) {
    printf("收到 SIGHUP!\n");
    fflush(stdout);
    exit(1);
}

int main() {
    setbuf(stdout, NULL);
    printf("PID=%d\n", getpid());

    signal(SIGHUP, handler);
    sleep(2); // 模拟耗时2秒的处理
    return 0;
}

脚本中反复调用这个程序。

#!/bin/sh

while true
do
    ./simulate_mysql
    ret=$?
    if [ $ret != 0 ]; then
        echo "操作失败, ret:$ret"
        exit 1
    fi
done

启动方式仍然是:

nohup sh import.sh &

1、观察进程状态

通过 ps 查看进程关系。

# 未退出终端时
ps -o pid,ppid,pgid,sid,tty,cmd -p `pidof simulate_mysql` 46587
  PID  PPID  PGID   SID TT       CMD
46587 52562 46587 52562 pts/0    sh import.sh
56975 46587 46587 52562 pts/0    ./simulate_mysql

# 退出终端后
ps -o pid,ppid,pgid,sid,tty,cmd -p `pidof simulate_mysql` 46587
  PID  PPID  PGID   SID TT       CMD
46587     1 46587 52562 ?        sh import.sh
78911 46587 46587 52562 ?        ./simulate_mysql

在 SSH 会话退出前:SID 未变、TTY 为 pts/0,进程与控制终端关联着。

退出 SSH 会话后:SID 仍然存在、TTY 变成 ?,进程脱离了控制终端。

2、观察信号行为

  • 正常 exit SSH,不会触发 SIGHUP;
  • 若手动发送 SIGHUP(pkill -HUP simulate_mysql),程序立即退出;

nohup 场景下,系统层面的行为是符合预期的。

七、信息不足

到这里,就还可以确认一件事:

在标准的 nohup + shell 场景中,内核不会主动给子进程发送 SIGHUP。

但 MySQL 客户端确实收到了。

这说明,中间还是有一些没注意到的现象。

八、模拟程序也有局限性

回头看 simulate_mysql,会发现它还是 too young too simple 了。

这个模型有几个明显问题,而 MySQL 客户端会做这些事情:

  • 不读取 stdin
  • 不检查是否为 tty
  • 不访问 /dev/tty
  • 不做密码处理
  • 不涉及大量 I/O

simulate_mysql 只能证明操作系统机制本身没有问题(所以我为啥要来验证操作系统的机制?)。

它不能证明 MySQL 客户端在真实运行路径中不会触发终端相关逻辑。

九、下一步排查方向

结合之前的经验,使用 mysql 导出数据时也会出现这种现象。

相比 import,export 更容易用来复现这个问题。

如果要继续排查,更合理的方向是:

  • 直接使用 mysql 客户端本身复现;
  • 观察是否访问 /dev/tty;
  • 结合系统调用层面的观测;

而不是继续扩展模拟程序了。

十、临时可行方案

虽然没有完全找到原因,但在工程上还是需要一个可用解法。

最终采用的启动方式是:

nohup sh import.sh < /dev/null > import.log 2>&1 &

显式切断所有标准流:

  1. stdin 指向 /dev/null
  2. stdout / stderr 写入文件

这种方式下,进程从一开始就不具备任何终端关联。

另外一种更彻底的方式是创建新会话:

setsid sh import.sh < /dev/null > import.log 2>&1 &

两种方式本质相同。

都是在 shell 层面完成隔离。

十一、一些保留下来的结论

这次排查没有闭环,但仍然留下了一些确定的东西。

  1. Terminal close 一定来自 SIGHUP;
  2. SIGHUP 一定不是内核在 nohup 场景下自动发送的;
  3. 问题更可能出现在 mysql 客户端的终端相关路径;

十二、结尾

这次排查,并没有真正把问题找出来。

不过我也意识我们平时写的那些模拟程序,跟真实系统之间的差距还是有点大。

特别是终端、信号、I/O 这种偏底层的东西,一旦脱离真实环境,很多结论其实并不靠谱,太多分支情况了。

所以以后再碰到这种问题, 可能要更早地去面对真实系统, 而不是指望靠一个简化模型就把事情解释清楚。

折腾这一圈,本身也不算白折腾。