
1. 一次常规性能专项为什么会卡在存储模型上1.1 项目背景与性能目标今年上半年我接手了一个订单中心核心查询接口的性能专项。这个接口叫订单列表聚合查询业务方给到的背景很简单大促期间订单量翻倍现有接口的TP99已经逼近3秒DBA和开发团队自查了两周加了几个索引、调了连接池收效甚微。领导层对性能测试提出的要求是在模拟日常3倍峰值流量的压测环境下TP99必须压到1秒以内QPS至少要从当前的800提升到3000以上。这个目标初看不算激进但了解完接口逻辑后我心里打了鼓。该接口一次请求要聚合订单主表、订单明细表、商品快照表、优惠分摊表、物流轨迹表五张表的数据其中订单明细表当时已经接近1.2亿行优惠分摊表也有4600万行。更关键的是接口原本是为单订单详情页设计的后来被复用到订单列表页后开发直接在循环里调用了详情的组装逻辑——一次列表请求极端情况下会触发几十次子查询。这类问题的典型特征就是单次查询看起来都不慢但复杂调用关系叠加后整体响应时间完全不可控。做性能测试的人最怕的就是这种每层都合理、整体不合理的系统。所以我的第一步不是压测而是先把调用关系摸清楚。1.2 第一轮压测结果比预期差了一个数量级按照常规流程我先把JMeter脚本搭了起来。场景设计不算复杂300个并发线程Ramp-Up时间60秒持续压测10分钟事务控制器里放登录获取token→调用列表接口→随机点击进入详情三个步骤。测试数据全部用线上脱敏数据导入订单号、用户ID的分布保持和线上一致避免出现压测全打在热数据上的假象。第一轮结果出来后会议室安静了。接口的平均响应时间达到了2.6秒TP99直接飙到4.8秒而QPS只有可怜的520——连当前线上水平都没跑满。监控大屏上MySQL的CPU使用率倒是不高只有30%左右但数据库的QPS查询次数高得离谱平均每秒有将近12000次查询。也就是说一次用户请求平均要触发23次SQL查询。把这个数字摆出来开发负责人马上意识到问题不在数据库的硬件能力上而在SQL的调用次数上。我当时的判断是这个系统不是查得慢而是查太多。存储模型层面的调用链路优化空间比单纯调MySQL配置要大得多。1.3 先别急着调参性能调优的正确顺序这里我想先聊一个经常被忽略的原则性能调优最忌一上来就调参数。很多人拿到性能问题第一反应是改JVM堆大小、改MySQL的buffer pool、加大连接池。这些动作不是没用而是很可能把真正的问题掩盖住。我个人的调优顺序是固定的三板斧先做调用链路分析确定耗时和查询次数的分布再做存储模型优化从表结构、索引、SQL写法上消除不必要的开销最后才轮到配置层面的调优比如JVM参数、MySQL参数、连接池大小。原因是配置参数是放大器不是消除器。如果底层要执行100次低效查询你把连接池从50调到200只会让100次低效查询并发执行得更猛烈数据库很快会被打挂。这次的实战也证明了这一点——后续所有有效收益几乎都来自前两步JVM调优只是把尾部延迟再压缩了一部分。2. JMeter压测方案设计场景、数据、监控一个都不能少2.1 压测场景与参数化数据设计性能测试要得出可信结论脚本设计必须贴近真实流量模型。这次压测我用了三个独立的线程组分别模拟三类流量第一类普通用户浏览行为占比70%只调用列表查询和订单详情查询第二类高频用户行为占比25%会追加查看物流轨迹和申请售后的操作第三类后台运营批量查询占比5%模拟运营人员按时间范围拉取订单列表单次查询数据量较大。每个线程组的请求频率用常数吞吐量定时器控制而不是简单地让每个线程跑满。这样更接近真实场景的有思考时间的用户行为压出来的QPS和TP99才有参考价值。参数化方面我建了三个CSV文件用户ID池10万个、订单ID池50万个、商品ID池30万个。在JMeter里通过CSV Data Set Config配置策略选择随机分配确保压测请求不会集中在某几个用户或订单上。这一步非常关键——如果参数化做不好压测结果容易出现缓存命中率虚高或单行热点锁竞争两种极端假象。注意压测数据一定要做数据量级校验。比如列表查询按用户ID维度过滤如果测试库里每个用户的订单数只有十几条而线上平均是几百条那压测耗时一定偏短测试结论不可信。这次我专门写了个校验SQL统计了测试库和线上库每个用户平均订单数的分布差异。2.2 监控指标采集从系统层到应用层性能测试过程中监控体系比脚本本身更重要。只盯着响应时间和QPS看出了问题根本没法定位。这次的监控分为四层监控层级核心指标采集工具系统层CPU、内存、磁盘IO、网络带宽Prometheus Node Exporter应用层接口RT、QPS、线程池活跃数、GC频率Spring Boot Actuator SkyWalking数据库层慢SQL数量、连接数、缓冲池命中率、锁等待MySQL Performance Schema 慢查询日志中间件层Redis命中率、MQ积压数量云监控控制台我特别关注两个容易被忽视的指标一个是数据库连接数曲线如果压测过程中连接数持续增长且不回落说明连接池配置或SQL执行时间存在瓶颈另一个是Full GC频率如果一分钟内超过一次JVM调优就必须提上日程。这两个指标在第一轮压测中就有异常表现后面会详细说。2.3 压测过程中最容易出现的三类假象实战中JMeter结果漂亮上线就崩的例子太多了。这里总结三类我踩过的坑第一类缓存命中假象。如果参数化数据量太小大量请求会命中最热的几页数据Redis命中率被拉得很高接口响应时间自然好看。但线上用户数据分布远没有这么集中一上线缓存命中率立刻掉下来。规避办法就是做足数据量且压测前清除关键缓存模拟冷缓存启动的最坏情况。第二类事务内耗时被平均掩盖。JMeter聚合报告里的Average是最骗人的指标。有的接口内部有极端耗时的重试逻辑可能99%的请求都很快1%的请求超时重试平均耗时看似正常但TP99难看得没法交代。所以我的压测结论从来不看Average只看TP99、TP999和错误率。第三类客户端瓶颈假象。load generator所在的机器资源不够导致JMeter本身成为瓶颈发送请求的速率上不去误判为服务端性能不足。这次我用了3台压测机每台最多跑150个线程压测机CPU保持在70%以内确保瓶颈永远在服务端而不是压测端。3. 调用链路分析把耗时从接口一路拆到SQL3.1 链路追踪的落地方式性能专项的第一步不是猜而是把调用链路的耗时分布可视化。我们当时系统里已经接入了SkyWalking但配置粒度比较粗很多接口只做了入口埋点内部数据库操作的Span信息没采集全。我做的第一件事就是补全链路的私有Span采集特别是把MyBatis执行SQL的耗时单独打点。具体做法是给订单中心的Mapper接口加了一层基于MyBatis拦截器的耗时统计插件每次SQL执行都会记录接口方法名 SQL摘要 执行耗时。同时把这些数据输出到日志文件再用脚本按traceId聚合生成一次请求内部的SQL调用清单。这一套下来每个请求的耗时构成就一目了然了。如果你用的不是SkyWalking也可以简单在当前项目里引入brave或Zipkin做轻量级埋点或者直接用日志里已有的traceId做聚合分析。工具不重要重要的是能把接口耗时逐层拆到方法耗时再到SQL耗时。3.2 耗时拆解找到那个占比67%的慢SQL链路数据拉出来后问题非常清晰。一次典型的列表查询请求总耗时约2.6秒其中订单主表查询耗时120ms1次订单明细表查询耗时180ms1次优惠分摊表查询耗时45ms却在循环内被调用了12次合计540ms商品快照查询耗时80ms循环内调用8次合计640ms物流轨迹查询耗时140ms循环内调用10次合计1400ms其他业务逻辑与JSON序列化约300ms网络与框架开销约220ms。看到这组数据所有人都不说话了。物流轨迹查询单次只要140ms但循环10次就是1.4秒占总耗时的67%。商品快照和优惠分摊同理加起来又是1秒多。这就是典型的N1查询问题——接口的实际耗时和列表长度成正比列表越长接口越慢。提示N1查询问题在ORM框架项目中极其常见尤其是那种先查主表再循环查子表的组装方式。性能测试中如果发现数据库查询次数远高于业务合理值基本可以锁定是这个原因。3.3 慢SQL的根因存储模型设计缺陷找到慢SQL之后下一步是分析为什么单次查询也偏慢。物流轨迹表当时的表结构有几个典型问题索引缺失。表里有order_id字段但只建了一个普通索引查询条件是order_id ? AND status IN (1, 3, 5)这个查询条件只用了order_id索引前缀status列的过滤是在回表后做的等于把大量无效行读进内存再丢弃。超大字段未拆分。物流轨迹表里有个tracking_detail字段类型是TEXT存的是一次物流的所有轨迹点JSON单行数据平均有4KB。按order_id查询时即便只取更新时间InnoDB也必须把整行数据读出来才能判断是否满足条件。表数据冗余混乱。因为历史原因同一个订单的物流轨迹会被写入多条记录用户每次刷新轨迹就插一条导致order_id对应记录数膨胀到几十条。这直接放大了N1问题的影响。商品快照表的问题类似快照内容是一个大的JSON字段但列表展示只需要商品名称、图片URL、价格三个字段。每次循环查询都把这个大JSON字段整个拖出来IO开销成倍增加。这就是存储模型层面的设计缺陷。这类问题靠加索引能解决一部分但治本的方式是重新设计存储模型——把高频查询的低频大字段拆出去把聚合查询变成批量查询。4. 存储模型优化实战表结构、索引与SQL改写4.1 表结构设计上的调整垂直拆分与冗余字段性能测试团队通常不直接改业务表结构但可以给出存储模型优化建议并推动开发落地。这次的改动分三步第一步垂直拆分物流轨迹表。把tracking_detail这个大字段拆分到独立的tracking_detail_ext表主表只保留order_id、status、tracking_no、update_time等高频查询字段。这样按order_id查询轨迹状态时每行数据从4KB降到了200字节左右同样的IO能扫描的行数提升20倍。第二步商品快照表增加冗余列。在商品快照表里直接冗余goods_name、goods_image、goods_price三个高频展示字段查询时不再加载大JSON字段如果需要完整快照信息再走一次扩展查询。这个改造对于列表类接口收益极高因为列表页90%的场景只需要这三个字段。第三步订单明细表增加状态索引。原先明细表查询订单明细时条件为order_id ?但日常列表场景大部分只关心有效明细我们加了一个(order_id, is_valid, create_time)的联合索引让过滤在索引层完成减少回表。这三步做完等于把每次循环查询都要拖大对象变成了每次循环查询只需扫索引和轻量行。存储模型层面的优化往往比SQL改写更彻底因为它是从根上减少了数据读取量。4.2 索引优化与SQL改写从循环查询到批量查询表结构调整之后核心的SQL改写动作是消除N1查询把循环内的单查变成批量查。以物流轨迹为例原来的伪代码逻辑是ListOrder orders orderMapper.queryByUserId(userId); for (Order order : orders) { ListLogisticsTrack tracks logisticsMapper.queryByOrderId(order.getId()); // 组装每个订单的物流信息 }改写成批量查询ListOrder orders orderMapper.queryByUserId(userId); ListLong orderIds orders.stream().map(Order::getId).collect(Collectors.toList()); MapLong, ListLogisticsTrack trackMap logisticsMapper.queryByOrderIds(orderIds) .stream() .collect(Collectors.groupingBy(LogisticsTrack::getOrderId)); // 直接用 trackMap 组装避免循环内查询对应的Mapper SQL用IN查询SELECT order_id, status, tracking_no, update_time FROM logistics_track WHERE order_id IN foreach collectionorderIds itemorderId open( separator, close) #{orderId} /foreach这里有一个必须注意的细节IN列表的长度不能无限大。一次列表接口如果涉及上百个订单IN条件里上百个ID没太大问题但如果是后台批量拉取几千个订单IN会很长可能导致SQL解析变慢。稳妥做法是分批查询每批200个订单ID用分片方式聚合结果。商品快照和优惠分摊的改写思路完全相同。另外一个SQL改写点是列表接口不需要查询明细数据时跳过订单明细表只在用户点击查看详情时才加载。这个改动从业务层面直接砍掉了请求中最重的一次单表查询。4.3 MySQL配置层面的调优动作存储模型优化到位后MySQL配置调优才真正有意义。这次的配置调整有四个# my.cnf 关键优化项 innodb_buffer_pool_size 32G # 原值为16G约为物理内存的70% innodb_buffer_pool_instances 8 # 降低大缓冲池的并发竞争 innodb_io_capacity 2000 # 配合SSD提升刷新脏页能力 innodb_flush_neighbors 0 # SSD下关闭相邻页刷新 max_connections 800 # 从500提升配合应用层的连接池上限其中innodb_buffer_pool_size是收益最明显的参数。订单中心的核心数据基本都在1.2亿行级别16G的缓冲池在压测高并发下命中率只有96%左右意味着每100次查询有4次要落盘调到32G后命中率提升到了99.5%以上落盘IO几乎消失。但我必须提醒一句innodb_buffer_pool_size不宜超过物理内存的80%否则会挤压操作系统Page Cache的可用空间反而拖慢冷数据读取。调这个参数一定要看服务器的物理内存总量不是越大越好。5. 批量调优与JVM调优把吞吐量再往上顶5.1 批量接口的瓶颈特征与优化思路订单中心还有一个后台批量导出接口业务方抱怨一次导出5万条订单需要40秒。压测结果显示这个接口的瓶颈不在SQL而在逐条处理的业务逻辑上每条订单都要调用一次优惠计算服务远程RPC5万条就是5万次RPC平均每次0.6ms光RPC耗时就占了30秒。这类批量场景的通用优化思路有三个方向RPC批量接口化。把优惠计算服务从单订单计算改造成批量计算一次传入100个订单ID返回计算结果数组。这一步直接把RPC调用次数从5万次降到了500次。并行化处理。用线程池把批量任务分片8个线程同时处理8个分片单个分片内部继续走批量RPC。压测下来的吞吐量从每秒1250条提升到4000条。结果缓存。优惠计算结果在短时间内不会变化加了一层本地Caffeine缓存命中率大约35%进一步降低了重复计算的RPC压力。批量调优的核心理念和接口调优一脉相承减少调用次数、提升单次调用效率、把串行变并行。很多批量接口慢其实慢在次数上而不是慢在单次计算上。5.2 JVM参数调优与GC日志分析存储模型优化和批量改造完成后接口的TP99已经降到了800ms左右但压测中发现一个反常现象TPS曲线每隔几分钟会出现一次明显的锯齿形下跌。查看监控发现每次下跌对应一次Full GC频率大约3分钟一次一次Full GC耗时450ms左右。我通过GC日志确认了问题所在./jstat -gcutil pid 1000 # 观察结果Eden区使用率经常飙到98%Old区持续缓慢增长原本的JVM参数是典型的默认配置无脑设大堆-Xms8g -Xmx8g -XX:UseConcMarkSweepGCCMS收集器在堆内存8G、对象分配速率极高的情况下并发标记阶段跟不上导致Full GC频繁触发。这次的JVM调优方向有两个一个是换垃圾回收器一个是调整堆内存分配比例。最终采用G1收集器配合显式堆大小配置-Xms16g -Xmx16g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.log换用G1后Full GC基本消失取而代之的是耗时更短的Mixed GC单次停顿控制在80ms以内。压测曲线从锯齿形变成了平滑曲线。这里的关键认知是不要盲目追求降低GC频率而调大堆内存堆越大GC单次停顿越久响应时间的尾部延迟反而会恶化。合理的目标是把Full GC频率降到几小时一次甚至一天一次而不是三分钟一次。5.3 最终的压测结果对比三轮优化后的最终压测数据如下指标优化前存储模型优化后批量JVM调优后平均响应时间2600ms890ms420msTP994800ms1350ms780msQPS52021003400单请求SQL次数23次4次4次Full GC频率3分钟一次3分钟一次8小时以上一次数据库CPU使用率30%55%70%一个很有意思的结果数据库CPU使用率从30%涨到了70%看起来变差了实际上恰恰说明系统在真正干活了。以前的一次请求触发23次SQL大量时间耗在无效循环和网络往返上数据库CPU反而吃不满优化后单请求只查4次查询更精准、扫描行数更少资源利用率自然上去了。性能优化追求的是系统吞吐量最大化不是某个组件负载越低越好。6. 积微成著整个调优过程的方法论复盘6.1 每个微优化对最终结果的贡献拆解这个项目给我最深的体会就是标题里的四个字——积微成著。最终3400的QPS不是某一个改动的功劳而是十几个微优化叠加的结果。我按贡献度大致拆解了一下N1查询改为批量查询贡献了约60%的收益这是最大的一块物流轨迹表垂直拆分贡献了约15%的收益让单次查询更快商品快照冗余字段贡献了约8%的收益减少大字段读取明细表联合索引贡献了约5%的收益批量导出的RPC批量化和并行化贡献了约7%的收益针对导出场景JVM换成G1收集器贡献了约5%的收益主要改善尾部延迟MySQL缓冲池调整让上述收益在高并发下更稳定单独看贡献不大但没有它压测结果会有波动。很多团队做性能调优总指望找到一个银弹——改一个参数、换一个中间件就解决问题。但真实情况是性能问题是系统性的每一个微小的不合理设计都在损耗资源只有把所有微小的不合理都修掉才能拿到质的飞跃。这跟前端性能优化的关键渲染路径是一个道理——影响速度的是一整条链路不是某一个节点。6.2 沉淀的检查清单与工具项目结束后我把这次的排查经验沉淀成了一份订单类系统性能压测检查清单分享给团队后反馈非常不错这里摘录核心条目查询次数核查单请求SQL次数是否超过10次如果是先排查N1再考虑加缓存。大字段核查表的单行数据平均大小是否超过1KB高频列表查询是否每次都加载大字段索引匹配核查慢查询日志里是否经常出现Using filesort、Using temporary索引顺序是否和WHERE条件匹配数据分布核查压测数据是否覆盖了每个用户的订单数从少到多的完整分布GC曲线核查压测期间TPS曲线是否出现周期性锯齿如果有查GC日志和堆内存配置。连接池水位核查数据库连接数是否持续上涨不回落连接池上限是否和数据库max_connections匹配缓冲池命中率核查InnoDB buffer pool命中率是否长期低于99%如果是优先调整存储模型而不是加内存。工具方面除了JMeter和SkyWalking这次还用了Arthas做应用层的在线诊断比如用trace命令直接看某个Mapper方法的耗时分布用dashboard看线程状态。在定位N1问题时Arthas的stack命令帮了大忙——它能直接打印指定方法的所有调用栈比翻代码快得多。6.3 给后来者的一句话建议如果只能给做性能测试和调优的同行一句建议我会说先让系统少做事再让系统快做事最后才让系统多做事。少做事对应调用链路分析和存储模型优化这是根本快做事对应索引、缓冲池、GC这些调优动作多做事才轮到加机器、加线程、加连接池。顺序一旦反了你可能会花大量时间调参最后发现瓶颈还在原来的循环查询上怎么调都调不动。这次项目从启动到收尾一共花了三周时间其中链路分析和存储模型优化占了十一天实际压测和调参只有四天。多数团队觉得性能调优是压测阶段的事但我的经验是压测代码只占了整个专项20%的工作量剩下80%的时间都在分析、改造、验证。把时间花在分析和存储模型上回报率远高于把时间花在压测脚本的精细打磨上。最后补充一个小技巧所有存储模型优化上线前一定要做AB压测对比用完全相同的压测脚本和参数化数据跑优化前后的版本记录每条SQL的执行计划差异。这样上线后的性能提升才能被量化验证而不是靠感觉快了很多。这次的每一个优化项我都在压测报告中附上了优化前后的SQL执行计划截图和耗时对比业务方看到数据后推进后续改造的阻力小了很多。