资讯动态

Spring Boot零停机更新实战:优雅停机与滚动发布指南

发布时间:2026/10/11 11:46:54 来源:尧图企业网站定制
前阵子我把一套基于 Spring Boot 的业务服务的发布流程彻底翻了一遍目标只有一个零停机更新。意思很简单就是发版的时候线上流量不中断、用户无感知。以前每次发代码都像开盲盒——接口正被调用着进程突然被杀用户那边页面转圈、数据加载失败业务方在群里连环夺命催。折腾完这一整套之后我才摸清楚这里面的门道所谓零停机从来不是某一个开关而是多实例、优雅停机、健康检查、滚动发布这些机制咬合在一起的结果。这套组合拳听起来不复杂可真落地的时候坑一个接一个。尤其是 Spring Boot 2.3 之后才把内置的优雅停机配置带出来很多人以为配一个server.shutdown: graceful就完事了结果脚本里一个kill -9把所有努力全部毁掉。这篇文章不打算写那种泛泛的概念科普就把我实际走通的一套方案、关键配置和踩过的几个典型问题完整记录下来。适合正在用 Spring Boot 做业务系统、又不想每次发版都搞得跟事故演练一样的开发同学也适合想从“单机重启”走向“多实例滚动”的团队。1. 零停机更新到底在解决什么问题1.1 传统发布方式为什么会有“停机窗口”如果你经历过最早那批 Java Web 项目的发布一定对“停机窗口”这个词不陌生。上线前先发公告半夜十二点之后停止服务备份、替换 jar 包、重启Tomcat 要启动三五十秒期间所有用户请求全部打不进来。放到今天这种接口每秒钟都在被调用的环境里这种搞法基本不可行了。问题不只在启动阶段停机阶段同样痛。旧进程不管还挂了多少正在处理的请求直接被杀掉。轻则一个请求报 500重则内存里正在处理的数据丢一部分用户要重新提交表单、重新走流程。遇到那种长事务、异步回调比较多的系统发一次版能收到一堆投诉工单。这种“停机窗口”本质上是一个资源规划问题服务只有一份没办法边升级边对外提供服务。就像只开了一家餐厅后厨装修的时候餐厅必须打烊客人只能白跑一趟。零停机更新的思路说白了就是别只开一家店装修其中一间的时候让客人去另一间吃。1.2 零停机的本质一套组合机制不是某个功能网上聊零停机更新很多文章一上来就甩出 K8s 滚动更新什么的导致不少人以为装个编排平台就完事了。实际上零停机更新是一个纵向切分、横向分工的组合机制实例层面要有冗余至少同时存在多个服务实例兜底单个实例退出时要优雅停机把手头正在处理的请求做完再退出流量层面要在实例退出前把它从负载均衡或服务列表里摘除新实例启动后要等它真正就绪、能接收流量了再放量进去。这四个环节少一个都会出问题没有冗余再怎么优雅停机总有一个时刻没有人在接客有了冗余但摘流不及时新流量还是往正在退出的实例上打新实例启动但健康检查没跟上流量过早放进去照样一串 5xx。所以我不太喜欢把零停机更新当成某一个“新技能”来看它更像是一个发布策略的整体升级。把发布当成产品来做每一个环节都值得抠细节。2. Spring Boot 优雅停机从配置到生效2.1 优雅停机与强制停机的区别先讲一个最容易混淆的点。很多同学对停机信号没概念以为杀进程就是杀进程。实际上在 Linux 下kill -9SIGKILL和默认的killSIGTERM是两回事。SIGKILL 是直接拉电闸操作系统立刻回收进程资源程序没有任何机会做善后正在处理的请求关掉内存里没落库的数据丢连接池里的连接瞬间断给对端。而 SIGTERM 是“通知关门”系统告诉进程“准备下班了”程序可以收到信号、执行清理逻辑把当前的工作干完再主动退出。Spring Boot 2.3 之前应用收到 SIGTERM 之后默认是 immediate 关闭也就是能少等就少等直接结束 Tomcat、释放端口处理到一半的请求一样被中断。Spring Boot 2.3 起配置项server.shutdown多了graceful这个选项才把“优雅关门”变成一件开箱即用的事。2.2 最小化配置让 Spring Boot 优雅停机最简单的配置在application.yml里加两行server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s第一行告诉 Spring Boot 在收到停机信号时进入优雅停机流程。第二行是每一阶段的超时时间默认就是 30 秒。整体含义是允许正在处理的请求继续跑最多再等 30 秒超时之后强制结束。这段配置对 Spring Boot 2.3 以及 3.x 都适用底层对 Tomcat、Jetty、Undertow 以及响应式场景下的 Reactor Netty 都做了适配。也就是说只要你的应用是通过 Spring Boot 启动的 Web 服务加了这段配置收到 SIGTERM 后就不会再把正在处理的请求一把掐死。有人会问那是不是把这个配好杀进程时就会自动把请求等完答案是“部分对”。Tomcat 层面的确会停止接收新连接、等待在途请求结束但你自己代码里挖的坑框架管不到。比如自己创建的线程池还在往数据库写数据比如定时任务刚好跑到一半比如消息队列消费者还在处理一条消息这些都不是 Web 容器的一部分Spring Boot 的优雅停机逻辑不会自动替你等它们。2.3 只改配置远远不够业务侧要处理的事这里就要上真正的经验了。我在改造过程中发现要让优雅停机真正优雅业务代码里至少要做几件事。第一线程池要能感知停机事件。如果有用Async或者自建的ThreadPoolTaskExecutor需要在停机时先拒绝新任务再等待正在执行的任务跑完。Spring 的SmartLifecycle接口是这一场景的标准入口。我自己写了一个很简单的处理类Component public class JobShutdownHandler implements SmartLifecycle { private volatile boolean running; Override public void start() { running true; // 任务调度框架在这里启动 } Override public void stop() { running false; // 告诉任务调度框架不要再派发新任务了 // 等待当前正在执行的任务全部完成再做线程池 shutdown System.out.println(开始等待在跑的任务结束...); } Override public boolean isRunning() { return running; } Override public int getPhase() { return Integer.MAX_VALUE - 100; } }getPhase()控制停止顺序。Spring 在关闭容器时会按 phase 从小到大执行 stop数值越大越靠后。我把这个组件放到比较靠后的位置就是为了确保它前面的 Web 容器、消息监听容器都已经停止接收新东西了这个任务调度模块才开始回头等待内部任务跑完。第二消息队列消费者要注意“先停消费再确认已拉取的消息”。Spring AMQP 的监听容器在停止时会等待acknowledge-mode配置下的消息处理结果。如果你用的框架没那么智能就得自己实现一个类似的逻辑先把自动 ack 改成手动配合 shutdown 钩子把队列里正在处理的消息处理完再basicAck最后关闭连接。否则优雅停机期间处理到一半的消息一断消息就跑到死信队列去了。第三数据库连接池和事务边界要明确。HikariCP 在 Spring 容器关闭时本身会做 shutdown但如果你有一个大事务正在执行超时时间到了之后依然会被强杀回滚。所以timeout-per-shutdown-phase并不是越大越好而是要结合你的业务请求耗时来定。接口平均 200 毫秒30 秒完全够如果系统里有分钟级的长任务就得考虑把这种任务从 Web 请求链路里剥离开或者给它们单独的关闭补偿机制。2.4 容器环境下的信号传递一个很容易被忽略的点配置写得没问题业务侧也处理了到了容器环境依然有可能翻车。Docker 里跑 Java 应用一个经典问题是进程号 1 不是 Java 进程。很多基础镜像习惯用启动脚本脚本sh作为 PID 1Java 进程其实是它的子进程。这种情况下Docker stop 发过来的 SIGTERM 是发给 PID 1 的如果启动脚本没有把信号转发给 Java 进程整个优雅停机配置就形同虚设最后只能等容器超时被强杀。解决方式因人而异。把启动命令写成exec java -jar app.jar让 Java 进程直接顶替 shell 成为 PID 1是最直接的做法。如果对 PID 1 上的信号处理不放心也可以引入 tini 这类轻量级 init 进程来转发信号。注意不是所有场景都必须这样但如果你发现容器里配置了 graceful 却完全不停等先检查信号到底有没有传到 Java 进程头上。3. 多实例滚动发布把优雅停机变成真正的零停机3.1 实例冗余是零停机的前提聊到这里必须把话说透只有一个实例时无论怎么优雅停机都不可能做到零停机。Tomcat 停止接收新连接的那一瞬间到新进程启动完成、端口可用的这一段时间用户请求就是没有地方可以落。区别只是从“请求被直接拒绝”变成“请求等待一段时间后完成”但那种连接级的中断依然存在前端会报网关超时。真正能扛住零停机的是至少两个实例同时在线。一实例停机时流量由另一个实例吸收等前者启动完毕、确认健康再把流量切回来依次滚动替换。这里说的“实例”可以是你部署的两台虚拟机上的应用进程也可以是 Kubernetes 里的两个 Pod本质上都是“冗余”。我之前接手的一个项目是 Spring Cloud 体系拆了一堆微服务出来。当时就发现很多人以为有了注册中心发布时候随便 kill 就行。实际上 Spring Cloud 的服务发现链路是有时效性的服务实例被 kill 之后它还可能在注册中心的实例列表里存活几十秒期间调用方依然会往这个死实例上发请求。这就是为什么滚动发布不能只靠“杀掉重启”必须先把流量摘掉。3.2 标准发布节奏摘流、停机、启动、加流我推进到多实例环境后把发布节奏固定成四步摘流、停机、启动、加流。每一步都不能跳。摘流是把要发布的实例从负载均衡或服务列表里摘出去保证新的请求不再进来。如果你前面是 Nginx就是把它从 upstream 里移除如果是服务注册中心就是主动把实例标记下线如果在 Kubernetes 里最简单的办法是让 readiness 探针失败让 Endpoint 自动摘除。停机是给实例发送 SIGTERM执行第 2 章那套优雅停机逻辑。这里的关键点是摘流之后不要立刻 kill最好等一小会儿确认没有新请求再进来了再发信号。否则你摘流的速度赶不上负载均衡的刷新速度依然会有漏网之鱼打过来。启动是拉起新版本实例。此时它应该是“待命但不接客”的状态。很多团队会在这里犯一个错新进程刚启动端口有监听了就立刻把流量切过来。其实 JVM 起来之后Spring 容器还在初始化数据库连接还没建好业务线程池还没准备完全这时候接流量就等于让一个还没清醒的人去接紧急电话。加流是等待新实例健康检查通过后把它重新挂回负载均衡或服务列表正式接收请求。我早期是纯手工操作流程整理成脚本大概是这样的思路for node in node1 node2 node3; do echo 开始处理节点: $node # 1. 摘流从网关/注册中心下线这里用 curl 示意 curl -s -X POST http://注册中心地址/instance/deregister?ip$node || true # 2. 等几秒让流量彻底不再进入 sleep 10 # 3. 优雅停机注意用 TERM 而不是 KILL ssh $node kill -TERM $(pgrep -f app.jar) # 4. 等待旧进程退出加超时保护 ssh $node for i in $(seq 1 60); do pgrep -f app.jar /dev/null || break; sleep 1; done # 5. 部署新包并启动 ssh $node nohup java -jar /opt/app/app.jar /opt/app/app.log 21 # 6. 等待健康检查通过再继续下一个节点 until curl -sf http://$node:8080/actuator/health/readiness /dev/null; do sleep 2 done done这段脚本的思路是可参考的但生产环境我强烈建议别这么蛮干。节点多了、服务多了之后手工脚本的边界条件会把人逼疯。而且脚本里如果要内联处理注册中心摘流、重试、回滚复杂度会直线上升。这也是为什么后来我把服务迁到容器编排平台的原因。3.3 健康检查与就绪探针加流之前要看这些健康检查是整个滚动发布里最容易出问题的一环。Spring Boot Actuator 提供了两组探针概念搞混了会出事liveness存活探针进程活着没。如果进程还活着只是满负载或者依赖挂了它不应该被判死。K8s 里如果 liveness 失败Pod 会被重启这是强力恢复手段配置时一定要保守。readiness就绪探针进程能不能接收流量。只有这个应该和“是否把实例加回负载均衡”绑定。Spring Boot 2.3 之后可以用配置直接暴露这两组端点management: endpoint: health: probes: enabled: true暴露之后就有两个地址/actuator/health/liveness和/actuator/health/readiness。在 K8s 里readinessProbe 配到 Deployment 上readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 10这个配置的语义就是新实例启动后最少等 10 秒才开始探测就绪状态之后每 5 秒探一次连续失败 3 次才认为未就绪。未就绪的 Pod 会被自动从 Service 的 Endpoint 里摘掉流量也就不往它身上打了。有一点值得单独提醒不要盲目让 readiness 依赖所有外部组件。数据库、缓存、消息队列全部塞进 readiness会导致一个 Redis 抖动就把大量实例从流量里摘除引发更严重的雪崩。我在一个项目里就干过这种事上线当天数据库主从切换请求量瞬间掉了一大截。判断逻辑应该是能接受该实例继续接流量的最小条件是什么通常本地基础组件、本地资源配置没问题就够了外部依赖的健康状态要单独监控告警而不是和流量调度强绑定。3.4 蓝绿、滚动、金丝雀三种模式怎么选多实例发布不是只有“滚动”一种花样实际常被放到一起比较的是三种蓝绿、滚动、金丝雀。滚动发布是默认选择。它把实例分批替换旧版本机器逐台退、新版本逐台上。优点是资源占用少、改造量小缺点是整个发布过程中新旧版本会共存一段时间对前后兼容性要求比较高。数据库字段改名了、接口协议变了老的调用方还没升级完就会出问题。蓝绿发布是直接准备一套“绿色环境”流量一次性从“蓝色环境”切到“绿色环境”。回滚非常快只要把流量切回来就行。代价是要多准备一整套环境资源成本高。适合那种核心链路、发布期间绝对不能出岔子的场景。金丝雀发布是灰度思想的落地先放 1 个新版本实例接一点流量观察日志、监控指标确认稳定后再逐步放量。它是三种模式里对“发布质量”把关最严的但也最依赖完善的观测体系。没有监控数据别碰金丝雀不然你观察到的那一小撮流量用户就是小白鼠。我自己的倾向是常规小版本用滚动大版本、底层框架升级用金丝雀最好能做成平台能力而不是每次上线都靠人肉判断。蓝绿成本太高很多中小团队支撑不起没必要为了“看起来高端”去上。4. 实战中踩过的坑与排查方法4.1 配置了优雅停机请求还是断这是被问得最多的一个问题。明明server.shutdown: graceful配了优雅停机也生效了为什么发布时还是有一批请求报错第一反应是查信号。确认是不是运维系统或发布脚本里执行了kill -9。有些发布平台默认发的就是强杀信号就算应用侧配置再优雅也无济于事。正确姿势是先找平台配置把停止命令改成发送 SIGTERM如果平台不支持就只能把发布流程挪到 K8s 这类标准环境里让平台自己处理信号语义。第二反应是查流量摘除。如果实例还在 Nginx upstream 里或者注册中心里状态还没有变成下线优雅停机只会让 Tomcat 停止接收新连接但那一瞬间才转发过来的请求依然会失败。这种错位特别容易出现在“手动发布脚本”里先 kill、后摘流顺序完全反了。4.2 超时时间设置互相打架容器环境里踩过一个大坑K8s 中terminationGracePeriodSeconds默认 30 秒。这个参数的意思是 Pod 收到 SIGTERM 后最多等 30 秒到点就直接 SIGKILL。问题来了我在 Spring Boot 里把timeout-per-shutdown-phase配成 60 秒以为粒度很宽结果 K8s 30 秒一到直接强杀优雅停机等于还是没执行完。这个事告诫了我容器的停止宽限期必须和 Spring 的优雅停机超时时间统一规划。后来我定下来的组合方式是Spring 优雅停机超时 30 秒K8s 的terminationGracePeriodSeconds配 70 秒同时加了一个 preStop 钩子先睡 5 秒再开始优雅停机流程spec: terminationGracePeriodSeconds: 70 containers: - name: app lifecycle: preStop: exec: command: [sh, -c, sleep 5]preStop 睡 5 秒的目的很明确给 K8s 一点时间把 Pod 从 Service Endpoint 里摘掉避免优雅停机刚开始时还有新流量打进来。5 秒预留 30 秒优雅停机 再留一点缓冲所以宽限期配到 70 秒是比较稳妥的。如果你用的 Docker 直接跑也要注意docker stop默认等待 10 秒就会发 SIGKILL记得用-t参数把宽限期调大或者等优雅停机流程自己走完再让编排系统收尸。4.3 健康检查依赖过度导致雪崩这个坑前面提到过但值得再展开一下。我给一个服务写 readiness 探针时把数据库、Redis、远程接口全部串进了判断逻辑本意是“依赖全好才给流量”。结果外部服务一次抖动一大批实例全部被判为未就绪流量集中打到剩下几个实例上直接冲垮了。排查问题时我盯了半天才发现不是应用代码坏了是健康检查把局部故障放大了成全局故障。readiness 探针需要的是“这个实例是否有能力处理请求”的最小判断而不是“整个系统是不是完全健康”的最大判断。外部依赖故障应该交给熔断、降级、重试机制去处理而不是让整个实例退出流量调度。如果确实有特殊场景比如某个接口强依赖外部系统、没有它就做不了任何业务那也要单独定义一个自定义健康指标把它的权重调低而不是直接加入内置的健康聚合里。4.4 长连接和异步请求带来的“假优雅”WebSocket、SSE、长轮询这类连接是优雅停机的死角。Tomcat 默认会把长连接也算在“在途请求”里如果是长轮询一个请求能挂几分钟timeout-per-shutdown-phase设小了会把它强行掐断。我在改造一个消息推送服务时就遇到这个问题。前端和服务保持 WebSocket 长连接服务发布时连接必须平滑迁移。方案是让服务端在优雅停机流程里主动向所有 WebSocket 推送一条“即将断开请重连”的消息客户端收到之后重新建立连接。这就需要在 Spring 容器关闭前写一个监听器遍历当前 WebSocket 会话并主动 close。别指望框架自动帮你做这种事它只知道“这是一个连接”不知道“这是一个应当优雅转移的业务会话”。同样的道理也适用于Async线程池。我见过一个项目在停机时Web 请求全部优雅结束了但异步线程池里的任务还在跑超时一到被强杀数据库里留下一批半成品数据。解决方案还是回到SmartLifecycle把异步执行器的关闭时机纳入统一管理。4.5 发布过程中请求失败但找不到任何异常日志还有一种非常气人的情况发布时确实有少量请求失败但后端日志里什么都没有。这类问题往往不在应用代码里而在负载均衡层。短连接还好如果是开启了 keep-alive 的 HTTP 连接客户端复用了一个已经半关闭的死连接请求发过去之后对端没有任何响应前端收到连接错误但后端应用连请求都没解析到日志当然一片空白。处理方式有几种一是让负载均衡在摘流前主动 close 空闲连接二是在客户端设置合理的连接空闲超时和重试策略三是如果用的是 K8s 就接受这一小段抖动通过客户端重试机制消化掉。这里要认清一个现实基础设施层的连接迁移永远存在微小缝隙运维的目标是通过重试机制把用户感知降到零而不是追求物理层面的一帧都不丢。5. 零停机更新的落地路线与检查清单5.1 最小可行方案从今天就能开始改造不一定非要上一套 K8s 才能开始最小可行方案可以分两步走。第一步先给你的 Spring Boot 应用配置优雅停机梳理业务侧的线程池、消息监听器、异步任务该实现SmartLifecycle的实现。这一步做完你的应用至少具备了“被温柔对待”的能力。第二步如果有多实例梳理发布流程把“摘流、停机、启动、加流”四步固化下来。哪怕开始时手动执行也要把每一步的检查点打印出来尤其是“旧进程已退出”“新进程健康检查通过”这两个节点。我曾在一个纯 Nginx 多台物理机的环境里完成过这套改造。核心动作只有两个Nginx upstream 里手动摘节点以及发布脚本里用kill -TERM替换kill -9。效果呢之前发布必断改完之后业务方基本感知不到。改造量不大但收益极其明显。5.2 进阶用 Kubernetes 把滚动发布变成平台能力等手动流程跑顺了再迁到 Kubernetes 就是水到渠成的事。Deployment 天然支持滚动更新两个关键参数是maxSurge和maxUnavailablestrategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0maxUnavailable: 0表示整个发布过程中任何时刻都不能有实例不可用。maxSurge: 1表示可以多启动一个新实例先保证新实例 Ready 了再下线旧实例。这对零停机发布来说是非常友好的配置代价是发布期间会多占用一份资源配额。再进一步配合 PodDisruptionBudget 可以防止集群节点维护时把服务全部摘掉apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: app-pdb spec: minAvailable: 1 selector: matchLabels: app: demo-api这个 PDB 的意思是自愿干扰比如节点维护、资源回收时最少保持一个实例可用。不是等业务发布时才起作用是集群日常运维时的安全网。有条件的团队还可以接上垂直扩展、水平扩展和监控告警让发布期间自动观察错误率。当新版本实例的错误率超过阈值时自动暂停滚动这就是平台化发布的基础原型了。5.3 上线前检查清单最后给一份我每次梳理发布流程都会对一遍的清单避免上线时脑子发热漏掉东西检查项怎么查为什么停机信号确认平台/脚本发的是 SIGTERM 而不是 SIGKILL优雅停机的前提是信号能到且被正确处理超时时间Spring 的优雅停机和容器宽限期、preStop 时长匹配某一层超时过短前面的配置都可能白做流量摘除摘流操作在停机之前完成且有时间缓冲避免新流量打向正在退出的实例readiness 探针检查路径、延迟、阈值不能过度依赖外部组件决定新实例何时能接流量、旧实例何时被摘除线程池关闭所有业务线程池、异步任务接入停机流程Web 请求结束了不等于业务都结束了消息消费者ack 模式、容器关闭顺序、死信处理防止停机导致消息丢失或重复投递长连接WebSocket/SSE 有平滑迁移机制长连接是优雅停机的最大死角回滚方案明确失败时是回滚版本还是继续滚动发布失败时最怕临时拍脑袋这套东西整体做完之后我个人最大的感受是零停机更新考验的从来不是某一个配置项而是你把发布当成了产品来设计还是在把它当成一个不得不做的动作。优雅停机给了你关门的时间和体面多实例给了你腾挪的空间健康检查给了你判断的依据但把它们全部串起来、并且能在事故发生时快速回退的是流程本身。把这个流程跑顺了Spring Boot 的新技能才真正变成你自己团队的日常能力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑