☰
服务不睡觉就会完犊子:高可用系统的优雅关停与资源回收实战
2026/10/6 16:20:06 网站建设 项目流程

这个标题看起来像是娱乐号随手起的,知念侑李是谁并不重要,真正有意思的是后半句:“人不睡觉就会完犊子”。把主语换成线上服务,这句话的成立程度高得惊人——进程一直不重启、资源一直不回收、状态一直不清理,系统未必马上崩溃,但会以延迟上升、连接耗尽、内存飙升、句柄泄漏的方式,逐步走向“完犊子”。

很多团队对高可用的理解是“让服务永不宕机”,于是想方设法延长进程生命周期,宁愿背锅也不敢碰重启按钮。这个方向其实反了。真正的高可用不是永不重启,而是随时能被安全地重启。人需要睡眠来完成记忆巩固和身体修复,系统同样需要“睡眠”来完成内存回收、连接清理、状态校准和故障自愈。没有这一层机制,长时间运行的进程就是一台连续熬夜的机器,出问题是必然,只是时间早晚。

这篇文章不讨论明星,只借用这个标题讨论一个非常工程化的问题:为什么长期运行的系统会劣化?系统级的“睡眠机制”到底是什么?作为开发者,怎么从代码、配置、部署三个层面让服务睡个好觉?以及怎么判断一个服务“该睡了”。

1. 为什么长期不休息的系统一定会出问题

先看一个很多人遇到过的现场。某个服务刚上线时一切正常,延迟低、错误率为零。运行两三个月后,监控曲线开始悄悄变化:内存从 400MB 慢慢爬到 1GB,某几个接口的 P99 延迟从 50ms 涨到 700ms,时不时冒出几个连接池超时报警。运维执行一次重启,所有指标瞬间恢复,像换了台机器。

这种“重启大法好”的现象,本质上是长时间运行进程里的资源劣化在累积。常见的劣化路径有这么几种:

现象典型原因
内存持续上涨对象未被释放、静态集合持有引用、缓存没有淘汰策略
延迟逐渐升高线程阻塞、GC 频繁、锁竞争加剧
数据库连接耗尽连接未归还、事务未关闭、异常路径漏了 close
文件句柄耗尽流未关闭、日志文件句柄不断累积
日志撑满磁盘日志切割策略缺失、debug 日志全量开启
任务重复执行或堆积定时任务没有幂等控制、队列消费速度下降

这些不是一种“罕见故障”,而是一类必然会出现的规律。只要进程在跑,就一定有新的对象被创建、新的连接被打开、新的任务被提交。如果释放路径不完整,或者释放速度跟不上创建速度,资源就会像熬夜累积的疲劳一样,越积越多。

这里要区分一个概念:服务“还活着”不等于“是健康的”。进程 200 只是说明进程没退出,不代表它能处理新请求、能在合理时间内返回结果、能在故障后自己恢复。很多系统在彻底崩溃之前,已经经历了一个漫长的亚健康阶段,只是没有被监控发现。等到用户投诉和告警同时涌过来时,问题已经从单点资源泄漏扩散成了整体性的连锁反应。

因此,稳定性设计的第一个判断是:不要把“不重启”当作目标,要把“能优雅地重启、能快速恢复”当作能力。给系统设计睡眠机制,本质上是给运行中的资源一个强制整理的机会。

2. 核心概念:系统的“睡眠”到底是什么

睡眠在生物学上的作用可以粗略概括为三点:清理代谢废物、巩固记忆、恢复状态。系统的“睡眠”对应着另外三件事:资源回收、状态校准、流量接管。

先解释几个容易被混淆的词。

GC(Garbage Collection):JVM、Go Runtime 等运行时环境里的自动内存回收机制。它负责把不再被引用的对象内存腾出来。GC 是系统级“打盹”,颗粒度小、自动发生,但只能解决内存这一个维度,管不了连接、句柄、线程、磁盘文件。

资源池回收:数据库连接池、线程池、HTTP 连接池等组件,会按空闲时间淘汰闲置资源。比如 HikariCP 里有 idleTimeout、maxLifetime,目的是把长期空闲或可能已被服务端断开的连接清理掉。这相当于主动“翻身”,避免僵死连接占着位置。

优雅关停(Graceful Shutdown):进程在退出前,停止接收新请求,让已接收的在途请求执行完,再释放外部资源,最后退出。这相当于“睡前关灯、锁门”,把该收尾的事做完,而不是直接拉电闸。

健康检查与自愈:通过探针机制判断服务是否可用,发现异常后自动重启或剔除流量。这相当于“身体出了问题,不再硬撑,先睡一觉恢复”。

对比起来会更直观:

人的睡眠系统的睡眠
记忆巩固、神经修复内存回收、缓存整理
免疫力修复连接池、线程池资源回收
入睡前放慢活动停止接收新流量(摘流)
睡眠不足导致认知下降资源泄漏导致延迟上升、错误率增加
生物钟规律定时任务、滚动重启策略

理解了这一点,后面的实操就都有方向了:不是简单地写一个kill脚本定死重启,而是从代码层面保证资源能释放,从配置层面保证池化组件能自清理,从部署层面保证重启过程不影响流量。

3. 事前设计:怎么写不会“漏睡”的代码

很多线上资源泄漏问题,回头排查源码时,原因都非常朴素:某个InputStream没关、某个Response没 close、某个线程池在异常路径里没有 shutdown。代码能跑不等于释放逻辑完整,恰恰是异常路径最容易漏。

3.1 Java 资源释放的正反例

反例非常常见:

// 文件路径:src/main/java/com/example/demo/BadReadFile.java public class BadReadFile { public void readLog(String path) throws IOException { FileInputStream fis = new FileInputStream(path); BufferedReader reader = new BufferedReader(new InputStreamReader(fis)); String line; while ((line = reader.readLine()) != null) { System.out.println(line); } // 漏了 close:正常路径没释放,异常路径更不会释放 } }

这段代码在本地跑一两次没问题,一旦放进长时间运行的 Web 应用里,每次调用都会打开一个文件句柄,积累下来就是“Too many open files”。

正确写法是用try-with-resources让 JVM 保证资源关闭:

// 文件路径:src/main/java/com/example/demo/GoodReadFile.java public class GoodReadFile { public void readLog(String path) { // try-with-resources:无论正常还是异常,都会自动调用 close try (FileInputStream fis = new FileInputStream(path); BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) { String line; while ((line = reader.readLine()) != null) { // 业务处理 } } catch (IOException e) { // 这里可以记录日志,而不是只吞掉异常 System.err.println("读取文件失败: " + e.getMessage()); } } }

关键点在 try 关键字后面括号里声明的资源,编译器会为它们生成 finally 块,保证 close 被调用。这不是语法糖的问题,而是把“必须做的释放”从业务代码里抽离成了语言机制,从根上减少漏关的可能。

3.2 Go 中的 context 超时与 defer 关闭

Go 服务里最容易泄漏的有两类:一是 goroutine 里发出 HTTP 请求后忘了关闭响应体,二是调用外部服务时没有超时控制,导致 goroutine 长时间阻塞。

看下面的示例:

// 文件路径:cmd/sleep/main.go package main import ( "context" "fmt" "net/http" "time" ) func callHealth(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://service-a/health", nil) if err != nil { return fmt.Errorf("构建请求失败: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() // 必须关闭响应体,否则连接无法复用 if resp.StatusCode != http.StatusOK { return fmt.Errorf("健康检查返回异常状态: %d", resp.StatusCode) } return nil } func main() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // cancel 要尽早 defer,避免已完成请求的 context 资源悬挂 err := callHealth(ctx) if err != nil { fmt.Println("调用失败:", err) return } fmt.Println("健康检查通过") }

context.WithTimeout给外部调用设了一个硬性截止时间,超过 3 秒就返回DeadlineExceeded,避免 goroutine 因为对端不响应而无限卡死。defer resp.Body.Close()保证响应体关闭,让底层连接能被连接池复用,而不是一直占着。

这个模式比 Java 的 try-with-resources 更依赖编程者的自觉,因此更要养成习惯:每个允许 defer 的函数,在拿到资源后立刻写 defer,不要等业务逻辑完成后再补。

3.3 线程池必须在合适时机 shutdown

线程池泄漏是另一个隐蔽问题。如果线程池本身的线程数是固定的,泄漏主要指的是对任务队列的无限提交,或者在线程池不再需要时没有调用 shutdown,导致非守护线程阻止 JVM 退出。

ExecutorService pool = Executors.newFixedThreadPool(8); try { // 提交业务任务 pool.submit(() -> doTask()); } finally { // 停止接收新任务,等待已提交任务全部完成后关闭线程 pool.shutdown(); }

在实际项目里,线程池通常会交给 Spring 管理,这时可以显式指定 destroy 方法。例如在配置类里:

@Bean(destroyMethod = "shutdown") public ExecutorService taskExecutor() { return Executors.newFixedThreadPool(8); }

这样容器关闭时会自动把线程池优雅关闭,而不是让 JVM 因为残留的非守护线程无法退出。

4. 事中治理:连接池、线程池与定时清理

代码层面做对了资源释放,可以解决大部分单个请求层面的泄漏。但长期运行的系统还会遇到另一个问题:池化组件自身的配置不合理。最常见的案例是数据库连接池,连接长期不回收导致数据库端主动断开,服务端却还残留着“半僵死连接”。

4.1 数据库连接池配置:HikariCP 示例

假设项目使用 Spring Boot + HikariCP,连接池是关键配置:

# 文件路径:src/main/resources/application.yml spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 validation-timeout: 5000

关键参数不只是在调大小,每项都有自己要解决的问题:

参数作用常见问题
maximum-pool-size最大连接数设置过大会拖垮数据库,过小会导致并发不足
minimum-idle最小空闲连接数长期低峰期也要保留少量连接
connection-timeout等待连接的最大时间请求堆积时容易在这里暴露超时
idle-timeout空闲连接回收时间空闲连接不回收会占用数据库资源
max-lifetime连接最大存活时间必须小于数据库的wait_timeout,否则连接被服务端切断后客户端不知道
validation-timeout连接校验超时太短容易产生误判,太长拖慢获取连接的路径

这里最容易踩的坑是max-lifetime和数据库wait_timeout的矛盾。MySQL 默认的wait_timeout可能是 8 小时,如果 HikariCP 的max-lifetime设置成 30 分钟,连接会在客户端主动回收前被数据库断开,但连接池可能还认为它可用。合理做法是让max-lifetime比数据库wait_timeout小几百秒,保证连接只在安全生命周期内被使用。

4.2 线程池的饱和策略与队列监控

线程池不是越大越好,也不是把任务塞进队列就没事了。常见的配置是核心线程数、最大线程数和阻塞队列的三角组合。当任务提交速度超过消费速度时,队列会持续增长,延迟会一路走高,但进程看起来还活着。

实际项目里建议:

  • 优先监控队列深度,而不是只盯着线程数。
  • 给线程池命名,方便排查jstack时一眼识别。
  • 设置合理的拒绝策略,不能让任务无限堆积。
  • 对拒绝的任务和积压的任务做好业务告警。

4.3 定时清理:系统级的“主动睡眠”

连接池能清理连接,GC 能清理内存,但磁盘上的临时文件、过期的本地缓存、失效的令牌、堆积的日志,需要主动清理。这类任务适合用 cron 或定时任务完成。

一个最简单的临时文件清理脚本:

# 文件路径:scripts/clean-temp.sh #!/usr/bin/env bash # 清理 3 天前的临时文件,避免磁盘写满 TARGET_DIR="/tmp/myapp" if [ ! -d "$TARGET_DIR" ]; then echo "目录不存在: $TARGET_DIR" exit 1 fi find "$TARGET_DIR" -type f -mtime +3 -delete find "$TARGET_DIR" -type d -empty -delete

这类脚本要特别注意两点:一是-mtime +3的时间含义是“超过 3 天”,不是“3 天以上没访问”,不同场景要区分-atime、-mtime、-ctime;二是删除操作要先在测试环境核对路径,确认不会误删业务数据。清理动作本质上也是一种运维操作,同样需要日志和检查。

如果集群里有 Kubernetes,这类清理任务通常写成 CronJob,而不是在每个 Pod 里同时执行,避免所有实例在同一个时间点一起做同样的删除动作。

5. 让服务可以“随时入睡”:优雅关停与滚动重启

有了代码层面的资源释放,有了池化组件的自动回收,最后一步是让整个进程可以安全地退出和重启。这一步如果做得粗糙,就会出现“重启用一次,线上事故一次”的情况。

5.1 Spring Boot 优雅关停配置

Spring Boot 2.3 之后提供了内置的优雅关停支持:

# 文件路径:src/main/resources/application.properties server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=30s

第一行表示应用收到关闭信号后,先停止接收新请求,再等待已接收请求处理完成;第二行是给每个关闭阶段设置的最大等待时间。如果 30 秒内没有完成处理,应用会被强制关闭,避免无限等待。

配合 Spring Cloud 或其他注册中心时,还应该注意顺序:先从注册中心摘除服务,再等待一段缓冲时间,让上游感知到实例不可用,最后执行进程退出。只调kill不摘流,即使进程本身处理了在途请求,上游把新流量发过来的概率依然不低。

5.2 Kubernetes 探针与滚动重启

在 Kubernetes 环境里,“睡眠”能力的核心是探针配置。一个常见的误解是只配置livenessProbe而不配置readinessProbe,或者两者阈值设置不合理,导致 Pod 频繁被误杀。

一个比较稳的 Deployment 配置示例:

# 文件路径:k8s/order-service.yaml apiVersion: apps/v1 kind: Deployment metadata: name: order-service spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 selector: matchLabels: app: order-service template: metadata: labels: app: order-service spec: containers: - name: app image: registry.example.com/order-service:v1.4.2 ports: - containerPort: 8080 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 10 failureThreshold: 6 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"]

拆开解释:

  • readinessProbe决定 Service 是否把流量转发给这个 Pod。当探针失败时,Pod 不会被删除,但会从 Endpoint 里摘除,新请求不再进来。
  • livenessProbe决定容器是否需要被杀死重启。它失败的次数超过阈值时,kubelet 会杀掉容器。
  • preStop的sleep 10很简单,但很实用:它让 Pod 在被终止前多等待一段时间,保证 Service 已经把它从负载均衡列表里摘除,在途请求也能处理完。

这里的关键原则是:不要把业务逻辑的临时抖动交给 liveness 处理。如果一出现 GC 停顿或者缓存预热就把容器杀了,系统会在高峰期频繁重启,反而更不稳定。liveness 适合检测“进程是否彻底卡死”,readiness 适合检测“当前是否可以接收流量”。

5.3 分批滚动重启的节奏

即使有探针,生产环境的重启仍然应该分批进行。一次性把所有副本全部杀掉,哪怕每个副本都能正常启动,整个服务的可用性窗口也会急剧缩小。

比较稳妥的节奏是:

  1. 先摘流量,确认剩余副本能承载当前请求量。
  2. 每次只重启 20% 到 30% 的实例。
  3. 等新副本通过 readinessProbe,再继续下一批。
  4. 如果重启后健康检查不通过,立即停止下一批,保留剩余副本继续服务。

在 Kubernetes 里,上面的 RollingUpdate 策略已经实现了这个能力。在裸机或虚拟机环境,则要自己写分批脚本,并且加上前后检查。

6. 运行验证:怎么知道它“该睡了”

一个服务是不是到了该“入睡”的时间,不能靠直觉,要看指标。指标能帮助我们区分:是临时抖动,还是长期劣化已经累积到临界点。

6.1 核心要看的指标

指标怎么看异常信号
JVM 堆内存监控中看 GC 后堆使用是否持续走高GC 后内存不回落,说明对象泄漏
Full GC 次数与耗时jstat -gcutilFull GC 频繁、单次耗时超过秒级
活跃线程数线程池监控持续增长且没有下降
活跃连接数数据库连接池指标接近maximum-pool-size上限
TCP 连接数ss -s连接数异常增长,TIME_WAIT 堆积
文件句柄数`lsof -pwc -l`
P99 延迟APM 工具长期高于基线值

6.2 常用排查命令

服务已经“睡不着”时,先留现场再处理。几个常用命令:

# 查看 JVM 堆内存与 GC 情况 jstat -gcutil <pid> 2000 # 打印线程栈,看看线程卡在哪里 jstack <pid> > /tmp/jstack-$(date +%F).log # 查看进程打开的句柄数量 lsof -p <pid> | wc -l # 查看系统 TCP 连接状态汇总 ss -s # 查看进程线程数与内存 top -H -p <pid>

6.3 一个简单的健康检查脚本

对于没有 K8s 的裸机环境,可以写一个看门狗脚本,做基础的失败重启。需要强调一点:脚本类自愈手段只适合单实例场景,而且必须有日志和人工确认机制,不能让它变成一个循环重启的黑洞。

# 文件路径:scripts/watchdog.sh #!/usr/bin/env bash SERVICE_NAME="myapp" HEALTH_URL="http://127.0.0.1:8080/actuator/health" LOG_FILE="/var/log/myapp/watchdog.log" FAIL_COUNT=0 MAX_FAIL=3 for i in 1 2 3; do if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then FAIL_COUNT=0 break else FAIL_COUNT=$((FAIL_COUNT + 1)) sleep 5 fi done if [ "$FAIL_COUNT" -ge "$MAX_FAIL" ]; then echo "$(date '+%Y-%m-%d %H:%M:%S') health check failed, restarting $SERVICE_NAME" >> "$LOG_FILE" systemctl restart "$SERVICE_NAME" fi

这个脚本的核心逻辑是连续三次健康检查失败才重启,避免单次网络抖动误杀。每次重启都会留下日志,后续可以回溯“当时发生了什么”。在 Kubernetes 环境里,这个职责应该交给 kubelet 和探针,而不是在容器里再套一层脚本。

7. 常见问题与排查思路

即使配置都做对了,实际落地还是会遇到各种问题。这里整理一份排查对照表,按出现频率排序。

问题现象可能原因排查方式解决方案
重启一瞬间有请求超时或报错注册中心摘流太慢或没摘流查看注册中心实例状态、链路里请求打到哪个实例启动前先摘流,关闭前 sleep 缓冲,配合 readiness 探针
重启后 Kafka 消费组重复消费或丢消息消费位点提交时机不对、优雅关停没等消费线程结束查看消费组 lag、检查 shutdown hook先停止拉取,再提交位点,最后关闭消费者
内存一直上升,Full GC 后也不回落代码持有对象引用,或缓存无淘汰jmap -dump分析堆,重点看大对象和集合类修复引用、给缓存加 TTL、用弱引用
进程重启很慢,探针频繁失败启动阶段加载太慢,initialDelaySeconds 太小看应用启动日志、监控各阶段耗时调大 initialDelaySeconds,或加 startupProbe
连接池配置调整后性能反而抖动max-lifetime 太短或 validation 逻辑过重对比调整前后连接创建频率排查数据库 wait_timeout,让 max-lifetime 适配数据库
定时清理误删了业务文件清理路径写得太宽、时间条件理解错误检查脚本日志、恢复备份缩小范围,增加 exclude 清单,测试环境先演练
滚动重启过程中错误率升高可用副本太少,或新副本尚未就绪查看滚动进度和 Deployment 状态maxUnavailable 设置成 0 或 1,等待新副本 ready 后再继续

这些问题的共同规律是:大部分重启事故不是重启动作本身造成的,而是“重启前没有摘流”“重启中探针不准”“重启后状态没恢复”这三件事没做好。

8. 最佳实践与工程建议

“让服务能睡个好觉”不是某一个配置项的问题,而是一套工程习惯。以下几点是实际项目中比较值得投入的。

8.1 把“可重启性”当成需求,而不是应急手段

上线清单里应该包含三个问题的答案:服务停止时会不会在处理一半的任务?启动后需要多久才能提供完整服务?如果新版本有问题,回滚到旧版本需要几步?这些问题没有答案,重启就是一场赌博。

8.2 监控先行原则

不要等到事故出现再上监控。至少先有这几项:进程存活、健康检查接口、JVM 堆内存、线程池队列深度、连接池活跃数、错误率、P99 延迟。指标是判断“该睡觉了”和“入睡成功没有”的唯一客观依据。

8.3 所有外部资源都要有 owner 和释放路径

任何一个连接、流、线程、临时文件,都应该能回答一个问题:它由谁创建、由谁关闭、异常路径下由谁兜底。代码评审时,把“是否缺少释放路径”作为固定检查项,比事后追查省力得多。

8.4 优雅关停的超时时间要合理

给在途请求太多时间会让运维等太久,给得太少会让请求被掐断。建议从业务响应时间分布反推,先统计 P99 耗时,把超时时间设置成 P99 耗时的两到三倍。不要拍脑袋写一个 30 秒,也不要为了稳妥写 10 分钟。

8.5 定期做一次“强制睡眠”演练

生产环境确实应该避免频繁重启,但完全不重启,会让“重启能力”退化成理论能力。比较务实的做法是:在低峰期,每季度挑一个非核心服务,演练一次滚动重启,验证注册中心摘流、探针判断、优雅关机、启动恢复这条链路是否真的通畅。演练通过后,再遇到真实故障,团队才有信心按流程操作。

8.6 日志要能追踪“入睡前后”的状态变化

重启操作不能只留下运维平台的记录。服务自身的日志最好体现:收到关闭信号、开始摘流、在途请求数量、停止接收新请求、释放连接、进程退出、启动完成、通过就绪检查。这样,每次重启后都能复盘一个完整生命周期,而不是只看到“已停止”和“已启动”两个状态。

9. 总结与后续学习方向

写到这里,可以回到开头那句话:人不睡觉就会完犊子,服务不回收、不重启、不自愈,最终也会完犊子。两者共享同一个底层逻辑:持续运行的系统需要周期性整理资源、清理状态、验证恢复能力,否则劣化会一点一点累积到不可收拾。

这篇文章的核心观点可以压缩成三句话:

  • 高可用不是不让服务停机,而是让服务随时能被安全地停机。
  • 代码层管好资源释放,配置层管好池化回收,部署层管好优雅关停和探针,三者缺一不可。
  • 判断服务“该睡了”不能靠感觉,要靠监控指标;验证“睡得好不好”,要看重启前后是否有请求受损。

如果下一步想继续实践,建议按这个顺序来:先给自己最核心的服务补上健康检查接口和监控告警;然后给 Spring Boot 或 K8s 加优雅关停配置;最后挑一个低峰期,做一次小范围滚动重启演练,把“重启能力”从理论变成可验证的团队技能。

下次再看到那个深夜拉群的报障,可以先问一句:这个服务上一次安稳“入睡”是什么时候?很多问题的答案,往往就从这里开始。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询