
前段时间有个订单查询接口线上 P99 一直压在 120ms 左右日均调用量几百万次。平时看着不算夸张结果大促前一次全链路压测这个接口的线程池直接被打满下游数据库连接数飙到上限连带整个订单服务差点雪崩。当时领导只给了一句话这个延迟必须降下来目标先定 10ms 以内。后来整个优化做完接口平均耗时从 82ms 降到了 0.8msP99 也稳定在 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或constrows是不是接近实际返回行数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反射映射占了 7msJSON 占了 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.8msP99 3.1msP99.9 8.2ms相比最初的 82ms提升了约 100 倍而且是在 200 并发持续压测下的结果。从毫秒到微秒不是靠某一项神操作而是把网络、计算、存储、并发、监控、GC 每个环节的“浪费”都抠掉一点积少成多。5. 验证与回归优化成果如何不被下一次发布推翻5.1 正确的压测方法和指标口径性能优化最怕的是“优化了个寂寞”或者“这次好了下次又坏”。我强烈建议从优化第一天起就定好压测规范和指标口径。压测工具我用 wrk 和 JMeterwrk 适合测 HTTP 接口吞吐和延迟JMeter 适合复杂业务链路。一条标准的 wrk 压测命令大概是这样的wrk -t8 -c200 -d30s --latency http://localhost:8080/api/order/detail?orderId10086输出里会给出 Latency 的平均值、标准差、最大值以及 P50、P75、P90、P99 分位数。注意一定看--latency参数打出来的分布不要只看平均值。平均值会被少数慢请求拉高P99 才能反映真实用户体验。压测时机的选择也有讲究避开业务高峰保持测试环境与生产环境配置接近尤其 CPU 核数、内存、磁盘类型、网络带宽这四项否则结果没有参考价值。5.2 建立性能基线和自动化回归优化完成不是终点是持续维护的起点。我建了一张性能基线表把接口的平均耗时、P99、P99.9、数据库查询次数、缓存命中率、GC 频率全部记录下来作为后续每次发布的对比基准。CI 流水线里加了一个性能冒烟任务每次代码合并前在测试环境跑 3 分钟压测如果 P99 比基线退化超过 20%就直接拦住合并。这是最重要的一道防线。性能问题之所以反复出现是因为代码评审和功能测试基本发现不了毫秒级的劣化只有持续的性能回归测试才能兜底。实际操作中基线表长这样指标优化前优化后回归阈值平均延迟82ms0.8msP99 5msP99 延迟120ms3.1msP99 5ms数据库查询耗时28ms1.2ms10ms缓存命中率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 里防止性能回退。最后再分享一个小技巧做性能优化时把每次改动前和改动后的压测报告截图保存按时间顺序命名。一个月后回看这些报告你会清晰地看到延迟是如何一步步降下来的这个过程比最终的数字更有价值。延迟优化从来不是一次性的项目它应该成为日常开发的一部分——每次写代码时多想一句“这行代码在热路径上是否必要”比任何事后优化都更有效。