ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

微服务性能调优实战:从线程池饿死到P99延迟飙高的排查与修复

微服务性能调优实战:从线程池饿死到P99延迟飙高的排查与修复 凌晨两点半被值班电话叫醒的时候我其实已经有预感订单服务的P99延迟曲线在20分钟前陡增从日常的80ms一路拉到接近1200ms。对任何一个跑过微服务架构的人来说这都不是“加点机器”就能解释的现象——它更像是一次典型的性能调优战役正式打响。那会儿距离我们刚交付的构建版本20260122172653上线还不到48小时线上流量又正好卡在月末活动高峰。运营群里开始有用户反馈“下单转圈超过三秒”客服那边的零散工单也在增加。接下来的十几个小时里我们完成了从告警响应、链路定位、组件整改到压测回归的完整闭环。这篇就是当时那次调优的复盘不绕理论只记录我们踩过的坑和每次改动前后最真实的数据。如果你正在做微服务性能调优或者线上服务突然变慢却不知道从哪查起这篇应该能给你一条可以直接照抄的排查路径。1. 这次调优的起因不是CPU打满而是线程池饿死1.1 告警现场指标看起来都很正常说实话第一眼看到监控面板时我是懵的。CPU使用率只有35%内存还有大量余量磁盘IO也平稳网络入流量没有突变。按大多数人的第一反应这种状态不该“卡”但用户的体感就是慢。关键指标暴露在另外几个地方Tomcat活跃线程数长时间贴着max值数据库连接池的连接数在往上爬订单服务的P99从80ms一路升到1100ms错误率也出现了一个0.3%左右的尖峰。这时候才意识到问题不是哪台机器被打满而是“某个资源在等待链路上被堵死了”。这类问题在单体应用里其实不容易出现。单体架构下请求进来之后线程拿到数据库连接、执行SQL、返回结果链路短路径简单就算某个环节慢影响面也很容易控制。但到了微服务架构一次请求要跨服务、跨网络、跨数据库任何一层出现资源等待都会沿着调用链一路向上传导。更麻烦的是如果上游配置了超时重试超时的请求还会成倍地再次涌入把故障进一步放大。1.2 为什么微服务链路会把慢放大成“假死”先解释一个特别容易误导人的现象为什么CPU不高服务却响应极慢我当时跟同事做了一个类比——就像收费站。假设一个收费站有50个车道对应线程池但是ETC通道对应数据库连接池只有10个窗口。高峰期同时来了40辆车前面10辆进ETC后都堵在了收费窗口上后面的30辆即使排进了车道也只能停在原地等待。CPU对应的是“收费员的工作量”收费员其实很闲但车子就是动不了。对应到系统里就是Tomcat线程都在等待数据库连接释放活跃线程数堆满新请求进不来但CPU几乎没有繁忙计算。从调用链数据看那会儿订单服务访问用户服务的耗时只有18ms左右真正的时间黑洞是在用户服务访问MySQL的阶段SQL本身的执行时间平均不到5ms但连接池获取连接的等待时间平均高达780ms。这说明SQL写得并不差问题是连接池容量不够同时连接占用时间被一些慢查询拉长把整个池子占满了。这件事也奠定了后面整个调优的核心方法论不能靠“加机器”来应对必须先搞清楚请求时间都花在了哪一个具体环节上再分而治之。1.3 为什么没有第一时间去扩容这里多说一句为什么当时没有选择直接扩容机器其实我们评估过。从指标看CPU、内存都不高单纯加机器解决的是“计算资源不够”的问题而当前瓶颈在数据库连接池的等待上加服务实例只会让更多实例去抢同一批数据库连接反而可能加剧竞争。所以扩容只能在极少数场景下兜底比如确实存在单机CPU打满、内存不足这类物理资源瓶颈。资源等待型瓶颈扩容往往治标不治本必须先定位等待发生在哪一层。2. 定位瓶颈把“感觉慢”拆成可验证的数据链路2.1 先确保看得到从三个维度补齐观测调优的前提是可见性。如果线上监控只有CPU和内存遇到这种问题基本要靠猜。我们在这次事件里用到了三层数据缺一不可基础设施层CPU、内存、磁盘IO、网络带宽用来排除硬件和机器容量问题。这一层很快就能看完确认不是资源打满。中间件层连接池活跃数、等待数、线程池活跃数、GC暂停、Redis命中率、MySQL慢查询数。这一层是定位“资源等待”的关键连接池pending和线程池active就是在这里拿到的。业务链路层也就是分布式链路追踪系统。一秒钟几万个请求进来具体哪个环节拖慢的必须靠traceId把一次请求的经过完整串联起来。这里有个很实在的经验很多团队接了监控但指标之间是割裂的。比如只看服务CPU和内存挂了也看不出来只有把基础设施指标、中间件指标和链路追踪打通到同一块看板上才能在做性能调优时快速缩小范围。这次我们是用了Prometheus加Grafana做指标监控链路追踪用的是SkyWalking再把关键指标和trace入口放在同一个面板上省去了来回切换的麻烦。2.2 用一条traceId把问题钉死在“DB连接等待”上当时我们做了这样一件事从网关的访问日志里随机挑了一个P99附近的慢请求拿到它的traceId。在链路追踪系统里展开这个请求总共耗时1100ms分布很清晰网关耗时12ms主要是转发和鉴权正常。订单服务处理28ms路由、参数校验、业务编排正常。用户服务分片983ms进一步展开发现其中连接MySQL的“获取连接”阶段占了780ms真正执行SQL不到5ms。看到这个分布基本就可以确定链路追踪里最长的环不是业务代码也不是SQL执行质量而是连接池资源竞争。再结合连接池监控里pending等待数维持在20以上几乎可以实锤。这里有一个容易被忽视的细节链路追踪不能只记录“服务调用了哪个服务”还要把每次数据库操作的“获取连接/执行SQL/归还连接”作为独立span记下来。很多团队接入SkyWalking时只看了RPC调用层结果数据库的等待时间被算进服务自身耗时里问题定位就要多绕一大圈。我们在接入时特意自定义了JDBC拦截器把connection acquire和statement execute分开埋点这才让问题浮现得这么清晰。2.3 压测复现动手之前先锁定可复现路径定位到假设之后没有直接上生产改参数而是先在预发环境压测复现。原因是线上流量是多因素叠加的直接改动后如果指标好转很难确定到底是哪一步起的作用。压测可以把问题缩小成“可控变量下的复现实验”。压测脚本用JMeter写了下单场景400并发持续5分钟。预发环境很快就复现出了线上症状连接池pending从0涨到15~25P99冲到1150ms和线上几乎一致。有了这个可复现路径后面每一个改动都能用同一套脚本验证改完一版压一版数据变化一目了然。3. 分而治之几类典型瓶颈的完整处理记录3.1 服务编排把串行调用改成并行要小心上游依赖排查过程中我们还发现订单预览接口存在明显的串行编排问题。一次请求里先调用户服务拿地址再调库存服务查库存再调营销服务算优惠价三个服务各自耗时30~50ms串行加起来接近120ms。在高峰期这120ms会让连接池的占用时间变得更长属于典型的“无依赖却排队”。改造逻辑很简单三个调用之间没有数据依赖时并行执行。Java这边用CompletableFuture也可以选择WebFlux这类响应式方案但考虑到团队上手成本还是选了CompletableFuture并指定独立的线程池避免把Tomcat工作线程池的任务编排吃光。CompletableFutureUserAddress addrFuture CompletableFuture.supplyAsync(() - userClient.getAddress(uid), bizThreadPool); CompletableFutureStockInfo stockFuture CompletableFuture.supplyAsync(() - stockClient.getStock(skuId), bizThreadPool); CompletableFuturePromoInfo promoFuture CompletableFuture.supplyAsync(() - promoClient.calcPrice(orderId), bizThreadPool); CompletableFuture.allOf(addrFuture, stockFuture, promoFuture).join();改造后这个接口P95从380ms降到了170ms。注意两点一是如果接口之间存在数据依赖A的结果是B的入参就不能盲目并行二是并行后一定要给每个远程调用设超时我们当时把Feign的connectTimeout设成500msreadTimeout设成1000ms防止单个慢服务把整个CompletableFuture卡死。并行改造看起来简单但超时、异常聚合、线程池隔离这三个细节缺一不可否则一个慢依赖就能把整个并行池拖垮。3.2 用户服务连接池先用最稳妥的参数改动完成“第一波止血”回到最核心的连接池问题。用户服务的HikariCP当时配置是maximumPoolSize10minimumIdle5。根据Littles Law的经验估算连接池容量至少应该覆盖“单位时间并发 × 单请求连接占用时间”高峰下单QPS大约800。每个请求事务平均占用连接约30ms不是SQL执行时间而是整个事务从开启到提交的时间。理论并发连接数 800 × 0.03 24。再预留50%冗余取40左右。我们把maximumPoolSize从10调到40minimumIdle调到20连接池等待时间立即从780ms降到15ms。这一步看似直接其实有几个容易忽略的关联项数据库侧max_connections当时是200单服务就得留足余量所以顺手把数据库max_connections调整到了500同时确认没有其他服务把连接数打到上限。调整后P99从1100ms降到了280ms第一波止血成功。为什么说这是“止血”因为连接池容量只是兜底。如果慢查询继续长时间占着连接调大池子迟早会撞上数据库连接上限。所以我们把SQL层面的问题当成下一步的必须动作而不是调完连接池就收工。3.3 MySQL侧调优深分页、隐式类型转换和一堆“以为有用”的索引慢查询日志里捞出了几条典型的这里说两个影响最大的。第一个是深分页。下面这条SQL在订单列表翻页时高频出现SELECT * FROM order_item WHERE order_id 123456 ORDER BY create_time DESC LIMIT 100000, 20;执行时间稳定在1.2s。MySQL在LIMIT偏移量很大时会先把前100000行数据都查出来再丢弃回表次数相当惊人。我们改成了“延迟关联”写法先查主键再用主键回表取完整行。SELECT t1.* FROM order_item t1 JOIN (SELECT id FROM order_item WHERE order_id 123456 ORDER BY create_time DESC LIMIT 100000, 20) t2 ON t1.id t2.id ORDER BY t1.create_time DESC;同样场景下执行时间降到52ms。如果产品允许更彻底的方式是改成游标分页用上次查询的最后一条create_time作为下一次的查询条件但前端改动较大我们暂且保留了延迟关联方案配合做了一层“只允许查看前N页”的交互限制。第二个是隐式类型转换。手机号字段在表里是varchar但代码里传参时写成了BigInteger导致MySQL无法使用索引只能全表扫。还有一条查询里有DATE_FORMAT(create_time, %Y-%m-%d) ?对索引列做了函数处理索引直接失效。这两种情况都很好修参数类型转成String函数处理改成范围查询create_time ? AND create_time ?单从执行计划上看扫描行数从几十万降到了几百。另外还清理了一批“以为有用、实际没人用”的二级索引。清理之前每插入一条数据都要同时维护七八棵索引树实测写入性能提升了15%左右。索引不是越多越好它要占存储还要拖慢写入调优时值得把冗余索引纳入清扫范围。这里我特别建议定期用sys.schema_unused_indexes或者慢日志分析来排查无效索引而不是靠DBA拍脑袋删除。3.4 缓存层三兄弟穿透、击穿、雪崩的一次系统性处理第三个瓶颈在缓存。商品详情和价格信息都走了Redis但大促预热期间热key集中过期数据库在凌晨出现过几次短时打满。我们把这一类问题拆成三个场景分别处理缓存穿透查询的key在DB中根本不存在大量请求绕过缓存直接打DB。处理方式是加布隆过滤器前置过滤同时把空值也缓存到RedisTTL设为60秒防止恶意或无效参数扫库。布隆过滤器用Guava的BloomFilter就够数据量不大时没必要上Redis模块。缓存击穿某个热点key过期瞬间大量并发请求同时回源DB。我们在热key场景选了“逻辑过期”方案缓存里同时存数据和逻辑过期时间请求发现逻辑过期后不阻塞等待而是先返回旧数据让拿到重建锁的线程异步刷新缓存。相比互斥锁逻辑过期对RT更友好——互斥锁会让请求排队等锁逻辑过期则完全不等。RedisData redisData redis.get(key); if (redisData.getExpireTime() System.currentTimeMillis()) { return redisData.getData(); } // 逻辑过期先尝试获取重建锁 if (redis.setnx(LOCK: key, 1, 3, TimeUnit.SECONDS)) { executor.execute(() - reloadCache(key)); } // 无论是否拿锁成功都先返回旧值 return redisData.getData();缓存雪崩大量key在同一时间点过期。做法是TTL设置成基础值加0~300秒的随机数把过期时间打散。同时把核心热数据在预热脚本里做一次错峰加载避免凌晨批量任务同时刷新缓存。加上这套缓存策略之后数据库峰值QPS从3800降到了500压力直接小了一个量级。这三个场景往往同时出现排查时建议按“穿透→击穿→雪崩”的顺序逐个排除不要混在一起改否则出了问题很难回推。4. 验证与回归压测数据不会骗人4.1 压测怎么设计才不是走过场三块改动都落地后我们回到预发环境重新压测。压测设计上我坚持了几个原则先单接口后混合场景。单独压一遍下单接口压一遍商品查询接口确认各自没有瓶颈再按线上流量比例混合跑下单30%、查询50%、支付20%避免单一接口优化好但混合场景互相争抢资源。持续时长不能短。400并发下至少跑10分钟只看前10秒的数据没有意义线程、内存、GC、连接池都需要时间才会暴露问题。关注分位数而不是平均值。平均值容易被少数慢请求拉高P95/P99更能反映用户真实体感。执行压测时还有个容易踩的坑压测机本身会成为瓶颈。我们当时用两台8C16G的JMeter节点做分布式压测并对压测结果做了校验确保每个节点的线程都能发满而不是攒在本地排队。4.2 优化前后的数据对比同配置、同脚本、同持续时长整理出对比数据指标优化前优化后下单接口P991100ms220ms平均RT420ms88ms连接池等待时间780ms12ms下单接口TPS6501800数据库慢查询10分钟38081800对650的TPS提升不是哪一项的功劳而是连接池、SQL、缓存、服务编排一起作用的结果慢查询少了连接占用时间短了池子不用一直等串行改并行后单请求占用的线程时间短了同样的线程数能扛更多并发。需要提醒的是压测时一定要保留同一套脚本和参数否则数据没有可比性。每次改动只动一个变量压测一轮记录一轮最后形成了上面这张表。4.3 压测意外收获另一个被掩盖的隐患这一轮压测还压出了一个此前被“大故障”掩盖的问题网关实例在高峰期出现了一次1.8秒的GC暂停P99抖动明显。虽然不在核心链路里但这种停顿在流量尖峰到来时很致命。随后我们把网关JVM堆从4GB调到6GB给G1设置-XX:MaxGCPauseMillis100重新压测后GC暂停控制在150ms以内。这件事让我更确信一个结论压测不是做完一次就结束的动作每次压测都可能暴露新的瓶颈性能调优本身就是“发现、修复、再发现”的循环。5. 调优持续落地把“救火”变成日常节奏5.1 给核心接口建性能基线这次调优之后最该做的是把临时成果固化成基线。我们给订单、商品、支付三类核心接口建立了一张基线表服务场景P99目标TPS目标资源规格order-service下单300ms15008C8G/3实例product-service商品查询200ms30008C8G/4实例pay-service支付回调500ms8008C8G/2实例每周在预发环境自动跑一遍压测超过基线5%就告警保证性能不会在一次优化之后慢慢回退回去。建立基线的过程中最重要的是先跑三四轮压测拿到方差而不是拿单次数据当目标。我们的经验是压测数据本身有波动用中位数或P90作为基线比用平均值更稳。5.2 三个值得长期养成的习惯第一发布前做性能验收。新功能、新接口合并到主干之前在CI/CD流水线里加一个冒烟压测阶段花十几分钟就能拦截大多数性能回归比上线后再发现要便宜得多。第二慢查询日志和链路追踪从第一天就打开不要等出事了再开。慢查询日志的阈值也别设太宽long_query_time设置为1s就好太大会漏掉很多有问题的SQL。第三做容量预估时连接池数量和实例数按高峰期QPS乘1.5冗余度去定而不是拍脑袋。可以先用当前监控里的峰值数据推算一次再给下次活动的增长留出余量。最后再分享一个在这轮调优里最重要的习惯每次只改一个变量。我们过程中有一度想把连接池、SQL、缓存、并行全改完再压测后来发现一旦压测数据变差完全分不清是哪个改动引起的。后来强制改成“一次只改一个单独压测通过之后再改下一个”再难的性能问题也能变成可追溯的逐步迭代。性能调优不像解数学题它更像给病人做诊断改一针观察一针确认有效之后再改下一针。这一轮下来我们学到最多的不是某条SQL怎么优化而是这套“可观察、可复现、可对比”的调优节奏本身。
返回列表