声明:该问题我还未完全搞明白原因,文章纯属记录排查思路。
一、问题背景
日常工作中经常会写一些 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 & 时,大致流程可以理解为:
- shell 解析命令,因存在 &,决定以后台作业方式启动该进程;
- shell fork 出子进程,在子进程中 exec nohup;
- nohup 在自身进程中:
- 将 SIGHUP 的处理方式设置为 SIG_IGN(就是忽略 SIGHUP 信号);
- 如果 stdin 仍然指向终端,则主动使其不可用(通过替换为写方式打开的 /dev/null);
- 如果 stdout / stderr 仍然指向终端,则将其重定向到 nohup.out;
- nohup 调用 exec,将自身替换为 sh import.sh;
- shell 不等待该进程结束,继续返回提示符。
在 nohup sh import.sh & 这种典型用法下,shell 会将命令作为后台作业启动。
对于后台作业,shell 在终端关闭时并不会向其发送 SIGHUP。
因此,后续启动的 sh 以及 MySQL 客户端,实际上并不会因为终端关闭收到 SIGHUP 信号。
所以,还是那个疑问,是谁,给 MySQL 客户端,发送了 SIGHUP 信号?
五、进程、会话和终端的关系
为了进一步缩小范围,需要把几个基础概念放到同一个坐标系里。
一次 SSH 登录,通常会发生这些事情:
- sshd fork 出子进程;
- 子进程调用 setsid 创建新会话,并成为会话首进程,也叫控制进程;
- 控制进程打开一个伪终端,如 /dev/pts/0;
- 该终端成为会话的控制终端;
- 启动 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 &
显式切断所有标准流:
- stdin 指向 /dev/null
- stdout / stderr 写入文件
这种方式下,进程从一开始就不具备任何终端关联。
另外一种更彻底的方式是创建新会话:
setsid sh import.sh < /dev/null > import.log 2>&1 &
两种方式本质相同。
都是在 shell 层面完成隔离。
十一、一些保留下来的结论
这次排查没有闭环,但仍然留下了一些确定的东西。
- Terminal close 一定来自 SIGHUP;
- SIGHUP 一定不是内核在 nohup 场景下自动发送的;
- 问题更可能出现在 mysql 客户端的终端相关路径;
十二、结尾
这次排查,并没有真正把问题找出来。
不过我也意识我们平时写的那些模拟程序,跟真实系统之间的差距还是有点大。
特别是终端、信号、I/O 这种偏底层的东西,一旦脱离真实环境,很多结论其实并不靠谱,太多分支情况了。
所以以后再碰到这种问题, 可能要更早地去面对真实系统, 而不是指望靠一个简化模型就把事情解释清楚。
折腾这一圈,本身也不算白折腾。