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 性能建模。