发版改到凌晨两点,不是因为我们爱加班,而是每次SpringBoot应用一重启,几十秒的停机窗口都会让线上请求断崖式下跌。零停机更新这个词听起来像大厂专属,实操下来其实单体SpringBoot项目也完全能做到,核心就一句话:把“停服更新”换成“滚动摘流 + 优雅停机”。这篇内容我会从方案选型、SpringBoot配置、Nginx流量切换、K8s容器环境配合到代码层优化,把我在生产环境里验证过的整套思路和踩坑记录都写出来。适合被发版窗口折腾过的后端同学,也适合刚接触SpringBoot、想提前了解生产发布机制的新手。
1. 传统SpringBoot发布方式到底痛在哪
1.1 每次重启都是一次线上事故
很多团队发布SpringBoot应用的流程还是这样:把新Jar包传到服务器,kill掉旧进程,等端口释放,再启动新进程。整个过程看起来没什么问题,但线上环境里会暴露出一堆连锁反应。
先说说最直接的痛点:kill之后到新进程启动完成之前,应用端口是无人监听的。这个时间窗口里,所有经过Nginx或网关进来的请求都会直接报错,轻则502,重则连接超时。单体SpringBoot应用如果启动要20秒、30秒,那这个Gap就是20秒、30秒。流量稍微大一点,全站监控告警能刷屏,用户反馈“页面打不开”就会准时出现在客服群。
还有更隐蔽的问题:kill -9强杀进程,等于直接切断所有正在处理的HTTP请求和数据库事务。用户可能正在提交一个订单,请求已经在Controller里执行到一半,这时候进程没了,数据库事务要么回滚,要么因为连接池里的连接被物理断开而留下一个异常状态。虽然加事务能保证一致性,但前端拿不到响应,用户只能刷新重试,体验非常差。
所以很多团队把发版时间定到半夜,不是因为发布本身需要那么久,而是为了避开流量高峰,给自己留出“出错后回滚”的缓冲时间。这种做法本质上是用业务损失换安全窗口,但遇到紧急修复、线上事故要立刻上线时,根本等不到半夜。
1.2 “零停机”的本质是管理流量而非管理进程
想明白这件事,我花了很长时间:零停机更新不是让你把更新过程做得“不可见”,而是让更新过程中随时有健康的实例在承接流量。
整个过程可以拆成两个动作:先把流量从旧实例上摘掉,再重启旧实例。只要摘流量和重启之间存在时间差,用户请求就会全部落在新实例或者尚未摘流的其他旧实例上,用户自然感知不到更新发生。
这个思路落地到SpringBoot单体项目里,就是一套很标准的组合操作:
- 准备新版本Jar包,在备用端口先启动新实例;
- 确认新实例健康后再把它加入负载均衡;
- 通过负载均衡把旧实例的流量摘掉;
- 等旧实例无流量后关闭进程。
这套流程里SpringBoot本身能做的,主要是优雅停机和健康检查两个部分,而流量切换则需要Nginx、注册中心或者K8s配合。接下来我把每一步的原理和实操都拆开讲。
2. 方案选型:滚动发布、蓝绿发布还是灰度发布
2.1 三种主流方案的对比与适用场景
聊零停机更新,绕不开术语。先把三种方案摆在一起对比,大家心里有个数。
| 方案 | 原理 | 优缺点 | 适合场景 |
|---|---|---|---|
| 滚动发布 | 多个实例逐个更新,始终保持有实例在服务 | 成本低、操作简单,但发布期间新旧版本共存 | 单体SpringBoot集群,最常用 |
| 蓝绿发布 | 保留老环境,新环境整套部署,通过负载均衡一键切换 | 回滚极快,但需要双倍资源 | 资源充足、对稳定性要求极高的核心应用 |
| 灰度发布 | 只让部分流量进入新版本,观察无异常后再全量 | 风险可控,但需要流量路由与权重控制能力 | 大版本升级、接口不兼容的更新 |
上面三种方案不是互斥的。灰度发布可以理解为滚动发布的精细化版本,蓝绿发布也完全可以和滚动发布组合使用。对于大多数团队,我建议先从滚动发布入手,因为它的基础设施要求最低,只要有一个Nginx和两台服务器就能跑起来。
2.2 单体SpringBoot项目最容易落地的路径
我见过很多团队一上来就上K8s,还没搞明白Pod调度就被各种概念绕晕。其实单体SpringBoot做零停机更新,最顺滑的路径是“Nginx轮询 + SpringBoot优雅停机 + 健康检查接口”。
Nginx做反向代理时,upstream后面可以挂多个后端节点。发布时先把某个节点从upstream里暂时摘掉,Nginx reload后流量就不再打到这个节点上,这个节点处理完手头请求就可以安全退出。整个过程不涉及蓝绿环境切换,也不需要额外引入注册中心,成本几乎为零。
如果你的项目已经上了K8s,那路径会更简单,滚动更新本身就是K8s的内置能力。但即便在K8s里,SpringBoot侧的优雅停机配置依然不可或缺,否则POD被删除那一刻,正在处理的请求照样会被掐断。所以后面讲SpringBoot优雅停机,是两种路径都要做的必修课。
3. SpringBoot侧核心配置:优雅停机
3.1 优雅停机的原理与参数解析
SpringBoot应用在收到停机信号时,默认行为是立即关闭所有线程,不管请求处理到哪一步。Spring Boot从2.3.0版本开始支持优雅停机,核心配置只有两行:
server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s第一行告诉SpringBoot:收到停机信号后,先不要再接收新请求,让正在处理的请求继续执行完成再关停。第二行是兜底熔断:每个停机阶段最多等30秒,超时后强制关闭,避免线程池里有慢请求卡住导致进程永远退不掉。
配置这个参数后,SpringBoot处理停机信号的完整流程大概是这样的:
- 收到SIGTERM信号;
- Spring容器开始进入关闭流程;
- Web服务器(Tomcat内嵌)停止接收新连接,等待正在处理的请求完成;
- 其他Spring Bean按依赖顺序执行销毁前逻辑;
- 所有阶段完成或超时后,进程退出。
这里要注意,Tomcat内部还有一个连接池。即使配置了优雅停机,如果有请求长期挂起,比如某个接口在等一个永远不返回的第三方调用,那么连接池里的线程会被一直占用,直到timeout-per-shutdown-phase超时。所以这个30秒不是拍脑袋定的,要根据你线上最慢接口的耗时间来评估。如果最慢接口耗时是25秒,那超时时间至少要30秒以上,否则请求会在停机过程中被切断。
3.2 版本差异:SpringBoot 2.3以下和SpringBoot 3.x怎么处理
如果你的项目还在SpringBoot 2.2或者更早版本,server.shutdown这个配置项是不存在的。老项目只能靠自定义停机钩子,常见做法是注册一个ApplicationListener,监听ContextClosedEvent,在容器关闭前做清理:
@Component public class GracefulShutdownListener implements ApplicationListener<ContextClosedEvent> { @Override public void onApplicationEvent(ContextClosedEvent event) { // 这里做自定义清理,比如通知注册中心下线、停止定时任务 System.out.println("容器开始关闭,执行预热清理"); } }但老版本这么做问题很多:ContextClosedEvent触发时,Tomcat的请求线程还在处理请求,你无法控制“等请求完再断开”的时机,经常出现监听器跑了但连接还是被强制断开的情况。所以老项目如果要做零停机更新,我强烈建议优先升级到SpringBoot 2.3及以上,哪怕只是升一个小版本,也比自己造轮子省心。
至于SpringBoot 3.x,优雅停机的配置方式和2.3-2.7完全一样,只是底层Servlet API变成了Jakarta命名空间,不过server.shutdown配置不受影响。网上很多说“SpringBoot版本太高配了不生效”的帖子,大多数情况是配置位置写错,或者容器配置了SIGKILL信号导致优雅停机根本没机会执行,我会在后面的问题排查章节展开。
3.3 容器环境下:K8s中的preStop与探针配合
如果你在用Docker+K8s部署,光在application.yml里配置优雅停机还不够。K8s删除Pod时会向容器发送SIGTERM信号,但K8s默认等待时间由terminationGracePeriodSeconds控制,默认是30秒。如果你的优雅停机超时设置是60秒,那K8s会在30秒后直接发SIGKILL把Pod杀掉,优雅停机变成了强制停机。
K8s环境的标准配置需要做两件事:
第一,放大Pod的终止宽限期:
spec: terminationGracePeriodSeconds: 60第二,在Pod生命周期里加preStop钩子,让Pod在收到SIGTERM之前先做一次“流量摘除等待”。最简单的方式就是sleep几秒,给K8s Service和Endpoint控制器足够时间把该Pod从负载均衡池里摘掉:
spec: containers: - name: app lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"]preStop的sleep很关键,因为K8s向Pod发SIGTERM信号是即时的,但Endpoints信息同步到各节点的kube-proxy和云负载均衡器需要时间。如果Pod关得太快,流量还会往这个Pod上打,请求就直接失败了。
我还建议在Deployment里配置好readinessProbe,健康检查接口指向SpringBoot的Actuator端点:
spec: containers: - name: app readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5这样K8s在滚动更新时,会先等新Pod readiness探针通过再继续推进,不会出现新Pod还没起来就切流量的问题。
4. 基于Nginx的零停机更新实操流程
4.1 场景假设与前置准备
下面给一个可直接照抄的实操场景。假设你有两台服务器:A和B,Nginx安装在其中一台,或者独立部署在nginx服务器上,upstream配置指向A和B两个节点。应用是标准SpringBoot单体,两个节点各跑一份,通过Nginx做负载均衡。
发布前要准备的东西列一下:
- 新版本Jar包,已经本地测试通过;
- SSH终端,能登录A、B和Nginx服务器;
- 线上数据库兼容新版本,不需要执行破坏性变更;
- 确定发布时间窗口,即便零停机,也需要给自己留出操作时间。
4.2 分步操作:从启动新实例到摘除旧实例
第一步,先登录A服务器,把新版本Jar包传到指定目录,但不要覆盖旧Jar包,而是用新文件名比如app-v2.jar。
第二步,在A服务器上启动新版本实例,端口换成一个临时端口,比如8081:
java -jar /data/app/app-v2.jar --server.port=8081先启动到临时端口而不是直接替换8080,是为了避免新旧进程抢端口。等A上的新实例启动完成后,用curl验证健康:
curl http://127.0.0.1:8081/actuator/health看到返回up就说明新实例本身没问题。这时候A服务器上有两个SpringBoot进程在跑:8080旧版本、8081新版本。
第三步,修改Nginx配置,把A节点从upstream里摘掉。假设原配置是这样:
upstream backend { server 192.168.1.10:8080; server 192.168.1.11:8080; }先把A节点的状态改成down,并且把B节点的权重保持正常:
upstream backend { server 192.168.1.10:8080 down; server 192.168.1.11:8080; }保存配置后执行nginx -s reload。注意,Nginx reload不会中断现有连接,正在处理中的请求不会断开,只是新的请求不再往A节点分发。
第四步,观察A节点旧版本8080进程的访问日志,确认流量已经归零。可以用tail持续观察:
tail -f /data/app/log/app.log | grep "8080"如果等了2-3分钟日志不再增长,说明Nginx已经把流量完全切到B节点和A节点的新版本8081端口。这里还没结束,还需要把A节点的8081新实例加入upstream。修改Nginx配置:
upstream backend { server 192.168.1.10:8081; server 192.168.1.11:8080; }再次reload。这时候A节点的新版本正式接流量。
第五步,在A服务器上停止旧的8080进程。用kill发SIGTERM,而不是kill -9:
kill $(lsof -t -i:8080)配合SpringBoot优雅停机配置,旧进程会等待已接收的请求处理完成再退出。
第六步,重复以上步骤处理B服务器。等两台服务器都切到新版本后,把Nginx配置里的8080端口全部替换成对应节点的新版本端口,清理临时文件,发布完成。
4.3 几个关键细节
上面这套流程有很多细节,没处理好的话,零停机也会翻车。
端口切换上,我习惯一个节点一个节点推进,不要同时操作两台服务器。如果新版本有隐藏Bug,单节点切换后可以先观察错误日志,发现问题就再把流量切回旧节点,至少还有一台旧的兜底。
Nginx的upstream里如果用keepalive指令维护长连接,摘除节点后,已经建立的长连接不会立即失效。Nginx会尽量复用旧连接池,所以你会发现down掉节点后有一段时间日志还在增长,这是正常现象,等连接池自然老化即可,不用急着kill进程。
另外,新实例配置的健康检查接口要和Nginx的proxy_next_upstream区分开。不建议直接在Nginx里频繁探测health接口,健康检查可以交给监控系统或K8s探针来做,Nginx只负责转发,否则Nginx日志会被探测请求刷爆。
下面我把这一步的流程浓缩成表格,方便做操作记录:
| 阶段 | 操作 | 关键验证点 |
|---|---|---|
| 启动新实例 | A服务器8081端口启动新版Jar | 健康检查接口返回up |
| 摘除旧节点 | Nginx配置A节点8080状态down | A节点旧版本日志停止增长 |
| 接入新节点 | Nginx配置A节点8081状态正常 | curl测试A节点新版本接口正常 |
| 关闭旧进程 | kill旧8080进程 | 容器优雅停机日志输出,进程退出 |
| 完成切换 | 重复上述步骤处理B服务器 | 两台服务器均运行新版本 |
5. 代码层面的进阶优化
5.1 预下线逻辑:让健康检查先“报假”
K8s里preStop的sleep只是权宜之计,更精准的做法是让应用在收到停机信号前主动向注册中心或负载均衡器报告“我不健康了”。SpringBoot结合Actuator可以在准备停机时把health状态改为DOWN,从而让健康检查失败,触发负载均衡器把节点摘掉。
实现方式不复杂,核心是注册一个自定义HealthIndicator,用一个内存标记控制状态:
@Component public class CustomHealthIndicator implements HealthIndicator { private volatile boolean ready = true; public void setReady(boolean ready) { this.ready = ready; } @Override public Health health() { if (!ready) { return Health.down().withDetail("reason", "prepare shutdown").build(); } return Health.up().build(); } }然后在停机前通过Spring的事件机制或者自定义Endpoint把ready标记设为false。这样即使Nginx不做主动摘流,健康检查也会告诉下游“这个节点不行了”,流量会被快速摘除。
不过这套做法需要你的负载均衡器或注册中心真的会主动去探测health接口,并把不健康节点踢出流量池。如果只是用Nginx做静态upstream,health接口变化并不会影响Nginx的转发行为,还需要配合Nginx的主动健康检查模块或者直接把节点down掉,这点要区分清楚。
5.2 延迟注册:解决“新实例刚启动就被打流量”的问题
SpringBoot实例刚启动时,内嵌Tomcat端口已经监听,但Spring容器还在初始化,很多Bean还没准备好。如果此时注册中心已经把实例注册进去,流量就会打到这个半成品实例上,轻则接口报错,重则因为无法获取数据库连接池而雪崩。
常见的解决方案是延迟注册。在注册中心(比如Nacos或Eureka)侧,把新实例的注册延迟到应用完全就绪之后。SpringCloud的注册逻辑通常依赖健康状态,可以让注册中心等待健康检查通过后再注册:
spring: cloud: service-registry: auto-registration: enabled: true更简单的做法是在应用启动类里做一次主动等待,等本地健康检查通过后再把注册开关打开。例如通过一个ApplicationRunner延迟执行注册逻辑,或者配置注册中心的元数据加入“预热时间”。
K8s场景下,readinessProbe天然支持这个逻辑:Pod启动后,探针会周期性探测SpringBoot的readiness端点,连续成功后才把Pod标记为Ready,加入Service的Endpoints。所以如果你已经在K8s里用了探针,延迟注册其实是自动完成的。
5.3 定时任务、消息队列与事务的停机边界
优雅停机只保证HTTP请求能处理完,但SpringBoot进程里还有其他线程,尤其是@Scheduled定时任务和消息消费者的线程。这些线程如果不做处理,停机时可能出现两种问题:
- 定时任务重复执行:多个实例同时在线时,如果定时任务每台机器都跑,发布期间会让任务多执行一轮,导致重复发短信、重复跑批处理;
- 消息丢失或重复消费:停机瞬间正好有消息在消费,进程退了,消息既没消费成功也没ack,就会触发重新投递,但新实例可能重复处理。
对定时任务,最理想的方案是引入分布式锁,把“只有一台机器能跑任务”作为前提。没有分布式锁的话,退而求其次的做法是停机前先停掉任务调度器,让正在执行的任务自然结束。
利用SmartLifecycle可以控制调度器的停止时机:
@Component public class SchedulerLifecycle implements SmartLifecycle { private volatile boolean running = false; @Override public void start() { running = true; } @Override public void stop() { running = false; // 停止调度器逻辑 } @Override public boolean isRunning() { return running; } }SmartLifecycle会在Spring容器关闭时按顺序触发stop,通过配置phase可以控制它在Web服务器停止之前还是之后执行。建议把定时任务的停止时机放在Web请求处理完之后,避免正在处理的HTTP请求依赖定时任务的数据刷新。
消息消费这块,停机前要尽量让消费者停止拉取新消息,同时把正在处理的消息完整消费掉。RabbitMQ的prefetch机制可以限制未ack消息数量,ShutdownHook里可以等待channel关闭;Kafka消费组则依赖再均衡机制,进程退出后分区会自动转移到其他消费者,重复消费只能靠消费端幂等来兜底。
6. 常见问题与排查技巧实录
6.1 server.shutdown=graceful配了却不生效
这个坑我反复见过。配置优雅停机后,发SIGTERM信号,进程还是秒退。排查思路按下面几步走:
先确认SpringBoot版本是不是2.3及以上,低版本根本没有这个配置项,配置了也会被忽略。再看配置命名空间,server.shutdown是放在application.yml的server节点下,不是spring节点下,位置错了会导致配置不生效。最后看容器是不是把SIGTERM转成了SIGKILL,比如Dockerfile里如果用了exec形式的CMD会正确传递信号,如果是shell形式很可能会把信号吞掉。
Dockerfile建议使用如下格式:
ENTRYPOINT ["java", "-jar", "/app.jar"]而不是:
ENTRYPOINT java -jar /app.jar这两种写法对信号处理的影响非常大,前者Java进程是1号进程,能收到SIGTERM;后者shell是1号进程,Java只是子进程,信号传递链容易断开。
6.2 旧节点流量已经归零,但kill之后还是一堆错误日志
如果你确认Nginx已经把旧节点down掉,访问日志也不再增长,但kill进程时依然有请求错误,多半是长连接或下游超时引起的重试流量。
有两种情况:一种是Nginx和SpringBoot之间配置了keepalive长连接,down掉节点后,之前建立的连接还挂在连接池里,旧节点能收到的是连接池中已建立的连接上的残留请求。另一种是上游服务重试机制,比如其他服务调你的接口,第一次请求超时后自动重试,在这段时间内把请求打了过来。
排查时可以看进程的网络连接状态,用netstat或ss查看8080端口是否还有established连接,抓包看这些连接是从哪个客户端IP过来的。如果是Nginx IP,就让Nginx连接池自然老化;如果是应用服务,需要考虑在下游调用方配置短超时或停止重试,才能彻底规避。
6.3 新版本启动正常,但一到流量切换就出现大量超时
这是典型的“实例就绪但业务未就绪”问题。SpringBoot的/actuator/health返回up,只代表应用本身健康,不代表依赖的资源都准备好了,比如JPA的EntityManager可能还在懒加载、Redis连接池可能在第一次请求时才真正建立连接、本地缓存还没预热完。
解决思路是在health里叠加业务就绪检查。定义一个BootstrapHealthIndicator,启动时把必要资源初始化完成后才返回up:
@Component public class BootstrapHealthIndicator implements HealthIndicator { private final AtomicBoolean initialized = new AtomicBoolean(false); public void markInitialized() { initialized.set(true); } @Override public Health health() { return initialized.get() ? Health.up().build() : Health.down().build(); } }在ApplicationRunner里调用markInitialized,这样Nginx或K8s探针只会在业务初始化完成后才切流量。这个方法很简单,但对大型SpringBoot项目效果极其明显,我建议每个项目都加上。
6.4 常见问题速查表
| 现象 | 可能原因 | 处理手段 |
|---|---|---|
| 优雅停机配置不生效 | SpringBoot版本过低/配置位置错误 | 升级到2.3+,检查server.shutdown位置 |
| 进程被强制杀死 | K8s terminationGracePeriodSeconds不足 | 调大终止宽限期,与优雅停机超时对齐 |
| 摘除节点后仍有流量 | Nginx keepalive连接池未老化 | 等待或执行reload释放连接池 |
| 新实例接口偶尔超时 | 业务资源就绪慢,health提前返回up | 自定义就绪检查,延迟注册 |
| 发版期间定时任务重复跑 | 多实例同时执行任务 | 引入分布式锁或控制停机顺序 |
| 停机时消息被重复消费 | 消费者未停止,重复投递 | 消费端幂等,停机前停止消费者 |
7. 个人实操体会与建议
我这里写的一个小经验是,零停机更新不要一上来就追求“全自动化”。我最早做这套方案时,总想把Nginx配置、健康检查、发布脚本全部串成一条流水线,结果第一版自动化脚本上线就出了问题:某个节点健康检查还没通过就被自动接入了生产流量,瞬间一批请求超时。
后来我改成了“半自动化”模式:所有重复性的准备动作用脚本完成,但“流量切换”和“旧进程关闭”这两个高风险动作保留人工确认。确认的依据就是监控面板上的QPS曲线和日志实时输出。跑顺几十次之后,我才逐步放开自动切换。
还有一个技巧是发布前提前检查一下Nginx的错误日志。很多零停机方案翻车不是应用的问题,而是Nginx负载均衡配置本身就有隐患,比如某个节点挂了但upstream里还留着它,导致部分请求持续打到一个已经离线的进程上。把基础环境清理干净,后面再做的所有精细操作才有意义。
最后分享一个我自己固定下来的发布前检查清单:先看QPS基线,再看错误率基线,接着逐个节点执行“临时端口启动新版本 → 验证健康 → 摘流量 → 等日志归零 → 关旧进程”这套流程,最后统一清理临时文件和旧Jar包,并留好上一个版本的回滚包。按这个节奏做了几十次,期间误操作过、超时过、长连接拖过后腿,但整体上没有再因为发布让用户感受到明显的服务中断。
对刚起步的团队,我建议先照着本文第4章的Nginx流程手动跑通一遍,再逐步叠加优雅停机配置、健康检查和延迟注册,最后再谈自动化。零停机更新不是一个“开关”,而是一套配合良好的工程习惯,每加一块,系统的韧性就多一分。