免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

微服务性能调优实战:链路梳理、线程池与缓存优化

微服务性能调优实战:链路梳理、线程池与缓存优化 1. 调优前的链路梳理从一次压测事故说起微服务架构下的性能调优十个有九个是从线上事故开始的。我遇到的最典型的一次是某个交易系统在大促前压测核心下单接口的TPS死活上不去CPU没跑满数据库也没见锁等待但接口RT从50ms一路飙到2000ms。当时团队几个人盯着监控面板谁也说不出问题出在哪儿。后来把链路完整拉出来问题才浮出水面——用户服务调用订单服务订单服务同步调用库存服务库存服务又去查MySQL然后MQ异步通知积分服务。整个链路里有三次HTTP调用、一次数据库查询、一次消息投递。任何一个环节抖动都会像多米诺骨牌一样传导到入口。这就是微服务性能调优和单体应用最大的区别单体应用瓶颈相对集中而微服务的瓶颈分散在服务间调用、线程池、连接池、网络带宽、序列化开销、中间件配置等各个层面。调优的第一步不是急着改参数而是把链路画出来把所有依赖关系、同步调用点、异步解耦点标注清楚。我习惯用SkyWalking或者Zipkin这类链路追踪工具先跑一轮把耗时分布拉出来看。一般重点看三组数据服务间调用耗时占比、数据库操作耗时、缓存命中率。多数情况下性能问题都能在这三组数据里找到线索。比如那次压测链路追踪显示用户服务到订单服务的调用耗时占了70%以上而订单服务内部逻辑只有30%初步怀疑就是HTTP调用环节出了问题。画链路图还有一个好处就是能顺手梳理出哪些调用是必须同步的哪些可以异步化。很多性能问题本质上是架构问题而不是参数问题。下单流程里扣减库存必须同步确认但发短信、加积分、写操作日志这些完全可以通过MQ异步处理。链路梳理完往往能发现几个可以异步化的点这一步的收益远大于后面调任何线程池参数。注意画链路图不要凭记忆画一定要对着实际的调用链数据整理。微服务跑久了服务间的调用关系早就和设计文档不一样了凭记忆容易漏掉隐性的深度调用链。2. 线程池与连接池最容易踩坑的核心配置链路梳理清楚后就能定位到具体的服务实例做参数调优了。微服务场景下线程池和连接池是最常见的两个坑位也是调整后收益最明显的两个位置。2.1 Tomcat线程池参数不要盲目调大maxThreads很多同学一遇到接口RT变长第一反应就是调大server.tomcat.max-threads从200调到500甚至调到1000。实测下来这样做往往适得其反。Tomcat线程池是IO密集型场景线程数多了CPU上下文切换开销会飙升。单个线程在阻塞等待下游服务返回时虽然不占CPU但一旦拿到响应需要继续处理就要立刻抢CPU时间片。线程数过多时大量时间片浪费在线程切换上吞吐反而下降。我一般用这样一个经验公式做初始值maxThreads CPU核数 * 4 500仅适用于标准HTTP长连接场景。这个公式并不绝对准确但它给了个起始参考。真正的确定方式还是要靠压测观察线程池活跃度曲线如果长时间维持在maxThreads * 0.8以上说明线程池真的不够用再考虑加大如果活跃度一直在50%以下调大也白搭。更隐蔽的问题是server.tomcat.accept-count这个参数控制请求队列长度。调大maxThreads而不调accept-count一旦线程池满了多余的请求会直接排队排队超过超时时间就报Connection timed out。我习惯把两个参数一起看参数名推荐初始值调整依据server.tomcat.max-threadsCPU核数 * 4 500压测线程活跃度超80%时调大server.tomcat.accept-count1000出现排队超时且maxThreads已较大时调大server.tomcat.max-connections与maxThreads保持一致或略大不要远大于maxThreads防连接堆积2.2 Dubbo/Feign线程池调用超时才是真正的硬约束服务间RPC调用的线程池比Tomcat线程池更容易被忽视。以Dubbo为例provider端的线程池如果被慢调用占满所有消费者都会被拖垮这就是服务雪崩的雏形。我调Dubbo线程池时有一个血泪教训线程池大小不是核心超时设置才是核心中的核心。记得有一次排查一个线上问题发现某个核心服务的Dubbo线程池满了但实际上是下游服务响应变慢每个线程都在等线程池自然就被占满了。这时候调大线程池只是把等待的线程变多CPU不会增加下游不会变快问题依然存在。正确的做法是给每次RPC调用设置合理的超时时间比如默认1000ms重试次数设为0或1。宁可让一次调用快速失败也不让线程池里的线程全部卡在慢调用上。快速失败后消费者可以通过熔断降级走兜底逻辑系统还能维持部分可用反过来硬等的话就是全链路雪崩。Feign的场景类似但有个额外要注意的点Feign默认的连接池是HttpClient或OkHttp连接池最大连接数要配合超时时间来设置。连接数设太小高并发下连接不够用会频繁创建连接TCP握手开销足以拖垮RT连接数设太大又可能打满下游服务的连接数上限。常规做法是把maxConnections设成下游服务Tomcat maxThreads的80%左右然后通过压测确认。2.3 数据库连接池HikariCP最小空闲连接数的门道数据库连接池这种基础组件不出问题的时候没人关心一出问题就是大事。HikariCP是目前Spring Boot的默认连接池性能确实好但参数不对照样翻车。很多人会把maximumPoolSize调得很大觉得连接数越多越好。实际上HikariCP官方文档反复强调maximumPoolSize过大数据库端还要维护大量空闲连接反而增加负担。我的经验是对于单实例应用连单库maximumPoolSize设在10~20就足够了C3P0时代动辄50、100的配置已经过时了。更值得关注的是minimumIdle这个参数控制最小空闲连接数。默认值和maximumPoolSize相同意味着连接池预热后始终保持全部连接可用。但问题是流量低谷期这些空闲连接会占用数据库内存。我习惯把minimumIdle设置成maximumPoolSize的一半既保证突发流量来了有一定缓冲又不会在低谷期浪费太多资源。还有一个容易被忽略的参数是connectionTimeout默认30秒。如果连接池满了新请求会在达到connectionTimeout后抛异常。正常情况下30秒足够但如果频繁出现连接获取超时不要急着调大connectionTimeout先查是不是有连接泄漏——很重要的排查方式是监控activeConnections曲线如果长时间居高不下大概率是代码里忘了归还连接。踩坑提示数据库连接池调整后一定要观察一个完整的流量周期不要只看高峰期。有些参数在低峰期看不出问题高峰期才暴露比如连接池在高并发下频繁创建新连接数据库端可能会触发max_connections限制。3. MySQL性能调优从慢SQL到索引失效的实战排查微服务架构里数据库往往是最后的瓶颈。缓存扛不住、消息队列消化不了流量最终还是会压到数据库上。这块我单独拿出来写是因为MySQL调优的坑比线程池深得多而且一旦上到生产环境排查成本极高。3.1 慢SQL排查先开慢查询日志再谈优化线上环境排查SQL性能问题第一步永远是看慢查询日志。很多公司的规范是long_query_time设置成1秒但这个标准在微服务高并发场景下太宽松了。接口要求RT 200ms以内SQL执行花800ms虽然没到1秒但已经是拖垮接口的大头。我推荐把long_query_time设置成300ms甚至100ms宁可多记录一些慢SQL也不要漏掉影响用户体验的查询。同时开启log_queries_not_using_indexes这个参数能抓出那些没用上索引的SQL价值非常高。慢SQL日志只是第一步真正定位问题还要靠EXPLAIN。拿到一条慢SQL后我按这个顺序排查type字段看有没有ALL全表扫描这是最需要警惕的key字段看实际用了哪个索引是不是和预期一致rows字段预估扫描行数和实际返回行数对比差距大说明索引选择性差Extra字段看到Using filesort或Using temporary大概率就是排序或分组导致的性能问题3.2 索引优化实战最容易被忽略的几个场景索引优化写过太多文章了这里我只讲微服务实践中真正高频踩坑的几个场景。第一个场景是隐式类型转换导致索引失效。用户表里user_phone字段是varchar类型结果代码里传参传成了Long类型。MySQL隐式把varchar转成数字比较索引直接失效全表扫描跑起来。定位这类问题EXPLAIN里能看到typeALL但SQL本身没什么明显的毛病。排查建议是在代码层就统一参数类型不要指望数据库层面做转换。第二个场景是联合索引最左前缀原则被违背。表里建了(order_id, item_status, created_time)联合索引结果查询条件只带了item_status和created_time跳过order_id索引就用不上。微服务里每个服务维护各自的表结构特别容易因为接口需求不同写出和索引定义不匹配的查询条件。建议联合索引的设计要和该表所有的查询场景对齐不能只考虑核心接口。第三个场景是深分页问题。LIMIT 100000, 20这种写法数据库需要扫描前100000条才能取到后面的20条越往后翻页越慢。微服务里常见的解决方案是改用游标分页也就是WHERE id 上次最大id LIMIT 20走索引定位扫描行数大幅下降。如果产品形态不允许改游标分页就用延迟关联——先在子查询里用索引拿到目标id再回表取完整数据。-- 深分页优化方案延迟关联 SELECT * FROM order_detail WHERE id IN ( SELECT id FROM order_detail WHERE order_id 12345 ORDER BY created_time DESC LIMIT 100000, 20 );3.3 事务与锁高并发下的连接池耗尽隐患数据库调优不止是索引的事事务和锁的合理使用同样关键。微服务场景下一次接口请求可能涉及多个数据库操作如果代码里用一个大事务包住所有操作事务持有连接的时间就会很长。我踩过一个印象很深的坑一个订单状态更新接口调用了远程库存服务后还在事务里继续执行了几次其他表的更新。结果下游库存服务慢了一下整个事务迟迟不提交数据库连接池被占满其他所有需要数据库连接的操作全部阻塞。排查下来解决方案很直接——把远程调用从事务里拆出去事务只保留本地数据库操作远程调用的结果通过最终一致性来保证。锁的问题主要看两类一是行锁升级到表锁通常是更新条件没走索引比如UPDATE xxx SET status1 WHERE biz_codexxxbiz_code没索引每次更新扫描全表锁范围就扩大到了表二是死锁导致的事务回滚多表更新时各服务加锁顺序不一致就容易互相等待。排查锁问题可以在MySQL里执行-- 查看当前锁等待情况 SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits;重点观察trx_state为RUNNING且trx_started很早的事务大概率就是持锁时间过长的元凶。解决办法是给这类事务设置合理的超时时间innodb_lock_wait_timeout同时规范代码层面的加锁顺序。4. 缓存层优化热点Key与缓存穿透的攻防战MySQL扛不住高并发大家的第一反应是上Redis。但缓存层用不好有时候比不用缓存还危险。Redis本身性能很强瓶颈往往出在使用姿势上。4.1 热点Key缓存过期瞬间的雪崩风险双11那种流量场景某个爆款商品的详情数据会集中在同一个Key上。这个Key在缓存里的过期时间一到如果有几万个请求同时打进来Redis会瞬间承受巨大的读压力MySQL也一样因为所有请求都会穿透到数据库。应对方案主要有三个方向一是过期时间加随机扰动。不要设固定的过期时间比如3600 random(0, 300)秒让热点Key的过期时间错开避免同时失效。二是逻辑过期。热点Key永不过期但Value里存一个逻辑过期时间。读的时候发现逻辑上过期了就异步去更新缓存先返回旧数据给用户。这种方式能保证极端情况下缓存永远不失效但实现上要处理好异步更新的并发问题。三是热点Key提前识别并预热。通过监控Redis的hotkey信息提前发现访问频率极高的Key在活动开始前就把它们加载到缓存里避免活动瞬间的缓存穿透。4.2 缓存穿透布隆过滤器的那点事缓存穿透指的是查询一个根本不存在的Key缓存里没有数据库里也没有每次请求都直接打到数据库。如果用随机ID扫描接口的方式攻击数据库压力会非常大。解决缓存穿透有一个经典方案是布隆过滤器把所有可能存在的ID提前加载到布隆过滤器里查询请求先过布隆过滤器如果判定不存在直接返回空结果不再查询数据库和Redis。不过布隆过滤器有个误判率的问题它说“不存在”就一定不存在说“存在”却有可能不存在。误判率可以通过位数组长度和哈希函数数量来控制实践上一般控制在1%以内。对误判的那部分请求后端数据库仍然能扛住不会构成雪崩。另一个简洁方案是缓存空值。查询结果为空时也往Redis里写一个value为null的缓存过期时间设短一点比如30到60秒。这么处理能在大量重复请求场景下有效防穿透但恶意攻击者用随机ID打的话空值缓存也没用因为永远不会命中同一个Key。布隆过滤器才是治本的方案。4.3 Redis慢查询与连接管理最后提一下Redis本身的慢查询排查。很多人以为Redis是内存操作就不会慢实际上下面的场景照样会让RT爆炸KEYS *命令全量遍历Key会阻塞Redis服务大Value读写比如存了个几MB的JSON单次读写耗时极高SORT、ZRANGEBYSCORE等复杂操作在大集合下耗时不可控排查Redis慢查询用SLOWLOG GET命令看一下最近记录重点分析耗时排名靠前的命令然后去代码里找对应的Redis调用。大部分情况下问题都在于开发同学把Redis当成了关系型数据库在Redis里做复杂聚合操作。我在Redis连接管理上还有一个建议连接数一定要设置上限。微服务实例多的时候每个实例维护一个Redis连接池连接数加起来是惊人的。之前遇到过一个情况服务从10个实例扩容到30个Redis的连接数从500直接涨到1500虽然Redis maxclients默认有10000但连接数太多会导致每次命令的执行排队变长RT增加。解决办法是给连接池设置合理的maxTotal比如单个实例20~50并且连接空闲超时及时回收。5. 压测、监控与排查实录一次完整的调优落地流程讲完各个层面的调优手段最后把整个流程串一遍以一次真实压测和优化过程为例让你对调优的完整路径有个直观认识。5.1 压测方案设计场景、数据与并发模型压测不是随便拿Jmeter打几个并发就完事。微服务架构下的压测要分场景、分步骤做。场景设计上至少要有三种单接口基准压测、核心链路混合压测、全链路容量压测。单接口压测用来定位单个服务的瓶颈混合压测模拟用户真实操作路径比如“浏览商品→加购物车→下单→支付”能暴露服务间调用的问题全链路压测则是把整个系统拉起来最接近线上真实情况。数据准备上压测数据要符合线上分布特征。有些公司拿几万条数据就开压SELECT查出来的全是几百毫秒那压测结果完全失真。建议压测数据量至少和生产环境当前数据量在同一量级。还有SQL条件里的数据分布也要真实比如订单状态字段生产环境大部分是“已完成”压测环境就得同样分布否则索引选择性完全不同。并发模型上建议从低到高逐步加压50并发跑5分钟100并发跑5分钟200并发跑10分钟。每档压测结束后观察TPS、RT、错误率和资源水位找到拐点。所谓拐点就是并发继续增加时TPS不再增长但RT急剧攀升、错误率上升的时刻。拐点对应的并发数就是这个接口在当前架构下的容量上限。5.2 监控指标看什么、怎么看压测过程中监控指标要分层看。入口层看整个网关的吞吐量、平均RT、错误率。RT的P99比平均值重要平均值被少数慢请求拉高P99更接近真实用户体验。应用层用Arthas或者Spring Boot Actuator看关键的线程池活跃度、连接池使用率、GC频率和STW时间。JVM相关指标我用Arthas来看比如dashboard命令可以实时看到CPU、内存、线程情况。数据层MySQL重点看Threads_running、Innodb_buffer_pool_reads物理读次数、slow_queries。Redis看instantaneous_ops_per_sec和慢日志。压测时发现RT突然升高我习惯按照“先看中间件监控排除外部依赖再看本服务线程池活性最后看GC日志”的顺序排查比漫无目的地抓日志效率高得多。5.3 一次典型问题的排查复盘最后分享一次在压测过程中真实遇到的问题这个问题非常有代表性。压测商品列表接口50并发一切正常200并发后TPS不升反降平均RT从80ms冲到800ms。监控面板上CPU只有40%内存正常GC频率不高。看起来不太像服务本身的问题。然后看Redis监控发现instantaneous_ops_per_sec飙升到10万以上进一步查慢日志大量GET命令耗时在50-100ms。这就很奇怪Redis是内存操作正常应该是微秒级的。用Arthas把商品列表接口的调用链抓出来发现代码里遍历商品ID列表每个ID单独调一次Redis GET100个商品ID就是100次串行GET调用。200并发乘以100次串行调用Redis的Ops就爆了。这是个典型的N1缓存调用问题。解决办法很简单改用pipeline或者MGET批量获取。压测数据对比明显同样200并发下优化后TPS从压测前的几百提升到了2000多P99从800ms降到120ms。这个案例让我形成了一个习惯——拿到任何接口都要先问一句有没有批量调用Redis或数据库的情况很多性能问题不是配置不行而是代码一次请求里的IO次数太多了。5.4 开源工具与项目参考上面提到的Arthas、SkyWalking、Jmeter都是微服务调优里很实用的开源项目。Arthas是阿里开源的Java诊断工具可以在生产环境实时查看线程栈、反编译、监控方法调用耗时对排查线上问题帮助很大。SkyWalking是开源的应用性能监控系统APM提供链路追踪、服务拓扑、性能分析等功能和SkyWalking配合的还有Prometheus Grafana这套监控体系。Jmeter则是压测领域的标配生态成熟、支持分布式压测。每个项目的部署和使用都有一套独立的知识体系这里不展开写了但建议各位至少要把SkyWalking和Arthas用起来。特别是Arthas生产环境排查问题时它往往是最后一个救命工具接口慢用trace命令跟踪方法耗时看看耗时都花在了哪里线程阻塞用thread命令抓线程栈看看线程卡在哪个方法上。都是即时生效不需要重启服务。我个人在实际操作中的体会是微服务的性能调优不存在一次性完美方案。线上流量是波动的代码版本在迭代中间件配置在变化调优是一个持续观察、持续调整的过程。最重要的不是记住某个参数的最优值而是掌握一套定位问题的方法论链路梳理确认问题在哪一层监控指标定位具体的资源瓶颈压测验证调整效果线上持续观察确认无副作用。这套循环跑熟了再复杂的性能问题也有迹可循不会慌了手脚。
返回列表