2024 年,我们团队有个心照不宣的"重启服务"习惯动作:kill -9。发版要重启,kill -9;服务卡了要重启,kill -9;反正 -9 一上,没有杀不掉的进程,干净利落。直到有一次,运营找过来说,每次我们发版的那个时间点,监控上总会准时冒出一小撮"订单提交失败"的用户投诉,而且偶尔还能查到几笔状态卡在"处理中"、再也不动的脏数据。我一开始根本没往重启上想——重启而已,几秒钟的事,怎么会搞坏数据?可一次次发版、一次次对时间点,那个规律清晰得让人无法回避:问题就出在 kill -9 这一下。一个我用了好几年、自以为无比可靠的"重启"动作,竟然是在制造故障。这件事逼着我把 Linux 的信号机制、SIGTERM 与 SIGKILL、优雅关闭这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个处理订单的 Java 服务
事故现象:
- 每次发版重启服务,监控准时出现一小撮请求失败
- 偶尔产生状态卡在"处理中"的脏数据
- 重启方式:kill -9 ,一直这么干的
现场排查:
# 1. 复盘发版动作,我们一直是这么"重启"的
$ kill -9 8123 # ★ 直接 -9,从不含糊
# 2. 看服务日志,重启那一刻是怎么结束的
$ tail -100 /var/log/app/app.log
... 正在处理订单 ORD20240518xxx
... 正在写入数据库 ...
(日志到这里【戛然而止】,没有任何"关闭"相关的行)
# ★ 进程是被【当场打断】的,不是正常退出的
# 3. 对比一个用 kill(默认 SIGTERM)停的服务,日志是这样
... received SIGTERM, shutting down gracefully
... waiting for 3 in-flight requests to finish
... all requests done, closing db pool
... bye
# ★ 收到 TERM 的进程,有一段【收尾过程】
根因(后来想清楚的):
1. ★ kill -9 发的是 SIGKILL 信号,它【不可被捕获、
不可被忽略】—— 内核直接把进程干掉,进程【没有任何
机会】执行自己的关闭逻辑。
2. 我们的服务本来写了优雅关闭:收到关闭信号后,会
先处理完手里在途的请求、刷盘、关连接,再退出。
3. ★ 但 kill -9 根本不给它这个机会 —— 进程在"订单
写一半"的瞬间被瞬间抹掉。
4. 于是:在途请求 -> 客户端报失败;写一半的事务 ->
留下"处理中"的脏数据。
5. ★ 正解是 kill(默认 SIGTERM):给进程发"请关闭"
的信号,让它自己优雅收尾。-9 只该是最后手段。
kill 不是"杀",是"发信号"。-9 和默认,天差地别。
修复 1:kill 不是"杀"——它是"发信号"
# === ★ 第一个要纠正的认知:kill 这个命令名,起得有误导性 ===
# kill 干的事,根本不是"杀死",而是【给进程发送一个信号】。
# "杀死"只是某些信号【碰巧】会导致的【默认结果】而已。
# === 信号是什么 ===
# 信号是内核提供的一种【异步通知机制】——
# 用一个小小的编号,通知一个进程"发生了某件事"。
# 进程收到信号后,可以:
# 1. 执行该信号的【默认动作】(终止 / 忽略 / 暂停…)
# 2. 【捕获】它,运行自己注册的处理函数
# 3. 【忽略】它(部分信号可以)
# === 看系统支持哪些信号 ===
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 6) SIGABRT
9) SIGKILL 15) SIGTERM 17) SIGCHLD 18) SIGCONT
19) SIGSTOP 20) SIGTSTP ...
# 每个信号一个编号、一个名字。kill -9 就是发 9 号 = SIGKILL。
# === ★ kill 的几种写法,都是"发信号" ===
$ kill 8123 # 不指定 -> 默认发 SIGTERM(15)
$ kill -15 8123 # 显式发 SIGTERM
$ kill -TERM 8123 # 用名字,等价于 -15
$ kill -9 8123 # 发 SIGKILL
$ kill -HUP 8123 # 发 SIGHUP,很多服务用它"重载配置"
# === 给一批进程发信号 ===
$ killall myapp # 按【进程名】发(默认 TERM)
$ pkill -f 'java.*myapp' # 按命令行【模式】匹配着发
$ kill -- -8123 # ★ PID 前加负号 = 发给整个【进程组】
# === ★ 关键结论 ===
# "kill 一个进程",准确说法是"给它发一个信号"。
# 发哪个信号,决定了进程是【优雅退场】还是【当场暴毙】。
修复 2:SIGTERM、SIGKILL、SIGINT——区别与该用哪个
# === ★ 停止一个进程,最该分清的三个信号 ===
# === SIGTERM(15)—— "请你关闭" 的【礼貌请求】 ===
$ kill 8123 # 或 kill -15 / kill -TERM
# - 默认动作:终止进程
# - ★ 但它【可以被进程捕获】—— 进程可以注册一个处理函数,
# 收到 TERM 时不立刻退,而是先做完收尾工作再退。
# - 这是【停止服务的标准、首选方式】。
# === SIGKILL(9)—— "立即处决",不容商量 ===
$ kill -9 8123
# - 默认动作:终止进程
# - ★ 它【无法被捕获、无法被忽略、无法被阻塞】——
# 信号根本不交给进程,由【内核直接】把进程干掉。
# - 后果:进程【没有一丝机会】执行任何清理逻辑。
# 在途请求、没刷的缓冲、没关的连接、没提交的事务,
# 全部【当场丢弃】。这就是我们这次踩的坑。
# === SIGINT(2)—— 你按 Ctrl+C 时发的就是它 ===
$ kill -2 8123 # 等价于在前台按 Ctrl+C
# - 默认动作:终止;也【可被捕获】。
# - 语义上是"用户请求中断",前台程序常用它来优雅退出。
# === ★ 一张表记住差异 ===
# 信号 能否被捕获 进程有机会清理吗 该什么时候用
# SIGTERM ★ 能 ★ 有 停服务的【首选】
# SIGINT 能 有 前台程序 Ctrl+C
# SIGKILL ★ 不能 ★ 没有 ★ 万不得已的【最后手段】
# === ★ 正确的停服务姿势:先礼后兵 ===
$ kill 8123 # ① 先发 TERM,给它优雅退出的机会
$ sleep 10 # ② 给它一点时间收尾
$ kill -0 8123 2>/dev/null && kill -9 8123 # ③ 还活着,才动用 -9
# kill -0 :不发真信号,只用来【探测进程是否还存在】。
# ★ 这个"先 TERM、等一会、还赖着不走才 KILL"的套路,
# 就是 systemd 停服务时内部干的事。
修复 3:优雅关闭——进程收到 SIGTERM 该做什么
# === ★ "优雅关闭",本质就是:进程【捕获】SIGTERM,
# 收到后不立刻死,而是先把手里的活儿干完再退 ===
# === Shell 脚本里用 trap 捕获信号 ===
#!/bin/bash
cleanup() {
echo "收到关闭信号,正在清理..."
rm -f /tmp/myapp.lock # 释放锁
echo "清理完成,退出"
exit 0
}
trap cleanup SIGTERM SIGINT # ★ 捕获 TERM 和 INT,交给 cleanup
# ... 主逻辑 ...
while true; do work; sleep 1; done
# ★ 这个脚本被 kill(TERM)时,会先跑 cleanup 再退;
# 被 kill -9 时,trap 完全无效 —— cleanup 不会执行。
# === 一个服务"优雅关闭"通常要做的几件事 ===
# 1. ★ 停止接收【新】请求(从负载均衡摘掉 / 关监听端口)
# 2. ★ 把【已经在处理】的请求处理完(in-flight requests)
# 3. 刷新缓冲区,确保数据落盘
# 4. 提交或回滚未完成的事务
# 5. 关闭数据库连接池、消息队列连接
# 6. 删除 pid 文件 / 锁文件,然后退出
# === 各语言捕获 SIGTERM 的入口(概念示意)===
# Java : Runtime.getRuntime().addShutdownHook(...)
# Go : signal.Notify(ch, syscall.SIGTERM) 然后 <-ch
# Python : signal.signal(signal.SIGTERM, handler)
# Node : process.on('SIGTERM', () => {...})
# ★ 它们捕获的都是 SIGTERM —— 这正是为什么停服务要用
# kill(TERM)而不是 kill -9:-9 让以上所有钩子统统失效。
# === ★ 优雅关闭要配一个【超时】,不能无限期等 ===
# 万一某个请求卡死了,优雅关闭不能就一直等下去。
# 通行做法:给收尾过程设一个上限(比如 30 秒),
# 到点还没收完,就放弃剩下的、强制退出。
# systemd 的 TimeoutStopSec 干的就是这件事(见修复 5)。
# === 验证你的服务到底有没有优雅关闭 ===
$ kill 8123 ; tail -f /var/log/app/app.log
# ★ 看日志里有没有 "shutting down / 收尾 / closing" 这类行。
# 有 -> 优雅关闭生效了;
# 没有、日志直接断掉 -> 要么没写优雅关闭,要么你发的是 -9。
修复 4:kill -9 为什么是最后手段
# === ★ 把 kill -9 当默认动作,是这次事故的总根源 ===
# === -9 的代价:进程"暴毙",留下一地未收拾的烂摊子 ===
# - 在途请求:客户端那头直接收到连接中断 -> 报错
# - 写缓冲区:还在内存没落盘的数据 -> 丢失
# - 数据库事务:写了一半没 commit -> 留下中间态脏数据
# - 锁文件 / pid 文件:没人删 -> 下次启动可能被"已在运行"挡住
# - 子进程:可能变成没人收养的孤儿进程
# === ★ 那 kill -9 什么时候才该用 ===
# 只有一种情况:你先发了 SIGTERM,给了它合理的时间,
# 它【就是不退】—— 这时候才轮到 -9 出场。
$ kill 8123 ; sleep 15
$ ps -p 8123 && kill -9 8123 # 等了 15 秒还在,才 -9
# === ★ 一个 -9 都杀不掉的进程:D 状态(不可中断睡眠)===
$ ps -eo pid,stat,comm | grep 8123
8123 D myapp # ★ STAT 是 D
# D 状态的进程,正陷在内核里等一个不可被打断的操作(通常是磁盘 IO)。
# ★ 此时连 SIGKILL 都得【排队等它从内核返回】才能生效 ——
# 所以你会看到"kill -9 了,进程却还在"。
# 别跟它死磕,去查它在等的那个 IO(见负载排查相关文章)。
# === 僵尸进程(Z 状态):kill 它没有任何意义 ===
$ ps -eo pid,stat,comm | awk '$2 ~ /Z/'
# 僵尸进程【已经死了】,只是父进程还没 wait() 回收它。
# ★ 你 kill(无论几号信号)一个僵尸都是徒劳 —— 它已经没了。
# 要清掉它,得去处理它的【父进程】。
# === 找出一个进程到底在响应哪些信号 ===
$ cat /proc/8123/status | grep -i sig
SigCgt: 0000000000014002 # ★ SigCgt = 进程"捕获"了哪些信号
# 这个掩码能看出进程有没有注册 SIGTERM 处理函数。
修复 5:systemd 是怎么优雅停服务的
# === ★ 与其自己 kill,不如把服务交给 systemd 管 ===
# systemd stop 一个服务时,内部就是一套标准的"先礼后兵"。
# === systemctl stop 背后的完整流程 ===
$ systemctl stop myapp
# systemd 实际做的:
# 1. 给主进程发 SIGTERM(可由 KillSignal= 改)
# 2. ★ 等待,最多等 TimeoutStopSec 这么久
# 3. 进程在超时前退出 -> 完美,优雅停止
# 4. 超时还没退 -> 发 SIGKILL 强制干掉
# ★ 这正是修复 2 那个套路的"产品化"版本。
# === service 单元里和"停止"相关的关键配置 ===
$ cat /etc/systemd/system/myapp.service
[Service]
ExecStart=/opt/myapp/start.sh
KillSignal=SIGTERM # ★ 停止时先发的信号(默认就是 TERM)
TimeoutStopSec=30 # ★ 给优雅关闭 30 秒,超时才 KILL
KillMode=control-group # ★ 杀谁:control-group=整个 cgroup 里的进程
Restart=on-failure
# === ★ KillMode 几个取值,决定"停服务"波及谁 ===
# control-group 停掉该服务 cgroup 内的【所有】进程(默认,最干净)
# mixed 主进程发 TERM,其余进程发 KILL
# process ★ 只停【主进程】—— 它 fork 的子进程可能变孤儿
# none systemd 不发任何信号(极少用)
# === 服务优雅关闭需要更久?调大超时 ===
$ systemctl edit myapp # 改 TimeoutStopSec
# 比如批处理服务,收尾要 2 分钟,就设 TimeoutStopSec=120。
# ★ 设太小,systemd 会在服务还没收完尾时就 -9 它 —— 等于又踩坑。
# === 看一次 stop 到底花了多久、怎么结束的 ===
$ systemctl stop myapp ; journalctl -u myapp -n 30
# 日志里能看到 "Stopping..." 到 "Stopped" 之间的过程,
# 以及 systemd 有没有因为超时而动用 SIGKILL。
# === ★ 结论:能交给 systemd 管的服务,就别手动 kill ===
# systemd 的停止流程,天然就是"先 TERM、给时间、超时才 KILL",
# 比人肉 kill -9 安全得多。
修复 6:信号与进程停止排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 停服务的默认动作是 kill(SIGTERM),不是 kill -9 ===
$ kill # 先发 TERM,让它优雅退出
# kill -9 是"最后手段",不是"顺手就来"。
# === 2. ★ 先礼后兵:TERM -> 等一会 -> 还在才 KILL ===
$ kill ; sleep 10; ps -p && kill -9
# === 3. SIGKILL 不可捕获,进程没机会清理 ===
# -9 会让所有 shutdown hook / trap 失效,
# 在途请求、未刷数据、未提交事务全部丢。
# === 4. 服务要写优雅关闭:捕获 TERM,处理完在途请求再退 ===
# 并给收尾过程配一个超时,别无限期等。
# === 5. kill -9 都杀不掉,先看进程状态 ===
$ ps -eo pid,stat,comm | grep
# D 状态:在等 IO,-9 也要排队 -> 去查 IO
# Z 状态:僵尸,已经死了 -> 去处理它的父进程
# === 6. 能交给 systemd 管就别手动 kill ===
# 配好 KillSignal / TimeoutStopSec / KillMode,
# systemctl stop 天然就是安全的"先礼后兵"。
# === 7. 排查"进程停不掉 / 停出问题"的命令链 ===
$ kill -l # ① 信号编号速查
$ ps -eo pid,stat,comm | grep # ② 进程在什么状态(D/Z?)
$ cat /proc//status # ③ 它捕获了哪些信号(SigCgt)
$ kill && tail -f 日志 # ④ 发 TERM,看有没有优雅收尾
$ journalctl -u 服务 -n 30 # ⑤ systemd 管的服务看停止过程
# 按这个顺序,信号与进程停止的问题基本能定位。
命令速查
需求 命令
=============================================================
看所有信号编号 kill -l
优雅停止(发 SIGTERM) kill 或 kill -15
强制杀死(最后手段) kill -9
探测进程是否还活着 kill -0
按进程名发信号 killall
按命令行模式发信号 pkill -f '模式'
给整个进程组发信号 kill -- -
看进程捕获了哪些信号 cat /proc//status | grep SigCgt
让服务重载配置 kill -HUP
优雅停止 systemd 服务 systemctl stop <服务>
口诀:停服务先发 TERM 给它优雅退出 -> 等一会还在才动 -9
kill -9 不可捕获 = 进程暴毙 -> 在途请求和数据全丢
避坑清单
- kill 不是"杀死",是"给进程发信号",杀死只是某些信号的默认动作
- kill 不带参数默认发 SIGTERM(15),不是 SIGKILL(9)
- SIGTERM 可被进程捕获,进程能借机优雅收尾;SIGKILL 不可捕获
- kill -9 由内核直接干掉进程,在途请求、未刷数据、未提交事务全丢
- 停服务标准姿势是先礼后兵:TERM 等一会,还不退才 KILL
- 优雅关闭要捕获 TERM,处理完在途请求再退,并给收尾配超时
- kill -9 杀不掉的进程先看状态:D 是等 IO,Z 是僵尸已死
- 僵尸进程已经死了,kill 它无意义,要处理它的父进程
- 能交给 systemd 管的服务别手动 kill,配好 TimeoutStopSec 就是安全的
- systemd 的 TimeoutStopSec 设太小,会在服务没收完尾时就 -9 它
总结
这次"kill -9 重启搞坏数据"的事故,纠正了我一个用了好几年、从来没被质疑过的操作习惯背后的认知错误。在这次之前,我心里对 kill 这个命令的理解,简单粗暴到只有一个词:"杀"。kill 一个进程,就是把它杀掉;而 kill -9,在我心里是这把"杀"刀里最锋利、最可靠的那一把——别的写法或许偶尔会"杀不干净",但 -9 从不失手,一刀毙命。正因如此,我把 kill -9 当成了重启服务的默认动作,图的就是它的"可靠"。我从来没想过,这个我引以为傲的"可靠",恰恰是在制造故障。复盘到根上,我才真正理解,kill 这个命令名,起得是有误导性的——它干的事,根本不是"杀死",而是【向一个进程发送一个信号】。信号,是内核提供的一种异步通知机制,它用一个小小的编号,去通知进程"发生了某件事"。而"进程被终止",仅仅是某些信号会导致的【默认结果】,不是信号本身。想通这一层,SIGTERM 和 SIGKILL 这两个我以前混为一谈的东西,差异就豁然清晰了。kill 不带参数时发的 SIGTERM(15 号),它的本质是一句【礼貌的请求】——"请你关闭一下"。它最关键的性质是:它【可以被进程捕获】。一个设计良好的服务,会注册一个处理函数专门"接住"这个 SIGTERM,收到它以后,进程不会立刻死,而是从容地开始收尾:先停止接收新请求,把手里那几个【已经在处理】的请求老老实实做完,把内存缓冲区刷到磁盘,提交或回滚未完成的事务,关闭数据库连接池,删掉锁文件——这一整套动作,就是所谓的"优雅关闭"。而 kill -9 发的 SIGKILL(9 号),则是完全不同的另一种东西:它【不可被捕获、不可被忽略、不可被阻塞】——这个信号根本不会被交到进程手里,而是由【内核直接】把进程从这个世界上抹掉。这意味着,进程【没有一丝一毫的机会】去执行它那套精心写好的优雅关闭逻辑。我们的订单服务,其实是写了优雅关闭的;可我那句习惯性的 kill -9,等于在它即将开始收尾的前一刻,直接拔掉了它的电源——进程在"一笔订单的数据库事务写了一半"的精确瞬间被瞬间冻结、抹除:那个在途的请求,客户端那头收到的是连接的猝然中断,于是报"提交失败";那个写了一半、还没 commit 的事务,在数据库里留下了一条状态卡在"处理中"、再也不会推进的脏数据。监控上那一小撮准时出现的投诉,全都是被我 -9 当场打断的"半成品"。想清楚这一切,正确的停服务姿势也就不言自明了——它是一套"先礼后兵":先发 SIGTERM,把"请关闭"这个请求递给进程,给它一段合理的时间去优雅地收尾;只有当它在这段时间之后,依然赖着不退,我才有理由动用 SIGKILL 这个最后手段。这个"先 TERM、给时间、超时才 KILL"的套路,其实并不需要我自己人肉去做——systemd 在 systemctl stop 一个服务时,内部干的恰恰就是这件事:先发 KillSignal(默认 SIGTERM),然后最多等待 TimeoutStopSec 那么久,进程在超时前退出就皆大欢喜,超时还没退才补上一刀 SIGKILL。这次从一个"重启怎么会搞坏数据"的困惑出发,我最大的收获,是把脑子里"kill = 杀,-9 = 最可靠的杀"这个粗暴的等式,彻底拆掉了,换成了一个准确的理解:kill 是"发信号",SIGTERM 是给进程留了一条体面退场的路、是停服务的首选,而 SIGKILL 是一种剥夺了进程所有善后权利的"暴力处决",它只配做万不得已时的最后手段。一个 -9 敲下去快是快,但它快的代价,是进程手里那些没来得及收拾的烂摊子——而那些烂摊子,最后都会变成监控上的告警和用户的投诉,加倍地还回来。
—— 别看了 · 2026