尧图精选

系统压测调优实战:从慢SQL到索引与链路的性能提升

🕒 发布时间:2026/10/2 2:57:24 📁 来源:尧图网络
系统压测调优这事儿说难不难说简单也真不简单。前阵子做了一轮线上系统的性能测试与调优核心走的是“存储模型优化 调用链路分析”两条线中间踩了不少坑也沉淀了一些可复用的排查方法。标题叫“积微成著”是因为这次调优并不是靠某个单点大招而是从一条慢SQL、一个多余的远程调用、一处不合理的索引设计一点点抠出来的。这轮实战做完之后接口的TP99从原来的2.3秒降到320毫秒单机吞吐也翻了接近三倍所以把整个过程和方法论整理出来给碰到类似问题的朋友一个参考。如果你正准备做性能测试或者你的系统已经出现“测试环境没问题、一压测就卡死”的症状这篇文章应该能帮到你。内容会覆盖MySQL存储模型优化、JMeter压测脚本设计、SkyWalking调用链路分析、JVM和线程层面的排查手段以及我在过程中总结的常见坑和排查技巧。没有太多高深理论更多的是实际操作记录和可复用的命令、参数、判断思路。1. 性能测试的切入点先搞清楚“测什么”和“怎么调”1.1 性能测试不是压完就结束很多团队做性能测试流程就是写好JMeter脚本跑一轮并发出一张聚合报告然后就没有然后了。这不是性能测试这只是“压力验证”。真正的调优是在压测数据出来之后回答几个关键问题瓶颈在哪个环节是数据库、应用线程、连接池、第三方调用还是GC系统还能撑多久怎么改才能让结果变好这次项目的背景是一条核心查询接口业务上承担着批量订单数据的聚合查询。压测时发现TPS卡在400左右上不去响应时间的分布非常不均匀TP90是600msTP99却飙到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值然后用条件查询代替offsetSELECT 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_size1G4G按数据库总数据量的60%-70%设置减少磁盘IOmax_connections150500压测需放开连接上限但不宜过大防止数据库被打爆innodb_flush_log_at_trx_commit12压测环境可放宽为每秒刷盘提升写入性能生产需权衡可靠性wait_timeout28800600减少空闲连接占用防止连接数被耗尽这里要提醒一句参数调整一定要结合机器配置和数据量不要盲目抄。比如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-threads200在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是我最常用的压测工具但这套流程换成其他工具也一样适用。我设计的压测场景如下场景线程数持续时间说明单接口基准505min摸底观察基准TPS和响应时间负载爬坡100/200/400/600每档5min找到拐点和瓶颈稳定性测试30030min观察内存泄漏、连接池耗尽等问题尖峰测试10002min模拟瞬时流量冲击看系统恢复能力线程数不是一个固定值更不是越大越好。建议按“系统预估峰值QPS ÷ 单线程可承载TPS”的方式来估算。举例如果预估峰值QPS是2000单线程实测TPS是5那理论并发线程数就是400左右。我一直用这种倒推方式确定压测强度比拍脑袋设5000线程靠谱得多。4.2 聚合报告和一两个容易被忽视的指标JMeter的聚合报告大家都看但很多人只盯着Average。我这里建议重点看三个指标TP99 Error% TPS。平均值是会骗人的TP99和错误率才能反映出真实用户体验。还要提醒一个容易被忽视的点JMeter本身也可能成为瓶颈尤其是跑大并发的时候。我曾经遇到压测机CPU打满导致压测结果全乱。解决方法是使用分布式压测或者在JMeter里开启modeStandard以外的精简模式关闭所有不必要的Listener。JMeter最佳实践是不要开图形界面跑正式压测用命令行jmeter -n -t order_list_test.jmx -l result.jtl -e -o dashboard/-e -o dashboard/会生成一套HTML报告里面有响应时间分布、吞吐量变化曲线非常直观。这套报告也是我给团队同步调优结果的主要依据。4.3 调优效果的回归验证调优不是一劳永逸每次改完代码或配置都必须重新压测一遍。我通常先用一个固定的“回归压测基准”脚本跑一遍对比前后数据。这套基准脚本是固定的线程数、固定的数据量、固定的参数不能每次手抖改掉。以这次调优为例回归数据对比是这样的指标调优前调优后变化TPS4201150173%TP50480ms120ms-75%TP992.3s320ms-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-size20在600并发下完全不够用大量线程在等待获取数据库连接。我把maximum-pool-size调到60同时把minimum-idle调到20。连接池大小不是越大越好因为并发活跃连接过大会让数据库端出现连接争用和锁等待。这里有一个经验公式可以参考最大连接数建议按(核心线程数 * 2 有效磁盘数)来估算。比如8核机器加一块SSD初始值可以设到16到24再根据压测效果调整。5.4 批处理场景下的JVM调优专项这个项目里有批量数据处理的场景和普通接口调优不同批量任务的特点是内存消耗大、执行时间长、GC压力高。我针对批量任务单独调整了JVM参数把年轻代调大减少短生命周期对象频繁进入老年代-Xms4g -Xmx4g -Xmn2g -XX:UseG1GC -XX:MaxGCPauseMillis200G1GC配合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优化稳定了长跑表现每一项单独看都是很小的改动但叠加起来效果非常可观。积微成著性能调优本就是一个从细节里抠出系统的潜力、再把潜力变成稳定能力的过程。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →