ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

线程未正常退出导致进程崩溃?从自动重启到优雅关闭的完整复盘

线程未正常退出导致进程崩溃?从自动重启到优雅关闭的完整复盘 做后台服务维护久了我对“进程还在业务却没了”这种事情特别敏感。前阵子我们内部一个常驻的消息网关服务表现很怪白天业务量不大时一切正常一到定时发布或手动重启的窗口就有概率卡在“停止服务”这一步日志停在某一行像是某个线程没有按预期退出。如果只是卡住倒也算了更麻烦的是有几次卡到监控系统超时触发保护机制系统检测到进程不健康后自动把进程拉起重启结果启动以后反而因为上一轮资源没释放、状态文件损坏起来一个崩一个。这个案例简而言之就是一句话线程未正常退出导致程序崩溃系统检测到崩溃后自动重启进程。但这句话背后藏着的问题远不止“重启一下”那么简单。线程为什么退不出去线程不退出为什么能把整个进程搞崩自动重启怎么写才不会在崩溃循环里来回横跳这些问题如果不梳理清楚重启就是拆东墙补西墙。这篇文章我从这次实战出发把现象、原因、监控设计和代码层面的修复都串一遍适合正在维护常驻服务、桌面客户端或者被“偶发崩溃/卡死”折磨过的开发、运维、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 threadC 中 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, buffering0) proc subprocess.Popen( WORKER_CMD, stdoutlog, stderrsubprocess.STDOUT, cwd/opt/app, universal_newlinesFalse, ) 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}, filelog) 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, filesys.stderr) sys.exit(1) backoff BACKOFF_BASE_SEC * len(restart_times) print(f[watchdog] waiting {backoff}s before restart, filelog) 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(timeout3) # except subprocess.TimeoutExpired: # proc.kill() # proc.wait()完整看门狗里我把退出码监控和心跳监控放在一起如果心跳过期就主动杀掉后进入重启逻辑。这样既覆盖了真崩溃也覆盖了假死。3.4 更轻量的部署方式systemd 与 Windows 服务恢复如果你用的是 Linux且不想自己维护 Python 看门狗脚本可以使用 systemd 的进程管理能力。systemd 可以直接监控进程退出码并在异常退出时重启还能设置重启频率限制。一个常驻 Java 服务的 unit 文件长这样[Unit] DescriptionStuck Worker Service Afternetwork.target [Service] Typesimple WorkingDirectory/opt/app ExecStart/usr/bin/java -cp /opt/app StuckWorker Restarton-failure RestartSec5 StartLimitIntervalSec60 StartLimitBurst3 [Install] WantedBymulti-user.target加了配置后用systemctl daemon-reload、systemctl start stuck-worker启动。当进程退出码非 0 时systemd 会等待 5 秒自动拉起如果 60 秒内重启超过 3 次systemd 会放弃重启并标记服务为 failed。systemd 的 Restarton-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::atomicbool作为退出标志线程内部循环中检查。对于阻塞在 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 崩溃自动重启场景速查表实际运行中我积累了一张表格帮助团队快速根据现象定位原因是“该重启”还是“不该重启”是“重启能解决”还是“重启反而更糟”。现象可能原因该怎么做退出码为 137OOM 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或存在未清理的临时文件就先进入“修复模式”比如重放事务日志、清理待处理任务再对外提供服务。否则自动重启会把一个内部状态损坏的进程重新暴露到线上让问题雪上加霜。回到开头那个案例最终解决靠的是两手第一手是外部看门狗把线程未正常退出导致的“假死崩溃”及时拦截住不让业务中断超过一分钟第二手是根治线程退出协议给所有阻塞调用加超时规范线程池关停顺序把“线程不退出”这个源头斩断。现在这个服务已经平稳运行了很长时间自动重启次数从每周三次降到了零。我个人在实际操作中的体会是程序崩溃后自动重启是一个成本很低、非常有效的兜底方案但它最容易暴露的恰恰是工程上的侥幸心理。只要是“线程没退出去”引发的崩溃不管重启多少次都还会有下一次。看门狗只是提醒你系统不健康真正能让你睡好觉的永远是那个写着“线程必须能退出”的代码规范。
返回列表