做后台服务维护久了,我对“进程还在,业务却没了”这种事情特别敏感。前阵子我们内部一个常驻的消息网关服务表现很怪:白天业务量不大时一切正常,一到定时发布或手动重启的窗口,就有概率卡在“停止服务”这一步,日志停在某一行,像是某个线程没有按预期退出。如果只是卡住倒也算了,更麻烦的是有几次卡到监控系统超时,触发保护机制,系统检测到进程不健康后自动把进程拉起重启,结果启动以后反而因为上一轮资源没释放、状态文件损坏,起来一个崩一个。
这个案例简而言之就是一句话:线程未正常退出,导致程序崩溃,系统检测到崩溃后,自动重启进程。但这句话背后藏着的问题远不止“重启一下”那么简单。线程为什么退不出去?线程不退出为什么能把整个进程搞崩?自动重启怎么写才不会在崩溃循环里来回横跳?这些问题如果不梳理清楚,重启就是拆东墙补西墙。这篇文章我从这次实战出发,把现象、原因、监控设计和代码层面的修复都串一遍,适合正在维护常驻服务、桌面客户端,或者被“偶发崩溃/卡死”折磨过的开发、运维、QA同学参考。
1. 崩溃现场复盘:一个线程“退不干净”是怎么拖垮整个进程的
1.1 现象复盘:服务慢、连接徘徊、最后整个进程消失
我第一次注意到问题,是在一次版本发布后的自动重启日志里。服务主线程已经收到“停止”信号,开始执行优雅关闭流程,日志也打出了“开始关闭线程池”,然后就没有然后了。进程既没有正常退出,也没有崩溃退出,就这么挂在那里。从外部看,进程还活着,端口还在监听,但业务已经无法处理新请求,因为线程池已经被 shutdown 了,队列里的任务全部卡在等待状态。
这种“活着但死了”的状态最讨厌。监控系统只检查端口和进程存活,自然以为服务还在正常运行。直到健康检查接口超时,系统才判定进程异常,然后启动自动重启逻辑。如果只是到这里,问题还不算大,重启一次可能就恢复了。最致命的是第二次启动失败:因为第一次“假死”时,有些线程还抓着数据库连接池里的连接、持有本地文件锁,进程被强制杀掉后这些资源没有机会释放,数据文件里的临时状态没有回滚,于是新的进程起来以后,初始化阶段直接读取到脏数据,App 都没起完就崩了。
后来我把问题收敛到一个很具体的点:一个负责“长轮询外部超时任务结果”的工作线程没有退出。这个线程内部使用了阻塞队列的 take(),同时没有给底层 HTTP 客户端设置读取超时,导致 shutdown 时发送中断信号也无法唤醒它。主线程等待线程池结束等不到,优雅关闭流程卡死,最终被外部看门狗强杀。整个现象的起点就是那一行没有考虑“线程退出协议”的阻塞调用。
1.2 线程退不出去的四种典型场景
我梳理了手头几个项目,发现线程“退不干净”基本逃不出下面四类。
第一类是死循环但缺少明确的退出标志。很多后台线程的写法是while (true),循环内部再靠break跳出。一旦某条分支忘了 break,或者循环里有一个被 catch 吞掉的异常,线程就会永远跑下去。最典型的是写日志的死循环,主流程已经停止了,日志线程还在空转,看起来不影响业务,但进程怎么都退不掉。
第二类是阻塞在 IO、锁、队列或者第三方调用上。这一类比死循环更隐蔽,因为它不是 CPU 占用高,而是线程状态一直是 WAITING 或 BLOCKED。比如BlockingQueue.take()在没有数据时无限等待,Socket.read()没有设置 timeout,数据库连接池获取连接时永久等待,还有 JVM 的 synchronized 锁被别的线程一直占着。Java 的Thread.interrupt()只能唤醒一部分可中断的阻塞,例如Object.wait()、Thread.sleep()、Lock.lockInterruptibly();但普通 IO、NIO 的 Selector、没有被设计成响应 interrupt 的第三方库,中断根本不起作用。要命的是很多线程池 shutdownNow 的机制就是靠 interrupt 来通知线程停止,遇到这种无法响应中断的任务,shutdownNow 也拿它没办法。
第三类是线程池任务队列不断积压,看起来像是“线程没退出”,其实是线程池里的任务永远跑不完。常见原因是用了无界队列,生产者提交任务的速度远大于消费速度,或者消费者的单个任务执行时间被外部依赖拖得很长。这时候线程池的 worker 线程明明是空闲的,但队列里塞满了任务,应用进入关闭流程后,线程池要等所有任务执行完才能停止,于是关闭流程被无限拉长。
第四类是线程自己异常退出后,状态没有被上层感知。比如 C++ 的线程函数抛出了未捕获异常,会直接触发std::terminate,整个进程就 abort 崩溃了;Java 线程如果抛出未捕获的 RuntimeException,默认会打印堆栈并终止该线程,但不会崩溃进程,可如果这个线程负责清理某个共享标记,它一死,其他线程永远拿不到释放信号,进程就会卡在下一次的等待上。
1.3 为什么线程不退出,最后会变成进程崩溃
很多人会疑惑:一个线程不退,最多是关不掉,为什么会直接把进程搞崩?我总结出三条最常见的传导路径。
第一条路径是关闭顺序被破坏。假设主线程先停止了业务模块,然后开始释放共享对象,比如 HttpClient、数据库连接池、全局配置对象。此时残留线程还在执行请求,它可能已经拿到了某个对象的引用,正准备调用对象的方法,结果这个对象的底层资源已经被关闭,等于访问了一块被回收的内存。在 C/C++ 里这叫 use-after-free,直接段错误;在 Java、C# 里会抛各种Closed异常或ObjectDisposedException,如果线程边缘没有兜底 catch,再往外抛一层就可能导致进程崩溃或被运行时按未处理异常处理。
第二条路径是“等不及之后的强行了断”。有些关闭逻辑发现自己等不到线程退出,就调用 Thread.Abort、TerminateThread、pthread_cancel 之类的接口去强杀线程。这类强制终止并不会让线程在安全点退出,它可能正持有锁、正在修改链表结构、正在操作文件缓冲区。强杀之后,进程的全局状态可能处于半更新状态,下一次分配同一个锁或者复用同一个对象时,就崩了。这就像你正在写一份文件,只写了一半就拔了电源,下次想再用这台电脑,文件系统可能直接损坏。
第三条路径是资源耗尽。线程不是免费的,每个线程默认都有独立栈空间,Linux 上通常是 8MB 的虚拟内存,频繁创建线程且不回收,很快就会触到进程或系统资源上限。除了内存,还有线程句柄、文件描述符、内核任务结构。等资源耗尽时,再想创建一个线程就会失败。Java 中会抛OutOfMemoryError: unable to create native thread,C++ 中 pthread_create 返回错误码,很多程序没有处理这种错误,直接把空指针当正常返回继续用,下一个动作就可能是崩溃。
想明白这三条路径,你就会明白“自动重启进程”只是在处理最终结果,真正的病根在线程生命周期管理和关闭顺序上。看门狗要做,但根因更要修。
2. 崩溃自愈机制怎么设计:不是简单“挂了就拉起来”
2.1 先想清楚目标:监控的是“进程崩溃”还是“业务假死”
自动重启的脚本其实是系统性的,不能拍脑袋写。设计前先分清楚两个概念:进程崩溃和业务假死。
进程崩溃是最好检测的,因为操作系统会给你一个退出事件和退出码。正常情况下进程退出码是 0,非 0 表示异常退出。但有些进程被 OOM Killer 杀掉,退出码是 137;有些是因为段错误,退出码是 139;有些是主动abort(),退出码是 134。这些都可以通过父进程 fork/wait,或者交给 systemd、supervisor 等进程管理工具处理。
业务假死则更隐蔽,进程还活着,退出码不存在,但线程已经无法正常处理业务。这种情况下你等不到进程退出事件,只能靠健康检查。健康检查有两种做法:一种是流量侧探测,通过 HTTP、TCP、RPC 接口周期性地确认服务能不能处理请求;另一种是应用内部心跳,由服务自己定期向一个状态文件、日志或外部存储写入“我还活着”的信号。监控程序把心跳时戳和当前时间做对比,超过阈值就判定假死。
我之前那个项目两种都用了。进程级监控负责处理真崩溃,业务级健康检查负责处理假死,避免把“卡住不退出”和“进程消失”混为一谈。
2.2 崩溃检测与健康检查的几个关键信号
在自研看门狗或配置进程管理器时,至少要评估下面的信号,缺了哪一个都可能误判。
用表格整理一下:
| 检测信号 | 判断方式 | 优点 | 缺点 |
|---|---|---|---|
| 进程退出事件 | waitpid/proc.poll(),观察退出码 | 最直接,任何异常退出都能发现 | 无法发现假死 |
| 端口连通性检查 | 定期连接服务端口 | 能排查连接级别的假死 | 只能代表端口在,不代表线程健康 |
| HTTP/健康接口探测 | 调用指定接口并验证返回内容 | 能反映真实业务链路 | 如果业务线程池被耗光,探测也可能超时,需要调低超时时间避免误报 |
| 心跳文件/心跳日志 | 应用定期更新时间戳 | 与业务无关,实现简单 | 与应用代码耦合,写日志本身也可能阻塞 |
| 系统指标(CPU、内存、句柄数、线程数) | 从 /proc、psutil 等采集 | 能发现资源异常增长 | 指标阈值需要长期数据统计,容易误报 |
实际项目里,最好把“进程退出”和“心跳超时”组合起来判断。只依赖“进程退出”,会漏掉假死;只依赖“心跳超时”,又可能在服务 GC、慢请求、主线程繁忙时导致误杀。
2.3 自动重启的“防抖”设计:避免崩溃循环
自动重启本身不复杂,复杂的是防抖,因为很多程序崩溃后根本没有恢复的条件,靠重启只能无限循环空转。
在我的方案里,重启逻辑必须包含三个机制。
第一个是重启次数限制。如果同一个进程在短时间内反复崩溃,说明不是偶发故障,而是代码存在必然的 bug,这时候继续重启只会把外部依赖全部打挂。我通常设置最近 60 秒最多重启 3 次,超过后就不再拉起,转人工。
第二个是退避延时。崩溃后不要立刻拉起,应用可能还占着端口、锁、共享文件没有释放干净。立刻重启大概率失败,而且错误日志会被反复覆盖,丢失第一现场。我会让重启等待时间按次数递增,比如第一次 2 秒、第二次 5 秒、第三次 10 秒,给系统留出资源回收的时间。
第三个是现场保留。进程崩溃前,要把当时的线程栈、dump、崩溃日志保留下来。如果没有这些,看到的永远只是“又崩了”,永远不知道崩在哪一行。自动重启是恢复业务的降级手段,不是诊断手段。
3. 实操:守护进程 + 健康检查自动拉起崩溃应用
3.1 内存场景复现:一个卡住线程的问题应用
为了把整个流程跑通,我先写了一个简单的 Java 应用来模拟问题程序。这个应用启动后提交一个任务到线程池,任务线程会周期性地打印日志,并且不响应线程中断请求。主线程收到关闭信号后,尝试优雅关闭线程池,但等了几秒发现线程仍然活跃,于是只能强制退出。
下面是这个模拟应用的完整代码:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class StuckWorker { private static volatile boolean running = true; public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(2); pool.submit(() -> { int count = 0; while (running) { System.out.println("worker tick " + (++count)); try { Thread.sleep(2000); } catch (InterruptedException e) { // 这里吞掉了中断信号,线程不会退出 System.out.println("worker got interrupt, but I ignore it"); } } }); // 模拟收到停止信号后执行优雅关闭 Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("shutdown hook start, try to close pool"); running = false; pool.shutdown(); try { if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { System.out.println("thread pool not terminated after 5s"); // 在实际场景中,这里如果继续往下释放共享资源, // 残留线程很可能访问到已关闭的资源,造成崩溃。 Runtime.getRuntime().halt(1); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } })); System.out.println("StuckWorker started, pid=" + ProcessHandle.current().pid()); Thread.sleep(60_000); } }这段代码里的坑在于任务线程把InterruptedException吞掉了,并且在running置为 false 后也没有退出循环。如果只运行这个程序,你会在 5 秒后看到thread pool not terminated after 5s,然后 JVM 因为halt(1)直接以退出码 1 终止,模拟了“关闭超时导致非正常退出”的过程。
有人会问,真实项目中谁会写这种代码?事实上很多人不是在catch里吞异常,而是在自己的 while 循环中把Thread.currentThread().isInterrupted()的判断条件写反了;或者任务里嵌了一个永不返回的第三方调用。模拟代码只是把最脏的情况画出来。
3.2 Python 看门狗最小实现:检测退出码并自动拉起
针对上面的 Java 应用,我写了一个 Python 看门狗。它的职责很简单:启动子进程,等待退出;退出码不为 0 且频率不高时重新拉起;为了可观测,把子进程的标准输出和错误输出都重定向到固定日志文件。
#!/usr/bin/env python3 import subprocess import sys import time WORKER_CMD = ["java", "-cp", ".", "StuckWorker"] LOG_FILE = "worker.log" MAX_RESTARTS = 3 RESTART_WINDOW_SEC = 60 BACKOFF_BASE_SEC = 2 def start_worker(): log = open(LOG_FILE, "ab", buffering=0) proc = subprocess.Popen( WORKER_CMD, stdout=log, stderr=subprocess.STDOUT, cwd="/opt/app", universal_newlines=False, ) return proc, log def main(): proc, log = start_worker() restart_times = [] while True: code = proc.poll() if code is not None: print(f"[watchdog] worker exited with code {code}", file=log) log.close() now = time.time() restart_times = [t for t in restart_times if now - t < RESTART_WINDOW_SEC] restart_times.append(now) if len(restart_times) > MAX_RESTARTS: print(f"[watchdog] restart too many times, give up", file=sys.stderr) sys.exit(1) backoff = BACKOFF_BASE_SEC * len(restart_times) print(f"[watchdog] waiting {backoff}s before restart", file=log) time.sleep(backoff) proc, log = start_worker() continue time.sleep(1) if __name__ == "__main__": main()这个看门狗的逻辑比较简单,但已经覆盖了核心点:进程异常退出后能拉起、有重启次数限制、有退避时间。运行一段时间后,你会在日志里看到反复重启的过程。
不过要注意,它只能监控“进程真的退出”的场景。如果 StuckWorker 卡在 shutdown hook 中不退出,进程还活着,这个看门狗就会一直等下去。所以更完整的看门狗必须加入心跳检测。
3.3 改进版:加入心跳检测,处理“进程活着但线程卡死”
为了能让看门狗发现线程卡死,我在 Java 应用里增加了一个“心跳线程”。心跳线程每隔 2 秒往一个文件写入当前时间戳,同时看门狗会定期读取这个文件的修改时间。如果进程还活着,但心跳超过 5 秒没有更新,看门狗就判断进程假死,主动 kill 掉再重启。
应用端心跳代码:
import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; public class HeartbeatThread extends Thread { private final Path heartbeatFile = Paths.get("/tmp/worker.heartbeat"); private volatile boolean running = true; @Override public void run() { while (running) { try { Files.write(heartbeatFile, System.currentTimeMillis() / 1000 + "\n".getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Thread.sleep(2000); } catch (Exception e) { Thread.currentThread().interrupt(); break; } } } public void stopHeartbeat() { running = false; interrupt(); } }看门狗端除了 wait 退出码,还要周期性检查心跳时间:
import os import time HEARTBEAT_FILE = "/tmp/worker.heartbeat" HEARTBEAT_TIMEOUT_SEC = 5 def heartbeat_stale(): if not os.path.exists(HEARTBEAT_FILE): return True mtime = os.path.getmtime(HEARTBEAT_FILE) return (time.time() - mtime) > HEARTBEAT_TIMEOUT_SEC # 主循环中,在进程存活的状态下额外增加以下判断: # if heartbeat_stale(): # print("[watchdog] heartbeat stale, killing worker") # proc.terminate() # try: # proc.wait(timeout=3) # except subprocess.TimeoutExpired: # proc.kill() # proc.wait()完整看门狗里,我把退出码监控和心跳监控放在一起,如果心跳过期就主动杀掉后进入重启逻辑。这样既覆盖了真崩溃,也覆盖了假死。
3.4 更轻量的部署方式:systemd 与 Windows 服务恢复
如果你用的是 Linux,且不想自己维护 Python 看门狗脚本,可以使用 systemd 的进程管理能力。systemd 可以直接监控进程退出码并在异常退出时重启,还能设置重启频率限制。
一个常驻 Java 服务的 unit 文件长这样:
[Unit] Description=Stuck Worker Service After=network.target [Service] Type=simple WorkingDirectory=/opt/app ExecStart=/usr/bin/java -cp /opt/app StuckWorker Restart=on-failure RestartSec=5 StartLimitIntervalSec=60 StartLimitBurst=3 [Install] WantedBy=multi-user.target加了配置后,用systemctl daemon-reload、systemctl start stuck-worker启动。当进程退出码非 0 时,systemd 会等待 5 秒自动拉起;如果 60 秒内重启超过 3 次,systemd 会放弃重启并标记服务为 failed。
systemd 的 Restart=on-failure 也有局限:它无法检测“进程活着但业务卡死”。如果要用 systemd 管理假死检测,可以配合WatchdogSec=和 sd_notify 机制,但 Java 端接入稍麻烦。所以我的经验是:进程级守护用 systemd,心跳级守护还是用自己的健康脚本更灵活。
Windows 上也有类似机制,Windows 服务恢复选项卡或 sc 命令可以设置服务失败后的操作:
sc failure MyService reset= 86400 actions= restart/5000/restart/5000/restart/1000这条命令表示重启失败 1 秒后自动重启,失败后继续重启,最多三次。桌面 GUI 程序则不适合直接用系统服务来做看门狗,通常我会再写一个小的守护进程,专门负责拉起主程序、监控主程序窗口是否响应。
4. 从根上修复线程退不出去的问题:排查方法与防御清单
4.1 排查线程卡住的标准姿势
自动重启只能救急,真正要花时间的是定位线程为什么卡住。下面这些排查方法是我实际使用频率最高的,按照系统和语言整理一下。
Linux 下先看线程 CPU 占用,找到可疑线程号:
top -H -p <PID>如果某个线程 CPU 占用特别高,大概率是死循环。如果进程整体正常,但部分线程 CPU 很低且一直阻塞,就应该抓线程栈看它们在等什么。
Java 服务用 jstack 抓线程栈:
jstack <PID> > thread_dump.txt然后在 dump 文件里搜索BLOCKED、WAITING、TIMED_WAITING,重点看哪些线程的状态异常,以及它们停在哪个类的方法上。jstack 还能看到锁的持有者,很多死锁问题一眼就能看出来。
C/C++ 服务用 gdb 附加进程,然后抓所有线程栈:
gdb -p <PID> (gdb) thread apply all bt抓完栈之后,把syscall附近的栈帧调出来,看阻塞在哪个系统调用里。也可以用 pstack,输出更简洁。
C#/.NET 服务在 Linux 上使用dotnet-dump收集 dump,再用 analyze 命令查看线程栈:
dotnet-dump collect -p <PID> dotnet-dump analyze core_xxx > threads > clrstack -allWindows 上 Windows 系统大量进程也可以使用 Process Explorer,双击进程后查看线程页签,能看到线程的起点、CPU 时间和内核栈。如果线程一直消耗 CPU,这里会非常明显。
4.2 不同语言的正确关闭线程方式
诊断之后,修复的核心是让退出协议符合线程的协作机制。这里有一个很反直觉的原则:线程退出最好靠“请求”而不是靠“命令”。
Java 里推荐使用中断标志和 volatile/AtomicBoolean 退出标志。发送中断用thread.interrupt(),线程内部在阻塞调用中收到 InterruptedException 后,要重新设置中断状态或根据场景退出循环。不要只用中断,也不要只用一个布尔变量,两个机制配合最好。我见过的很多坑是:只用一个 volatile 标志,但线程阻塞在Thread.sleep()或take()上,进程退时中断已经发过了,线程醒不过来;只靠中断,但如果线程在跑一个不响应中断的底层库,同样退不了。
C# 里对应的是 CancellationToken。使用CancellationTokenSource.Cancel()通知线程取消,线程内部在长时间操作、等待、循环时定期检查IsCancellationRequested,不要调用已经过时的Thread.Abort()。Abort 会强制抛 ThreadAbortException,可能让对象处于半初始化状态,非托管资源不一定被释放,风险非常大。
C++ 原生线程没有语言级的中断机制,标准做法是设置一个std::atomic<bool>作为退出标志,线程内部循环中检查。对于阻塞在 IO 上的线程,需要配合 poll/epoll、超时机制或者关闭文件描述符来唤醒。pthread_cancel虽然存在,但它选择取消点的行为非常微妙,绝大多数场景都不推荐依赖它。
Qt 子线程的退出建议使用QThread::requestInterruption(),线程内部用isInterruptionRequested()决定是否退出,并在必要的地方调用QThread::msleep()替代忙等。不要在线程还运行时直接销毁 QThread 对象或调用terminate(),terminate 可能使 Qt 内部状态崩溃,尤其是在子线程操作了事件循环、信号槽的情况下。
4.3 线程池的阻塞队列选择直接决定退出难易度
如果你用的不是裸线程,而是线程池,线程池的退出难度很大程度取决于阻塞队列的选择和任务超时设计。
Java 的ThreadPoolExecutor支持的有界和无界队列差别很大。LinkedBlockingQueue不设置容量就是无界队列,任务可以无限堆积,线程池始终不会拒绝任务。这样在关停时,线程池会尝试执行完队列里所有积压任务,可能永远等不完。使用有界队列ArrayBlockingQueue或设置容量的LinkedBlockingQueue,配合饱和拒绝策略,至少能把任务积压限制在一个范围,关停时心里有数。
同时要注意submit()和execute()的区别。submit()会把任务包装成 FutureTask,即使任务本身抛了异常,也会被 FutureTask 捕获,需要调用future.get()才能拿到异常。如果你只 submit 不拿结果,异常就被吞在 Future 里,线程池反而不会崩溃,但任务失败完全不可见。这会让你在排查线程退出问题时,少了一半的线索。
有经验的团队在正式环境对每个对外请求都设置超时时间,例如 HTTP 连接超时 2 秒、读取超时 3 秒、数据库连接获取超时 5 秒。超时看起来是业务层面的配置,实则对线程退出至关重要。一个不设超时的阻塞请求,会让整个线程池的 worker 都挂在同一个慢依赖上,优雅关停当然退不掉。
4.4 守护线程、守护进程和进程组的关系要理清
热词里大量提到守护线程和守护进程。这两个词相近,但地位完全不同。
Java 守护线程(daemon thread)有一个特点:当进程中只剩守护线程在运行时,JVM 会自动退出。所以很多人会想到把后台轮询线程设置成 daemon 来避免退出阻塞。但这并不是万能药,守护线程并不是“自动清理”,它是在 JVM 退出时被强行终止,一样可能留下没有写完的文件、没有释放的锁,甚至被 kill 在修改共享数据的中间态。大量使用 daemon 线程做关键业务,反而会让问题更难排查。
守护进程通常指脱离终端会话、在后台运行的服务进程,比如系统服务。守护进程与线程不是同一层概念。在进程组的视角下,一个看门狗进程可以守护另一个服务进程;在语言运行时视角下,一个进程内部又有用户线程和守护线程。设计自动重启方案时,建议把“进程”和“线程”放在不同层考虑:跨进程保护用看门狗或 systemd,进程内部线程生命周期用线程池、中断、退出标志管理,不要混淆。
4.5 关闭顺序清单:先停新流量,再停存量请求,最后释放全局资源
线程不退出导致的崩溃,大多数发生在关闭顺序混乱的时候。我后来在代码里强制规范了一套关停顺序。
第一步先停止接收新的请求或任务。这一步通常通过反注册服务端口、从负载均衡摘除节点、调用shutdown()实现。我的实践是:先摘流量,暂停个几秒,让正在处理的存量请求自然结束,再开始关停内部模块。如果没有这一步,一边关线程池一边还有新任务进来,线程池永远无法到达“安静”状态。
第二步处理存量请求的任务超时。给存量请求一个宽限期,比如 10 秒。如果 10 秒没执行完,宁可记录日志并取消,也不要无限等待。服务升级时最忌讳“等到所有请求都结束”,因为总有一个慢请求会卡住整条发布时间线。
第三步关闭线程池,再关闭资源池,比如数据库连接池、HTTP 连接池、消息队列客户端。顺序一定不能反。如果先把连接池关了,线程池中的任务还在执行,后半段任务会突然拿不到连接,抛出一堆连接关闭异常;如果线程池先把所有任务执行完,最后关闭连接池,就不会出现“活干到一半手被砍了”的状态。
第四步清理临时文件和状态,比如锁文件、心跳文件、内存中的定时任务状态。清理完成后,再允许进程退出。
这四步看起来简单,实际操作中很容易被各个模块的初始化顺序拖乱。我的建议是在入口写一个ShutdownController或者统一的生命周期管理类,把模块的关闭顺序以显式列表维护,而不是依赖 JVM 的 shutdown hook 内部的随机顺序,后者一旦与 Spring、Netty 这类框架的钩子混合在一起,很容易出错。
5. 自动重启实践里的常见问题与避坑技巧
5.1 崩溃自动重启场景速查表
实际运行中,我积累了一张表格,帮助团队快速根据现象定位原因是“该重启”还是“不该重启”,是“重启能解决”还是“重启反而更糟”。
| 现象 | 可能原因 | 该怎么做 |
|---|---|---|
| 退出码为 137 | OOM Killer 杀掉的,往往是内存泄漏或容器内存限额过小 | 先看监控中的内存曲线,再决定是否调大限额或修泄漏,不要盲目重启 |
| 退出码为 139 | 段错误,大概率是 C/C++ 或 JNI 层访问非法内存 | 抓 core dump,用 gdb 分析,重启只能临时恢复 |
| 优雅关闭日志卡住,进程不退 | 线程池中的任务阻塞在不可中断的调用中 | 跳过超时任务,强制退出并保留 dump,后续修退出协议 |
| 重启后立刻又崩,反复循环 | 服务本身依赖的配置或数据已经损坏,或者端口被占用 | 先停止自动拉起,清理状态和端口,再手动单次启动 |
| 假死,心跳停止但进程存活 | 主线程阻塞/死锁,业务线程池耗尽 | 抓线程栈定位,看门狗 kill -9 拉起的动作治标不治本 |
| 进程正常退出码 0,但业务状态不对 | 关闭流程没执行完,或错误把业务失败也当成正常完成 | 增加状态校验,区分正常退出和业务关闭失败 |
这张表帮助团队避免一个思维惯性:看到“崩溃”就想加自动重启。实际遇到 137 或 139 时,自动重启只能救火,更重要的是保留崩溃现场做分析。
5.2 自动重启后的几个隐蔽坑
我要特别强调自动重启后容易遇到的一批隐蔽问题。
第一个坑是端口和锁文件残留。A 进程被强杀,它监听的端口可能还没有完全释放,尤其是 Linux 上 TIME_WAIT 状态的端口。如果 5 秒内重启 B 进程去监听同一个端口,经常报 bind 失败。解决办法是重启时检测端口可用性,或者在服务代码里设置SO_REUSEADDR。锁文件也一样,如果进程被强杀,遗留的.lock文件不会被删除,新的进程启动时看到锁文件存在就误以为有另一个实例在运行,拒绝启动。设计锁文件时不要只看文件是否存在,要去读取其中的 PID 并检查该 PID 是否存活,存活的才认为锁有效。
第二个坑是日志被覆盖。很多管理脚本是>重定向到日志,每次启动都把上一次的崩溃日志清空。结果自动重启十几次,最后只看到最后一次的日志,崩溃现场早就丢了。一定要用追加模式>>,并在每次启动前把旧日志按时间戳归档。
第三个坑是启动扇区效应。凌晨低峰时系统资源紧张,服务崩了一次,自动重启后资源还没缓过来,又崩了;如果重启脚本没有做次数限制,它可能会一直重启,把磁盘 IO 和 CPU 全部占满,让同一台机器上的其他服务也跟着受牵连。限流和退避不是可选项,是必须项。
第四个坑是健康检查探测自身。我自己犯过的错误是:监控脚本和服务部署在同一台机器,服务线程池耗尽,健康检查请求同样排不上队,于是监控脚本认为服务假死,直接重启了还在处理高负载请求的进程。后来我把健康检查接口设计成独立线程池执行,且线程池即使满也不阻塞,失败时直接返回 503,给监控脚本一个明确信号,而不是永远等下去。
5.3 一个更稳妥的双层保护设计
经过这次踩坑,我现在给常驻服务设计的保护体系是双层结构。
外层用 systemd 或 supervisor 管理进程生命周期,监控真崩溃和异常退出。内层由服务自己的健康检查接口暴露线程池状态、任务队列长度、最近一次心跳时间。外层守护定时请求内层接口,连续失败超过阈值才执行重启。同时内外层之间共享一个“熔断文件”,熔断文件里记录重启时间、次数和原因,外层守护每次准备重启前都会读取,避免多个守护进程同时拉起同一个应用。
这个体系还不能只靠守护进程的自愈,每个服务启动时都要主动检测上次退出是否异常。如果上次退出码非 0,或存在未清理的临时文件,就先进入“修复模式”,比如重放事务日志、清理待处理任务,再对外提供服务。否则,自动重启会把一个内部状态损坏的进程重新暴露到线上,让问题雪上加霜。
回到开头那个案例,最终解决靠的是两手:第一手是外部看门狗,把线程未正常退出导致的“假死+崩溃”及时拦截住,不让业务中断超过一分钟;第二手是根治线程退出协议,给所有阻塞调用加超时,规范线程池关停顺序,把“线程不退出”这个源头斩断。现在这个服务已经平稳运行了很长时间,自动重启次数从每周三次降到了零。
我个人在实际操作中的体会是:程序崩溃后自动重启是一个成本很低、非常有效的兜底方案,但它最容易暴露的恰恰是工程上的侥幸心理。只要是“线程没退出去”引发的崩溃,不管重启多少次都还会有下一次。看门狗只是提醒你系统不健康,真正能让你睡好觉的,永远是那个写着“线程必须能退出”的代码规范。