文档目录

7.2 减少工作:最可靠的一类优化

上一节:7.1 优先级金字塔 | 下一节:7.3 并发与并行 配套代码:07-optimization-and-validation/02-reduce-work


一句话结论

与其把一件事做得更快,不如不做它。 「减少工作」类的优化(批处理、消除 N+1、提前剪枝、避免重复序列化)收益最确定、副作用最小、几乎不需要权衡——因为没有引入新的组件、缓存或并发。


一、用「少跑腿」理解减少工作

你每天要跑 5 趟超市,每趟 20 分钟,共 100 分钟。

优化方向 做法 效果
提高速度 开车更快(危险) 100 → 80 分钟
减少工作 ⭐ 一次买齐(列清单) 100 → 24 分钟

后者的收益是前者的 8 倍,而且没有风险。

后端完全一样:把 N 次查询合并成 1 次,比「把每次查询优化得快 20%」收益大得多。


二、五种「减少工作」的模式

❶ 批处理:N 次变 1 次

// ❌ N 次往返
val orders = ids.map { repo.findById(it) }        // 100 次查询

// ✅ 1 次往返
val orders = repo.findByIds(ids)                  // 1 次:where id = any(?)

典型收益:组件基准实测(第 3 章 3.2 节)——批量比循环快 82 倍(P99 从 210 ms 降到 6.2 ms)。

注意适用场景:

场景 批量是否合适
导入、报表、批量操作 ✅ 非常合适(吞吐敏感)
单个用户点一下的接口 ⚠️ 要看批量会不会抬高单次延迟
实时性要求高的接口 ❌ 攒批会抬高延迟(第 0 章 0.2 节)

❷ 消除 N+1(批处理的特例,但值得单列)

N+1 的表现:DB 的 calls 与接口 QPS 成正比(第 6.6 节)。

三种修法:

// ① 批量查询(最简单)
val users = userRepo.findByIds(orders.map { it.userId })

// ② JOIN(如果数据来自同一个库)
// select o.*, u.name from orders o join users u on u.id = o.user_id

// ③ 预取(ORM 常见能力)
// Hibernate: @BatchSize / join fetch

验证方式:统计「每个请求执行了多少次查询」,改前改后对比。

❸ 提前剪枝:先判断要不要做

// ❌ 先算出结果,再判断是否需要
val fullReport = generateFullReport(data)      // 很贵
if (user.isAdmin) return fullReport
return fullReport.summary()

// ✅ 先判断
if (user.isAdmin) return generateFullReport(data)
return generateSummary(data)                   // 只算需要的

其他剪枝机会:

// 先检查空集合,避免无谓的查询
if (ids.isEmpty()) return emptyMap()

// 先检查缓存,避免构造请求
val cached = cache.get(key)
if (cached != null) return cached

// 先检查权限,避免加载数据
if (!hasPermission(user, resourceId)) return Forbidden
val data = load(resourceId)     // ← 只有有权限才加载

最后一条是典型的「安全即性能」——先鉴权既安全又快。

❹ 避免重复序列化

// ❌ 三次序列化/反序列化
val json = objectMapper.writeValueAsString(order)   // 1. 对象 → JSON
val map = objectMapper.readValue(json, Map::class.java)  // 2. JSON → Map
val result = map.filterKeys { it != "secret" }      // 3. 处理
return objectMapper.writeValueAsString(result)      // 4. Map → JSON

// ✅ 直接在对象上处理
return objectMapper.writeValueAsString(order.copy(secret = null))

其他重复模式:

反模式 修法
JSON → Map → JSON 用强类型对象(XxxResponse)
在中间层反复转换 DTO 明确分层,每层一次转换
服务间传 JSON 又解析成对象再传 JSON 直接透传字节流,或改成二进制协议
对同一份数据序列化多次 缓存序列化结果

一个容易忽略的收益:减少序列化不只是省 CPU,还省内存和网络——三重收益。

❺ 减少系统调用

系统调用很贵(每次陷入内核,sys CPU 上升)。常见的减少机会:

反模式 修法 收益
热路径写日志 异步 appender + 降低级别 可能省 10%–30%
小写入(逐条 flush) 缓冲 + 批量刷新 数量级
频繁读配置/文件 缓存到内存 高
每个请求建连 连接池 + keep-alive 高
大量小 IO 合并成大 IO 高

验证方法:看 sys CPU 占比 + JFR 的 jdk.FileWrite / jdk.SocketWrite 事件。


三、一个完整的「减少工作」审查清单

对每一个接口问这些问题:

## 减少工作审查

### 数据访问
- [ ] 有没有循环里的单条查询?(N+1)
- [ ] 能不能用一次批量查询/join 代替多次?
- [ ] 有没有查询了但没用的数据?(SELECT * 但只用 2 个字段)
- [ ] 有没有重复查询同一份数据?(同一请求内查两次同一行)
- [ ] 有没有不必要的 count 查询?

### 计算
- [ ] 有没有算了但没用的中间结果?
- [ ] 有没有在循环里重复计算不变的值?(循环不变量外提)
- [ ] 有没有可以先判断再执行的昂贵操作?(剪枝)
- [ ] 有没有重复的正则编译/格式化?

### 序列化
- [ ] 返回的数据结构能不能简化?(少传字段)
- [ ] 有没有 JSON → Map → JSON 的来回转换?
- [ ] 同一份数据有没有序列化多次?

### 系统调用
- [ ] 热路径上有没有日志?(每个请求都写)
- [ ] 有没有逐条 flush 的小写入?
- [ ] 有没有每请求建连(应该用池)?

### 冗余操作
- [ ] 有没有同一次请求里对同一个下游调了多次?
- [ ] 有没有可以先鉴权再加载数据(避免无谓加载)?
- [ ] 有没有重复的校验/转换?

这份清单应该成为你 review 代码的常规部分——它比"性能优化"更容易执行,收益也更确定。


四、三个真实的收益数据

优化 before after 倍数
循环单查 → 批量查询(100 个 ID) P99 210 ms P99 6.2 ms 34×
去掉热路径 INFO 日志 P99 180 ms P99 130 ms 1.4×
SELECT * → 只取需要的 3 个字段 P99 45 ms P99 12 ms 3.8×
同一请求内重复查询同一行 → 缓存到局部变量 P99 90 ms P99 40 ms 2.3×

注意这些优化都"很朴素"——没有新组件、没有缓存、没有并发。但收益是数倍级的。


五、为什么这类优化「副作用最小」

优化类型 引入的新风险
缓存 一致性、内存成本、失效逻辑
并发/并行 共享状态、竞争、复杂度
连接池调优 下游压力、配置依赖
微观常数 可读性下降
减少工作 几乎没有(只是少做了本不该做的事)

唯一需要注意的两点:

  1. 批处理可能抬高单次延迟(要判断接口是吞吐敏感还是延迟敏感)。
  2. 减少返回字段可能影响调用方(属于 API 变更,需要协调)。

这两点都是"显式的权衡",而不是"隐藏的风险"——这与其他优化类型有本质区别。


六、本节小结

  1. 减少工作是最可靠的一类优化:收益确定(常常数倍)、副作用最小(没有新组件)。
  2. 五种模式:批处理、消除 N+1、提前剪枝、避免重复序列化、减少系统调用。
  3. 批处理要注意场景:吞吐敏感的场景收益最大;延迟敏感的场景要谨慎(攒批会抬高延迟)。
  4. 提前剪枝的一个特殊价值:先鉴权再加载——既安全又快。
  5. 减少序列化有三重收益:省 CPU、省内存、省网络。
  6. 系统调用很贵(sys CPU),热路径日志、小写入、每请求建连都是常见浪费。
  7. 用**「减少工作审查清单」**作为代码 review 的常规部分。

七、自测

  1. 一个接口每次请求要执行 150 次数据库查询,返回 10 条记录。请说出最可能的问题、修法,以及预期的收益量级。
  2. 「先鉴权再加载数据」为什么既安全又快?请举一个具体的反例说明不做这件事的后果。
  3. 为什么「减少返回字段」可能同时带来三个方面的收益?请分别说明。
  1. 最可能的问题:N+1 查询(10 条记录 × 每条 15 次子查询,或类似的循环查询结构)。修法:① 把循环里的单条查询改成批量查询(where id = any(?));② 或者用 JOIN 一次取回(如果数据在同一库);③ 或者用 ORM 的预取/批量加载能力(Hibernate 的 @BatchSize、Exposed 的 join)。预期收益:从 150 次降到 1–3 次查询,P99 通常能降一个数量级(第 3 章组件基准的实测是 34–82 倍)。验证方法:统计"每请求查询次数"指标,改前改后对比;同时看 pg_stat_statements 里那条查询的 calls 是否从「与 QPS 成正比」变成「与 QPS 同阶」。
  2. 为什么快:鉴权通常在内存或缓存里(用户角色、权限位),成本极低;而加载数据涉及数据库/下游调用(毫秒级)。先做便宜的检查,可以避免昂贵的操作——这是典型的剪枝。反例(不做的后果):一个"查看订单详情"的接口,先 load(orderId) 加载完整订单(含 20 个关联项、3 次下游调用,耗时 80 ms),然后才发现调用者不是订单所有者,返回 403。结果:攻击者可以用未授权的 orderId 反复打这个接口,每次消耗 80 ms 的服务端资源(含数据库连接),形成放大攻击——一个 403 成本远低于它消耗的资源。加上"先鉴权"后:检查 owner 字段(一次轻量查询或缓存命中),不匹配立刻 403,成本从 80 ms 降到 1 ms。这既是性能优化,也是安全加固。
  3. 三个方面:① CPU——序列化的核心成本与字段数量成正比,少传字段直接减少序列化工作量;② 内存——更大的 JSON 字符串意味着更大的临时对象和更高的分配速率,进而增加 GC 压力(可能触发 Humongous 对象,第 2 章 2.5);③ 网络——响应体更小,传输时间更短、带宽占用更低,对大响应体(比如列表接口)尤其明显,而且网络传输时间会计入用户感知的延迟(第 1 章 1.3 节的"网络 + 客户端排队"那段)。额外的第四点:更小的响应体也让客户端解析更快(如果客户端也是你的服务)。所以「少传字段」是少见的"一处改动、多重收益"的优化——而且几乎没有副作用(除了 API 兼容性)。