1. 压测报告出来那一刻,我意识到事情没那么简单
先说背景。我负责的是电商核心链路里的订单服务,整个系统是标准的微服务架构:Nginx入口,网关做路由鉴权,下游拆了订单、库存、用户、支付、优惠券五个核心服务,服务之间走HTTP接口,数据库按业务域拆库,缓存统一走Redis。这套架构上线跑了快一年,平时流量不大,没出过什么大问题,直到那次618前的压测。
压测工具用的JMeter,线程数200,持续压了10分钟。结果报告一出来,整个人都有点懵:订单创建接口的QPS卡在800左右上不去,P99延迟却从平时的200ms一路飙到1.5秒,错误率在压测后半段开始抬头,逼近2%。更诡异的是,我看了所有Pod的监控,CPU使用率普遍在40%左右,内存也就用了50%不到,看着人人有余粮,但接口就是快不起来。这种"资源没打满、性能却上不来"的情况,在微服务架构里其实比CPU直接打满更让人头疼,因为它的根因往往藏在一整条调用链路的某个角落。
我当时的判断是:大概率不是某台机器扛不住了,而是链路里某个环节在排队、在阻塞,导致请求被卡住。这篇实战记录,就是我把整个调优过程从定位、分析到落地、验证完整跑一遍的复盘,包括链路追踪怎么用、线程池和连接池的参数到底怎么算、缓存策略怎么改才能扛住峰值、数据库层还能挤出多少性能,以及最后压测验证的真实数据对比。如果你也在做微服务架构的性能调优,或者在面试中被问到类似问题,这篇内容应该能给你一些可以直接落地的参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先别急着加机器,用链路追踪把瓶颈钉死在某一层
很多人遇到性能瓶颈,第一反应是扩容、加Pod、加机器。这在无状态服务上确实有效,但如果瓶颈在下游的数据库、Redis或者第三方依赖上,加机器不仅没用,反而可能让情况更糟——更多实例意味着更多的数据库连接、更频繁的缓存访问,把已经紧张的资源进一步压垮。所以我的原则是:调优第一步永远是定位,而不是拍脑袋扩容。
2.1 为什么微服务链路里"看不到"的延迟最致命
微服务架构和单体应用最大的区别,就是一次用户请求会经过多个服务。订单创建这个接口,表面上是一个HTTP请求,实际上后端要走网关、订单服务、库存服务、用户服务、优惠券服务,再加上若干次Redis读写和数据库操作。任何一环慢了,整个链路就会被拖住。
单体应用里,方法调用是进程内的,延迟以毫秒计,出问题可以用Profiler直接看。微服务就不一样了,服务之间走网络,每一次远程调用都有网络开销、序列化开销、连接池获取开销,这些在单体应用里根本不存在的成本,在微服务里会被放大很多倍。而且最麻烦的是,你很难凭直觉判断延迟到底消耗在哪一层——可能是订单服务自身逻辑慢,可能是库存服务响应慢,也可能是网络抖动导致重试,甚至可能是某个服务的GC停顿导致的偶发超时。
这时候就需要链路追踪工具来把整条调用链路的耗时清晰地摊开来看。
2.2 Trace数据怎么读:瀑布图才是定位的第一现场
我们用SkyWalking做链路追踪,服务通过Agent方式接入,不用改业务代码。每个请求会分配一个全局Trace ID,从网关入口开始,经过的每一个服务、每一次远程调用、每一次数据库访问,都会作为一个Span被记录下来,最终在SkyWalking的界面上形成一张瀑布图。
教大家一个读瀑布图的技巧:不要看平均耗时,直接看P99的慢请求。把慢请求的Trace ID拎出来,点进去看它的Span列表,重点找两个东西——第一个是耗时最大的Span,第二个是耗时占比异常的跨度。正常情况下降序排列后,耗时大头会集中在某一个Span上,其他Span都很快。但那次压测我看到的不是这样,订单服务调用库存服务的那一段,Span显示耗时800ms,而库存服务内部自己处理只用了50ms,剩下的750ms全部花在了网络上。
这个数据说明什么?说明库存服务本身不慢,慢的是两个服务之间的通信链路。进一步排查,发现三个问题叠加在一起。
2.3 实际案例:一次下单请求背后隐藏的17次远程调用
第一个明显的问题,是服务间调用次数多得离谱。我拉了一下一次完整下单请求的调用拓扑,好家伙,从网关到订单服务开始,整个链路前后触发了17次远程HTTP调用。订单服务查用户信息、查地址、查优惠券,每个都单独调一次接口;下单主流程里要调库存服务查询库存、锁定库存、扣减库存,每一步都是独立的一次HTTP往返。
第二个问题,是大对象传输。订单服务返回给前端的一个详情接口,响应体竟然有200KB。这200KB里大部分是嵌套的JSON字段,很多压根用不上。跨服务调用时这些数据要序列化、传输、反序列化,每一个环节都在吃性能。
第三个问题,是连接池等待。我在Trace里看到很多Span的耗时分布是:获取连接200ms、发送请求100ms、等待响应400ms、读取响应100ms。也就是说延迟很大一部分花在了等连接上,库存服务那800ms的"网络耗时"里,有很大概率是客户端的连接池没货了,线程在排队等连接。
到这里,瓶颈已经被初步钉在了两个方向:一个是服务间通信本身,另一个是连接池资源。接下来就是针对性的参数调优。
3. 线程池和连接池:参数调的是"并发"与"等待"的平衡
说起性能调优,线程池和连接池是最容易出效果也最容易踩坑的地方。很多人觉得线程池参数设大点总是好的,连接数配多点总是没错的——这个想法在微服务架构下特别危险。因为每个请求都会占用线程和连接,线程和连接不是越多越好,而是要和下游的处理能力匹配,否则只会把下游压垮,或者让CPU浪费在线程切换上。
3.1 线程数到底该设多少:用Little's Law算一笔账
线程池参数的设置,其实可以用一个很简单的公式来算,也就是排队论里的Little‘s Law:系统里的请求总量 = QPS × 平均响应时间。如果目标是支撑2000 QPS,平均响应时间目标是100ms,那系统里同时处理的请求数就是2000 × 0.1 = 200。也就是说,核心线程数至少需要200个才能支撑这个目标,否则请求就会在队列里积压。
但注意,这个200是"理论最小值",真实环境还要考虑两个因素。第一是业务的IO模型,订单服务是典型的IO密集型,大量时间花在等待下游HTTP响应和数据库返回值上,线程池可以适当调大一些;第二是下游能扛住多少,你这边200个线程同时打向库存服务,库存服务自己的线程池和数据库连接池如果没有相应扩容,反而会被你这边拖垮。
我当时把订单服务里那个混用的业务线程池从固定50个线程调整为核心200、最大300、队列容量1000。这里有个细节:队列容量不能设太大。很多人设队列是为了"缓冲尖峰",但队列越大,请求在队列里等待的时间越长,P99延迟会飙升。我见过一个项目把队列设成50000,结果压测时QPS看起来还行,但P99延迟直接到了十几秒。性能调优要看的是P99和P999,不是平均耗时,这个数据最容易骗人。
3.2 数据库连接池和HTTP连接池的联动调整
连接池的调整比线程池更讲究联动,因为一个请求从接入到完成,可能要经过Tomcat线程、HTTP客户端连接池、数据库连接池三层资源,任何一层卡住,整条链路的延迟都会崩。
先说数据库连接池。我们用HikariCP,默认配置是maximumPoolSize=10,这在低并发下完全够用,但压测到了800 QPS就明显捉襟见肘了。HikariCP官方文档里有个经验值:maximumPoolSize = 实际并发数 + 少量余量。怎么算实际并发数?用连接被占用的平均耗时乘以QPS。当时订单库的SQL平均耗时约20ms,2000 QPS下需要同时占用的连接数大约是2000 × 0.02 = 40个。所以我把订单服务的数据库连接池从10调到了50,留了25%的余量。
再说HTTP客户端连接池。服务间调用用的Apache HttpClient,默认maxConnPerRoute是10,这个就更不够了。一个下单请求要打库存服务好几次,10个连接在高并发下根本不够用,大量的请求会卡在"获取连接"这个步骤上。我把maxConnPerRoute调到了200,maxConnTotal调到了400。
这里说一个踩过的坑:调完连接池参数之后一定要做回归压测,而且要观察下游服务的连接池数量。我当时调大了订单服务到库存服务的HTTP连接池,结果压测时库存服务自己报出了"连接数超限"——因为库存服务里的Tomcat线程数没调,直接导致库存服务的连接被占满,请求开始排队,延迟不降反升。所以连接池调优必须带着上下游一起看,不能只调一处。
3.3 用Arthas在线诊断线程状态,别猜要看证据
调参数之前,建议先用Arthas做一次在线线程诊断。我当时就是通过Arthas的thread命令,看到大量线程处于WAITING状态,堆栈指向了httpclient的getConnection方法,这才确认了连接池是瓶颈之一。
用Arthas看线程状态有个小技巧:先执行thread -n 3看CPU占用最高的线程,再执行thread --state WAITING看等待态的线程。如果WAITING线程都卡在同一个方法上,那基本可以锁定是资源竞争问题;如果WAITING线程分散在各个业务逻辑里,那可能是下游服务响应慢导致的连带阻塞。
我那次看到的是大量线程卡在连接获取上,心里基本就有数了。这种"全链路卡在同一个地方"的现象非常典型,它说明不是业务逻辑有多重,纯粹是资源不够用。
4. 缓存不是越厚越好,但分层缓存让接口性能直线上升
线程池和连接池调完之后,重新压测,QPS从800涨到了1500左右,P99延迟降到了700ms。但离目标还有距离,而且我注意到一个问题:QPS到了1500之后,数据库的负载开始明显升高,慢SQL的数量也在增加。这说明下一层瓶颈在数据库。与其直接给数据库加索引、加机器,我决定先把缓存策略改一改——因为很多数据库压力本质上都是不合理的缓存策略导致的。
4.1 两级缓存设计:本地Caffeine + 分布式Redis
原来的缓存方案很简单,所有数据都放Redis,服务端请求先查Redis,没有再查数据库。这个方案在高并发下有个问题:每次缓存查询都有一次网络开销,而且热点数据集中在一个Redis实例上,Redis本身的QPS也成了瓶颈。
我把缓存改成了两级:一级是本地缓存Caffeine,放在每个服务实例内部,访问速度是纳秒级;二级是Redis,速度是毫秒级;最后才是数据库。查询逻辑变成:先查Caffeine,没命中再查Redis,再没命中才查数据库,并把结果逐级回填。
这里的关键是Caffeine的配置。我用的是maximumSize=10000,expireAfterWrite=60秒。为什么不用更长的时间?因为本地缓存最大的问题是数据不一致——每个服务实例各存一份,如果某个实例更新了数据,其他实例在TTL过期前看到的还是旧值。60秒的TTL对我来说是一个可接受的折中,既能挡住大部分流量,又不会让数据不一致太久。
4.2 缓存击穿、穿透、雪崩:一个都不能少地处理
加了Caffeine之后,缓存命中率从原来的70%提升到了95%左右,数据库的压力直接降了一个量级。但紧接着就暴露出了另一个问题:热点key的缓存击穿。
我们的商品详情接口,平时流量分散在上万个商品上,但压测脚本只压了那么几个热门商品的ID,这几个key的缓存一旦过期,瞬间会有大量请求同时回源数据库。最典型的例子是压测时QPS瞬间从1000冲到2000,其中一个热门商品的缓存刚好过期,几千个请求同时穿透到数据库,数据库的CPU瞬间彪到90%+。
怎么解决的?加互斥锁,也就是single flight模式。具体做法是:当缓存miss时,不是立刻查数据库,而是先尝试获取一个分布式锁,只有拿到锁的线程才去查数据库并重建缓存,其他线程在锁等待期间不断重试查询缓存,直到缓存被重建。我用Redisson实现的这个逻辑,超时时间设了500ms,防止某个线程一直握着锁不释放导致其他线程全部超时。
缓存穿透和雪崩也一并处理了:穿透用缓存空值的方式,对于数据库中不存在的数据,在Redis里存一个TTL为60秒的空值;雪崩方面,给不同的key设置不同的过期时间,加上一个随机的20%偏移量,避免大量key在同一时间集体过期。
4.3 缓存一致性的取舍:别过度设计
最后要说的是缓存一致性。很多人一聊到缓存就要上延迟双删、消息队列异步同步、Canal监听Binlog,这些方案不是不行,但要看你业务的容忍度。我的判断是:订单服务里的商品信息缓存,允许最多60秒的最终一致,完全没必要引入一套复杂的同步机制。
如果是实时性要求极高的场景,比如库存数量,这类数据我不会放缓存,直接走数据库,宁可牺牲一点性能也要保证绝对准确。热销商品的余量显示可以允许短暂的滞后,下单扣减库存时以数据库为准,所以不会出现超卖问题。
5. 数据库这一层的瓶颈:慢SQL和读写分离
缓存优化之后,再压测,QPS上到了2500左右,P99降到了300ms左右,已经明显好转。但距离目标3000 QPS还有差距,而且此时数据库成了唯一的瓶颈。我打开数据库的慢查询日志,发现几个SQL的执行时间在几百毫秒,明显不对劲。
5.1 慢SQL定位:explain好好看,别只看rows
那次定位到的第一个慢SQL,是对订单表按用户ID和时间范围分页查询的语句。表里数据量其实不大,才300多万行,但查询时间高达800ms。用explain一看,type是ALL,走的是全表扫描,索引完全没生效。
问题出在索引设计上:原来的联合索引是(user_id, status),但SQL里的筛选条件还有order_time范围查询。MySQL的联合索引遵循最左前缀原则,范围条件后面的索引列无法生效。所以我把索引改成了(user_id, order_time),这样既能覆盖用户维度的筛选,又能利用时间范围索引。改完之后,同样一条SQL,执行时间从800ms降到了30ms。
另一个慢SQL更隐蔽,业务代码里有个循环,在循环里逐条查数据库——典型的N+1问题。订单列表接口返回20条订单,每条订单都要查一次用户表和一次支付表,一个接口下来40次数据库查询。这在高并发下就是灾难。我把这个改成了一次关联查询,用IN条件把订单ID列表传进去,一次查出所有关联数据。
5.2 读写分离:主库只写,读库扛读写流量
慢SQL修完之后,数据库的单个查询已经很快了,但QPS到了2500之后,主库的CPU还是持续在70%以上。为什么?因为读流量太大了,商品详情、订单列表这些高频查询还是打在同一个数据库实例上。
这个阶段我上了读写分离。主库只承担写操作,读操作全部走从库。从库通过主从复制同步数据,业务层用ShardingSphere的数据源路由,把带读写标记的SQL自动路由到对应数据源。
这里有一个很容易被忽略的坑:主从复制的延迟。订单创建后,用户会立刻跳转到订单详情页,而订单详情是读操作,如果走了从库,而主从同步还没完成,用户会看到"订单不存在"。我当时的处理策略是:订单刚创建后的30秒内,详情查询强制走主库;过了30秒窗口期,再走从库。实现方式是在路由规则里判断订单的创建时间,如果和当前时间差小于30秒,就把查询路由到主库。
5.3 分库分表的时机:别为了分而分
很多团队一聊到数据库性能就说要分库分表。我的观点是,那是在单表数据量突破千万级别、而且索引优化和读写分离都到极限之后才需要考虑的方案。分库分表会带来分布式事务、跨库join、全局ID等一系列复杂度,如果数据量没那么大,完全没必要给自己找这些麻烦。
当时订单表300万行,做完索引优化、读写分离之后,数据库完全没有压力了,压测时主库CPU稳定在30%左右,从库CPU在50%左右,这个配比还能撑住未来半年到一年的增长。所以我没有上分库分表,而是把重心放在了监控和容量规划上。
6. 调优效果复盘:从QPS 800到3000,我做了什么,又没做什么
最终回归压测用了三天时间,结果我自己还是比较满意的。调优前后的关键指标对比如下:
| 指标 | 调优前 | 调优后 | 变化 |
|---|---|---|---|
| QPS | 800 | 3000+ | 提升275% |
| P99延迟 | 1500ms | 180ms | 降低88% |
| 错误率 | 2% | 0.01% | 降低99% |
| 数据库CPU | 持续70%+ | 峰值40% | 明显下降 |
| 服务CPU | 40% | 60% | 利用率提升 |
我能达到这个效果,做的核心事情就四件:第一,用链路追踪把瓶颈从猜测变成了定位;第二,用并发公式合理调整线程池和连接池参数,并且做了跨服务联动;第三,引入两级缓存加缓存击穿防护,把数据库压力降下来;第四,索引优化加读写分离,让数据库本身能扛住更高的吞吐。
但我也想说说我没做什么,以及为什么不做。我没做分库分表,因为数据量没到那个程度,过早引入复杂度只会得不偿失;我没上服务网格,因为团队对现有架构的运维已经很熟了,引入新基础设施需要更大的收益来对冲学习和运维成本;我也没把每个服务的线程池都调成同一套参数,因为不同服务的下游依赖、响应时间、并发模型都不一样,参数必须一个服务一个服务地单独测、单独调。
整个调优过程花了大约两周时间,其中将近一半的时间花在定位瓶颈上,真正改代码和参数的时间反而没多少。这一点我也想重点提醒大家:性能调优里最贵的不是优化本身,而是定位。所以监控、链路追踪、日志这类可观测性基础设施,一定要在最开始就建好,它们是定位问题的眼睛,没有眼睛,调优就只能靠猜,而靠猜的调优,几乎不可能达到预期效果。
