34.4 优化手段 (缓存 / 异步 / 批处理 / 池化 / 索引 / 并发)
一句话
优化手段不是一张清单,而是一张映射表:先确定瓶颈属于「重复计算、等待、往返、固定开销、扫描、串行」中的哪一类,再选对应手段;选错手段的代价,通常比不优化更大。
优化的顺序:先别做,再做少,再做快
| 顺序 | 动作 | 例子 | 典型收益 | 风险 |
|---|---|---|---|---|
| ① 消除 | 不做这件事 | 去掉无用的远程校验、少查一个字段、合并两次请求 | 100% (直接省掉) | 需求/功能取舍 |
| ② 减少次数 | 缓存、批量、合并、去 N+1 | 100 次查询变 2 次 | 10–100× | 一致性、延迟窗口 |
| ③ 降低单次成本 | 索引、算法、序列化、零拷贝 | 全表扫变索引查 | 2–10× | 写放大、复杂度 |
| ④ 并行 | fan-out、流水线、分片 | 3 个 100ms 下游变 100ms | 受串行比例限制 | 下游压力、失败处理 |
| ⑤ 扩容 | 加核、加实例、加带宽 | 4 核变 8 核 | 线性到 USL 上限 | 成本、协调开销 |
🔑 先问「这件事能不能不做」,再问「能不能少做」,最后才问「能不能做快」。绝大多数性能问题在 ① 和 ② 就解决了,但工程师最兴奋的往往是 ③ 和 ④。
手段 ↔ 瓶颈映射表
| 症状 | 首选 | 次选 | 不要用 | 详见 |
|---|---|---|---|---|
| 重复查询相同数据,读多写少 | 缓存 | 物化视图 | 加索引 (只省单次) | 41 章 |
| 下游 RTT 高,一次请求多次往返 | 批处理 | 并行 fan-out | 加线程 (往返次数不变) | 本节 |
| 单请求要等多个独立下游 | 并行 | 预取/缓存 | 批处理 (无法合并) | 本节 |
| 连接/TLS/事务等固定开销占比高 | 池化、长连接 | 批处理 | 缓存 (不解决建连) | 8.3 |
| 全表扫描、回表、排序 | 索引/覆盖索引 | 分区、物化 | 加 CPU | 44.8 |
| 单核打满且任务可拆 | 分片/并发 | 加机器 | 缓存 (计算型) | 37.5 |
| 锁竞争、临界区过长 | 缩小临界区、分片 | 无锁/消息传递 | 加线程 (争用更严重) | 本节 |
| 队列堆积、尾延迟 | 限流、隔离、降变异 | 扩容 | 无限加队列 | 35.7 |
| 大 payload、序列化慢 | 裁剪字段、压缩、二进制协议 | 分页/流式 | 索引 | 42.2 |
缓存:用一致性换延迟
缓存的收益只由一个公式决定:
E[T] = h × T_fast + (1-h) × T_slow
设本地缓存 0.5ms、回源 10ms:
| 命中率 h | 平均延迟 | 相对无缓存 |
|---|---|---|
| 50% | 5.25ms | 1.9× |
| 80% | 2.40ms | 4.2× |
| 90% | 1.45ms | 6.9× |
| 95% | 0.98ms | 10.3× |
| 99% | 0.60ms | 16.8× |
命中率从 80% 到 90%,延迟只降 1.66 倍;从 90% 到 99%,再降 2.4 倍。因为未命中率从 10% 降到 1%,少了 10 倍的回源。这解释了为什么「缓存命中率 85%」通常还不够:剩下 15% 的慢请求会主导 P99。
| 维度 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 延迟 | 50–200ns | 0.2–2ms |
| 一致性 | 每个实例一份,需广播失效 | 单一事实源 |
| 故障影响 | 只影响本实例 | 影响全部实例 |
| 容量 | 受堆限制 | 可到 TB |
取舍与失效策略见 41.2 本地缓存与分布式缓存。
🔑 缓存不是「免费的加速」,而是「允许数据陈旧一段时间」的产品决策。不能回答「旧 5 秒行不行」的系统,不该加缓存。
缓存的三个坑
| 坑 | 触发条件 | 后果 | 对策 |
|---|---|---|---|
| 穿透 | 查不存在的数据,缓存不命中 | 每次都打 DB | 空值缓存、布隆过滤器 |
| 击穿 | 热点 key 过期瞬间 | 大量请求同时回源 | 单飞 (singleflight)、逻辑过期 |
| 雪崩 | 大量 key 同时过期 / 缓存宕机 | DB 瞬间被压垮 | TTL 加随机抖动、多级缓存、限流 |
异步:把等待移出关键路径
并行是「同一请求内的异步」;后台任务、消息队列是「跨请求的异步」。先看最常用的并行 fan-out。
同步串行调用三个下游,每个 100ms:
总延迟 = 100 + 100 + 100 = 300ms
并行 fan-out:
总延迟 = max(100, 100, 100) + 调度开销 ≈ 101ms
实测 (Apple M1,Go):
package main
import (
"context"
"fmt"
"sync"
"time"
)
func callDown(_ context.Context, _ string, d time.Duration) (string, error) {
time.Sleep(d) // 模拟下游 100ms
return "ok", nil
}
func seq(ctx context.Context) {
start := time.Now()
for _, n := range []string{"user", "order", "coupon"} {
callDown(ctx, n, 100*time.Millisecond)
}
fmt.Printf("串行:%v\n", time.Since(start).Round(time.Millisecond))
}
func parallel(ctx context.Context) {
start := time.Now()
var wg sync.WaitGroup
for _, n := range []string{"user", "order", "coupon"} {
wg.Add(1)
go func(n string) {
defer wg.Done()
callDown(ctx, n, 100*time.Millisecond)
}(n)
}
wg.Wait()
fmt.Printf("并行:%v\n", time.Since(start).Round(time.Millisecond))
}
func main() {
ctx := context.Background()
seq(ctx)
parallel(ctx)
}
串行:303ms
并行:101ms
本章的 benchmark 数值均为 Apple M1 单次运行,重跑会有几个百分点的波动。
并行把「等待之和」变成「等待的最大值」:下游耗时相同时,3 个下游就是 3 倍,10 个就是 10 倍;实际收益取决于最慢的那个,不取决于总数。
异步的三种形态
| 形态 | 做法 | 适合 | 代价 |
|---|---|---|---|
| 并行 fan-out | 同一请求内并发调用多个下游 | 无依赖的读聚合 | 下游压力 ×N、部分失败处理 |
| 后台任务 | 请求内只写任务,异步执行 | 发邮件、生成报表、写日志 | 需要持久化、幂等、可观测 |
| 消息队列 | 投递事件,消费者处理 | 跨服务解耦、削峰 | 最终一致、积压、重复消费 |
⚠️ 异步不减少工作量,只移动等待。把 100ms 的写操作丢进队列,请求变快了,但队列积压、消费者扩容、失败重试、重复消费的问题一个都不会少。没有持久化 + 幂等 + 监控的「异步」,只是把故障藏起来。
什么时候不该异步
| 情况 | 原因 |
|---|---|
| 调用方需要结果才能继续 | 异步后还是要等,反而多一次调度 |
| 需要强一致 (余额、库存扣减) | 异步窗口内读到旧值,业务不可接受 |
| 失败必须同步告知用户 | 异步只能事后补偿/通知 |
| 任务量远小于消费能力 | 引入队列的复杂度 > 收益 |
批处理:摊薄固定成本
一次远程调用的成本 ≈ 固定开销 a (RTT、解析、事务) + 每条记录成本 b。逐条 vs 批量:
逐条:n × (a + b)
批量:a + n × b
每条成本:逐条 a+b → 批量 a/n + b
实测 (模拟 DB:单条 1ms;批量 1ms + 50µs/条):
逐条插入 1000 行:1.15s
批量 10 行/批:171ms
批量 100 行/批:66ms
批量 1000 行/批:52ms
| 批大小 | 总耗时 | 相对逐条 | 单批延迟 (总耗时÷批数) | 适用 |
|---|---|---|---|---|
| 1 | 1.15s | 1× | 1.15ms | 强实时、单条事务 |
| 10 | 171ms | 6.7× | 1.71ms | 低延迟写入 |
| 100 | 66ms | 17.4× | 6.6ms | 大多数后台任务 |
| 1000 | 52ms | 22× | 52ms | 离线导入、日志落库 |
模型值 (n×(a+b) / a+n×b) 是 1.05s / 150ms / 60ms / 51ms;实测多出约 2%–15%,来自每次调用的固定开销与 sleep 精度。本表用实测值,避免把模型当成测量。
批大小从 100 加到 1000,吞吐只再提升 27%,但单批延迟从 6.6ms 涨到 52ms。批处理的收益递减,而延迟成本线性上升。
批处理的设计要点
| 要点 | 做法 | 不做的后果 |
|---|---|---|
| 双阈值 | 达到 maxSize 或 maxDelay 就 flush | 低流量时数据永远不落库 |
| 背压 | 队列满时阻塞/拒绝,而不是无限堆积 | 内存爆、延迟无上限 |
| 部分失败 | 整批重试 or 逐条重试,需幂等 | 重复写入或丢数据 |
| 顺序 | 同 key 哈希到同一批/同一分区 | 乱序覆盖 |
| 可观测 | 批大小、flush 延迟、失败率 | 出问题只能靠猜 |
代码骨架 (双阈值:满 maxSize 或等 maxWait 就 flush):
package main
import (
"fmt"
"time"
)
type Item struct{ ID int }
type Batcher struct {
in chan Item
maxSize int
maxWait time.Duration
flush func([]Item)
}
func (b *Batcher) Run() {
ticker := time.NewTicker(b.maxWait)
defer ticker.Stop()
buf := make([]Item, 0, b.maxSize)
flush := func() {
if len(buf) == 0 {
return
}
batch := buf
buf = make([]Item, 0, b.maxSize) // 不复用正在被处理的切片
b.flush(batch) // 生产代码里应处理错误/重试
}
for {
select {
case it, ok := <-b.in:
if !ok { // 关闭时冲刷剩余数据
flush()
return
}
buf = append(buf, it)
if len(buf) >= b.maxSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
func main() {
b := &Batcher{
in: make(chan Item, 100),
maxSize: 100,
maxWait: 50 * time.Millisecond,
flush: func(items []Item) { fmt.Printf("flush %d 条\n", len(items)) },
}
go b.Run()
for i := 0; i < 25; i++ {
b.in <- Item{ID: i} // 不足 100 条,靠 maxWait 触发
}
time.Sleep(120 * time.Millisecond)
close(b.in)
time.Sleep(10 * time.Millisecond)
}
池化:复用昂贵资源
池化只在一件事上成立:构造/销毁成本 ≫ 借用成本。连接、线程、大 buffer 符合;小结构体通常不符合。
池大小由利特尔法则定 (34.2 性能建模):
池大小 L = QPS × 单次占用时间
建议值 = L × 1.3 ~ 1.5 (给尾部留余量)
| 场景 | QPS | 单次占用 | L | 建议池 |
|---|---|---|---|---|
| 读缓存 | 5000 | 2ms | 10 | 13–15 |
| 查数据库 | 2000 | 20ms | 40 | 55–60 |
| 调下游 RPC | 1000 | 100ms | 100 | 130–150 |
池化的真实收益
4KB buffer 的分配 vs sync.Pool 复用 (Apple M1,Go benchmark):
| 方式 | ns/op | B/op | allocs/op |
|---|---|---|---|
make([]byte, 4096) | 480.9 | 4096 | 1 |
sync.Pool 复用 | 13.49 | 0 | 0 |
35 倍差距。但这是纯分配成本;如果对象构造还涉及 TLS 握手、TCP 连接、认证,差距会到毫秒级。
| 池类型 | 复用什么 | 典型大小 | 主要风险 |
|---|---|---|---|
| 连接池 | TCP/TLS 连接 | 10–200 | 池太小排队、太大压垮下游 |
| 线程/协程池 | 执行上下文 | 核数–数千 | 任务阻塞导致池饥饿 |
| 对象池 | 大对象/buffer | 按峰值 | 内存滞留、GC 后失效 |
| 内存池 | 固定大小块 | 按业务 | 碎片、释放时机 |
⚠️ 池不是越大越好:池大小超过下游承载能力,只是把排队从客户端挪到下游,同时增加内存和超时。
sync.Pool在 GC 时可能被清空,不能当缓存用。
索引:把扫描变查找
B+ 树索引把 O(N) 的全表扫描变成 O(log N) 的定位。以 200 万行的订单表为例:
| 查询 | 无索引 | 有索引 | 扫描行数 |
|---|---|---|---|
WHERE user_id=? AND created_at>? | 全表 200 万行 | (user_id, created_at) | ~120 |
WHERE email=? | 全表 200 万行 | UNIQUE(email) | 1 |
WHERE status='paid' | 全表 200 万行 | 低选择性,可能不走索引 | ~80 万 (40%) |
WHERE DATE(created_at)='2026-10-24' | 全表 200 万行 | 函数导致索引失效 | 200 万 |
ORDER BY created_at LIMIT 20 | 排序 200 万行 | (created_at) | 20 |
SELECT status FROM orders WHERE user_id=? | 全表 200 万行 | 覆盖索引 (user_id, status) | 120,无回表 |
索引的代价是写放大:每个二级索引都要在 INSERT/UPDATE/DELETE 时维护。一张写多读少的表挂 5 个索引,写入路径要做 6 次 B+ 树更新,SSD 的 IOPS 和锁竞争都会上来。索引不是越多越好,是按查询模式精确建。
索引失效的五个常见原因
| 原因 | 例子 | 改法 |
|---|---|---|
| 列上套函数 | WHERE DATE(created_at)=? | 改成范围 created_at >= ? AND < ? |
| 隐式类型转换 | WHERE user_id='123' (列是 int) | 参数类型与列一致 |
| 前导模糊匹配 | LIKE '%abc' | 后缀匹配/全文索引/倒排 |
| 低选择性 | WHERE status='paid' 占 40% | 组合索引把高选择性列放前,或接受全表 |
| OR / NOT | WHERE a=? OR b=? | 拆成 UNION,或建组合索引 |
🔑 索引解决的是「定位」,不解决「次数」。N+1 查询加索引,只是把 100 次 2ms 变成 100 次 0.2ms;改成 1 次批量查询,才是 100 倍 (34.2 强制流定律)。详见 44.8 索引原理 / 44.9 执行计划与慢查询。
并发:只在能并行且无争用时有效
Amdahl:串行部分决定上限
加速比 S(N) = 1 / (s + (1-s)/N)
| 串行比例 s | 4 核 | 8 核 | 16 核 | 64 核 | 理论上限 |
|---|---|---|---|---|---|
| 5% | 3.48× | 5.93× | 9.14× | 15.42× | 20× |
| 10% | 3.08× | 4.71× | 6.40× | 8.77× | 10× |
| 25% | 2.29× | 2.91× | 3.37× | 3.82× | 4× |
10% 的串行代码,64 核也只有 8.77 倍。所谓「加机器就能扩容」,前提是串行部分足够小。
并发度怎么定
| 任务类型 | 并发度 | 公式 |
|---|---|---|
| CPU-bound | ≈ 核数 | 超过核数只增加上下文切换 |
| IO-bound | 核数 × (1 + 等待/计算) | 8 核、90% 时间在等 → 80 并发 |
| 混合 | 按瓶颈资源算 | L = QPS × 延迟 (34.2 性能建模) |
| 下游受限 | 下游容量 / 单请求下游调用数 | 并发再高只会压垮下游 |
争用:并发不等于快
8 个 goroutine 并发自增,三种实现 (Apple M1,Go benchmark):
| 实现 | ns/op | 相对单锁速度 |
|---|---|---|
单个 sync.Mutex | 133.6 | 1× |
| 64 个分片锁 | 14.60 | 9.1× |
单个 atomic.AddInt64 | 52.95 | 2.5× |
两个反直觉的结论:
- 分片锁比单锁快 9.1 倍:每个分片落在不同缓存行,争用被拆开。
- 原子操作不是最快的:它比单锁快 2.5 倍,但比 64 分片锁慢 3.6 倍——所有 goroutine 抢同一个缓存行,缓存一致性协议把「无锁」变成了硬件级串行。
🔑 并发优化的关键是减少共享,不是「加锁改无锁」。分片、线程本地存储、消息传递、批量合并,都是把「多人抢一个」变成「各管各的」。
组合拳:一次改造的收益分解 (示意)
| 步骤 | 手段 | 优化前 | 优化后 | 省下 | 验证方式 |
|---|---|---|---|---|---|
| 0 | 基线 | 800ms | 800ms | — | 端到端 P99 + 分段 |
| 1 | 缓存热点数据,命中率 85% | 800ms | 500ms | 300ms | 缓存命中率、回源 QPS |
| 2 | 3 个下游并行 | 500ms | 350ms | 150ms | 各段耗时、下游错误率 |
| 3 | 20 次查询合并成 2 次 | 350ms | 230ms | 120ms | DB QPS、查询次数 |
| 4 | 索引 + 覆盖索引 | 230ms | 130ms | 100ms | EXPLAIN 扫描行数 |
| 5 | 连接池预热/扩容 | 130ms | 100ms | 30ms | 获取连接等待时间 |
| 6 | 裁剪 payload + 压缩 | 100ms | 80ms | 20ms | 响应体大小、序列化 CPU |
每一步都单独上线、单独测量。如果一次性全改,P99 没降你都不知道是哪一步的问题,更不知道收益是否真实。
什么时候不该优化
| 情况 | 决策 |
|---|---|
| 当前满足 SLO,且余量 > 30% | 不优化,把时间花在别处 |
| 瓶颈不在你改的模块 | 先解决瓶颈 (Amdahl) |
| 优化成本 > 收益 | 放弃,或换方案 |
| 代码半年内会被替换 | 只做止血,不做深度优化 |
| 可以通过砍需求解决 | 优先砍需求 |
| 优化让代码难以维护,收益 < 20% | 不值得 |
🔑 性能是约束,不是目标。满足 SLO 之后的优化,本质是在用「可维护性、交付速度、成本」换「用户几乎感知不到的毫秒」。
反模式
| 反模式 | 后果 |
|---|---|
| 不测量直接优化 | 优化了非瓶颈,收益接近 0 |
| 缓存一切 | 一致性/雪崩/内存问题,比慢更严重 |
| 异步化没有持久化和幂等 | 任务丢失、重复执行,故障被藏起来 |
| 批处理只设 maxSize 不设 maxDelay | 低流量时数据永远不落库 |
| 池越大越好 | 下游被压垮,内存和超时一起涨 |
| 索引越多越好 | 写放大、空间膨胀、优化器选错索引 |
| 无脑加线程/协程 | 锁争用、上下文切换、下游过载 |
用 sleep/轮询等异步结果 | 延迟固定、CPU 空转、时序不可控 |
| 只优化单机,不看分布式瓶颈 | 本机快了,整体被下游/网络卡住 |
| 用微基准代替端到端 | 常量折叠、缓存命中,线上完全不是这样 |
| 只优化平均值 | 平均降了,P99 不动,用户无感 |
自检
Q1 一个接口 800ms,profile 显示 600ms 在等 3 个互不依赖的下游 (各 200ms),CPU 只有 20%。你先做什么?为什么「加线程」不是答案?
▸ 答案:先做 fan-out 并行,把 600ms 的等待之和变成 200ms 的最大值 (加调度开销)。加线程不改变「串行调用三个下游」这件事——每个请求仍然要依次等 200ms,只是同时处理更多请求,单请求延迟不变,反而给下游更大压力。并行之后还要看:下游能否承受 3 倍并发、部分失败怎么降级、超时预算怎么分配。
Q2 缓存命中率从 80% 提到 90%,平均延迟降多少?为什么 90%→99% 的收益反而更大?
▸ 答案:按 E = h×0.5 + (1-h)×10,80% 时 2.40ms,90% 时 1.45ms,降 1.66 倍;99% 时 0.60ms,比 90% 再降 2.4 倍。原因是未命中率:80%→90% 让回源从 20% 降到 10% (少一半),90%→99% 让回源从 10% 降到 1% (少 10 倍)。但命中率越高越难提升,且要考虑失效成本、冷启动和一致性窗口。
Q3 8 核机器,任务 90% 时间在等 IO,理论上多少并发合适?加到 200 会怎样?
▸ 答案:N = 8 × (1 + 0.9/0.1) = 80。加到 200 不会提升吞吐:瓶颈是下游/IO,不是 CPU;多出来的并发只会在连接池、下游队列里排队,增加内存、上下文切换和超时,P99 反而变差。正确做法是先确认下游容量,再按 L = QPS × 延迟 配池,并加背压/限流。
Q4 一条 SQL 从 1.2s 优化到 20ms,但接口 QPS 没涨、P99 也没降。可能的原因是什么?
▸ 答案:① 这条 SQL 不在关键路径上,或者只占端到端延迟的一小部分;② 瓶颈已经转移到别处 (CPU、连接池、锁、下游、网络);③ 优化后并发更高,把下游压出了新的排队;④ 测量的是不同负载/不同数据分布。用 USE + 端到端分段重新找瓶颈,而不是继续优化这条已经很快的 SQL (Amdahl)。
Q5 批大小 100 和 1000 的吞吐只差 27%,为什么生产上常常选 100?
▸ 答案:批处理的收益递减,但延迟和失败成本线性上升:1000 条/批要等更久才 flush (单批约 52ms,且要等攒够),失败重试要重放 1000 条,事务/锁持有时间更长,内存峰值更高。100 条/批在吞吐 (17.4×) 和延迟 (6.6ms) 之间更平衡。最终取决于 SLO:离线导入可以选 1000,在线写入通常 50–200。
跨篇相关:本节讲「选哪种手段」,把流量挡在单点之前的多级链路见 34.7 多级分流;「该拆哪一处、按什么顺序拆」见 37.9 单点与拆分;限度与排队模型见 34.2 性能建模。