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× |
注意这些优化都"很朴素"——没有新组件、没有缓存、没有并发。但收益是数倍级的。
五、为什么这类优化「副作用最小」
| 优化类型 | 引入的新风险 |
|---|---|
| 缓存 | 一致性、内存成本、失效逻辑 |
| 并发/并行 | 共享状态、竞争、复杂度 |
| 连接池调优 | 下游压力、配置依赖 |
| 微观常数 | 可读性下降 |
| 减少工作 | 几乎没有(只是少做了本不该做的事) |
唯一需要注意的两点:
- 批处理可能抬高单次延迟(要判断接口是吞吐敏感还是延迟敏感)。
- 减少返回字段可能影响调用方(属于 API 变更,需要协调)。
这两点都是"显式的权衡",而不是"隐藏的风险"——这与其他优化类型有本质区别。
六、本节小结
- 减少工作是最可靠的一类优化:收益确定(常常数倍)、副作用最小(没有新组件)。
- 五种模式:批处理、消除 N+1、提前剪枝、避免重复序列化、减少系统调用。
- 批处理要注意场景:吞吐敏感的场景收益最大;延迟敏感的场景要谨慎(攒批会抬高延迟)。
- 提前剪枝的一个特殊价值:先鉴权再加载——既安全又快。
- 减少序列化有三重收益:省 CPU、省内存、省网络。
- 系统调用很贵(
sysCPU),热路径日志、小写入、每请求建连都是常见浪费。 - 用**「减少工作审查清单」**作为代码 review 的常规部分。
七、自测
- 一个接口每次请求要执行 150 次数据库查询,返回 10 条记录。请说出最可能的问题、修法,以及预期的收益量级。
- 「先鉴权再加载数据」为什么既安全又快?请举一个具体的反例说明不做这件事的后果。
- 为什么「减少返回字段」可能同时带来三个方面的收益?请分别说明。
- 最可能的问题: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 同阶」。 - 为什么快:鉴权通常在内存或缓存里(用户角色、权限位),成本极低;而加载数据涉及数据库/下游调用(毫秒级)。先做便宜的检查,可以避免昂贵的操作——这是典型的剪枝。反例(不做的后果):一个"查看订单详情"的接口,先
load(orderId)加载完整订单(含 20 个关联项、3 次下游调用,耗时 80 ms),然后才发现调用者不是订单所有者,返回 403。结果:攻击者可以用未授权的 orderId 反复打这个接口,每次消耗 80 ms 的服务端资源(含数据库连接),形成放大攻击——一个 403 成本远低于它消耗的资源。加上"先鉴权"后:检查 owner 字段(一次轻量查询或缓存命中),不匹配立刻 403,成本从 80 ms 降到 1 ms。这既是性能优化,也是安全加固。 - 三个方面:① CPU——序列化的核心成本与字段数量成正比,少传字段直接减少序列化工作量;② 内存——更大的 JSON 字符串意味着更大的临时对象和更高的分配速率,进而增加 GC 压力(可能触发 Humongous 对象,第 2 章 2.5);③ 网络——响应体更小,传输时间更短、带宽占用更低,对大响应体(比如列表接口)尤其明显,而且网络传输时间会计入用户感知的延迟(第 1 章 1.3 节的"网络 + 客户端排队"那段)。额外的第四点:更小的响应体也让客户端解析更快(如果客户端也是你的服务)。所以「少传字段」是少见的"一处改动、多重收益"的优化——而且几乎没有副作用(除了 API 兼容性)。