☰
滚动发布成功率暴跌?从假健康到优雅下线的完整排查与优化
2026/10/8 3:38:06 网站建设 项目流程

先交代个背景。我这边维护着一批阿里云ECS实例,跑的是Java微服务,前面挂SLB做负载均衡,发布走的是自研的滚动部署平台,流程基本是经典三板斧:拉起一批新实例,等健康检查通过,切流量,摘旧实例。这套流程用了差不多大半年,一直没出什么大问题。直到有一次周四例行发版,滚动到中间,监控曲线突然变得很难看:整体成功率从99.9%一路掉到92%左右,持续了将近四分钟,然后又自己缓过来了。

当时第一反应是新代码引入了bug,马上回滚,确实恢复了。但诡异的是,下一周再发同一个新版本,同样的现象又出现一次。这回就不能用“代码有bug”解释了——同一个版本,我单独部署到一台实例上压测,接口表现完全正常。也就是说,问题出在“滚动发布”这个动作本身,而不是代码。

这篇文章就是那次问题从定位到优化的完整复盘。核心思路不是靠运气等它自己恢复,而是把一次发布拆成流量接入、存量连接、容量水位、依赖预热四段去查,最后用就绪探针、预热度、优雅下线和发布门禁把成功率拉回正常水平。内容比较适合正在做滚动发布,或者发布窗口经常出现成功率抖动的SRE、运维和后台开发,当然,如果你只是被“发布期成功率掉点”折磨过,也值得翻一翻。

1. 滚动部署的机制盲区:为什么“分批上线”并不等于“无损上线”

1.1 滚动部署到底在滚动什么

很多人对滚动部署的理解是“一批一批替换实例,服务不中断”,这个理解没错,但不够深。以20台ECS、每批5台为例,平台真正干的事是:先新建5台新版本实例,等这批实例通过健康检查,从负载均衡摘掉5台旧实例,把流量切到新实例上;确认稳定后,再进入下一批,直到所有旧实例都被替换掉。

整个过程里负载均衡始终在转发请求,从外部看服务没有“中断”,这是滚动部署最大的优点。

但问题恰恰出在“平滑”这两个字上。滚动部署保证的是服务进程不中断,并没有保证单次请求质量不下降。新实例加入和老实例退出,本质上都是对流量池的扰动,而所有失败的种子都埋在这两次扰动里。

1.2 新实例“健康”,不代表它已经准备好接客

这是我在这次事件里踩得最深的一个坑。平台判断新实例是否就绪,靠的是健康检查。默认的健康检查路径往往只检查进程是否活着,或者访问一个极简单的/health接口。进程起来了,端口监听着,/health返回200,平台就认为“这实例可以接流量了”。

实际上呢?Java服务启动完成和真正准备好处理线上请求之间,隔着一大截:

  • JIT还没把热点方法编译成机器码,第一个请求进来要边解释边执行,慢得离谱;
  • 数据库连接池还在初始化和建连阶段,可能也就刚建了5个连接,流量一来直接排队;
  • 本地缓存、配置拉取、定时任务预热都还没跑完,第一次查缓存全是Miss;
  • 注册中心那边,服务消费者可能还没刷新实例列表,或者拿到了新实例但本地路由规则还没生效。

那段时间里,新实例就像一家刚开门、桌子还没擦、服务员还没到岗的餐厅,外面已经开始排队点菜了。你能说餐厅“没开业”吗?不能,门是开着的。但体验一定差。健康检查看到的是“门开着”,但请求感受到的是“没人理我”。

1.3 发布窗口期的错误率,往往不是一条“平滑曲线”

还有一点容易被忽略:滚动发布期间,新旧版本是长时间同时对外服务的。如果一个接口的新版本对下游依赖做了变更,或者数据库表结构有调整但兼容没做好,那么旧实例还在用旧逻辑,新实例跑新逻辑,两边行为不一致,成功率自然会有波动。

这种新旧混跑带来的问题,不是简单的“新实例冷启动”,它和业务代码强相关。更让人头疼的是,很多时候这两类问题是叠加在一起的:冷启动让RT升高,新逻辑又暴露了兼容问题,两条曲线缠在一起,很难一眼分辨到底是谁的问题。

所以,看到“发布期间成功率掉了”,先别急着甩锅给代码。滚动部署本身的机制已经有足够多的机会让成功率下降,第一步应该把发布动作和失败请求在时间上对齐,看看到底是哪一段出的问题。

2. 成功率掉在哪一段:把发布时间轴和失败曲线对齐

2.1 失败请求都长什么样

那次事件里,我把发布平台的“批次时间戳”和监控系统的“错误率曲线”拉到同一张图里看,发现一个非常明显的规律:错误率是呈台阶状上升的,平台刚打出“批次1完成”的标记,大约20秒后错误率开始往上跳;等“批次2开始”的时候,错误率又跳一次。整条曲线跟发布批次的对应关系清晰得吓人。

再点进具体的错误详情,失败请求集中在三类:

上报状态失败特征最可能出现的阶段
连接超时客户端连不上服务端,connect timeout新实例刚接入流量,连接池未建好
读超时请求发出去了,但服务端迟迟不响应新实例处理慢,多为冷启动/缓存Miss
502/503负载均衡报错,或服务端主动拒绝旧实例被摘除瞬间,或剩余实例触发限流

这张表当时给我的最大提示是:失败并不是随机分布的,而是跟着“批次切换”走的。新实例一上线,出现连接超时和读超时;旧实例一摘除,出现502。两种失败特征对应两个不同的发布动作,不应该混在一起排查。

2.2 一个容易被忽略的对照信号:新实例的P99 RT

光看错误率还不够,我建议把P99 RT也单独拉出来,按实例分组看。那次我按“新实例”“旧实例”拆了两组,发现新实例在启动后的前两分钟,P99 RT比集群均值高了一倍不止,甚至一度冲到3秒以上。但过了大概两分半钟,RT慢慢回落到和旧实例齐平的水平。

这个信号很关键。它说明代码逻辑本身没有大问题,慢就是慢在“刚起来那几分钟”。如果新实例P99一直高、且高得很稳定,那才是代码问题;如果只在启动早期高,随后回落,基本可以断定是冷启动/预热问题。

还有一个容易漏掉的地方:看旧实例摘除前后的CPU水位。我这边发布窗口里,旧实例的CPU峰值水位明显比平时高出一截。原因不难理解:切走一批实例后,在流量没变的前提下,剩下实例要扛的QPS变多了。如果原本水位就不低,发布这段时间真的可能被打到限流阈值。

到这一步,失败到底掉在哪一段已经有了眉目:新实例进来时被“假健康”骗了,流量过早涌入;旧实例出去时被硬杀,存量连接直接断;再加上批次过大导致剩余实例容量吃紧。下面把四条根因的完整排查路径一条条说清楚。

3. 四类高频根因的完整排查路径

3.1 根因A:新实例“假健康”,流量涌入时还没做好接客准备

排查这类问题,不要只看健康检查是否返回200,要做一个关键实验:单独启动一台新实例,不切任何流量,等3分钟再手动打几个请求。

我当时就是这么干的。发现手动打请求时RT很正常,新实例的服务逻辑没问题。然后我又换了个方式:启动后立刻打请求,结果前十几个请求的RT全在1秒以上,有的甚至超时。一来一回,原因就很清楚了——新实例本身没问题,但它需要时间“热身”。

更重要的是判断“假健康”的根源在哪。是在JIT编译?是连接池没建立?还是注册中心没把实例拉进路由?我当时的做法是:登录新实例看线程栈,发现大量线程阻塞在数据库连接池获取连接上,同时GC频率也比正常高。结合新的连接池配置来看,是初始连接数太少,加上首次建连要去做认证,流量一挤全堵住了。

这里要提醒一句:不同服务“热身”的内容不一样。有的服务启动时会加载大量配置,有的会预热本地缓存,有的依赖定时任务捞数据。排查时一定要结合自己服务的启动逻辑,不要只盯着JIT,JIT只是最常见的一层外衣。

3.2 根因B:旧实例被摘除时,存量连接“突然死亡”

这个原因我一开始完全没意识到,因为它是“看不见”的。SLB和后端实例之间,客户端SDK和实例之间,都维持着大量TCP长连接。滚动发布时,负载均衡把旧实例的权重调成0,新的流量确实不再打过去,但已经建立的长连接还在。

如果这个时候平台直接把进程kill掉,这些存量连接就会立刻被RST,正在这些连接上传送的请求全部失败。客户端这边通常还会自动重试,重试请求又打到其他实例上,等于把一个本来很小的故障放大成了对剩余实例的流量冲击。

排查方法不难:看发布平台的摘除动作和旧实例退出日志。如果旧实例退出时没有收到任何SIGTERM信号,或者收到信号后进程立刻退出,没有等待在途请求处理完,那基本就是这里出了问题。在监控里也能看到:每次“切换批次”的时间点上,502数量冒一个尖,尖峰持续几秒就消失,正是存量连接被一锅端的典型特征。

3.3 根因C:单批次替换数量过大,剩余实例容量扛不住

这一类根因排查起来更直观,看一下发布期间的CPU水位和活跃线程数就能定位。核心逻辑可以写成一条简单的容量公式:假设实例总数是N,单批次替换m台,那么发布期间最多只剩N减m台实例在扛流量。如果原来每台实例的CPU峰值水位是w,那发布期间水位会变成w乘以N除以N减m。

举个实际数字:20台实例,单批5台,剩余15台。如果原本CPU峰值是50%,发布期间就变成50%乘20除以15,约66.7%,还能扛。但如果原本水位是70%,发布期间直接到93%,离限流阈值就很近了。

我在那次事件里为什么要查这条线?因为在错误率曲线上,我看到了503状态码。503不是冷启动会出现的错误,它往往是服务端主动拒绝流量。一查CPU,果然发布期间的活跃线程数已经顶到线程池上限,部分请求被拒绝。

需要注意的是,这条根因不像前两条那样“每次发布都必然发生”,它取决于发布前的集群水位。如果平时水位低,批次大一点也没事;一旦业务高峰期发版,同样参数就可能出事。这也是为什么参数不能拍脑袋定,要从容量倒推。

3.4 根因D:新实例集中建连,把下游依赖打出“连接风暴”

这一类问题表现得很像根因A,但危害范围更大,因为它不只是新实例自己慢,而是连累所有实例一起慢。

新实例启动时,数据库连接池、Redis连接池、消息队列连接都要新建。如果一批同时拉起5台实例,每个连接池初始配20个连接,数据库那边瞬间要多建100个连接。这还不算启动后的一些定时任务会去刷缓存、拉配置,可能造成缓存穿透。下游依赖一旦被打穿,所有实例的服务质量都会下降,错误率就不是某个批次的问题,而是全局性的。

排查时看数据库监控最直接。发布窗口内,数据库的连接数曲线会有一个很陡的尖峰,CPU使用率同步抬头。如果错误率曲线和数据库连接数尖峰高度重合,基本就是连接风暴无疑。

这里有一个经验值得记下来:单独看应用错误率,往往会以为是上游代码问题;一旦把下游依赖的曲线拉出来对照,很多问题其实一目了然。排查发布期故障,一定不要只看自己的服务,要多往下游看一眼。

4. 优化落地的四个关键动作:接入、预热、摘除、验证

4.1 就绪探针:让流量在“真正确认可用”之后才进来

第一个要改的是健康检查。不改它,后面所有优化都是空谈。

目标很简单:健康检查不能只看进程活没活,要看业务是否真的就绪。我在服务里加了一个内部就绪接口,它检查三样东西——数据库连接池能否正常获取连接、核心缓存是否加载完成、关键线程池是否初始化完毕。只有这些检查全部通过,接口才返回200,否则返回503。

如果用的是Kubernetes或类似平台,可以把探针配成:

readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 15 periodSeconds: 5 failureThreshold: 30

initialDelaySeconds设成15秒起,是给JVM一个基本的启动时间;failureThreshold设成30,是防止启动慢一点就被平台误判为不健康而杀掉。如果是ECS加SLB的架构,原理一样,把SLB的健康检查URL指到就绪接口,同时把“健康检查失败多少次后判定实例不健康”的阈值调高一些。

这个改动看起来小,但它把“进程活着”和“服务可用”两个概念彻底分开。之后新实例不会再裸奔着进流量池了。

4.2 预热度:让新实例先扛小流量,再逐步放大

就绪探针解决了“实例没准备好就接流量”的问题,但“实例已经就绪、能力还没完全热身”的问题依然存在。就好比一个人已经到岗了,但手生,直接让他满负荷工作还是容易出错。

更稳妥的做法是给新实例一个“预热度”:它进入流量池后,一开始只分到很小的权重,然后在几十秒到几分钟内逐步把权重加到正常值。这样新实例在低流量状态下完成JIT编译、连接池扩容、缓存预热,再慢慢接满负荷。

如果注册中心用的是Nacos或Consul,可以直接调整实例权重;如果用的是SLB,可以在发布平台里写一个简单的预热调度,比如:

# 预热调度伪代码:实例上线后权重从10%逐步增加到100% total_seconds = 90 start_weight = 0.1 for step in range(1, 10): weight = start_weight + 0.1 * step set_weight(new_instance_id, weight) sleep(total_seconds / 10)

权重爬坡要做多快,取决于服务启动后多久能进入稳定状态。先把新实例的RT曲线打出来,看曲线什么时候走平,那才是预热窗口的合理长度。我当时测下来是两分钟左右,所以就把预热窗口设成了90秒。

4.3 优雅下线:让旧实例把手上请求处理完再退场

这一条解决的是“切换批次时502扎堆”的问题。核心动作只有一个:先摘流量,再等存量连接耗尽,最后杀进程。

正确顺序应该是:从负载均衡/注册中心摘除实例权重,让新流量不再过来,同时旧实例主动向负载均衡发送“我不再接收新请求了”的信号,然后等待已接收的请求处理完,最后才终止进程。顺序反了,或者少了中间任何一步,都会出现连接被硬掐断的情况。

Spring Boot服务可以这样配:

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

同时建议在进程退出前,主动做一次“摘除等待”,比如在代码里加一个关闭钩子,标记当前实例不能再接收新任务,然后sleep一个窗口,让手里正在跑的请求有时间返回:

Runtime.getRuntime().addShutdownHook(new Thread(() -> { // 标记实例不再接收新请求 registry.deregister(); // 等待存量请求处理完 Thread.sleep(5000); }));

这一步做完之后,我这边批次切换时段的502几乎是肉眼可见地消失了。注意别把这个等待时间设得太短,建议至少覆盖P99 RT的两倍,比如P99是1500毫秒,等待时间就设3秒以上。

4.4 批次节奏调整:从容量水位反推单批次数量

最后是动发布参数。动之前先用公式算一下安全边界:假设实例总数N,当前集群CPU峰值水位w,希望发布期间水位不超过目标值T,那么单批次可替换数量m要满足:

m ≤ N × (1 - w / T)

这个公式的逻辑是:发布期间剩余实例扛着全部流量,水位会从w抬升到w乘N除以(N减m),要求这个值不超过T。

举个例子。20台实例,平时CPU峰值水位60%,目标水位不超过80%,那m最多等于20乘(1减60%除以80%),算下来是5台。如果平时水位已经到70%,同样目标水位下m最多只能到2.5台,也就是一次只能替换2台。

批次间隔时间也同样重要。上一批新实例还没完全热身,就开始滚下一批,等于把一个短暂的冷启动期叠加到另一个冷启动期上。我这边把批次间隔从默认的30秒拉长到3分钟,等上一批RT曲线走平了再动下一批。发布总时长变长了,但成功率稳了。

5. 参数计算与一组可直接抄的发布配置

5.1 从容量水位算批次大小:两个案例

公式有了,给两组实际计算的数字,方便直接代入理解。

第一组:20台实例,平时CPU峰值水位50%,目标水位控制在75%以内。m上限等于20乘(1减50%除以75%),约等于6.6台,取保守值,单批3到4台。

第二组:20台实例,平时水位已经到70%,目标水位控制在85%以内。m上限等于20乘(1减70%除以85%),约等于3.5台,取保守值,单批2台。

可以明显看到,发布前水位越高,能同时替换的实例就越少。很多人发布失败恰恰是因为在高峰时段用了非高峰时段的参数。

5.2 一套经过实测的推荐参数组合

结合上述计算,我给出自己目前在生产环境使用的一组参数,仅供参考,不要照抄:

配置项推荐值说明
单批次实例数20台集群取2~3台必须按公式反推,不拍脑袋
批次间隔3分钟以上一批实例RT趋稳为准
就绪探针initialDelay15秒给JVM基本启动时间
就绪探针period5秒不要太频繁,避免误判
预热窗口90秒权重从10%逐步到100%
优雅停机超时30秒至少覆盖P99 RT的两倍
全量完成预估时长约15~20分钟比原来的5分钟慢很多,但安全

这组参数的关键价值不是“数字好看”,而是把每个环节都有明确的判断标准:实例什么时候算就绪,权重多快加到满,旧实例什么时候真正退出。每一步都有依据,发布成功率自然有保证。

5.3 发布期间需要盯的四个指标

参数调整完之后,发布期间不要只盯一个成功率。我当时给自己定了四个必盯指标:应用成功率、P99 RT、实例CPU水位、数据库连接数。

成功率下降已经是“结果”,真正需要盯的是导致结果的“前因”。P99 RT是预警,它通常会比成功率先恶化;CPU水位直接反映容量边界;数据库连接数则监控下游依赖有没有被打穿。四个指标放在一张dashboard上,和发布批次时间轴对齐,任何异常都能很快定位到是哪一段动作引起的。

每批发布完成后,我还加了一步对比:本批次完成前两分钟,和完成后两分钟,P99 RT变化幅度。如果上涨超过20%,下一批立即暂停,等人去查原因。

6. 把成功率从“人肉盯”变成发布门禁

6.1 发布平台挂钩:每批次自动判断是否继续

参数和代码都改了之后,最后一步是把经验固化到发布平台里,别靠人肉盯监控。我在发布平台加了一层简单的发布门禁:每一批部署完成后,平台自动拉取最近一分钟的监控数据,和发布前的基线做对比,满足条件才继续下一批。

门禁规则大致是这样:

允许进入下一批需同时满足: - 当前错误率 - 发布前基线错误率 < 0.5个百分点,持续30秒 - 当前P99 RT / 发布前基线P99 RT < 1.2,持续1分钟 - 健康实例数 >= 预期实例数 - 数据库连接数 < 设置上限

任一条件不满足,平台暂停发布并告警。这等于把“感觉要出事”变成了“系统认为不能继续”,新人来发布也不会踩同样的坑。从那次之后,发布窗口的成功率基本维持在99.9%以上。

6.2 第一波失败时,快速判断是部署问题还是代码问题

哪怕有了门禁,你依然会遇到“第一波失败”的情况,这时候最考验判断力。我的经验是:不要急着回滚,先看失败特征。

如果失败集中在连接超时、读超时、502、503这些状态码,而且只出现在发布窗口,大概率是部署机制问题——冷启动、存量连接、容量水位、连接风暴,四选一,按前面几章的方法排查即可。

如果失败是业务异常码居多,比如特定接口返回非5xx的错误,那就真可能是新代码的bug。这个时候再考虑回滚代码。

还要记住一条:如果错误率超过2%,并且持续5分钟没有回落,不管原因是什么,先回滚再说。发布安全永远优先于定位精度。回滚之后,再拿那批新实例做单独压测,慢慢查原因也不迟。

另外建议别在一次改动里同时上太多优化。我当时的顺序是:先只加就绪探针和优雅下线,观察两个发布窗口,确认502消失、新实例不再裸奔;然后再加预热和门禁,观察RT曲线和自动暂停是否正常。每步都只改一个变量,出了问题才知道是哪个改动引起的。

这套方案落地之后,最大的感受是发布终于变得“可预期”了。滚动部署本质上是把“替换”做得很简单,但把“流量交接”想得太简单。新实例接入、能力预热、旧实例退场、每批验证,这四个环节只要有一个模糊,成功率就会给你颜色看。把这些环节一个一个定义清楚,配上参数和门禁,滚动部署才能真正做到让人敢在上班时间安心点那个发布按钮。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询