文档目录

7.1 优先级金字塔:先问要不要做

上一节:无 | 下一节:7.2 减少工作 配套代码:07-optimization-and-validation/01-priority-pyramid


一句话结论

优化的收益从「不做」到「调 JVM 参数」递减,而成本和风险递增。 把顺序做反了,你会花一周调 GC 参数去优化一个本该加索引的查询。


一、用「医院分诊」理解优先级

急诊室不会按「谁先来」看病,而是按紧急程度分诊:

优先级 情况 处理
1 心跳骤停 立刻抢救
2 大出血 尽快处理
3 骨折 排队等
4 擦伤 最后处理

如果先给擦伤的人包扎,心跳骤停的人就死了。

性能优化完全一样。「不做某个功能」的收益常常大于「把它优化得快一点」。


二、六级金字塔

⑥ JVM 参数调优          ← 收益最小、风险最高、最容易做错
⑤ 微观常数优化(减少分配、避免装箱、换序列化器)
④ 缓存与池化
③ 架构与并发(批量、异步、隔离、背压)
② 算法与数据访问(复杂度、IO 次数、N+1、缺索引)
① 先问「这个功能真的需要吗」  ← 收益最高、成本最低

每一级的说明与典型收益

级别 做什么 典型收益 成本/风险
① 不必要 砍掉不必要的功能、减少返回字段、去掉重复计算 极大(直接省掉) 低(但是产品决策)
② 算法与数据访问 加索引、消除 N+1、批量查询、改进算法 数倍 低–中(改 SQL/代码)
③ 架构与并发 批量、异步化、隔离资源池、加背压 数倍 中(改架构)
④ 缓存与池化 多级缓存、连接池调优 中(P50 明显,P99 未必) 中–高(一致性风险)
⑤ 微观常数 减少分配、避免装箱、换序列化器 个位数百分比 中(可读性下降)
⑥ JVM 参数 堆大小、GC 选型 个位数百分比 高(需长期验证)

关键观察:①–③ 的收益是数倍级,⑤–⑥ 是个位数百分比级。


三、为什么顺序不能反

反例一:先调 GC 去优化一个缺索引的查询

❌ 观察到 P99 高 + GC 频繁
   → 调大堆、换 ZGC、调 G1 参数
   → 花了一周,P99 从 400ms 降到 380ms(提升 5%)
   → 实际上:根因是某条 SQL 全表扫描,加个索引 P99 会降到 90ms

✅ 正确顺序:
   → 第 6 章的分析流程定位到「缺索引」
   → 加索引(② 级),P99 从 400 → 90ms
   → 如果还有余力,再看 GC(⑥ 级)

注意那个巧合:调 GC 确实能带来 5% 的提升——这会给你错误的信心,让你以为方向对了。

反例二:先加缓存去掩盖 N+1

❌ 「查询慢,加个缓存吧」
   → 缓存命中率 60%,P50 明显下降,但 P99 还是高(未命中的那 40% 更慢)
   → 引入了缓存一致性问题、内存成本、失效逻辑复杂度

✅ 正确顺序:
   → 先用第 6 章的方法确认是 N+1(DB calls 异常高)
   → 改成批量查询(② 级),P99 从 400 → 95ms
   → 如果批量之后还不够,再考虑缓存(④ 级)

这条特别重要:缓存常常被用来掩盖问题,而不是解决问题。

反例三:先做微观优化(装箱、copy)

❌ 「我把 List<Int> 改成 IntArray 了,分配少了 30%」
   → 但接口 P99 只降了 2%(因为分配只占总开销的一小部分)
   → 而且代码可读性下降了

✅ 正确顺序:
   → 先用延迟分解看「分配/GC 占多少」
   → 如果 GC 停顿只占 5%,那减少分配最多省 5%
   → 应该去优化那个占 70% 的环节

四、怎么判断「该从哪一级开始」

方法:用延迟分解找最大的一块

第 6.2 节的分解表:
  网络 + 客户端排队     5%
  服务排队             72%   ← 最大
  应用自身              3%
  PostgreSQL           16%
  Redis + 下游          4%

然后按「这一级能改什么」对应:

最大的一块是 对应的层级 具体动作
服务排队(72%) ③ 架构与并发 查线程池/调度器、加隔离、改异步
数据库(16%) ② 算法与数据访问 加索引、消 N+1、批量
应用自身(3%) ⑤ 微观常数 最后才考虑(收益上限 3%)
网络(5%) ① 不必要 减字段、压缩、就近部署

这张表就是「从哪开始」的答案——从占比最大的那块对应的层级开始。

⚠️ 一个例外:低级优化可能是高级优化的前提

有时候,必须先做小的改动,才能做大的改动:

例:想加缓存(④ 级),但当前接口返回的数据结构太复杂(序列化成本高)
→ 先简化返回结构(① 级:减少返回字段)
→ 再加缓存

这种情况下,顺序是为了达成更大的目标,而不是「反正顺手做了」。


五、三个常见的「顺序错误」

错误 表现 后果
先调 JVM 「先试试 ZGC」 收益个位数,风险高,掩盖真问题
先加缓存 「加个缓存应该能解决」 掩盖问题,引入一致性风险
先做微观优化 「我把装箱都去掉了」 收益个位数,可读性下降

它们共同的特点:都是"技术上有意思"的事——调 JVM、设计缓存、优化分配,都比「加个索引」或「砍掉一个功能」更有技术含量。

这就是为什么需要纪律:技术上的趣味性与收益常常成反比。


六、本节小结

  1. 六级金字塔:不必要 → 算法/IO → 架构与并发 → 缓存与池化 → 微观常数 → JVM 参数。
  2. 收益从下往上递减(数倍 → 个位数百分比),成本从下往上递增。
  3. 顺序不能反:先调 GC 去优化缺索引的查询,会花一周只省 5%,还会给你错误的信心。
  4. 缓存常被用来掩盖问题,而不是解决问题——先确认根因再决定要不要缓存。
  5. 判断从哪开始的方法:用延迟分解找最大的一块,然后对应到层级。
  6. 例外:低级优化可能是高级优化的前提(先简化结构再加缓存),但这是为了达成更大的目标。
  7. 技术趣味性与收益常常成反比——这是纪律存在的理由。

七、自测

  1. 一个接口 P99 = 400 ms,你的延迟分解显示:服务排队 290 ms、PostgreSQL 65 ms、应用自身 13 ms。请说出你该从哪一级开始,以及前三件要做的事。
  2. 同事说:「我把 List 换成 IntArray,分配减少了 30%,接口 P99 降了 2%。」请评价这个优化的价值,以及它属于金字塔的哪一级。
  3. 为什么「调 JVM 参数」有时确实能带来提升,但仍然应该排在最后?请说明两个理由。
  1. 从 ③ 架构与并发级开始(因为服务排队占 72%,这是最大的一块)。前三件事:① 查饱和度和调度器——db_pool_pending、线程池队列深度、RUNNABLE 线程数 vs CPU 核数(第 6.5 节);② 采 wall 火焰图——看线程到底在等什么(如果 CPU 图很空,说明是等待类问题);③ 如果怀疑调度器污染——检查是否有阻塞调用在 Dispatchers.Default 上,并统计活跃协程数。注意:应用自身只占 13 ms(3.25%),优化代码的收益上限就是 13 ms——在解决 290 ms 的排队之前不应动它。PostgreSQL 的 65 ms 可以作为第二步(优化 SQL/索引),但优先级低于排队。
  2. 这个优化本身是对的,但价值有限:① 属于金字塔的 ⑤ 微观常数优化(减少分配、避免装箱);② 收益 2% 说明分配/GC 在这个接口的总开销里占比很小——即使把分配降到 0,收益上限也只有几个百分点;③ 代价是代码可读性下降(IntArray 的 API 不如 List 友好、与 Kotlin 集合生态的互操作变差)。结论:如果没有更好的优化点,这个改动可以保留(因为无副作用);但如果还有占比更大的瓶颈(比如排队、慢查询),应该把时间投到那里。更好的判断方式:先看 gc.alloc.rate 和 GC 停顿占总延迟的比例——如果 GC 停顿只占 5%,那么"减少 30% 分配"最多只能省 1.5%。
  3. 两个理由:① 边际收益小——JVM 参数影响的是「同一份代码的执行效率」,通常在个位数百分比;而算法、IO 次数、架构层面的优化是数倍级的。所以在同一份时间投入下,先做高层的优化收益更大。② 验证成本高、风险大——GC 参数的效果需要长期观测(要覆盖多个 GC 周期、多个流量形态)才能确认;而且参数之间相互作用复杂(堆大小与 GC 选型相互影响),调错了可能让吞吐下降 20% 而延迟只改善 5%。还有一个隐含理由:JVM 调优的效果依赖于代码的实际分配模式——如果代码里的问题是 N+1 导致的连接等待(不分配),调 GC 完全无效。所以正确顺序是:先消除那些"本不该发生的工作"(② 级),等代码本身合理了,再用 JVM 参数榨取最后的几个百分点。