1. 一次压测翻车现场:80%收益并不来自那100行代码
上个月帮一个电商团队做接口性能体检,场景很典型:一个订单批量查询接口,数据量刚过千万级,调用方就抱怨从原来的200ms涨到了2s,随着大促临近,这个接口的P99已经逼近4s,再拖下去就要触发熔断。
团队里的小伙子很勤快,第一反应是“SQL太慢了,赶紧加索引”,DBA查了一圈,执行计划没问题,索引都命中了。然后又怀疑是数据库连接池满了,于是把连接数从50调到200,结果RT不仅没降,数据库的负载反而被拖高了。折腾了两天,毫无进展。
我到了现场之后没有急着看代码,先把监控面板拉出来扫了一遍。接口的TP99从晚上8点开始爬升,和数据量的增长曲线几乎同步。直觉告诉我:这不是索引问题,也不像是连接池问题,更像是一个“循环内做重活”的典型场景。打开Arthas,用trace命令挂上接口的入口方法,跑了几百个请求,耗时分布非常清晰:90%以上的时间都耗在了一个叫fillOrderItems的私有方法里,而方法内部是一个for循环,循环体里逐条查询商品信息。
整个循环遍历的订单只有300个,但每个订单平均要查三次库、调一次外部价格服务,算下来将近1200次网络往返。2秒钟的RT,有1.8秒都花在这个循环里。
真正的修复只用了一百行代码出头:把逐条查询改成批量查询,把串行调用外部服务改成并发调用,再把循环内的重复计算提到循环外。上线之后接口RT直接掉到420ms,吞吐量提升了80%左右。
这个案例让我想聊一个比“100行代码”本身更重要的东西:技术直觉的校准。
说白了,大多数人做性能优化的思路,是靠“感觉”猜瓶颈。猜对了,皆大欢喜;猜错了,就像打靶不看靶纸,子弹飞哪儿去了全靠脑补。CTO让你优化接口,你拍胸脯说“加个Redis就好了”,结果缓存命中率不到10%,这就是直觉没校准的表现。
那怎么校准?这篇就用这个真实案例作为主线,把整个排查、定位、改造、验证的链路拆开讲清楚。你会发现,真正值钱的不是最后那100行代码,而是支撑你写对这100行代码的那套判断方法。
2. “伪优化”的三种典型死法:为什么你改了代码却毫无变化
先泼一盆冷水。很多时候我们辛辛苦苦优化了半天,性能纹丝不动,不是代码不行,而是压根没找对靶子。我把这些年见过的“伪优化”归成三类,你可以对照一下自己踩过哪个。
2.1 优化了弦乐四重奏里的小提琴手,噪音源是底鼓
第一类死法,最常见:整个请求链路里,你盯着的那个部分其实是小头。
举个例子。有次一个团队找我帮忙看一个报表导出功能,慢到每次导出要等30秒。他们怀疑是POI写Excel太慢,打算引入一个新的导出框架,还准备把报表拆成多个子表异步生成。我看了一眼他们的采样数据,先问了一个问题:“每次导出,查数据库花了多久?”没人答得上来。
后来用SkyWalking把整个链路追踪打开,结果一目了然:SQL查询加聚合花了26秒,POI写Excel只花了1.5秒。换句话说,就算把POI换成超算级别的库,整体时间也只能省1.5秒。他们打算花一周做的“优化”,换来的是5%的提升。
这就是典型的“优化了弦乐四重奏里的小提琴手,但噪音源是底鼓”。
性能优化有个黄金法则:先用度量工具定位耗时分布,再决定动哪里。不要看到“导出慢”就觉得是“写Excel慢”,看到“接口慢”就觉得是“SQL慢”,体感不是度量,慢SQL日志也不等于全链路真相。
2.2 放大器陷阱:找到了慢SQL,但QPS低到无所谓
第二类死法,找对了问题,但判断错了影响面。
有次一个开发同学很兴奋地告诉我,他从慢日志里揪出一条执行了8秒的SQL,把整个报表页面拖垮了。我问他这条SQL每秒被调用几次?他说大概每五分钟触发一次,而且只有几个内部用户会用。
这个就是“放大器陷阱”:你找到的问题是真的,但它的影响被想象放大了。
判断一个性能问题该不该优化,不只看单次耗时,还要看调用频率和用户影响半径。一个P99是5秒但每天只有一百次调用的内部接口,和一个P99是800ms但QPS是5000的核心接口,前者可能根本不用动,后者倒是值得投入精力。
我的习惯是,拿到任何性能问题,先建一张“影响等级表”:
| 指标 | 高优先级 | 中优先级 | 低优先级 |
|---|---|---|---|
| 调用频率 | QPS > 1000 | QPS 10~1000 | QPS < 10 |
| 用户影响 | 核心链路、线上故障 | 主流程体验受损 | 后台任务、内部工具 |
| 耗时趋势 | 随数据量线性恶化 | 偶发性尖刺 | 稳定但偏慢 |
用这张表卡一下,你会发现很多“优化机会”其实是无效功,真正值得做的,永远是那个发生频率高、恶化趋势明显、直接影响核心链路的问题。
2.3 缓存王者思维:把一切堆进Redis,反而堆出故障
第三类死法是最贵的:不加分析地堆缓存。
有个朋友的口诀是“万物皆可缓存”,模块响应慢?缓存。接口超时?缓存。连统计报表都敢缓存五分钟。直到有一天缓存服务内存被打满,触发了大规模的驱逐,一瞬间所有请求全部穿透打到数据库,数据库直接被打爆,业务停了将近一小时。
缓存是放大器,不是修复器。它能放大“本来就快”的查询,也能放大“结构不合理”的损耗。如果底层逻辑是一个循环做了1000次查询,你给每个查询加缓存,只是把1000次数据库往返变成1000次Redis往返,吞吐上限没有质的变化,还额外引入了缓存一致性、内存占用、缓存击穿三大麻烦。
那100行代码的价值恰恰就在这里:它不是往系统里叠一个重型组件,而是在原有的资源内消除浪费。这是性能优化最健康的方向——先省掉不必要的计算,再谈要不要加缓存。
3. 把直觉校准成“狙击步枪”:一套可复现的定位流程
说完了为什么经常找错靶子,接下来就是关键:怎么把“猜”变成“测”,把感觉变成刻度。我把完整的定位流程整理成四个步骤,每一步都对应我们这次订单接口排查的实践。
3.1 第一步:端到端记录链路,锁定RT的大头,而不是小头
别一上来就开Arthas、翻代码。先把链路追踪打开。如果你用的是SkyWalking、Zipkin、Jaeger这类工具,可以直接看到一次请求从网关到应用再到数据库/中间件的完整耗时瀑布图。如果没有现成的工具,临时打点也行:在接口入口、服务调用、SQL执行、外部HTTP调用这几个关键节点埋上耗时统计。
看完链路之后,圈出那个占比最大的区间。我们的原则很简单:如果这个环节能占到总耗时的60%以上,优先搞它;如果只占5%,以后再搞。这和二八法则同构,性能问题通常有一个主要矛盾,先把它找出来。
以这个订单接口为例,刚开始团队以为是SQL慢,但从链路追踪看,真正的耗时大头不在数据库那一段,而在应用层的一个循环。这个判断如果不基于链路数据,几乎不可能靠“读代码”一眼看出来。
3.2 第二步:用Arthas或profiler分层,别跳过中间件
锁定了大概方向之后,就要进入代码级别定位。
我是Arthas的忠实用户,最常用的三个命令:
trace:追踪一个方法的调用链,输出每个子调用的耗时分布。这次就是靠它圈出了fillOrderItems这个方法。watch:观察某个方法的入参、出参、异常,适合确认数据量级和是否存在意外分支。thread:查看线程栈和线程状态,适合排查线程阻塞、死锁、线程池耗尽。
如果是Python,就用cProfile或py-spy;如果是Go,pprof直接出火焰图。语言不重要,思路一致:用工具给方法调用做“分贝测试”,哪个方法声音最大就降哪个。
有一种很隐蔽的情况是“中间件拖慢”:比如Redis在某个时段变慢了,导致所有经过它的查询都变慢。如果链路追踪只覆盖了应用内部方法,没覆盖中间件的响应时间,你很可能把锅甩给业务代码。所以trace的时候一定要观察到最底层,确认时间到底是花在方法逻辑里,还是花在了IO等待上。
3.3 第三步:冷热数据分离,排除环境因素干扰
定位阶段最容易犯的错,是拿非典型流量下的数据做判断。
比如排查那天正好在做数据迁移,或者有个爬虫在打某个接口,这些干扰会把耗时分布搞得很奇怪。我的习惯是,先过滤出“正常流量”下的请求,再结合“异常流量”做对比。如果正常流量下接口也慢,那问题大概率在代码结构;只有在异常流量下慢,那要先处理流量问题。
另外,测试的时候尽量用两份数据:一份是冷数据(没有缓存、没有预热),一份是热数据(已缓存、已预热)。两者差出一个数量级是正常的,但如果冷数据慢热数据也不快,那就说明问题不在缓存链路上。
3.4 第四步:构造最小复现,验证你的假设
当你有了“我怀疑是循环里N次查询导致慢”的假设,不要急着改代码,先构造一个最小复现:写一段小的压测脚本,模拟数据量级,把循环逻辑跑一遍,看耗时曲线是否符合假设。
这一步成本很低,但价值极大。它能验证因果,而不是只看相关。很多工程师跳过这步,改完代码上线之后发现没用,就是因为没能在小范围内证明自己的判断是对的。
我们这次也做了最小复现:提取接口里那段循环逻辑,放到一个独立的测试方法里,分别模拟300个订单和1000个订单,测出来的耗时几乎是线性增长。这个结果证明了“循环内逐条查询是主瓶颈”的假设,后面改代码的时候非常笃定。
4. 那100行代码到底改了什么:一个批量查询接口的完整改造记录
定位完成、假设验证通过,接下来才是动代码。这一节我把改造前后的代码结构和每一步的收益拆开记录,你可以直接当case study看。
4.1 改造前的代码到底烂在哪里
原始接口的逻辑说白了是一个很常见的“主从表聚合”:
- 根据用户ID查询最近N笔订单主记录。
- 遍历每一笔订单,在循环里查询订单明细。
- 遍历每一个明细,拿到商品ID后逐条查询商品信息。
- 最后又遍历每个明细,调外部价格服务补齐实时价格。
听上去每一步都有道理,但放到数据量级下一算就知道了:300笔订单,平均每笔5个明细,就是1500条明细查询;如果再算上商品查询和外部价格服务调用,网络往返次数大概是300 + 1500 + 1500 = 3300次。哪怕每次往返只要0.5ms,光网络开销就是1.65s,这还没算锁竞争、线程切换、GC压力。
这块的代码我简化一下,核心结构长这样:
// 改造前:循环内逐条查询 public OrderListVO queryOrderList(Long userId, int limit) { List<Order> orders = orderMapper.selectByUserId(userId, limit); for (Order order : orders) { List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); // 逐条查明细 for (OrderItem item : items) { ProductInfo product = productMapper.selectById(item.getProductId()); // 逐条查商品 Price price = priceClient.getPrice(item.getProductId()); // 串行调外部服务 item.setProduct(product); item.setPrice(price); } order.setItems(items); } return assemble(orders); }看一眼就明白了:这是三个循环嵌套加上一个外部网络调用。数据量小的时候不觉得,数据量一上来就是灾难。
4.2 改动项清单:每项改动的收益换算
下面这张表记录了每一处改动的具体内容和收益逻辑,你可以直接拿这个清单去对照自己的项目。
| 改动项 | 原始做法 | 改后做法 | 收益估算 |
|---|---|---|---|
| 订单明细查询 | 逐单查selectByOrderId | 用where order_id in (...)批量查 | 网络往返次数从300次降到1次 |
| 商品信息查询 | 逐条查selectById | 按商品ID集合in查询 | 网络往返次数从1500次降到1次 |
| 外部价格服务 | 循环内串行HTTP调用 | 用线程池并发调用,每批50个 | 外部调用时间从O(N)降到O(N/并发度) |
| 循环内的重复计算 | 每次遍历都去get一个固定配置 | 提升到循环外只查一次 | 减少约80%的无谓计算 |
| 组装结构 | 内存里反复add导致List扩容 | 预估容量,一次性初始化 | 降低内存分配压力,减少GC |
很多人看到“批量查询”觉得是个老生常谈,但在实际工程里,批量化的收益比你想象的大得多。一个for循环里1000次数据库查询变1次IN查询,不是省了999次那么简单——它还省掉了连接获取、事务开启、网络RTT、结果集解析这些固定开销。如果连接池本身很紧张,这部分收益还要加倍。
4.3 并发调外部服务的正确姿势:线程池参数与兜底
串行改并发是收益最大但也最容易出问题的一步,必须单独讲。
外部价格服务一个单次调用的RT在50ms左右。1500次串行调用,理想情况下就是75秒,显然不可能接受。改成并发之后,用10个线程跑,理想情况下降到7.5秒,如果用20个线程,就能压到4秒以内。但我们最后没有无脑地调高并发,而是反复测了几组参数,选了“10个线程+50ms超时+降级返回缓存价”的组合。
线程池这里有几个坑值得专门提醒:
- 不要用
Executors.newFixedThreadPool的默认无界队列。外部服务一旦变慢,无界队列会吃掉所有内存,甚至把整个应用拖死。一定要用有界队列,并配上拒绝策略。 - 并发数不是越大越好。外部服务通常也有自己的容量上限,无限制地加大并发只会把对方打挂,得不偿失。
- 必须设置超时和降级。调用外部服务时如果没有兜底方案,一旦对方抖动,你的接口会跟着一起雪崩。我们当时给价格服务加了50ms超时,失败时读Redis里的上一个价格快照,接口的稳定性立刻上了一个台阶。
改造之后的代码核心部分长这样:
// 改造后:批量查询 + 并发调用 public OrderListVO queryOrderList(Long userId, int limit) { List<Order> orders = orderMapper.selectByUserId(userId, limit); List<Long> orderIds = orders.stream().map(Order::getId).toList(); // 1. 批量查明细 List<OrderItem> items = orderItemMapper.selectByOrderIds(orderIds); Map<Long, List<OrderItem>> itemsByOrderId = items.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); // 2. 批量查商品 List<Long> productIds = items.stream().map(OrderItem::getProductId).distinct().toList(); Map<Long, ProductInfo> productById = productMapper.selectByIds(productIds).stream() .collect(Collectors.toMap(ProductInfo::getId, Function.identity())); // 3. 并发查询外部价格 Map<Long, Price> priceByProductId = queryPricesConcurrently(productIds); // 4. 组装 ... }4.4 改造完整链路:从编译到灰度,每一步的验证目标
代码改完不是直接上线,我们走了一套完整的验证链路:
- 单元测试:保证核心组装逻辑没有丢字段、没有顺序错乱。
- 本地压测:用wrk模拟之前压测同样的QPS,观察RT是否真的下来。实测结果很理想,P95从2.1s降到400ms出头。
- 预发回归:在预发环境跑了全链路压测,确认数据库连接池、线程池、外部服务都没有被打到瓶颈。这一步很重要,因为本地压测用的是假数据,预发才是真实数据分布。
- 灰度发布:先放开5%流量,观察监控面板上的RT、错误率、GC曲线,确认没有异常后逐步放量到100%。
整个过程大概用了半天时间。那个团队问我:“为什么改这么少?我们之前加班改了两天都没动。”我说,因为之前的两天是在用身体感动自己,不是用数据驱动判断。
5. 让“子弹校准”成为肌肉记忆:日常工程里的四个软性习惯
代码能力可以靠刷题提升,性能优化的直觉只能靠一次次“假设-验证”的循环来校准。你想让这种“子弹校准”变成团队肌肉记忆,光靠一两次翻车案例是不够的,要把它固化成日常工程习惯。
5.1 性能预算表:把响应时间变成团队共识
强烈建议每个核心接口都做一张“性能预算表”,刚立项就定好各方可以占用的耗时上限。举个例子:
| 环节 | 耗时预算 |
|---|---|
| 网关+鉴权 | 20ms |
| 应用层逻辑 | 80ms |
| SQL查询 | 100ms |
| 外部服务调用 | 50ms |
| 总预算 | 250ms |
这样设计的价值不只是“有个数”,而是让开发同学从一开始就有边界感。写代码的时候会去想“我这个循环如果N很大是不是就超预算了”,而不是等接口上线变慢了才回头补课。
预算表定完之后,每次性能劣化都能快速定位到是哪个环节超了,不用每次都从头扫一遍全链路。
5.2 基线压测与巡检:不稳定环境才是真正的敌人
性能优化的一个核心事实是:你的代码是否变快了,需要在一个“环境噪声可控”的基准线下对比。如果每次压测都在不同环境、不同数据量、不同并发下做,你根本分不清提升的80%是代码变好了还是机器变闲了。
我们的做法是每个迭代都挑一个固定环境、固定数据量、固定并发模型跑“基线压测”,结果落到一个监控看板上。哪个迭代性能出现明显回归,看板会第一时间报警,根本不用等用户投诉。
这个习惯一开始做会觉得烦,但它能节省的排查时间远超投入。尤其当团队越来越大的时候,没有基线压测,性能回归几乎无法追踪。
5.3 复盘文化:每次优化后都问一句“你是怎么知道的?”
我每次做完一个性能优化,都会让团队复盘时回答三个问题:
- 你是怎么知道这里慢的?——靠工具还是靠猜?
- 你是怎么确认改动有效的?——压测数据还是体感?
- 下次遇到类似问题,你的第一步动作会是什么?
这三个问题的作用不是“整顿职场”,而是逼着大家把经验沉淀成方法论,而不是“运气好解决了”。长期下来,团队里就会积累一份“性能问题排查手册”,每个人遇到问题都能照方抓药,不用靠个别技术大牛到处救火。
5.4 一套够用的工具清单
最后整理一份我们日常最常用的工具清单,你可以直接参考:
| 用途 | 工具/手段 | 说明 |
|---|---|---|
| 全链路追踪 | SkyWalking、Jaeger、Zipkin | 看跨服务耗时分布 |
| JVM应用内部定位 | Arthas | trace、watch、thread三件套 |
| Python应用定位 | cProfile、py-spy、pyroscope | 火焰图、函数级耗时 |
| Go应用定位 | pprof、trace | 官方工具,火焰图必看 |
| SQL慢查询定位 | 慢日志+执行计划 | 但记得结合调用频率判断影响 |
| 压测 | wrk、JMeter、k6 | wrk适合单机快速压测,k6适合复杂场景 |
| 监控 | Prometheus + Grafana | RT、QPS、错误率、GC指标一站式 |
工具不在多,关键要形成一套“从宏观到微观”的立体打法:先看链路追踪锁定服务,再看trace确定方法,最后压测验证假设。
6. 这套思路的推广与边界:100行代码在哪些场景同样好使
最后聊聊这套“先校准再动手”的方法论到底能辐射多广,以及它什么时候不适用。
6.1 批量任务改造:一个一个处理改成一批一批处理
第一类非常适用的场景,是各种定时任务、离线批处理、消息消费逻辑。
最常见的拖慢批量任务的写法,就是遍历列表然后逐条查库、逐条调接口、逐条发消息。我们有个内部对账任务,原本跑完要40分钟,改完批量查询加并行处理之后只需要7分钟。改动的代码量不到150行,思路和上面订单接口完全一致。这类优化之所以有效,是因为批量任务天然符合“批量放大单次耗时”的特征,消除循环内IO的收益极其显著。
6.2 日志与序列化开销:看不见摸不着的隐形杀手
第二类容易被忽略的优化点,是日志和序列化。
有次排查一个接口,数据显示线程大部分时间不在业务逻辑里,而是在打日志。原因是一个老同学图省事,在循环里打印了每一笔订单的完整JSON报文,数据量一大,日志I/O和字符串拼接直接占掉了大量CPU。把日志级别调高、去掉循环内的日志之后,接口RT掉了一半。
序列化同理。特别是大对象反复序列化成JSON字符串再序列化回来,开销很高。像这类问题,往往不是“加缓存”能解决的,而是要把不必要的计算从热路径上拿掉。
6.3 合并网络请求:能少一次连接就少一次
第三类是基于“连接复用”思想的优化:把多次小请求合成一次大请求。比如原来前端调三个接口分别拿用户信息、订单列表、优惠券,最低级但有效的优化是把三者合并成一个聚合接口,省掉两次HTTP往返。
后端也是一样,多个下游服务的调用如果彼此没有依赖关系,就应该并行,而不是串行。这里有个度要把握:合并和并行会提高代码复杂度,包含聚合、拆分、异常处理、超时控制多个环节,建议在收益明显时再用。
6.4 边界提示:当瓶颈不在代码时,别硬写代码
最后必须说一下边界。
如果你的问题根因是数据库磁盘满了,或者带宽被打满,或者下游服务本身容量不足,这时候写一百行代码去优化“循环逻辑”是无济于事的。性能优化手段要和根因匹配:业务代码问题用代码解决,基础设施问题用扩容或架构调整解决,数据量级问题用分库分表或归档解决。
有一次一个团队让我看一个接口,我想尽办法从SQL、代码、缓存三个角度优化,都只能做小幅提升。后来发现是这台机器本身CPU配额被限制了,同一个宿主机上其他容器抢占了大量资源。这种基础设施问题,优化的方向应该是调整配额或做流量调度,而不是写代码。
所以在动手写那100行代码之前,先问自己一句:瓶颈真的在我的代码里吗?这个问题越早回答,浪费的时间越少。
7. 一些后续的碎碎念
回过头看这个案例,我最深的体会是:很多人做性能优化,缺的不是技巧,而是对“度量”的尊重。技术直觉非常重要,但它的价值不在于替代度量,而在于引导你更快地设计出验证方案。子弹校准的本质,就是让每一枪都落在有靶纸的地方,然后根据弹着点微调准星,而不是蒙着眼睛朝一个方向疯狂开火。
那100行代码能提升80%,并不是因为这100行代码本身有多高明,而是因为前面的定位过程让我们清楚地知道该改哪里、不该改哪里。如果你现在手头也有一个“怎么优化都没效果”的接口,我的建议很直接:先停下手里的代码,去把链路追踪打开,把耗时分布看明白,构造一个最小复现验证你的假设,再回来改代码。相信我,效率会完全不同。