前段时间有个订单查询接口,线上 P99 一直压在 120ms 左右,日均调用量几百万次。平时看着不算夸张,结果大促前一次全链路压测,这个接口的线程池直接被打满,下游数据库连接数飙到上限,连带整个订单服务差点雪崩。当时领导只给了一句话:这个延迟必须降下来,目标先定 10ms 以内。后来整个优化做完,接口平均耗时从 82ms 降到了 0.8ms,P99 也稳定在 3ms 上下——确确实实是从毫秒级干到了微秒级。这篇延迟优化实战复盘,就是完整记录当时怎么定位、怎么拆解、怎么动手、怎么验证的整个过程,也把里面踩过的坑一并写出来。做服务端开发、负责核心接口性能、或者正在被延迟问题折磨的同学,这篇文章应该能提供一套可以照着抄的方法。
1. 延迟优化不是玄学:先搞清楚“毫秒都花在哪了”
1.1 80ms 的接口真的慢吗?先看业务场景再说
很多人一听到"接口延迟 80ms",第一反应是"还行吧"。但延迟这个词,脱离业务场景谈绝对值没有意义。同样是 80ms,在后台批处理任务里完全不是问题,在用户点击下单、直播弹幕互动、广告竞价、量化交易这类链路里,就是实打实的体验损伤和收入损失。有统计说移动端页面每多 100ms 加载时间,转化率就可能掉几个百分点,这还是在用户无感知的情况下。而对机器对机器的调用来说,比如网关转发、风控计算、实时推荐,80ms 意味着每秒单线程最多只能处理 12 个请求,线程池稍小一点,流量一上来直接排队雪崩。
我这次处理的订单查询接口,平时平均 82ms、P99 120ms,从单次请求看确实算不上病入膏肓,但压测暴露了真正的问题:一旦 QPS 爬到 2000 以上,Tomcat 默认 200 线程全部占满,请求排队时间从 0ms 涨到 400ms,然后触发上游重试,重试又放大了流量,最后数据库连接池被打穿。这就是典型的"看起来不慢,但扛不住峰值"的延迟问题。所以第一步一定不是上来改代码,而是先判断瓶颈属于哪一种:是单次处理本身慢,还是并发上去之后排队慢。这两种问题的解法完全不同。
1.2 毫秒和微秒之间,隔着一整个“无效等待”时代
先建立一个量级概念。1 毫秒等于 1000 微秒,现代 CPU 主频 3GHz 左右,一个时钟周期大概是 0.33 纳秒,也就是说 1 毫秒相当于大约 300 万个时钟周期。在 CPU 眼里,1 毫秒漫长得离谱。一次 L1 缓存访问只要约 1ns,一次内存访问约 100ns,一次 SSD 随机读约 100 微秒,一次跨机房网络 RTT 可能就要 1 到 30 毫秒。这么一对照就明白了:当接口耗时在毫秒级时,大头往往不是 CPU 在“算”,而是 CPU 在“等”——等网络、等磁盘、等锁、等下游响应、等 GC。
这次优化的核心思路,就是把所有“等”的时间一项项揪出来,改成“不等”或者“少等”。从 82ms 到 0.8ms,本质上不是把代码算得快了 100 倍,而是把请求路径上那些无意义的等待全部砍掉了。这个认知很重要,如果一开始就想着“优化算法”“手写汇编”这类方向,方向就偏了。
2. 延迟构成拆解:先找到那 80ms 到底花在哪
2.1 全链路分段测量:从客户端到数据库逐段埋点
任何性能优化,第一步永远是测量。没有数据,后面所有操作都是瞎猜。我当时把一次请求拆成了五个阶段:客户端到网关的网络 RTT、网关到服务的网络 RTT、服务端线程池排队时间、业务代码处理时间、数据库查询时间。每一段都用独立的日志标识符串起来,在日志里打上时间戳。
工具选择上,我用的是三件套。Arthas 的 trace 命令用来盯单接口的方法调用耗时,async-profiler 用来生成 CPU 火焰图看热点方法,MySQL 的慢查询日志加 PERFORMANCE_SCHEMA 用来定位数据库侧的问题。如果是分布式系统,SkyWalking 或者 Pinpoint 这类 APM 工具会更方便,链路追踪天然按 Span 分段,不用自己埋点。但要注意,监控工具本身也会带来开销,后文会专门讲这个坑。
实际测出来的结果让所有人都意外。一次请求 82ms 的构成大致是这样的:客户端到服务端网络 RTT 约 12ms,服务端线程池排队约 6ms,业务代码处理约 34ms,数据库查询约 28ms,响应序列化约 2ms。也就是说,真正花在“业务逻辑计算”上的时间连 5ms 都不到,剩下几乎全是等待和无效开销。这验证了前面的判断——这不是计算能力问题,是等待问题。
2.2 一份真实的火焰图:热点根本不在“该在”的地方
拿到火焰图之后,问题更清楚了。CPU 采样显示,排名靠前的方法居然是字符串拼接、Map 遍历、JSON 序列化、日志输出,而不是订单计算逻辑。几个典型问题:
- 代码里用
+=在大循环里拼字符串,循环 500 次,产生了大量临时对象; - 每次请求都把整个订单对象序列化成 JSON 写进日志,即便日志级别是 INFO 也照样拼接;
- 查询数据库用的是 MyBatis 默认配置,每次查询都重新获取连接,连接获取本身走了不少锁竞争;
- 对象转换用了 Dozer 这类反射映射工具,一次转换要反射几十次。
这些在火焰图上一眼就能看出来:某个方法的占比越宽,耗时占比越高。之前大家都凭感觉认为是数据库慢,实际数据库只占 28ms,而业务代码里的反射、序列化和字符串操作加起来反而更多。
2.3 数据库慢查询的三种典型病根
数据库那 28ms 也不能放过。打开慢查询日志后,发现这个接口的主查询 SQL 跑了 18ms,剩下 10ms 耗在从库同步延迟和连接获取上。用EXPLAIN一看,典型的三个问题全占了:
- 索引失效:where 条件里对时间字段用了函数包裹,导致索引无法使用,全表扫描;
- 回表过多:查询条件是
SELECT *,但索引只覆盖了order_id和user_id,其他字段全部需要回表,一次查询回表几百行; - 锁等待:事务里先更新后查询,行锁没释放,并发一高查询就排队。
这里有个很实用的检查习惯:任何 SQL 只要执行时间超过 10ms,就必须EXPLAIN看type是不是ref或const,rows是不是接近实际返回行数,Extra是不是有Using filesort或Using temporary。这三个字段基本决定了 SQL 能不能救。
3. 从毫秒压到微秒的关键操作
3.1 网络层:把不必要的网络往返全部砍掉
网络 RTT 占了 12ms,这在跨机房调用里算正常,但在同城双机房或者本机房内部,12ms 是偏高的。我做了几件事:
- 客户端和服务端全链路开启 HTTP keep-alive,连接池复用,避免每次请求都重新三次握手。这一步直接把 TCP 建连开销从几十次握手降到零;
- 服务端接入层打开 TCP_NODELAY,避免因为 Nagle 算法导致小包等待,尤其在请求体很小的时候这个等待可能高达 40ms;
- 内网调用去掉不必要的重定向,让请求直接命中目标节点;
- 静态数据和配置类信息从接口下放到本地缓存,减少请求携带的数据量。
优化后网络段耗时从 12ms 降到了 2ms 左右。这里要说一句,网络层面的优化空间受物理距离限制很大,跨地域调用再怎么优化也有物理极限,如果业务允许,最好的方式是把服务部署到离调用方近的地方,或者用专线、就近接入这类基础设施手段,而不是在代码里死磕。
3.2 计算层:消灭对象分配和序列化开销
这一层是我投入时间最多、也是收益最明显的部分。业务代码处理 34ms,优化后直接压到 3ms 以内,手段不复杂,但每一条都很有效:
- 干掉大循环里的字符串拼接,全部改
StringBuilder,避免每次拼接产生新的 String 对象和 char 数组; - 热路径上避免创建不必要的大对象,比如把订单详情对象从每次 new 改成从对象池取,用完归还;
- JSON 序列化从 Jackson 换成 protobuf,响应体序列化时间从 2ms 降到 20 微秒级别,并且体积也小了 70%;
- 干掉 Dozer 反射映射,改成手写 getter/setter 赋值,或者用 MapStruct 这种编译期生成代码的映射工具;
- 日志里去掉业务数据全量打印,改为关键字段采样输出,并且日志落到异步 appender,避免同步磁盘 IO。
你以为这些是小事,但在单次请求 34ms 的消耗里,字符串拼接就占了 9ms,反射映射占了 7ms,JSON 占了 4ms。每一条优化都能看到实打实的时间减少。关键原则是:热路径上的每一行代码都要问一句“这是否必要”,不必要的直接删。
3.3 存储层:MySQL 索引优化加多级缓存
数据库侧做了两件事:先优化 SQL 本身,再上缓存。
SQL 层面:去掉 where 条件里对时间字段的函数包裹,改成范围查询;把SELECT *改成只查需要的字段;在(user_id, order_id, create_time)上建了组合索引,让查询直接走覆盖索引,Extra 从Using filesort变成Using index。主查询从 18ms 降到了 1.2ms。
缓存层面:增加两级缓存。第一级是进程内缓存 Caffeine,设置最大条目数和 5 秒过期时间,热点订单直接命中本地内存,耗时 20 微秒级别;第二级是 Redis,缓存订单基础数据和状态机,序列化用 protobuf,耗时约 0.3ms;只有两级都没命中才查数据库。为了防止缓存击穿,对空结果也做了短时间的 null 缓存;为了防止缓存雪崩,过期时间加了一个随机抖动。改造后配置大致是这样的:
// 伪代码示例:两级缓存读取逻辑 OrderDetail getOrderDetail(String orderId) { // L1: 本地缓存,微秒级 OrderDetail cached = localCache.getIfPresent(orderId); if (cached != null) { return cached; } // L2: Redis,亚毫秒级 byte[] data = redis.get(buildKey(orderId)); if (data != null) { OrderDetail detail = protobufDecoder(data); localCache.put(orderId, detail); return detail; } // L3: 数据库兜底 OrderDetail detail = orderMapper.selectByOrderId(orderId); if (detail != null) { redis.setex(buildKey(orderId), expireTimeWithJitter(), protobufEncoder(detail)); localCache.put(orderId, detail); } else { localCache.put(orderId, EMPTY_MARK, 3); } return detail; }数据库平均耗时从 28ms 降到 1.2ms,加上缓存后绝大多数请求根本不会再走到数据库。这一步做完,接口整体耗时已经压到 8ms 左右,离目标很近了,但还是没有达到微秒级。问题出在哪?下一章说。
4. 实测中的意外状况:几个差点推翻成果的性能陷阱
4.1 第一次压测结果反而更差:JIT 预热和 GC 的干扰
代码改完,我兴冲冲地压测,结果傻眼了:平均延迟 12ms,比优化前的 82ms 是好了一些,但跟预期的 2ms 差得远。后来才发现,这是典型的 JIT 预热问题。JVM 在短时间内对热代码是解释执行,要经过一定调用次数才会触发 C2 编译,第一次压测跑的前 30 秒数据完全不可信。
正确的做法是压测前先跑几分钟预热流量,让 JIT 完成编译、让缓存也热起来,然后再开始采样统计。我用 wrk 压测时分了三段:30 秒预热,30 秒正式采样,最后再跑 30 秒看稳定性。这样出来的数据才真正反映生产环境的表现。
GC 也一样。如果压测时间太短,刚好撞上一次 Full GC,数据会被瞬间拉高几十毫秒。所以压测必须看长时间段的 P99,而不是看瞬时平均值。我自己后来定了个规矩:任何性能数据,至少跑 3 分钟以上,取最后 2 分钟的 P50、P99、P99.9,才算数。
4.2 隐形的锁竞争与伪共享:线程越多反而越慢
还有一个非常隐蔽的问题。优化后接口耗时降到了 3ms,但当并发线程从 50 升到 200 时,耗时不降反升。用 async-profiler 再看火焰图,发现热点集中在ThreadLocal的get()方法和某些计数器自增上,而且 CPU 的 cache miss 比例高得异常。
这里涉及伪共享的概念。多线程访问同一个缓存行里不同变量时,CPU 缓存一致性协议会强制同步整条缓存行,导致互相等待。多个线程各自更新自己线程的计数器,如果这些计数器恰好落在同一个缓存行里,性能就会严重劣化。Java 中可以用@Contended注解(需要 JVM 参数-XX:-RestrictContended)或者手动 padding 到 64 字节对齐来解决。我把计数器数组改成每个线程独立填充缓存行的结构后,200 并发下的延迟从 3ms 降到了 1.2ms。
线程池参数也要重新审视。之前核心线程数 10、最大线程数 200、队列长度 10000,这种配置在高 QPS 下会导致大量请求积压在队列里,而线程池线程数远不够用。后来改成核心线程 50、最大线程 200、队列长度 1000,并且拒绝策略改成 CallerRuns,让超限流量在调用方限速,而不是无界堆积。排队时间从 400ms 降到了几乎为零。
4.3 监控埋点本身拖慢了接口
这个坑特别典型,也是我从一个开源项目问题里得到的启发——那个问题就是性能监控导致弹窗组件二次打开时卡顿,本质上就是监控逻辑嵌进了业务主链路,每次操作都触发一次监控上报。服务端也有一样的场景:全链路监控的 Agent 会在每个请求上做拦截、上下文透传、Span 采集、异步上报,这些逻辑本身是有开销的。
我检查自己服务时发现,日志切面在每次请求时同步做了 MDC 设置、参数序列化、Kafka 异步上报,虽然日志本身是异步的,但序列化和上下文传递仍然占掉了大约 1ms。用 A/B 验证法,把监控埋点临时关掉一版,延迟直接降了 20%。最终方案是:核心链路的监控改成采样埋点,比如 1% 采样率;参数采集去掉大对象字段;上报链路从 Kafka 改成内存队列批量发送。优化后监控自身的开销降到了 0.1ms 以内。
4.4 GC 停顿和高频内存分配:微秒级的最后一道坎
把上面这些做完,接口平均已经到了 1.5ms,离目标还差一点。此时再看火焰图,CPU 时间已经不多了,剩下的主要障碍是 GC。高频接口每秒要处理几百上千次请求,每次请求创建的对象虽然已经大幅减少,但还是会有零散的分配。当 Young GC 发生时,STW 停顿大约 0.5ms 到 2ms,对普通接口无所谓,但要冲击微秒级就必须处理。
我的做法是三管齐下:第一,把 JVM 堆适当调大,减少 GC 频率;第二,用-Xlog:gc*观察 GC 日志,确认有没有异常的分配速率;第三,在热路径上彻底消灭不必要的对象分配,比如用ThreadLocal复用字节数组、使用长连接而不是每次新建。还有一点容易被忽略:尽量让大对象直接进老年代,避免反复在年轻代之间复制。这些调整做完,GC 停顿对接口耗时的影响已经压到可忽略水平。
到这里,最终数据出来了:接口平均 0.8ms,P99 3.1ms,P99.9 8.2ms,相比最初的 82ms,提升了约 100 倍,而且是在 200 并发持续压测下的结果。从毫秒到微秒,不是靠某一项神操作,而是把网络、计算、存储、并发、监控、GC 每个环节的“浪费”都抠掉一点,积少成多。
5. 验证与回归:优化成果如何不被下一次发布推翻
5.1 正确的压测方法和指标口径
性能优化最怕的是“优化了个寂寞”或者“这次好了下次又坏”。我强烈建议,从优化第一天起就定好压测规范和指标口径。压测工具我用 wrk 和 JMeter,wrk 适合测 HTTP 接口吞吐和延迟,JMeter 适合复杂业务链路。一条标准的 wrk 压测命令大概是这样的:
wrk -t8 -c200 -d30s --latency http://localhost:8080/api/order/detail?orderId=10086输出里会给出 Latency 的平均值、标准差、最大值,以及 P50、P75、P90、P99 分位数。注意一定看--latency参数打出来的分布,不要只看平均值。平均值会被少数慢请求拉高,P99 才能反映真实用户体验。压测时机的选择也有讲究:避开业务高峰,保持测试环境与生产环境配置接近,尤其 CPU 核数、内存、磁盘类型、网络带宽这四项,否则结果没有参考价值。
5.2 建立性能基线和自动化回归
优化完成不是终点,是持续维护的起点。我建了一张性能基线表,把接口的平均耗时、P99、P99.9、数据库查询次数、缓存命中率、GC 频率全部记录下来,作为后续每次发布的对比基准。CI 流水线里加了一个性能冒烟任务:每次代码合并前在测试环境跑 3 分钟压测,如果 P99 比基线退化超过 20%,就直接拦住合并。
这是最重要的一道防线。性能问题之所以反复出现,是因为代码评审和功能测试基本发现不了毫秒级的劣化,只有持续的性能回归测试才能兜底。实际操作中基线表长这样:
| 指标 | 优化前 | 优化后 | 回归阈值 |
|---|---|---|---|
| 平均延迟 | 82ms | 0.8ms | P99 5ms |
| P99 延迟 | 120ms | 3.1ms | P99 5ms |
| 数据库查询耗时 | 28ms | 1.2ms | 10ms |
| 缓存命中率 | 0% | 97.4% | 90% |
| 日志同步 IO | 有 | 无 | 无 |
5.3 其他场景的延迟优化思路:移动端、小程序端和计算密集场景
这次复盘主要说的是服务端接口延迟,但“先测量、再拆解、再优化、再验证”的框架是通用的。移动端和小程序端的延迟瓶颈往往在资源加载、渲染线程和网络请求串行上,优化思路就是减少首屏请求数、图片懒加载、CDN 分发、缓存预加载;像 H5 嵌入小程序这类场景,还要额外关注 WebView 初始化耗时和 JS Bridge 的调用开销,能预热的尽量预热,能并行的不要串行。
计算密集场景则是另一套打法。大量算子对硬件性能的挑战,核心在算子调度、显存带宽、访存局部性和内核启动开销上;GPU 推理像 YOLO 系列模型,从数据预处理到推理再到后处理,每个阶段都有毫秒级的优化空间。Julia 这类注重科学计算的语言,性能优化和内存管理往往更依赖类型稳定性和减少隐式分配。不管哪个领域,第一步永远是一样的:找到延迟到底花在哪,再决定用什么手段。
6. 写在最后:几点个人体会
6.1 性能优化是一场“拆等待”的过程,不是“找魔法”
这次从 82ms 优化到 0.8ms,我最大的体会是:性能优化不是找一段神奇的代码,而是把等待一段一段拆掉。网络在等、线程在等、锁在等、GC 在等、反射在等、序列化在等,每一个“等”单独看都不算致命,但叠加在一起就是百毫秒级。真正的高手不是会写多快的代码,而是能准确指出“慢在哪里”。所以我建议任何人做优化前,先花至少半天时间把链路拆清楚、把工具用熟,这个时间花得绝对值得。
6.2 给后来者的一份执行清单
根据这次实战,我整理了一份自用的优化清单,分享出来供参考:
- 不要凭感觉优化,先做全链路埋点和火焰图,拿到数据再动手;
- 一次只改一个变量,改完立即压测,避免多个改动叠加导致无法定位收益来源;
- 压测数据必须包含预热阶段和分位数统计,3 分钟以下的数据不可信;
- 网络、日志、监控这类“支撑代码”的消耗往往被低估,仔细审查热路径上的每一行;
- 缓存不是银弹,要处理击穿、穿透、雪崩三个问题之后再上线;
- 优化完成后立刻建立基线表并跑在 CI 里,防止性能回退。
最后再分享一个小技巧:做性能优化时,把每次改动前和改动后的压测报告截图保存,按时间顺序命名。一个月后回看这些报告,你会清晰地看到延迟是如何一步步降下来的,这个过程比最终的数字更有价值。延迟优化从来不是一次性的项目,它应该成为日常开发的一部分——每次写代码时多想一句“这行代码在热路径上是否必要”,比任何事后优化都更有效。