这个标题看起来像是娱乐号随手起的知念侑李是谁并不重要真正有意思的是后半句“人不睡觉就会完犊子”。把主语换成线上服务这句话的成立程度高得惊人——进程一直不重启、资源一直不回收、状态一直不清理系统未必马上崩溃但会以延迟上升、连接耗尽、内存飙升、句柄泄漏的方式逐步走向“完犊子”。很多团队对高可用的理解是“让服务永不宕机”于是想方设法延长进程生命周期宁愿背锅也不敢碰重启按钮。这个方向其实反了。真正的高可用不是永不重启而是随时能被安全地重启。人需要睡眠来完成记忆巩固和身体修复系统同样需要“睡眠”来完成内存回收、连接清理、状态校准和故障自愈。没有这一层机制长时间运行的进程就是一台连续熬夜的机器出问题是必然只是时间早晚。这篇文章不讨论明星只借用这个标题讨论一个非常工程化的问题为什么长期运行的系统会劣化系统级的“睡眠机制”到底是什么作为开发者怎么从代码、配置、部署三个层面让服务睡个好觉以及怎么判断一个服务“该睡了”。1. 为什么长期不休息的系统一定会出问题先看一个很多人遇到过的现场。某个服务刚上线时一切正常延迟低、错误率为零。运行两三个月后监控曲线开始悄悄变化内存从 400MB 慢慢爬到 1GB某几个接口的 P99 延迟从 50ms 涨到 700ms时不时冒出几个连接池超时报警。运维执行一次重启所有指标瞬间恢复像换了台机器。这种“重启大法好”的现象本质上是长时间运行进程里的资源劣化在累积。常见的劣化路径有这么几种现象典型原因内存持续上涨对象未被释放、静态集合持有引用、缓存没有淘汰策略延迟逐渐升高线程阻塞、GC 频繁、锁竞争加剧数据库连接耗尽连接未归还、事务未关闭、异常路径漏了 close文件句柄耗尽流未关闭、日志文件句柄不断累积日志撑满磁盘日志切割策略缺失、debug 日志全量开启任务重复执行或堆积定时任务没有幂等控制、队列消费速度下降这些不是一种“罕见故障”而是一类必然会出现的规律。只要进程在跑就一定有新的对象被创建、新的连接被打开、新的任务被提交。如果释放路径不完整或者释放速度跟不上创建速度资源就会像熬夜累积的疲劳一样越积越多。这里要区分一个概念服务“还活着”不等于“是健康的”。进程 200 只是说明进程没退出不代表它能处理新请求、能在合理时间内返回结果、能在故障后自己恢复。很多系统在彻底崩溃之前已经经历了一个漫长的亚健康阶段只是没有被监控发现。等到用户投诉和告警同时涌过来时问题已经从单点资源泄漏扩散成了整体性的连锁反应。因此稳定性设计的第一个判断是不要把“不重启”当作目标要把“能优雅地重启、能快速恢复”当作能力。给系统设计睡眠机制本质上是给运行中的资源一个强制整理的机会。2. 核心概念系统的“睡眠”到底是什么睡眠在生物学上的作用可以粗略概括为三点清理代谢废物、巩固记忆、恢复状态。系统的“睡眠”对应着另外三件事资源回收、状态校准、流量接管。先解释几个容易被混淆的词。GCGarbage CollectionJVM、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.shutdowngraceful spring.lifecycle.timeout-per-shutdown-phase30s第一行表示应用收到关闭信号后先停止接收新请求再等待已接收请求处理完成第二行是给每个关闭阶段设置的最大等待时间。如果 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 -gcutilFull GC 频繁、单次耗时超过秒级活跃线程数线程池监控持续增长且没有下降活跃连接数数据库连接池指标接近maximum-pool-size上限TCP 连接数ss -s连接数异常增长TIME_WAIT 堆积文件句柄数lsof -pwc -lP99 延迟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 pid6.3 一个简单的健康检查脚本对于没有 K8s 的裸机环境可以写一个看门狗脚本做基础的失败重启。需要强调一点脚本类自愈手段只适合单实例场景而且必须有日志和人工确认机制不能让它变成一个循环重启的黑洞。# 文件路径scripts/watchdog.sh #!/usr/bin/env bash SERVICE_NAMEmyapp HEALTH_URLhttp://127.0.0.1:8080/actuator/health LOG_FILE/var/log/myapp/watchdog.log FAIL_COUNT0 MAX_FAIL3 for i in 1 2 3; do if curl -fsS $HEALTH_URL /dev/null 21; then FAIL_COUNT0 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 加优雅关停配置最后挑一个低峰期做一次小范围滚动重启演练把“重启能力”从理论变成可验证的团队技能。下次再看到那个深夜拉群的报障可以先问一句这个服务上一次安稳“入睡”是什么时候很多问题的答案往往就从这里开始。