
先交代下背景。我负责的一个核心报表接口线上监控显示平均耗时 2s 左右P99 直接飙到 3.5s。业务方天天在群里艾特我说运营同学每次打开页面都要转圈半天已经影响到了日常的数据决策。这接口可不是内部小工具是老板每天早上都要看的经营大盘数据链路长、查询逻辑复杂还不是简单的单表查询。从上个月中旬开始我集中花了大概三个晚上加班来做性能排查和优化最终把接口耗时从 2s 压到了 0.1s20 倍的提升。整个过程踩了不少坑也沉淀了一些通用方法论。这篇文章就把我完整的排查思路、优化手段、参数取舍和避坑经验分享出来希望对正在跟接口性能缠斗的朋友有所帮助。1. 别急着改代码先把瓶颈找出来拿到优化任务后我没有第一时间去翻代码。原因很简单如果你不知道时间到底消耗在哪儿任何改动都是在赌运气运气不好还可能引入新的问题。我先通过监控平台拉了这个接口最近一周的调用数据确认耗时分布不是偶发而是常态。平均 2sP90 在 2.3sP99 到 3.5s明显有规律可循。1.1 链路追踪快速定位“时间去哪了”我们内部用的是 SkyWalking 做全链路追踪我找到这个接口的 Trace ID拉出了完整的调用链路。不看不知道一看吓一跳这个接口内部竟然串行调用了 7 个下游方法包括查商品基础信息、查库存、查价格、查营销活动、查用户等级、查历史成交数据最后还要做一轮内存中的计算汇总。数据库查询占了大头累计耗时接近 1.4s另外远程调用RPC占了 0.4s剩下的 0.2s 是 Java 层的内存计算和 JSON 序列化。提示如果你所在的公司没有 SkyWalking 或者 CAT 这类全链路监控工具最简单的定位方式是先用 Arthas 的 trace 命令直接追踪目标接口的方法级调用耗时。Arthas 是阿里开源的一款 Java 诊断工具线上排查利器强烈建议每个后端同学都掌握。在 Arthas 里操作也很简单拿到接口对应的类和方法后执行 trace 命令就能看到方法内部每一步的真实耗时。它能告诉你具体是哪一行代码、哪个方法调用最慢精确到毫秒级别而且不需要重启应用对线上环境相当友好。1.2 慢 SQL 日志数据库侧的问题线索链路追踪只能定位到“某次查询慢”具体慢在 SQL 还是索引需要配合慢查询日志来分析。我打开 MySQL 的慢查询日志发现这个接口涉及的核心查询里有一条订单明细查询的执行时间竟然达到了 900ms。这条 SQL 逻辑本身不复杂就是关联了四张表按日期和商户 ID 做了筛选和排序问题在于关联字段和筛选字段上的索引建得很随意导致 MySQL 选择了全表扫描。全表扫描是什么概念相当于把好几万行数据从头到尾读一遍再逐行去匹配关联条件自然快不了。这里顺带说一个我在排查时养成的好习惯每次发现慢 SQL不要光看执行计划EXPLAIN就完事。我会把 SQL 拿到测试库用生产环境的真实数据量跑一遍 EXPLAIN重点看 type 字段。如果是 ALL 或者 index说明全表扫了如果是 ref 或者 eq_ref说明索引利用还行如果是 const那是按主键查询基本是理想情况。还要看 rows 预估的行数如果预估行数和实际返回行数差距巨大优化器选错索引的概率就很高。1.3 业务逻辑梳理发现无效与重复计算链路追踪和慢 SQL 之间我还做了一件事——把接口的完整业务逻辑在纸上画出来。这个习惯帮我发现了很多不必要的计算。举个例子代码里有一段循环每次循环都要调用一个获取商品分类名称的方法而这个方法内部又会做一次数据库查询。循环 100 次就是 100 次查询这属于非常典型的 N1 查询问题。另外还有一段逻辑接口在返回结果前会把所有商品的图片 URL 做一轮完整拼接但前端页面列表页只展示缩略图根本用不到高清大图这套处理。这些“不起眼”的小问题叠加起来对耗时的贡献相当可观。我统计了一下如果把 N1 查询问题解决掉光是这个方法就能减少将近 60 次重复的数据库查询这 60 次往返的网络开销和时间成本在日常流量下看不出明显差别但到了高峰期就是实打实的延迟来源。到这里整体的优化方向已经清晰了数据库查询侧是主要矛盾远程调用侧是次要矛盾内存计算和序列化是锦上添花的部分。我给自己定了个目标不求一步到位先分四轮来做优化。2. 数据库侧的优化收益最直接也最容易踩坑数据库查询占了这个接口近 70% 的耗时这是最值得投入的优化点。但数据库优化也是一个很容易“翻车”的领域索引不是随便加就完事加多了会影响写入性能加错了优化器根本不会用。2.1 从 N1 查询到批量查询前面说到的 N1 查询问题其实在很多老项目里都非常常见。比如查订单列表查到 100 个订单后代码里又循环去查每个订单对应的商品信息、用户信息、店铺信息。这种代码结构写起来很直观但在数据库侧就会放大成 1 N 条 SQL性能自然好不了。我的优化思路是把循环内的单条查询改成批量查询先一次性查出 100 个订单收集所有订单里的商品 ID 集合和用户 ID 集合再用 IN 条件一次性查询所有商品信息和用户信息最后在内存中通过 Map 做匹配和组装。从 100 次查询降到 2 次查询这个优化做完接口耗时直接降到了 1.2s 左右效果立竿见影。2.2 联合索引的字段顺序是个技术活那条耗时 900ms 的订单明细查询我通过 EXPLAIN 发现它走的是单列索引筛选完日期范围后再按商户 ID 做关联时还是要回表查大量数据。这里的核心问题是索引字段的顺序没有遵循最左前缀原则导致优化器预估成本之后干脆放弃走索引改用了全表扫描。我重新分析了这条 SQL 的 WHERE 条件、JOIN 条件和 ORDER BY 字段。实际场景里订单表的数据量已经有几百万行按日期筛选通常能过滤掉大部分数据但商户 ID 的区分度更高。最终我把索引调整为以商户 ID 开头然后是日期和状态这样既能满足筛选需求又能让 ORDER BY 走索引避免文件排序。建完索引后我重新执行同一套 SQL耗时从 900ms 降到了 130ms 左右。这是一个很典型的索引优化案例不是建了索引就一定快索引的字段顺序、区分度、和查询条件的匹配程度全都影响最终效果。2.3 覆盖索引白嫖查询结果的一种方式在优化过程中我还发现有些查询其实不需要回表但是因为 SELECT 的字段列表里包含了索引外的字段优化器只能老老实实地回表拿数据。比如我们查商品列表时只需要商品 ID、名称、价格、状态这些字段但原来的索引只覆盖了 ID 和状态名字和价格需要回表才能拿到。解决办法是创建一个覆盖索引把查询需要的字段都包含进去。这样 MySQL 在索引扫描时就能直接拿到所有数据不需要再回表。我从日志里挑出了这个接口最常执行的几条 SQL针对性地调整了索引结构。这里提醒一下覆盖索引不是建得越多越好索引本身也是需要空间和写入时的维护成本的。不建议让索引宽度无限膨胀覆盖高频查询即可低频查询走普通索引就行。数据库侧的优化做完之后接口整体耗时降到了 700ms 左右。到这里我停了一下没有急着继续压数据库因为再往下抠 SQL 的空间边际效应已经很明显了。接下来的重点应该转向缓存和并行调用这些手段带来的收益会更大。3. 缓存层优化把重复计算和重复查询干掉数据库优化做完了我重新审视了调用链发现一个问题同一个用户短时间内反复打开这个报表页面后端就会反复执行同样的查询和计算。这些结果其实在一分钟内不会有任何变化但系统不知道这一点每次都老老实实地把整个流程重新跑一遍。这就是典型的“重复造轮子”场景突破口很自然就落到了缓存上。3.1 本地缓存还是分布式缓存取决于数据一致性要求我们这个接口的数据敏感性中等对实时性要求没那么苛刻允许有 1 到 2 分钟的数据延迟。结合这个前提我优先选择了 Caffeine 本地缓存。为什么选本地而不是 Redis因为本地缓存的访问耗时基本在微秒级几乎可以忽略不计而 Redis 再快也有一次网络往返大约 0.5ms 到 1ms。对于访问频率极高、对一致性容忍度较高的数据本地缓存的性价比是最高的。Caffeine 的配置非常简单核心参数是 maximumSize 和 expireAfterWrite。我这边设置的是 maximumSize 为 5000expireAfterWrite 为 90 秒。5000 个条目的大小足够容纳活跃用户和商品的数据了90 秒的过期时间也能兼顾新鲜度和命中率。3.2 缓存穿透、击穿、雪崩处理不好反而拖垮系统引入缓存绝对不是“加一层就完事”这么简单。缓存穿透、缓存击穿、缓存雪崩这三个经典问题我在这次优化里都实际遇到了逐个说下处理方式。缓存穿透是指查询一个根本不存在的数据缓存里没有数据库里也没有导致请求每次都打到数据库。如果这个 key 被恶意大量请求数据库压力会骤增。我的处理方式是采用布隆过滤器前置拦截或者在缓存里也把这个“空结果”缓存起来设置较短的过期时间比如 30 秒。这样即便是空数据也不会每次查询都打到数据库。缓存击穿是指某个热点 key 在过期瞬间大量请求同时发现缓存没有于是全部涌向数据库。这个场景在我们的报表接口里很常见因为有一个平台维度的汇总数据访问量非常大。我用的是 Caffeine 的 CacheLoader 机制通过 synchronized 单线程加载来保证只有第一个请求才会真正去查数据库其他请求会等待并共享结果。缓存雪崩是指大量 key 同时过期导致请求全部打到数据库。解决的思路是给过期时间加一个随机偏移量比如 90 秒加 0 到 15 秒的随机数让过期时间分散开避免同一时刻集中重建缓存。缓存层优化完成后接口耗时降到了 300ms 左右。这个降幅相当可观而且从代码层面的改动量来说性价比非常高。不过到这里我还不满足因为链路里还有 0.2s 左右的远程调用耗时这部分其实也可以优化。4. 串行改并行把机器的多线程能力用起来接口调用链里的远程调用虽然单个耗时不高但问题是它们是串行执行的。商品服务一次 30ms库存服务一次 40ms营销服务一次 50ms用户服务一次 30ms加起来就是 150ms 到 200ms。如果是并行执行呢总耗时基本等于最慢的那个调用也就 50ms 左右。这就是并行化改造的价值所在不减少工作量但可以压缩时间。生活里一个很形象的类比是洗衣服和晾衣服你一个人串行做就是洗半小时、晾十分钟总共四十分钟如果你能把洗衣服和晾衣服这两件事并行安排比如让洗衣机先洗着你同时去准备衣架时间就压缩了。接口调用也是同样的道理让互相没有依赖的远程调用同时发出总耗时取决于最慢的那个。4.1 CompletableFuture 优雅实现并行调用Java 8 开始提供的 CompletableFuture 帮了大忙。它支持异步编排可以用 allOf 方法等待多个异步任务全部完成然后用 join 获取结果整个过程不用手写线程池的管理逻辑。这里要注意的是必须自己显式声明线程池不要使用默认的 ForkJoinPool。默认线程池的线程数核心大小是 CPU 核数减一对 IO 密集型任务来说远远不够很容易因为任务提交量太大而阻塞。我建了一个专门的线程池参数设置为核心线程数 16最大线程数 32队列容量 200。为什么是这个数我简单算了一下这个接口涉及到的下游调用大概 5 到 6 个每个调用的平均耗时在 30ms 到 50ms属于典型的 IO 密集型操作。IO 密集型的线程数经验公式是 CPU 核数乘以 2再加 1 或 2。我们的机器是 8 核所以核心线程设置为 8*2218我取了个整就是 16再留一点余量设置了最大 32。4.2 线程池隔离和超时控制是并行化的安全底线并行化最怕两件事一是下游服务变慢后线程池被占满导致接口整体拖垮二是某个下游调用没有设置超时时间一直等下去导致接口挂起。我在这两个问题上都做了加固。线程池隔离其实就是给不同的业务场景分配不同的线程池。我们这个报表接口用独立的线程池不让它和普通查询接口共享线程池。这样即使报表接口下游某个服务出了问题最多也就是报表接口自己的线程池被占满不会影响其他接口的正常工作。超时控制上我用的是 CompletableFuture 配合 Future.get 的超时机制或者更推荐的是用 orTimeout 方法给每个异步调用设置 500ms 的超时上限。一旦超时就回退到默认值或者直接抛出异常由上层统一捕获兜底。这里千万要注意远程调用必须有超时时间这是并行化改造的红线。4.3 并行化改造后的实际效果改造完成后我测了一轮接口耗时从 300ms 降到了 150ms 左右。虽然不是特别夸张的降幅但在已经做过数据库和缓存优化后还能砍掉一半说明方向是对的。而且我注意到P99 的下降幅度比平均值更明显说明并行化对长尾延迟的优化效果更好。这其实也符合预期因为 P99 场景里往往存在某一个下游调用偶发变慢的问题串行执行时这个变慢会被放大并行执行时它只会影响其中一个分支。到这里四轮优化里已经完成三轮接口从最初的 2s 降到了 150ms。剩下的 50ms 差距我决定从数据返回量和序列化角度去挤一挤。5. 响应体压缩和序列化优化把最后的“水分”挤掉接口返回给前端的数据经过 JSON 序列化后大约有 2MB 左右。这个体量对网络传输和前端解析来说都不小。特别是移动端弱网环境2MB 的 JSON 可能就要多花几十毫秒甚至上百毫秒来传输。5.1 裁剪字段能不给的就不给我仔细看了接口返回的字段发现里面有 10 多个字段前端根本用不到。比如商品详情页才需要的高清大图 URL、冗长的商品描述富文本、内部使用的状态码枚举名、后端处理时产生的中间字段等。这些字段全部被 JSON 序列化并传输到了前端既浪费带宽又拖慢前端解析速度。我跟前端同学确认了两遍把确认无用的字段全部从返回对象里删除。这个操作让返回体直接从 2MB 降到了 800KB效果显著。这里想多说一句接口设计时不少人习惯直接返回整个实体对象图省事。但在性能敏感的接口里这种省事会把成本转嫁给每次请求的传输和解析。更好的做法是为接口定义独立的 ViewObjectVO按需返回字段。5.2 开启 Gzip 压缩立竿见影删除无用字段后我又给接口层开启了 Gzip 压缩。Gzip 对 JSON 这类文本数据的压缩率非常高实测下来 800KB 能压到 120KB 左右压缩比在 85% 上下。网络传输时间大幅缩短用户感知的接口响应速度会有很明显的提升。需要注意的是开启 Gzip 后要确保 Content-Encoding 头正确设置避免前端解析出错。另外对于极小响应体比如小于 1KB压缩反而会引入额外的 CPU 开销不建议对这类接口开启。我这边是通过网关层统一配置只对超过阈值的大响应体开启压缩。5.3 序列化方式将 Jackson 换成高性能替代品在序列化这块我们项目里用的是 Jackson老牌稳定但在超高并发场景下性能并不算最优。我评估后把报表接口的序列化方式换成了基于二进制格式的高性能序列化框架比如 Kryo 或者 Protobuf。改动量不大但对大对象的序列化和反序列化耗时帮助明显。这里要提醒一点换序列化方案之前一定确认前端能够解析对应的格式。如果前端是浏览器环境那 Protobuf 需要额外的 JS 库支持不是所有团队都愿意引入。我这次是在接口层做了一个 JSON 和二进制序列化的开关先灰度流量验证稳定后再全量切换。如果你们的前端不支持二进制格式单纯做字段裁剪和 Gzip 压缩也已经能拿到大部分收益了。响应体优化做完接口耗时最终稳定在 100ms 左右也就是 0.1s。从最初的 2s 到现在的 0.1s优化目标完成。6. 优化过程中的典型问题与避坑记录整个优化过程持续了三天每天下班后加班到晚上十点左右。期间踩了不少坑有些问题如果不记录下来以后换个人来优化可能还会再踩一遍。6.1 索引加多了写入性能反而下降第一轮优化时我一口气给订单表加了 5 个索引。结果第二天业务反馈说订单导入变慢了。原因很直接每个索引在写入时都需要额外维护 B 树结构索引越多写入成本越高。我后来删掉了 2 个冗余索引只保留了查询频率最高的那 3 个组合索引导入性能才恢复。大家加索引时务必想清楚这个索引到底能覆盖多少查询如果两个索引的第一个字段相同大概率是可以合并的。6.2 缓存过期时间设成固定值险些造成雪崩我第一版缓存方案里过期时间固定设为了 60 秒。上线后观察监控发现每隔一段时间就会有一次数据库查询量的小高峰。排查下来发现是大量 key 在同一秒集中过期数据库瞬时压力增大。后来把过期时间改成 60 秒加 0 到 10 秒的随机值重新观察高峰明显平滑了。这个改动只花了几分钟但效果非常显著。6.3 并行调用时线程数开得太大把下游服务打垮过并行化改造时我曾把最大线程数调到了 64想着机器性能这么好多开点线程没关系。结果灰度验证时下游订单服务的 P99 直接从 30ms 涨到了 300ms吓我一跳。后来复盘发现下游服务同时收到太多并发请求超出了它的处理能力导致整体变慢甚至超时。我赶紧把最大线程数降回 32加上信号量机制限流下游才恢复正常。并行化改造绝对不等于无脑增加线程数要根据下游服务的承载能力来设定并发上限。6.4 缓存与数据库的一致性用异步淘汰兜底本地缓存的过期策略是时间过期但业务侧存在一些场景需要及时更新数据比如运营手动修改了某个商品的展示状态。我加了一个异步消息监听当商品状态变更消息发出后本地缓存会自动清理对应的 key下次请求时自然回源到数据库拿最新值。这样既保证了绝大部分场景的高性能缓存命中又能及时处理少数需要实时更新的数据兼顾了性能和一致性。这三天的优化做下来最大的体会是性能优化不是靠一招制胜而是一个系统性的工程。从定位链路瓶颈、改写慢 SQL、优化索引到引入缓存、并行化改造、裁剪返回体每一步的收益看起来都不是特别大但叠加在一起就是质的飞跃。最后再分享一个小技巧每次优化上线后我都会在监控平台建立一个对比视图把优化前后的耗时曲线放在一起持续观察一周。确认没有回退、没有抖动再关掉复盘文档。这个过程看似繁琐却能帮你建立起对系统性能的长期掌控感下次再遇到性能问题心里就有底了。这班加得值。