ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

重启后服务抖动排查思路:冷启动、注册中心与基础设施全解析

重启后服务抖动排查思路:冷启动、注册中心与基础设施全解析 服务重启后出现抖动这事儿我在生产环境里前前后后遇过不下十次。最典型的一幕是升级脚本跑完新进程起来了日志也没报错但接下来三五分钟监控图上全是锯齿——P99从平时的20ms飙到800ms错误率忽上忽下等你想抓现场的时候它又自己恢复了。这种查无实据但有明显体感的问题最烦人因为它不是单一原因造成的。如果你搜服务重启 抖动是因为Visual Studio Installer弹窗提示Windows服务不可用、要求重启系统那是Windows组件服务的问题不在本文射程内。但如果你说的是自己维护的后端服务每次重启后RT乱跳、超时频发、连接池被打满那就值得把链路从头到尾捋一遍。我把这几年排查重启抖动的完整思路整理出来从服务内部的冷启动代价到上游流量、注册中心、基础设施的连锁反应再到可落地的缓解方案一次说清楚。无论是自研Java服务、Spring Boot应用还是Kubernetes里跑的Pod这套方法都适用。1. 重启后那几分钟到底抖在哪里三种曲线的分工识别先别急着甩锅给JIT先搞清楚你面对的抖动属于哪一种。不同形态的抖动指向的根因完全不同。我把监控曲线分成三类对应三种典型场景。1.1 三种典型的抖动曲线长什么样第一类是锯齿型。响应时间像梳子齿一样每隔几十秒出现一根尖刺尖刺瞬间飙到几秒随后迅速回落重启后三四分钟内持续出现。这种形态十有八九和垃圾回收有关——堆内存从头开始分配对象进入老年代的速度比平时快导致频繁触发Full GC每次Full GC都是一次全停顿。配合GC曲线看你会发现锯齿的波峰和GC暂停的时间点高度吻合。第二类是驼峰型。重启后RT缓慢爬升到某个高点后逐渐回落整体像一个缓坡。这种形态典型的来源是连接池和缓存重建每一次新建TCP连接、每一次TLS握手的代价都远高于连接复用第一波流量一边建连一边处理请求自然比平时慢。随着连接池逐渐填满、热点数据重新加载曲线才会回落。第三类是断崖型。错误率直线上升要么是超时、要么是连接拒绝过一会儿又骤降回正常水平。这种通常不是服务自身的问题而是流量进来的时刻早于服务真正ready的时刻——健康检查机制没挡住启动期的流量或者注册中心还没摘除旧节点请求打到了一个正在关闭或尚未完全拉起的实例上。1.2 用户层面感觉到的抖到底是什么把三种曲线翻译成用户体感就是打游戏看直播时画面突然卡顿、弹幕里一堆人喊抖的那种体验。用户端看到的是偶发的超时、失败的请求、加载中的转圈服务端看到的是响应时间毛刺、活跃线程数满、队列堆积。这里有个特别容易误判的地方如果只看平均RTAvg可能完全看不出问题——大量正常请求把平均数值拉平了只有P99、P999才会有显著抬升。所以排查抖动场景我习惯先看P99曲线和P999曲线再看Avg顺序不能反。还有一个概念要先理清很多人在搜索引擎里看到的时钟抖动频偏和漂移是物理层术语描述的是晶振输出的时钟信号短期不稳定。服务端重启抖动和它不完全是一回事但存在交集——如果服务器时间本身被NTP强制校准时跳变定时任务和超时计算会跟着错乱表现就是假抖动。这一点我在第4节会专门展开。2. 服务自身冷启动的三层代价JIT、连接池和缓存穿透服务重启后进程内部的几乎所有热状态都被清空了。这不是一个开关而是三个叠加的冷启动过程代码还没编译成机器码、连接一个都没建、缓存全都miss。2.1 JIT编译和类加载从解释执行到机器码Java服务首次启动时代码不是立刻全部编译成机器码的而是先走解释执行等热点方法被反复调用到一定阈值才触发即时编译。C1编译的阈值通常是1000次调用C2编译的阈值是10000次左右不同JDK版本有差异。也就是说重启后前几千次请求很多核心方法还在解释模式甚至C1模式跑着性能可能差一个数量级。这个过程中还有两个隐藏变量。一个是方法如果编译失败或CodeCache满了JVM会选择反编译回解释模式这叫逆优化或CodeCache刷新现场通常伴随着Compilation resumed日志。另一个是类加载本身就是懒加载——很多类在第一次真正用到的时候才被ClassLoader加载类元数据、常量池的解析都需要CPU和内存这些开销也堆在启动后的第一批请求里。所以你会发现JIT导致的抖动往往不是均匀变慢而是某几条路径特别慢——调用较少的冷门方法在解释模式下响应反而比热门方法更不可控因为热门方法很快就被编译优化了冷门的却一直拖着。2.2 连接池与线程池所有连接的建立都要付一次全价如果说JIT影响的是CPU层面的执行速度连接池影响的就是IO路径上的大头。举一个实际对比一条已建立的数据库连接执行一次查询局域网内大概2ms新建一条连接要经历TCP三次握手、MySQL登录认证、权限校验通常需要80ms到200ms。跨机房场景更夸张TLS握手一旦加上往返次数翻倍建连成本轻松超过300ms。重启那一刻连接池里的连接数几乎为0。HikariCP如果没配置initialSize或minimumIdle会随着请求逐步创建连接也就是说前几十个请求都背着建连查询的双重成本。同样的情况也发生在HTTP调用方Apache HttpClient或OkHttp的连接池一样是空的每个上游接口都要重新握手。线程池这里有个容易被忽略的细节很多Web容器比如Tomcat的线程数是按需增长的。虽然配置了maxThreads200但服务刚启动时线程池里只有minSpareThreads10默认值突然涌入大量请求时线程在短时间内从10个猛增到200个。线程创建本身不贵但每一条新线程都要做栈分配、线程本地初始化配合JIT冷启动前几十毫秒的请求会同时踩中建线程建连接解释执行三个坑。2.3 缓存击穿重启后第一波流量直接打到数据库缓存层的抖动往往比连接池更隐蔽。本地缓存Caffeine、Guava Cache随进程一起清空第一波请求大部分cache miss全部穿透到下游分布式缓存Redis虽然数据还在但很多团队会给缓存设置较短的TTL来保证数据新鲜度服务重启这段时间恰好是过期高发期。最危险的是缓存击穿惊群组合同一个热点key失效几百个并发请求同时发现是miss同时去数据库查数据库连接池瞬间被打满然后连锁反应开始——数据库慢了上层缓存写入被阻塞本地缓存也没数据整个链路的RT一起抬升。这里提醒一句如果你重启后观察到数据库QPS暴涨、活跃连接数飙升不要先怀疑SQL问题大概率是缓存层的穿透叠加了连接池重建。把缓存雪崩的账算到SQL头上我见过太多冤假错案了。3. 抖动不只是自己的事注册中心、负载均衡与重试风暴服务重启不是孤立的进程事件它会影响所有和它交互的上下游。很多时候重启的服务本身恢复得很快反而是周边系统的连锁反应把抖动拖长了。3.1 注册中心看见重启的延迟窗口无论是Consul、Nacos、Zookeeper还是Eureka注册中心发现节点变化都有延迟。服务启动后实例状态从DOWN变成UP需要一段注册和健康检查周期——Consul默认每10秒做一次健康检查Nacos临时实例默认5秒一次心跳。这意味着窗口期内有两个问题同时存在旧节点已经下线但调用方还在往它的IP发流量结果就是连接被拒、超时新节点已经起好但调用方还没拿到新的实例列表以为服务还没恢复流量被分摊到剩余节点上。这个窗口恰恰是最容易出断崖型抖动的时间段。更麻烦的是如果注册中心配置了自我保护模式比如Eureka的自我保护默认开启在频繁上下线场景下它可能拒绝摘除失效节点让过时的路由持续更久。3.2 副本缺位时的容量放大效应假设服务有3个副本正常每台承担1/3流量。重启其中1台时集群实际容量短时间降到2/3。如果负载均衡还没来得及把它摘除或者摘除后又立即把它加回来剩余节点的流量会在短时间内放大1.5倍。这不是简单的数学问题。当剩余节点的CPU使用率从40%升到60%响应时间通常不会线性增长而是呈指数抬升——因为线程池排队开始出现等待中的线程积压TCP连接上的并发度升高GC压力加大。这就是为什么你看到的RT涨幅远大于流量涨幅。负载均衡自身的逻辑也会放大这个问题。拿Nginx的upstream举例如果配了weight且没有开启slow_start默认Nginx Plus支持开源版需要额外配置新节点恢复后立刻接收满权重流量等于把一个冷启动的实例和两个热实例拉到同等水平竞争天然会拖慢整体P99。下载场景里的平滑抖动和这个本质一样——平滑策略不到位阶梯式的流量变化就会变成毛刺。3.3 重试风暴会把抖动指数级放大重试是隐藏的放大器。某个依赖服务变慢之后调用方如果配置了重试很多RPC框架默认有重试机制一次慢调用会变成多次调用。假设每分钟有100个请求遇到下游超时每个请求重试2次下游就会额外收到200个请求——分不清是正常流量还是重试流量。重试通常还带退避策略。Spring Retry默认的退避是几百毫秒起步但极端情况下多个调用方的重试窗口会叠加在一起形成重试风暴。我曾经排过一个案例一个是重启后的服务另一个是调用它的网关两边都有超时重试最终数据库连接池被打爆整个集群雪崩。还有一个更隐蔽的放大器是队列堆积。Tomcat的队列满了以后新请求直接被拒客户端立即超时并触发重试重试又填满队列形成死循环。这种情况下错误率曲线会很规律像一个个等宽的方波——这时候大概率是上游重试策略本地线程池容量共同作用的结果单靠调大线程数解决不了。4. 基础设施里埋着的三个隐藏抖源停机窗口、时钟漂移与日志IO排查抖动时大家都盯着业务代码和JVM最容易漏掉基础设施层面的三个坑。这三个坑的共同特点是它们不在应用日志里报错但会在监控曲线上制造非常真实的抖动。4.1 优雅停机没配好重启本身就是一次抖动JVM收到SIGTERM信号后如果应用没注册关闭钩子进程会直接退出线程池里正在处理的请求全部被掐断。表现就是重启期间本来应该平滑排空的在途流量变成了大量超时和连接重置。即便配了优雅停机Spring Boot 2.3支持graceful shutdown也还有一个坑等待时间上限。如果配了server.shutdowngraceful和spring.lifecycle.timeout-per-shutdown-phase30s但有一个慢SQL卡住线程超过30秒剩下的请求照样被强杀。所以优雅停机不是简单打开开关就行要有排空确认机制——确认在途请求归零后再执行后续操作。这里还有个容易被忽略的窗口关闭钩子执行完了进程还没彻底退出的那几百毫秒端口实际已经释放但注册中心里的实例状态还是UP。要么靠负载均衡器主动探活要么就得自己在下线流程里先调注册中心的接口摘除节点再发SIGTERM。顺序反了必有抖动。4.2 时钟频偏与漂移服务器时间校正引发的连锁反应服务器的时间并不总是精准的。操作系统里跑的NTP服务如chronyd会周期性校准时间默认情况下如果偏差超过某个阈值比如1000ms它会执行一次step——直接把时间往前或往后跳一大格而不是缓慢微调。这一跳会引发一系列让人摸不着头脑的问题定时任务瞬间补跑Quartz或xxl-job里调度周期按时间点计算时间回调后积压的调度任务被批量触发数据库瞬间多出几十倍的查询量超时计算错乱分布式调用里用时间戳计算超时或者用System.currentTimeMillis()设置缓存过期时间时间跳变后某些缓存瞬间全部失效某些请求则错误判定为超时分布式ID时钟回拨snowflake方案对时间回拨极敏感一旦检测到时钟倒退会直接拒绝生成ID服务就会大量报错抛异常。这类问题的迷惑性在于监控看板显示服务重启后才出现抖动但实际根因是NTP校时恰好发生在重启时段。排查这类问题要看NTP服务的日志journalctl -u chronyd或/var/log/messages对比时间跳变的时刻和监控曲线的异常起点是否重合。4.3 日志与磁盘IO在启动瞬间的竞争服务启动时恰恰是日志输出量最大的时候启动横幅、配置读取记录、Beans初始化日志、依赖健康检查日志全部集中在几秒内。如果应用配置的是同步磁盘写日志比较常见的用logback的FileAppender启动瞬间的磁盘IO会冲得很高。磁盘IO高了以后不只是日志变慢还拖累GC日志落盘、Redis的AOF重写、数据库的binlog刷盘。你会发现业务请求还没进来磁盘利用率已经先飙了一轮然后所有涉及磁盘的操作全部跟着变慢。这属于典型的启动期的自伤性抖动加内存堆参数也没用核心解法是调整日志级别和异步落盘。5. 从看得见的抖动到能证明的根因一套可落地的排查路径聊完原因说排查路径。抖动类问题的难点不在于找不到原因而在于原因太多、每条链路都像。我的做法是分层递进不靠猜。5.1 先把三张图叠起来看我通常不单独看RT曲线。排查开始先打开三张监控图叠放在同一时间轴上响应时间P99/P999曲线JVM GC暂停时间和次数曲线线程池活跃线程数和队列深度曲线。这三张图叠一起看能快速分流。GC暂停时间点和RT尖峰重合优先怀疑堆内存、GC参数问题活跃线程数先冲高然后回落RT跟着回落优先怀疑线程池预热问题线程队列深度在高位徘徊RT持续恶化优先怀疑下游依赖变慢或数据库连接池竞争。这一步的产出是方向判断不是结论。不要试图跳过它直接去抓线程dump——没有方向抓出来的dump大概率看不懂。5.2 抓dump和拉日志的实际操作如果方向不够明确就要在现场取证。我的标准套路是在预知重启时间的情况下把线程dump采集脚本提前挂上在重启后的第1分钟、第3分钟、第5分钟各抓一次。Java可以用jstack pid一次dump大概几十毫秒对业务影响可以忽略。采集到dump之后重点看这四类线程BLOCKED状态的线程等待锁还是等待连接池租借WAITING且parked在connectionPool上的线程数据库连接耗尽的信号RUNNABLE但长期停留同一个native方法的线程很可能在做socket读写对应慢IO定时任务线程quartz等看是否堆积了大量待执行的Trigger。只在JVM栈里找证据还不够还要同步看网络层的证据。启动阶段可以用ss -s看socket状态如果大量连接停留在SYN_SENT说明建连超时大量出现在TIME_WAIT说明连接池没有复用每次请求都在新建连接。这些信息比只看应用日志直观得多。5.3 代码取证从慢日志到链路追踪监控和dump给出的是哪里在等代码取证回答的是为什么等。这一步依赖两类数据慢日志和统一链路追踪。以MyBatis为例开启slowSql记录后重启后前几分钟的SQL耗时会有明显差异。有一条平时2ms的SQL突然变成300ms如果同时段连接池里的事件是connection borrow wait那结论就清晰了——不是SQL本身变了是连接获取阶段的等待被记进了SQL执行时间。这类数据配合连接池监控HikariCP的metrics里能看到getConnection耗时能很快画出完整的等待链。链路追踪SkyWalking、Zipkin、Jaeger在这个阶段提供的价值更大。一次调用经过A→B→C如果C节点重启你会在链路数据里看到C应用的耗时飙升B应用出现大量connect refused或connection timeout错误同时A应用会因为下游错误触发重试重复调用B/C。链路图直接展示抖动的传播路径不用靠推断。5.4 容易踩的两个排查坑一个是只看平均RT忽略P99。很多监控系统默认展示avg而avg会把重启后的大量正常请求算进来把异常拉平。务必手动把P99、P999加上对比异常才能现形。另一个坑是健康检查通过≠服务ready。Spring Boot Actuator的health端点默认只检查应用进程是否存活、数据库连接是否能通它不会告诉你JIT有没有热、连接池有没有满。如果就绪探针配置的是/actuator/health那在探针通过的一瞬间流量就会全部放进来——恰好是性能还没恢复的时点。就绪探针应该指向一个能代表服务真正准备好了的检查逻辑比如缓存预热完成标记、关键依赖连通性检查而不是默认健康端点。我见过太多团队踩这个坑把恢复时间拉长了一倍。6. 让重启后的服务软着陆预热、优雅整容与缓冲策略原因清楚了就到了解决环节。我的原则是能提前热的热好能延迟放的延迟放能缓冲的缓冲。下面这些手段按实施难度从低到高排列都可以直接落地。6.1 让流量晚点进来就绪探针与健康检查的调优最简单的止损方式是调整流量放行节奏。Kubernetes场景下就绪探针readinessProbe的initialDelaySeconds不要设成0建议至少等于最慢依赖的初始化时间预热时间的一半。比如一个服务连接Redis需要3秒、初始化本地缓存需要5秒整体设为15秒到20秒比较稳妥。Kubernetes本身有Deployment的maxSurge和maxUnavailable参数滚动发布时可以控制新老副本的比例让新副本起来之后先接受一小部分流量。云厂商的负载均衡器很多也支持预热时间slow start window比如阿里云SLB、AWS ALB的target group slow start在这段时间内给新实例分配少量权重再逐步放大。这个机制解决的就是冷启动抖动值得优先开。如果用了Spring CloudNacos或Consul侧还可以配置服务的preserved.register.source或流量权重把新注册实例的权重暂时调低等运行几分钟后再调到正常值。一句话总结让流量晚点进来、小步进来、逐步放宽而不是一恢复就满负荷。6.2 应用级预热把热点提前焐热流量控制解决的是外部节奏应用内部的预热解决的是自身状态。两种做法我都用过各有适用场景。第一种是启动脚本里触发预热请求。服务监听端口之后、注册到注册中心之前自己调用几个核心接口人为把热点代码路径跑一遍。这样JIT会提前把热点方法编译掉本地缓存也趁机填充。具体实现不复杂一个继承ApplicationRunner的预热任务在run()里用应用的HTTP客户端循环调用内部接口每个接口预热几百次把C1/C2编译阈值打穿即可。有个小技巧预热请求最好在应用内部完成不要真的走网关避免把预热流量计入外部监控产生新的噪音。第二种是流量镜像预热。用压测工具JMeter、Locust或生产流量录制回放在服务真正上线前向内网地址打一批真实场景的请求把链路全部走一遍。这种方式预热效果最彻底适合重启频率不高的核心服务。预热时的细节也别忽略连接池的initialSize和minimumIdle显式配置一个有值的数值不依赖默认的懒创建Caffeine缓存的refreshAfterWrite配合asynchronousReload重启后由后台线程加载数据而不是等用户请求来触发加载。6.3 优雅下线与上线先摘流量再动线程池上下线的顺序问题是重启抖动的另一个大来源。我整理了一个万能顺序适用于大多数自研框架和容器平台先调注册中心接口把实例标记为DOWN或从服务列表摘除让新流量不再路由过来等一小段窗口期比如健康检查周期的2倍确认调度请求已经排空发送SIGTERM信号触发JVM的停机钩子停机钩子里先停接收新请求关闭线程池的入口再等待在途请求处理完最后释放连接池和缓存进程退出。上线顺序反过来但同样有一个关键点服务完成自身初始化本地缓存、连接池、线程池预热之后再注册到注册中心。上来就注册然后等春风吹进来和先焐热再开门对P99的影响天差地别。6.4 用缓冲思想对冲瞬时尖峰抖动的本质是系统还没准备好就遇到了一波流量而缓冲是解决瞬时尖峰的通用思路。播放器里的抖动缓冲区jitter buffer就是这个原理——在网络抖动时先把数据包暂存一下等稳定了再播放换取流畅度。服务端同样可以借鉴数据库连接池可以配成预热型的缓冲启动时提前创建maximumPoolSize的一半连接减少启动阶段的建连压力缓存穿透可以用单飞模式解决同一时刻对同一个key的并发请求只放一个去查数据库其余的全部等待这个结果。Caffeine的buildAsync配合singleflight思想就能实现避免缓存miss后的流量同时压向数据库限流和排队也可以当作一种缓冲给启动阶段设置一个更宽松的限流阈值让流量匀速进入而不是堵在门口硬挤。实现上可以在负载均衡器侧配一个启动期的WAF限流或者在应用内给核心接口单独设置RateLimiter。6.5 经验级检查表重启前、重启中、重启后看什么最后给一张我实际用的检查表可以贴在发布流程文档里阶段检查点建议动作重启前依赖的注册中心、数据库、Redis是否健康确认下游容量有余量避免叠加重启前是否开启优雅停机server.shutdowngraceful并确认超时时间足够重启前预热任务代码是否存在检查预热脚本覆盖率至少覆盖核心热点接口重启中就绪探针是否配置了initialDelay确保流量不会在冷启动期涌入重启中JVM GC日志是否开启-XX:PrintGCDetails方便事后取证重启后P99是否快速回落并稳定设定预期时间窗口比如3分钟不回落则告警重启后线程池活跃线程是否恢复正常水位观察是否长时间打满重启后数据库连接池空闲连接是否恢复到基线确认没有连接泄漏这套检查表看着简单但每次重启前花两分钟过一遍能避免大部分事后诸葛亮式的排查。重启抖动不是一个问题是三件事的叠加我在实际排查中最大的体会是重启抖动极少由单一原因造成它更像服务自身没热 上下游没适应 基础设施没跟上这三件事的叠加效应。只修其中一环效果都不明显三管齐下才能真正做到重启后P99曲线依然平直的观感。最后分享一个我常用的收尾检查每次发布完成后的第5分钟专门看一眼GC暂停时间、活跃线程数、数据库连接池空闲连接这三个指标如果都恢复到重启前的水位这次发布就算闭环了。如果还没恢复别急着下班大概率还有一条链路漏了预热——去翻一下NTP日志说不定有惊喜。
返回列表