ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot零停机发布实战:优雅停机与滚动更新全攻略

Spring Boot零停机发布实战:优雅停机与滚动更新全攻略

凌晨一点,某平台的搜索服务正在做例行版本升级。运维同事执行了重启脚本,然后聊天群里瞬间炸了锅——消息队列积压了上万条请求,一部分用户的登录态全部失效,线上监控面板的“5xx错误率”直接冲到了红色告警线。更麻烦的是,回滚并没有想象中那么快,新版本刚退出进程,旧版本还没来得及拉起来,流量已经涌进了空窗期。那次事故之后,“零停机更新代码”正式写进了这家公司的发布规范。

作为长期用 Spring Boot 做后端服务的人,我花了不少时间研究零停机发布的落地方式。今天想把这两年攒下的经验完整梳理一遍,包括它到底解决了什么问题、需要哪些前置条件、最典型的几种实现路径,以及我实际踩过的坑。如果你也在为“每次发版都提心吊胆”而头疼,这篇文章应该能给你一份可以直接抄作业的方案。

1. “零停机”到底在解决什么痛点:一次发布事故的复盘

很多人一听“零停机”,第一反应是“用 Docker 重启容器不就行了”。实际上事情远没有那么简单。容器重启只是让进程重新拉起来,真正影响用户体验的是重启过程中系统对外表现出的“不可用窗口”——这个窗口里发来的请求,要么连接被拒绝,要么超时无响应,要么数据写到一半进程就没了。零停机要消灭的,就是这个窗口。

1.1 停机发布的三个典型症状

先把话说透:传统 restart 式的发布,至少会带来三类可感知的问题。

第一类是“连接拒绝”。进程正在退出时,Tomcat 的端口已经不再接受新连接,但负载均衡器还没有及时感知到节点已死。于是原本应该被转发到这台机器的请求,直接被 TCP 层拒绝,用户的屏幕上就是一片“网络异常,请稍后重试”。所有对可用性有要求的系统,第一个要干掉的都是这个问题。

第二类是“会话丢失”。很多老项目把用户登录态塞在本地 Session 里,进程一退,所有内存状态全部清空。用户在下一次请求时发现自己“被登出”了,体验非常糟糕。即便项目已经把 Session 外置到 Redis,如果发布过程中刚好有请求打到了状态尚未初始化的节点,依然会出现偶发的“状态不一致”。

第三类是“回滚地狱”。发布失败后想回滚到上一个版本,但如果发布脚本里只有 kill 和 start,没有考虑流量摘除和依赖状态恢复,回滚期间一样会出现大面积报错。很多时候“回滚”只是把事故时间从五分钟拉长到半小时。

1.2 零停机的核心目标:用户无感知

厘清一个容易误解的点:零停机并不等于“进程不重启”。恰恰相反,绝大多数零停机方案里每个实例都会重启,只是重启的时机和方式经过编排,使得任意一个时刻都至少有一个健康实例在对外提供服务,用户请求始终有地方可去。

打个比方:饭店厨房有两位厨师,一位下班时另一位必须已经到岗接过手头的工作,客人点菜不会因为厨师换班而等着。零停机更新的本质是做“交接班编排”,而不是“不让厨师下班”。

要做到这一点,核心有三件事:一是让旧进程在处理完手头的请求之后再退出,也就是优雅停机;二是让负载均衡在节点退出前把流量摘掉,避免请求打到“正在下班”的进程;三是新实例必须在旧实例退出前完成启动并注册到服务发现组件。三件事缺一不可,下面逐个拆开讲。

2. 实现零停机的三个前置条件:优雅停机、连接排空、流量摘除

我把这三个条件称为“零停机三件套”。很多项目号称做了零停机,实际上只做了其中一件,结果发布时照样出问题。真正的落地必须三管齐下。

2.1 优雅停机:让 Spring Boot 礼貌地告别

Spring Boot 从 2.3 版本开始内置了优雅停机机制。在application.yml里只需要开启两个配置:

server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s

开启之后,应用收到SIGTERM信号时不会立刻退出,而是先停止接收新请求,再等待容器内已接收的请求处理完成,直到超时时间耗尽才强制退出。

这个机制解决的问题非常明确:进程退出时,正在处理中的数据库查询、外部 API 调用、消息回调等任务,都有机会走完自己的生命周期。尤其是那些“先更新数据库,再发消息”的链路,如果进程在两步之间被杀掉,就会产生一致性问题,事后排查起来非常痛苦。

timeout-per-shutdown-phase的值一般建议设置成 30 到 60 秒。太长会让发布节奏变慢,太短则可能导致长请求被强行切断。具体数值要根据业务的 P99 响应时间反推——如果 99% 的请求在 2 秒内完成,30 秒的排空窗口已经非常充裕了。

2.2 连接排空:把已有请求处理完再退出

优雅停机之后,还有一个更隐蔽的问题:长连接。WebSocket、消息队列消费者、HTTP Keep-Alive 连接如果处理不当,可能拖住进程无法退出,也可能在退出过程中丢掉半截数据。

实际操作中,我一般会给应用加上一层“排空逻辑”:在收到关闭信号后,先把消息队列的消费者从队列上摘掉,让新的消息不再进入本进程;然后等待 5 到 10 秒,让已经拉取到本地的消息处理完毕;最后再调用System.exit触发 Spring Boot 的优雅停机流程。这一步可以通过 Spring 的SmartLifecycle接口实现,在stop()方法里完成消费者暂停和等待动作。

另一个容易被忽视的点是数据库连接池。进程退出时,连接池里可能有尚未归还的数据库连接。如果连接池的testOnBorrow或者validationQuery没有配置好,新实例启动后复用这些残留连接,就会出现偶发的“连接不可用”报错。后面在踩坑部分我会专门展开讲这个问题。

2.3 流量摘除:在重启前把入口关掉

光有优雅停机还不够。假设进程正在优雅退出,但它已经不再接受新请求了,此时负载均衡器依然把新请求转发过来,这些请求会直接被拒绝。所以第一步应该是让入口感知到“这个节点要下线了”,也就是流量摘除。

常规做法有两种。如果用的是 Nginx,可以在发布前手动把目标节点的状态置为 down,或者临时改掉 upstream 配置的权重。比较现代化的方式是利用“健康检查”机制:应用在关闭前主动把健康检查端点返回为失败,负载均衡器连续探测失败后自动摘除节点。

Spring Boot 的spring-boot-starter-actuator提供了/actuator/health端点。我一般会自定义一个HealthIndicator,在应用收到关闭信号时,把这个端点的状态从 UP 改成 DOWN,这样负载均衡器会在几秒内自动把流量切走,随后再执行真正的退出流程。

3. 多实例滚动发布的完整落地:从 Spring Boot 配置到 Nginx 切换

理论讲完之后,上实操。这一节我会用一个最小但完整的架构来说明滚动发布的落地方式:两台 Spring Boot 实例,前面挂 Nginx 做负载均衡,发布时一台一台更新,全程无感知。

3.1 环境准备与架构选型

先交代架构。关于实例数量,很多人会问“需要几台机器”。答案是至少两台。一台机器无论如何都做不到严格意义上的零停机,因为只有这台机器在重启时没有其他节点可以承接流量。

机器资源不够的话,可以用“单机多实例”作为替代方案:在一台机器上跑两个端口不同的 Spring Boot 进程,每个进程分配独立的堆内存和日志目录。虽然共享了宿主机资源,重启时依然可以做到错峰切换,体验上一样是无停机的。

版本管理方面,推荐把产物做成可追溯的构建包,例如 Jenkins 或 GitLab CI 构建出的 jar 文件,带版本号和 git commit id。这样发布到哪台机器、部署的是哪个版本,一目了然。

3.2 Spring Boot 侧的关键配置

实例的application.yml至少需要包含以下几块内容:

server: port: 8081 shutdown: graceful spring: application: name: demo-server lifecycle: timeout-per-shutdown-phase: 30s management: endpoint: health: probes: enabled: true health: livenessState: enabled: true readinessState: enabled: true

这里的readinessState比较关键。它表示“实例是否准备好接收流量”。在启动早期,如果数据库连接还没建立完成、缓存还没加载完毕,readiness 状态会一直是DOWN,直到应用完全就绪。Nginx 或者 Kubernetes 会根据这个状态决定是否把流量打进来。

同一套代码在不同实例上,只需要通过启动参数覆盖端口即可,不需要修改配置文件:

java -jar demo-server.jar --server.port=8081 java -jar demo-server.jar --server.port=8082

3.3 Nginx 侧的流量切换细节

Nginx 配置的核心是 upstream 健康检查。下面的配置里,max_fails和fail_timeout决定了一个节点连续失败多少次之后会被摘除:

upstream demo_backend { server 127.0.0.1:8081 max_fails=3 fail_timeout=10s; server 127.0.0.1:8082 max_fails=3 fail_timeout=10s; } server { listen 80; server_name demo.example.com; location / { proxy_pass http://demo_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_connect_timeout 5s; proxy_read_timeout 30s; } }

发布时,先在 Nginx 里把 8081 节点的权重降为零,等待几秒让存量请求排空,然后重启 8081 的进程,等它通过健康检查后再恢复权重。这个过程可以用脚本自动化,核心逻辑是“先摘流量、再重启、最后回流量”。

有人说“现代架构都用 Kubernetes,不需要手动搞 Nginx 了”。这没错,但 K8s 的滚动更新本质上也是同样的三件事:新 Pod 先就绪,旧 Pod 再终止,Service 根据就绪探针决定是否转发流量。理解了 Nginx 这套手动流程,再看 K8s 的滚动更新原理会顺畅很多。

3.4 发布脚本的编排顺序

一个可用的发布脚本,大概长这样:

#!/bin/bash # 发布 8081 实例 NODE_PORT=8081 # Step 1: 从 Nginx 摘除流量 echo "draining node $NODE_PORT" # 修改 upstream 配置, 将 8081 的 weight 改为 0, reload nginx # Step 2: 等待排空, 建议等待 30~60s sleep 30 # Step 3: 停止旧进程 PID=$(pgrep -f "server.port=$NODE_PORT" | head -n 1) kill -TERM $PID # Step 4: 启动新进程 nohup java -jar demo-server.jar --server.port=$NODE_PORT > /data/logs/demo-$NODE_PORT.log 2>&1 & # Step 5: 等待健康检查通过 for i in $(seq 1 30); do STATUS=$(curl -s http://127.0.0.1:$NODE_PORT/actuator/health | grep -o '"status":"UP"' || true) if [ "$STATUS" = '"status":"UP"' ]; then break fi sleep 2 done # Step 6: 重新挂载流量 echo "recover node $NODE_PORT" # 修改 upstream 配置, 将 8081 的 weight 恢复为 1, reload nginx

每一步中间都有等待和校验,确保错误被提前发现。实际执行时,可以用 Ansible 或自定义的发布平台封装这套流程,但底层的逻辑不会变。

4. 蓝绿发布与灰度发布:什么时候不该用滚动更新

滚动更新是最常见的零停机方案,但它不是万能的。有些场景下,滚动更新反而会带来更大的问题,这时候需要考虑蓝绿发布或者灰度发布。

4.1 三种发布方式的对比

先用一张表把三种方式捋清楚:

发布方式核心思路优点缺点适用场景
滚动更新逐台替换实例成本低、资源占用少新旧版本共存期有兼容性问题多数后端服务
蓝绿发布同时保留两套完整环境,切换流量回滚极快、风险隔离好需要双倍资源核心交易、支付链路
灰度发布按规则让部分用户先体验新版本风险可控、可观测性强规则设计和数据拆分复杂用户量大的互联网产品

滚动更新最大的隐患是“新旧版本共存”。假设数据库表结构有变更,旧代码还在跑,新代码已经开始写新字段,两边同时操作同一张表,很容易出问题。蓝绿发布在这个场景下更保险,因为整套环境一起切换,不存在两版代码同时在线的情况。

4.2 蓝绿发布的数据库兼容问题

蓝绿发布听起来简单:准备绿色环境跑新版本,验证通过后把流量整体切过去。但数据库不是“准备一套”就行的,因为新旧环境共享同一个数据库实例时,新代码的建表、加字段操作会导致旧环境不可用,一旦需要回滚,旧代码面对新表结构照样跑不起来。

所以蓝绿发布的前置条件是“数据库变更向前兼容”。加字段是可以的,但删表、改字段类型这种破坏性变更,必须先做兼容处理,例如分两步走:先加新字段,等所有环境都切换到新版本后,再在下一个发布周期里清理旧字段。

4.3 灰度发布的规则与用户筛选

灰度发布适合业务逻辑变化大、担心影响面广的场景。实践中最简单的规则是按用户 ID 取模划分,比如userId % 100 < 10的用户走新版本,其余走旧版本。进阶做法是结合配置中心,通过开关动态调整灰度比例,从 10% 逐步扩大到 50%、100%。

灰度发布期间必须盯紧核心指标:错误率、接口耗时、业务转化率。如果新版本在灰度桶里的错误率明显高于旧版本桶,立刻把灰度开关关掉,让流量全部回到旧版本。这里建议强制要求灰度期间的监控粒度至少到分钟级,不要等第二天看日报,否则就失去了灰度的意义。

5. 实测中踩过的坑:优雅停机失效、连接池残留、缓存穿透

这里聊聊我在真实发布过程中遇到过的棘手问题,这些问题都不是下载现成脚本就能解决的,属于“不实际踩一遍根本想不到”的类型。

5.1 优雅停机不生效的排查链路

第一次配置完优雅停机后,我满怀期待地执行了kill命令,结果进程直接退了,日志里根本没出现预想中的“正在排空请求”记录。排查链路如下:

先确认 Spring Boot 版本。优雅停机是 2.3 才开始支持的,如果项目还在 2.2 或更早版本,配置了server.shutdown: graceful也不会生效。这一步检查最简单,也最容易被忽略。

再看容器或系统服务的启动方式。很多服务用 Docker 部署,Dockerfile 里如果直接写了CMD ["java", "-jar", "app.jar"],优雅停机机制是可以正常触发的。但如果用docker run启动了多个进程、或者用tini方式接管信号,SIGTERM可能没有被 Java 进程正确接收到,看起来就像是“配置了但没生效”。

最后检查注册中心的反注册逻辑。如果服务通过 Nacos 或 Consul 注册,实例在退出前并没有主动反注册的话,消费者还会继续把请求路由过来。此时即使服务本身在优雅停机,新请求进来依然会失败。我的解决办法是在SmartLifecycle.stop()里显式调用注册中心的注销接口,完成后再退出。

5.2 数据库连接池残留导致的对账失败

有一次滚动发布完成后,业务方反馈说定时对账任务出现了一小批失败记录。查日志发现,失败请求的报错是“Connection is not available, request timed out after 30000ms”。

排查了很久才定位到根因:旧实例退出时,数据库连接池里的一部分连接还没有归还。新实例启动后,数据库连接池在初始化时复用了旧的物理连接,但这些连接在数据库侧已经被断开了,于是新实例上出现了一批“死连接”。直接表现是连接池拿不到可用连接,请求排队到超时。

解决方法是给连接池配置空闲连接验证:

spring: datasource: hikari: validation-timeout: 3000 connection-test-query: SELECT 1

然后确保发布脚本在启动新实例前,等待旧实例的连接池完全释放。不同数据库驱动对断连的感知机制不一样,MySQL 8 之前的驱动在 TCP 连接被服务端回收后,可能需要等待超时才报错。连接池关闭后多等 5 到 10 秒再拉新实例,能有效避开这个问题。

5.3 缓存穿透与预热问题

发布完成后接口响应正常,但数据库的负载突然飙升了一倍。一查才发现,新实例的本地缓存是空的,大量请求直接穿透到了数据库。如果主从库的读延迟本来就高,穿透压力可能直接把数据库打垮。

解决思路是“预热”。我一般会在应用启动完成后,由后台任务主动加载一批热点数据到缓存,而不是等用户请求来了才慢慢填充。可以监听 Spring 的ApplicationReadyEvent,在这个时机执行预热逻辑。

另一个更轻量的做法是“缓存懒加载 + 合并回源”。在 Cache-Aside 模式下,如果缓存没有命中,先拿到一把分布式锁,只允许一个请求去查数据库并回填缓存,其余请求等待锁释放后直接读缓存。这能有效避免缓存穿透引发的并发压力。

6. 如何量化“零停机”:三类指标与验收标准

发布完成不等于零停机达成。要判断一次发布是否真的“零停机”,必须用数据说话。我总结了三个量化维度,发布前后进行对比,差异在允许范围内才算通过。

6.1 错误率:5xx 和 4xx 的对比

发布期间最直观的指标是错误率。正常的发布流程中,4xx 错误率应该和发布前基本持平,因为用户端不会因为版本更新而改变行为。5xx 错误率则是重点观察对象,发布窗口内的 5xx 数量必须为零,或者极少数由于网络抖动导致的零星报错。

建议监控系统至少做到分钟级聚合,发布窗口前后各取 15 分钟的数据做对比。如果发布期间的 5xx 走势图和发布前有明显差异,不管流程看起来多么顺畅,都要当成发布事故来处理。

6.2 请求耗时:P99 延迟抖动

零停机不只是“请求能成功”,还要关注“请求是否变慢”。旧进程排空期间,单体实例的线程池可能已经被长请求占满,新请求在等待队列里排队,表现为 P99 延迟明显上升。

验收标准建议看三条线:P50、P95、P99。发布期间的 P50 和 P95 应该与平时基本一致,P99 允许有轻微波动,但不应超过发布前的 1.5 倍。如果 P99 飙升,说明排空窗口不足或者实例数量不够,需要相应调大timeout-per-shutdown-phase或者增加实例数。

6.3 会话保持:登录态与分布式会话

第三个容易被忽略的指标是登录态保持率。发布期间如果出现大量“用户被登出”的反馈,说明 Session 数据没有被正确共享。

现代架构里,登录态一般放在 Redis 中,Spring Boot 通过 Spring Session 读取,发布时不会丢失。但有些老项目直接在本地内存里存了用户信息,这种情况必须先把 Session 迁移到 Redis 或者调整成无状态 JWT,否则零停机发布永远只是“服务器不报错,但用户感知明显”。

发布期间可以抽样检查登录接口的 Token 刷新率:如果发布窗口内出现了异常的 Token 失效事件,说明会话保持出了问题。这个指标很难从后端日志直接看到,建议在前端埋点或者网关层做记录。

做完上面这些实验和调整之后,我个人比较大的体会是:零停机更新不是一个“配置项”,而是一整套围绕发布流程的工程习惯。第一次配置优雅停机可能会踩坑,但只要把“流量摘除、优雅退出、健康检查、监控对比”这四个环节做成固定流程,后续每次发布都会变得非常顺。最后再分享一个小技巧:无论如何都要先在预发环境跑一次完整的发布演练,用压测脚本模拟请求,盯住错误率和 P99 曲线,确认达标后再上生产。发布不是赌运气,而是把不可控变成可控。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进