这个标题看起来像是娱乐号随手起的,知念侑李是谁并不重要,真正有意思的是后半句:“人不睡觉就会完犊子”。把主语换成线上服务,这句话的成立程度高得惊人——进程一直不重启、资源一直不回收、状态一直不清理,系统未必马上崩溃,但会以延迟上升、连接耗尽、内存飙升、句柄泄漏的方式,逐步走向“完犊子”。
很多团队对高可用的理解是“让服务永不宕机”,于是想方设法延长进程生命周期,宁愿背锅也不敢碰重启按钮。这个方向其实反了。真正的高可用不是永不重启,而是随时能被安全地重启。人需要睡眠来完成记忆巩固和身体修复,系统同样需要“睡眠”来完成内存回收、连接清理、状态校准和故障自愈。没有这一层机制,长时间运行的进程就是一台连续熬夜的机器,出问题是必然,只是时间早晚。
这篇文章不讨论明星,只借用这个标题讨论一个非常工程化的问题:为什么长期运行的系统会劣化?系统级的“睡眠机制”到底是什么?作为开发者,怎么从代码、配置、部署三个层面让服务睡个好觉?以及怎么判断一个服务“该睡了”。
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 分批滚动重启的节奏
即使有探针,生产环境的重启仍然应该分批进行。一次性把所有副本全部杀掉,哪怕每个副本都能正常启动,整个服务的可用性窗口也会急剧缩小。
比较稳妥的节奏是:
- 先摘流量,确认剩余副本能承载当前请求量。
- 每次只重启 20% 到 30% 的实例。
- 等新副本通过 readinessProbe,再继续下一批。
- 如果重启后健康检查不通过,立即停止下一批,保留剩余副本继续服务。
在 Kubernetes 里,上面的 RollingUpdate 策略已经实现了这个能力。在裸机或虚拟机环境,则要自己写分批脚本,并且加上前后检查。
6. 运行验证:怎么知道它“该睡了”
一个服务是不是到了该“入睡”的时间,不能靠直觉,要看指标。指标能帮助我们区分:是临时抖动,还是长期劣化已经累积到临界点。
6.1 核心要看的指标
| 指标 | 怎么看 | 异常信号 |
|---|---|---|
| JVM 堆内存 | 监控中看 GC 后堆使用是否持续走高 | GC 后内存不回落,说明对象泄漏 |
| Full GC 次数与耗时 | jstat -gcutil | Full GC 频繁、单次耗时超过秒级 |
| 活跃线程数 | 线程池监控 | 持续增长且没有下降 |
| 活跃连接数 | 数据库连接池指标 | 接近maximum-pool-size上限 |
| TCP 连接数 | ss -s | 连接数异常增长,TIME_WAIT 堆积 |
| 文件句柄数 | `lsof -p | wc -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 加优雅关停配置;最后挑一个低峰期,做一次小范围滚动重启演练,把“重启能力”从理论变成可验证的团队技能。
下次再看到那个深夜拉群的报障,可以先问一句:这个服务上一次安稳“入睡”是什么时候?很多问题的答案,往往就从这里开始。