系统压测调优这事儿,说难不难,说简单也真不简单。前阵子做了一轮线上系统的性能测试与调优,核心走的是“存储模型优化 + 调用链路分析”两条线,中间踩了不少坑,也沉淀了一些可复用的排查方法。标题叫“积微成著”,是因为这次调优并不是靠某个单点大招,而是从一条慢SQL、一个多余的远程调用、一处不合理的索引设计一点点抠出来的。这轮实战做完之后,接口的TP99从原来的2.3秒降到320毫秒,单机吞吐也翻了接近三倍,所以把整个过程和方法论整理出来,给碰到类似问题的朋友一个参考。
如果你正准备做性能测试,或者你的系统已经出现“测试环境没问题、一压测就卡死”的症状,这篇文章应该能帮到你。内容会覆盖MySQL存储模型优化、JMeter压测脚本设计、SkyWalking调用链路分析、JVM和线程层面的排查手段,以及我在过程中总结的常见坑和排查技巧。没有太多高深理论,更多的是实际操作记录和可复用的命令、参数、判断思路。
1. 性能测试的切入点:先搞清楚“测什么”和“怎么调”
1.1 性能测试不是压完就结束
很多团队做性能测试,流程就是写好JMeter脚本,跑一轮并发,出一张聚合报告,然后就没有然后了。这不是性能测试,这只是“压力验证”。真正的调优,是在压测数据出来之后,回答几个关键问题:瓶颈在哪个环节?是数据库、应用线程、连接池、第三方调用还是GC?系统还能撑多久?怎么改才能让结果变好?
这次项目的背景是一条核心查询接口,业务上承担着批量订单数据的聚合查询。压测时发现TPS卡在400左右上不去,响应时间的分布非常不均匀,TP90是600ms,TP99却飙到2.3秒,明显有长尾。我当时的判断是,先不要一上来就怀疑代码逻辑,先看存储层和调用链路上有没有明显的问题,因为这两个地方是性能瓶颈的高发区。
1.2 从指标反推问题,而不是凭空猜测
性能调优有一个原则:让数据说话。我会先把四个维度的数据拉齐:压测机的资源使用率(CPU、内存、网络)、应用节点的线程状态和GC日志、数据库侧的慢查询和连接数、链路追踪里的调用耗时分布。这四类数据凑到一起,基本能定位大部分性能问题。
这次项目特别关注了存储模型和调用链路,是因为观察到的现象非常典型:单条SQL执行并不慢,但接口整体很慢。这就说明耗时不是耗在单次查询本身,而是耗在了查询次数太多、或者多次查询之间的等待上。存储模型优化解决的是“一次查得太多/查得太碎”的问题,调用链路分析解决的则是“在看不见的地方浪费时间”的问题。
2. 存储模型优化:从慢SQL到表结构重构
2.1 慢SQL日志是第一个突破口
系统压测开始后,我做的第一件事就是打开MySQL的慢查询日志。实践下来,这个动作对绝大多数性能排查都是第一步。我用的是下面的配置:
slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 0.5 log_queries_not_using_indexes = ONlong_query_time设成0.5秒,是为了把压测期间所有超过500ms的查询都记录下来。这不是生产环境的推荐值,生产上一般可以设1秒,但压测调优时建议收紧,因为这样才能暴露出更多潜在问题。
拿到慢日志后,发现一个高频SQL长这样:
SELECT o.id, o.order_no, o.status, u.name, u.level, p.product_name, p.price FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN products p ON o.product_id = p.id WHERE o.created_at BETWEEN ? AND ? ORDER BY o.created_at DESC LIMIT 50 OFFSET ?单看这条SQL没有特别复杂的问题,但注意几个细节:一是orders表已经有三百万行数据,二是压测时并发量一上来,这条SQL的单次执行时间从150ms直接涨到了900ms。这不是SQL写得“烂”,而是执行计划没有走到理想的索引路径上。
2.2 explain看执行计划:索引失效的真相
我用EXPLAIN检查了这条SQL的执行计划。结果是这样的:
+----+-------------+-------+--------+------------------+----------+---------+---------------------+--------+----------------+ | id | select_type | table | type | key | rows | Extra | +----+-------------+-------+--------+------------------+----------+---------+---------------------+--------+----------------+ | 1 | SIMPLE | orders| range | idx_created_at | 178302 | Using where; Using filesort | | 1 | SIMPLE | users | eq_ref | PRIMARY | 1 | NULL | | 1 | SIMPLE | products| eq_ref| PRIMARY | 1 | NULL | +----+-------------+-------+--------+------------------+----------+---------+---------------------+--------+----------------+表面上看orders表走的是idx_created_at范围扫描,类型是range,不算差。但注意两个信息:rows达到17万,Extra里有Using filesort。这意味着查询在时间范围条件内扫描了17万行,再在内存或磁盘里做排序。offset越深,扫描的行越多,这就是分页查询性能恶化的经典原因。
2.3 存储模型调整:从“大查询”到“小结果集”
针对这个执行计划,我做了一个存储模型层面的调整,核心思路是“减少无效扫描范围”。直接上解决方案:
第一,把(created_at, status)建成联合索引。因为业务查询经常带status条件,联合索引能同时过滤时间和状态,减少回表行数。第二,给排序字段加覆盖索引,让查询可以用索引顺序返回结果,消除filesort。
调整后的索引如下:
ALTER TABLE orders ADD INDEX idx_created_status (created_at, status); ALTER TABLE orders ADD INDEX idx_created_id (created_at, id);这里多说一句,为什么我不直接删掉旧的idx_created_at?因为生产环境不能保证所有查询都走新索引,保留旧索引可以作为回退方案。压测验证没问题后再考虑下线。索引不是越多越好,但压测阶段宁可先验证再清理。
调整之后的执行计划,rows从17万降到3万左右,Extra里的Using filesort消失了,单次SQL执行时间从900ms回到180ms左右。这个效果非常明显。
2.4 分页深翻页问题:改写成游标翻页
索引优化完成后,TP99还是有点偏高,进一步看慢日志,发现有个规律:offset超过1000页的时候,查询还是会变慢。这就是深翻页问题,LIMIT 1000, 50即使走索引也需要扫描前1050行再丢弃前1000行。
我的做法是改成游标翻页,让客户端传入上一页最后一条记录的id或created_at值,然后用条件查询代替offset:
SELECT o.id, o.order_no, o.status, u.name, p.product_name, p.price FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN products p ON o.product_id = p.id WHERE (o.created_at, o.id) < (?, ?) ORDER BY o.created_at DESC, o.id DESC LIMIT 50;这个写法能一直利用索引定位到上一页的边界,翻到十万页也不会变慢。注意这里排序条件必须和WHERE条件匹配,都按(created_at, id)处理,否则索引又失效了。实际压测下来,深翻页场景的响应时间从之前的两秒多降到200ms以内,这个优化对列表类接口来说非常关键。
2.5 MySQL参数与批量调优的结合
存储模型优化不只是索引,MySQL的配置参数也会在并发压测时成为瓶颈。这次压测中观察到一个现象:数据库连接数到100左右时,应用层大量报“连接获取超时”。检查后发现连接池配置不合理,另外innodb_buffer_pool_size设置偏小,导致压测高峰期频繁刷盘。
我做了几项调整,供参考:
| 参数名 | 原值 | 调整后 | 说明 |
|---|---|---|---|
| innodb_buffer_pool_size | 1G | 4G | 按数据库总数据量的60%-70%设置,减少磁盘IO |
| max_connections | 150 | 500 | 压测需放开连接上限,但不宜过大,防止数据库被打爆 |
| innodb_flush_log_at_trx_commit | 1 | 2 | 压测环境可放宽为每秒刷盘,提升写入性能;生产需权衡可靠性 |
| wait_timeout | 28800 | 600 | 减少空闲连接占用,防止连接数被耗尽 |
这里要提醒一句:参数调整一定要结合机器配置和数据量,不要盲目抄。比如innodb_buffer_pool_size设得比内存还大,就会触发频繁swap,反而更慢。我调优时是看了数据库所在节点的总内存和实际数据文件大小之后才定的,4G是当时物理机16G内存下的安全值。
3. 调用链路分析:把耗时花在哪里查得明明白白
3.1 接入链路追踪:为什么靠日志猜测不靠谱
存储层优化做完之后,接口性能已经有明显改观,但TP99还是达不到目标。这个时候再靠慢日志和MySQL监控去排查效率就很低了,因为耗时可能发生在应用内部调用链路上。比如某个服务调用了缓存,缓存没命中又去查数据库;或者某个查询循环里调用了一个远程接口,一次请求触发了几十次HTTP调用。
日志里也不是完全没有线索,但逐条翻日志定位调用关系太慢了。所以我果断接入了链路追踪工具,用的是SkyWalking。它能一次请求从入口到数据库、到远程调用、到内部方法的完整调用树展示出来。接入成本很低,Java项目加一个agent参数就能跑起来。
3.2 一次典型的慢请求拆解
接入SkyWalking之后,我抓了一条TP99附近最慢的请求,调用链路长这样:
/order/list -> OrderController.list() -> OrderService.queryPage() 108ms -> UserService.getBatchUserByIds() 45ms -> ProductService.getBatchProductByIds() 52ms -> InventoryService.checkStock() 380ms问题一下就暴露了:InventoryService.checkStock()一次查询耗时380ms,占整条链路的60%以上。这个调用是同步远程HTTP调用,而且是在订单列表查询里循环调用的,每页50条订单就可能触发多次远程调用。
看完链路后我做了两件事:第一,把批量查询改为批量接口,用orderIds一次性传入,避免循环里逐个调用;第二,给这个远程调用加上本地短时间缓存,TTL设为3秒,因为库存变化不会秒级频繁发生。改造后,这条远程调用从380ms降到了峰值60ms,平均20ms以内。
这里也暴露了一个日常开发中常见但容易忽略的问题:列表接口里嵌入同步远程调用,尤其是循环内远程调用,会随着页容量线性放大延迟。链路追踪的价值就在于,让人一眼看穿这个放大过程。
3.3 连接池与线程池在链路中暴露的问题
链路追踪还把另一个隐藏问题暴露了出来:在压测高峰期,很多请求在进入业务代码之前就卡住了,整个链路时间几乎都消耗在“等待线程”上。看线程池的活跃数和等待曲线,发现Tomcat默认的max-threads=200在600并发下完全不够用,大量请求排队。
这个问题的判断方法很简单:看链路追踪里入口层的耗时,如果各服务内部方法耗时都很短,但入口总耗时很长且均匀分布,大概率是线程池排队。我用JMeter压测数据做佐证:线程数从100增加到600时,TPS先升后平,响应时间线性上涨,这基本是应用线程池到了瓶颈的信号。
调整方案是把server.tomcat.max-threads从200调到400,同时把accept-count从100调整到200。注意不要无限调大,线程太多会导致上下文切换开销反而增大。本次调优后,TPS从400涨到900左右,响应时间也稳定下来。
3.4 链路分析里容易忽略的依赖问题
这一轮吞吐量上来之后,还发现一个很微妙的问题:调用链路上有个Redis操作,单个耗时只有2ms,但一次请求里同一个key被重复读取了8次,累计20ms。虽然不起眼,但这是典型的“积微成著”式性能损耗。
排查方法是在SkyWalking的Span列表里按Redis操作分组统计次数,一眼就能看到热key重复访问。解决方案是在业务代码里把多次读取合并为一次,或者用请求级别的本地缓存避免重复查询。这个优化虽然单次效果不明显,但叠加在高并发场景下,减少的Redis QPS相当可观。
4. JMeter压测:脚本设计、指标解读与回归验证
4.1 压测场景怎么设计才靠谱
调优之前必须有一套可复用的压测方案,否则你根本不知道改动到底是变好了还是变差了。JMeter是我最常用的压测工具,但这套流程换成其他工具也一样适用。
我设计的压测场景如下:
| 场景 | 线程数 | 持续时间 | 说明 |
|---|---|---|---|
| 单接口基准 | 50 | 5min | 摸底,观察基准TPS和响应时间 |
| 负载爬坡 | 100/200/400/600 | 每档5min | 找到拐点和瓶颈 |
| 稳定性测试 | 300 | 30min | 观察内存泄漏、连接池耗尽等问题 |
| 尖峰测试 | 1000 | 2min | 模拟瞬时流量冲击,看系统恢复能力 |
线程数不是一个固定值,更不是越大越好。建议按“系统预估峰值QPS ÷ 单线程可承载TPS”的方式来估算。举例:如果预估峰值QPS是2000,单线程实测TPS是5,那理论并发线程数就是400左右。我一直用这种倒推方式确定压测强度,比拍脑袋设5000线程靠谱得多。
4.2 聚合报告和一两个容易被忽视的指标
JMeter的聚合报告大家都看,但很多人只盯着Average。我这里建议重点看三个指标:TP99 + Error% + TPS。平均值是会骗人的,TP99和错误率才能反映出真实用户体验。
还要提醒一个容易被忽视的点:JMeter本身也可能成为瓶颈,尤其是跑大并发的时候。我曾经遇到压测机CPU打满,导致压测结果全乱。解决方法是使用分布式压测,或者在JMeter里开启mode=Standard以外的精简模式,关闭所有不必要的Listener。JMeter最佳实践是不要开图形界面跑正式压测,用命令行:
jmeter -n -t order_list_test.jmx -l result.jtl -e -o dashboard/-e -o dashboard/会生成一套HTML报告,里面有响应时间分布、吞吐量变化曲线,非常直观。这套报告也是我给团队同步调优结果的主要依据。
4.3 调优效果的回归验证
调优不是一劳永逸,每次改完代码或配置都必须重新压测一遍。我通常先用一个固定的“回归压测基准”脚本跑一遍,对比前后数据。这套基准脚本是固定的线程数、固定的数据量、固定的参数,不能每次手抖改掉。
以这次调优为例,回归数据对比是这样的:
| 指标 | 调优前 | 调优后 | 变化 |
|---|---|---|---|
| TPS | 420 | 1150 | +173% |
| TP50 | 480ms | 120ms | -75% |
| TP99 | 2.3s | 320ms | -86% |
| Error% | 1.8% | 0.02% | 基本消除 |
回归验证时有一个细节,必须固定测试数据量。如果数据库数据翻倍了,同样的查询性能会明显下降,容易误判为“改动导致性能退化”。所以每次压测前我会检查测试库数据量是否一致,否则结果对比没有意义。
5. JVM与线程层面:压测中不可绕开的一关
5.1 GC日志分析:从停顿时间找隐藏瓶颈
存储和链路层面的问题处理完,TPS稳定在1000以上,但压测半小时后开始出现周期性抖动,TPS波动非常大。这种周期性问题,我第一反应就是看GC日志。
我给应用加上了GC日志参数:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime跑完压测打开gc.log,发现一个明显规律:每30秒左右出现一次Full GC,单次停顿超过800ms。排查堆内存配置后发现,JVM堆大小设置为2G,但系统实际活跃数据超过3G,导致频繁触发Full GC。
调整方案是把-Xms和-Xmx统一设为4G,同时把年轻代比例调大。调整后再次压测,Full GC频率从每分钟2次降为半小时1次左右,TPS曲线也平缓多了。
5.2 线程Dump:从堆栈里找锁和阻塞
另一个典型问题是压测过程中应用偶尔出现“假死”,并发用户大量超时。这通常不是CPU打满,而是线程阻塞。我的排查办法是连续抓三次线程dump,间隔5秒:
jstack -l <pid> > thread_dump_1.txt sleep 5 jstack -l <pid> > thread_dump_2.txt然后重点看dump文件里处于BLOCKED和WAITING状态的线程。这次我抓到一个非常明显的模式:大量线程阻塞在Object.wait()上,拿锁的线程被卡在一个Redis调用上。结合前一天Redis集群切换的情况,定位到是某个缓存客户端连接池配置超时时间太短,在Redis瞬时抖动时没有快速恢复。
解决方法是调大连接池的timeout参数并增加重试逻辑。线程dump一定要多抓几次对比,因为单次dump可能只是偶然现象,连续几次都显示同一处阻塞,才能判断是稳定问题。
5.3 连接池参数调优的平衡点
数据库连接池和HTTP连接池在压测中经常会成为隐藏瓶颈。这次项目里,我调整了HikariCP的配置,原来maximum-pool-size=20在600并发下完全不够用,大量线程在等待获取数据库连接。
我把maximum-pool-size调到60,同时把minimum-idle调到20。连接池大小不是越大越好,因为并发活跃连接过大,会让数据库端出现连接争用和锁等待。这里有一个经验公式可以参考:最大连接数建议按(核心线程数 * 2 + 有效磁盘数)来估算。比如8核机器加一块SSD,初始值可以设到16到24,再根据压测效果调整。
5.4 批处理场景下的JVM调优专项
这个项目里有批量数据处理的场景,和普通接口调优不同,批量任务的特点是内存消耗大、执行时间长、GC压力高。我针对批量任务单独调整了JVM参数,把年轻代调大,减少短生命周期对象频繁进入老年代:
-Xms4g -Xmx4g -Xmn2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200G1GC配合MaxGCPauseMillis的目标停顿时间,让GC停顿尽量控制在200ms以内。批量任务的调优思路是“减少GC次数,而不是消灭GC”,因为批量处理往往需要大量对象,完全避免GC不现实。调完后批量处理吞吐提升了40%左右。
6. 常见问题与排查技巧实录
6.1 压测中典型的“假瓶颈”与真实原因对照
为了给大家一个快速排查的参考,我整理了这次项目和其他调优项目中反复出现的高频问题对照表。
| 现象 | 最容易想到的原因 | 实际排查方向 |
|---|---|---|
| TPS上不去 | 代码效率低 | 先看CPU是否打满,再查数据库慢SQL |
| 响应时间分布不均匀 | 网络抖动 | 看GC日志和线程阻塞 |
| 数据库CPU飙升 | SQL写得差 | 抓慢日志,检查执行计划 |
| 内存持续增长 | 内存泄漏 | 用heap dump配合GC日志判断 |
| 排队超时 | 连接数不足 | 检查连接池和线程池配置 |
这里想强调的是,看到“TPS上不去”就重构代码,是最容易走弯路的方式。正确的顺序应该是“资源使用率 → 存储层 → 链路层 → 应用层”,一步步缩小范围。
6.2 几个值得记下来的JMeter使用小技巧
这里分享几个JMeter的高频技巧,都是实操踩坑后总结出来的。
第一个技巧是参数化一定要用CSV文件而不是随机函数。压测数据如果不稳定,比如每次请求都随机创建新订单,会导致数据库数据量膨胀,压测结果无法复现。我用CSV文件固定一批订单ID和用户ID,确保每次请求落在同一批数据上。
第二个技巧是合理设置Ramp-Up Period。如果线程数从0瞬间加到600,系统会被打一个毫无意义的瞬时冲击,掩盖真实瓶颈。我一般设置成线程数乘以0.5秒,比如600线程就设300秒平滑爬坡,等找到拐点后再做尖峰测试。
第三个技巧是压测后一定要清理测试数据。我遇到过测试库数据膨胀到生产库的三倍,之后压测结果完全失去参考价值。所以我在压测脚本结束位置加了一个JSR223清理逻辑,或者在每个压测阶段结束后手动清表,保持环境可信。
6.3 性能测试面试题里反复出现的考点
这个话题顺带聊一下,因为很多读者关心性能测试面试题该怎么准备。我在面试中经常问候选人三个问题:如何定位TPS上不去的瓶颈?如何设计压测场景?调优后如何验证效果?
回答这三个问题的核心,就是要展现出“数据驱动”的排查思路,而不是背概念。比如有人说“TPS上不去就加线程”,这显然是新手。有经验的人会说“先看线程池是否排队,再看数据库连接池是否打满,接着看GC,最后看代码逻辑”。这种层级化排查思路,才是性能测试调优面试题最想听的答案。
JVM调优也是高频考点。面试官喜欢问“什么时候调堆大小?”我的标准回答是:先通过压测拿到活跃数据大小和GC频率,再决定堆大小;盲目调大堆反而会让Full GC时间变长。你在简历里写过JVM调优,至少得能讲清这种因果逻辑。
6.4 调优过程中最值得保留的排查习惯
最后说点软性的经验。调优过程中我养成了几个好习惯,每一次都让我少走弯路。
第一个习惯是每次变更只改一个变量。很多人调优的时候同时改索引、改SQL、改连接池、改JVM,结果出问题时根本不知道是哪个变更导致的。我严格要求自己一次只改一处,改完立即压测并记录数据,再改下一处。
第二个习惯是所有优化动作都记录到变更文档里。我会把变更项、调整前指标、调整后指标、是否存在副作用全部整理成表格。这不仅是给团队看的,也是给自己一个月后复盘用的。
第三个习惯是保留每次压测的JMeter脚本和JTL结果文件。原始压测数据是最好的证据,不管调优有没有效果,都能随时回溯。这个习惯可能有些繁琐,但关键时刻能救命。
这轮性能调优让我最大的体会就是,不要迷信某一个“大招”。索引优化提升了单次查询速度,链路分析消除了远程调用放大,连接池参数调整撑住了并发,GC优化稳定了长跑表现,每一项单独看都是很小的改动,但叠加起来效果非常可观。积微成著,性能调优本就是一个从细节里抠出系统的潜力、再把潜力变成稳定能力的过程。