34.6 性能反模式
一句话
性能反模式不是「代码写得慢」,而是在单点上看是优化、在全局上把成本转移给了别人——转给下游、转给下一次请求、转给下一个人;它们共同的特征是:局部合理,系统性地错。
先看代价:延迟不是「慢一点」
| 延迟 | 用户感受 | 业务后果 |
|---|---|---|
| < 100ms | 瞬时,像本地操作 | 无感知 |
| 100ms–1s | 有停顿,但注意力还在 | 转化率开始下降 |
| 1s–10s | 明显等待,会切标签页 | 流失、重试、重复提交 |
| > 10s | 认为坏了 | 放弃、投诉、绕过系统 |
性能问题的真正成本不是「服务器多花 100ms」,而是重试放大、超时级联、用户流失、容量浪费。下面按五类拆:度量、架构、实现、数据/IO、组织。
一、度量类:数字本身就是错的
| 反模式 | 症状 | 后果 | 改法 |
|---|---|---|---|
| 只看平均延迟 | 平均 25ms,P99 500ms | 1% 用户等半秒,报表「一切正常」 | 看 P95/P99/P999 (34.1 性能指标) |
| 闭环压测当容量 | 压测 5000 QPS,上线秒超时 | 高估容量 2–5 倍 (CO) | 开环定速 (34.5 性能测试) |
| 只压单接口 | /search 很快,首页仍慢 | 混合链路的锁/连接池竞争没覆盖 | 按生产比例混合压测 |
| 测试数据太小 | 1 万行时 1ms,生产 1 亿行 100ms | 索引、缓存、查询计划全失真 | 数据量与分布接近生产 |
| 客户端瓶颈 | 服务端 CPU 30%,QPS 上不去 | 压测机端口/TLS/序列化先撑不住 | 分布式发压 + 客户端监控 |
| 超时算成功 | 错误率 0,但大量请求 30s 才返回 | 把「失败」统计成「慢成功」 | 超时即失败,单独统计 |
| 只测一次 | 这次 8000 QPS,下次 5000 | 噪声/缓存/邻居干扰被当成容量 | 多次取稳定值,记录环境 |
| 拿最大 QPS 当容量 | 压到错误率 20% 的峰值 | 上线即崩 | 取拐点 × 70% |
🔑 度量类反模式的共同点:你优化的不是系统,是报表。数字好看不等于用户好过。
二、架构类:把成本转移给下游
N+1 查询
一个列表页 100 条记录,每条再查一次作者:
查询次数 V = 1 + 100 = 101
入口 100 QPS → DB 10100 QPS
单次 0.2ms、4 核 → U = 10100×0.0002/4 = 50.5%
改成批量查询 (V=2):DB 利用率从 50.5% 降到 1%。加索引只能把 0.2ms 变 0.02ms (10 倍),减少次数是 50 倍。先改次数,再改单次成本 (34.2 强制流定律)。
扇出放大:1% 的慢,变成 63% 的慢
一个请求并行调用 100 个下游,每个下游有 1% 概率超过 SLO:
至少一个慢的概率 = 1 - (1 - 0.01)^100 = 63.4%
| 下游数 | 单下游慢概率 1% | 请求级慢概率 |
|---|---|---|
| 10 | 1% | 9.6% |
| 20 | 1% | 18.2% |
| 50 | 1% | 39.5% |
| 100 | 1% | 63.4% |
扇出越多,请求级 P99 越容易被最慢下游的长尾主导。对策:对下游设独立超时、降级返回默认值、限制扇出宽度、把串行依赖改成批处理。
同步串联:延迟相加,可用性相乘
5 个服务串行,每个 100ms、99.9% 可用:
延迟 = 5 × 100 = 500ms
可用性 = 0.999^5 = 99.5% (每月多出约 3.6 小时不可用)
10 个服务:0.999^10 = 99.0%,每月约 7.2 小时。每加一个同步依赖,都在同时加延迟和减可用性。能并行就并行,能异步就异步,能降级就降级。
其他架构反模式
| 反模式 | 数字/后果 | 改法 |
|---|---|---|
| Chatty calls | 一个页面 20 次 RPC × 2ms = 40ms,还受网络抖动影响 | 合并成 2 次批量调用,或 BFF 聚合 |
| 无界队列 | 入口 1000 QPS、消费 500 QPS → 每秒积压 500,1 万条队列 20 秒满 | 有界队列 + 背压 + 限流(43.5 积压与背压) |
| 无界并发 | 每请求一个 goroutine/线程,下游被打穿 | 信号量/工作池 + 超时 |
| 缓存当数据库 | 无 TTL、无失效,读到几天前的数据 | 明确陈旧窗口 + 失效策略 (41 章) |
| 重试风暴 | 3 层各重试 3 次 → 最坏 27 倍流量 | 只在最外层重试 + 预算 + 退避 (35.5 超时、重试与退避) |
| 共享数据库 | 多个服务互相锁表,一个慢查询拖垮全部 | 按服务拆库/拆表,或至少隔离连接池 |
| 大 payload | 1MB × 1000 QPS = 8Gbps,序列化 CPU 翻倍 | 裁剪字段、分页、压缩、二进制协议 |
| 轮询代替推送 | 1 秒轮询 × 10 万客户端 = 10 万 QPS 空转 | 长连接/WebSocket/Webhook |
🔑 架构类反模式最贵:它们不是「改一行代码」,而是改边界。发现得越晚,修复成本越高。
三、实现类:把常数因子写成数量级
循环里拼字符串
5000 行数据,两种写法 (Apple M1,Go benchmark 单次运行):
| 写法 | ns/op | B/op | allocs/op |
|---|---|---|---|
s += name + "," + itoa(n) + "\n" | 18,359,142 | 177,781,716 | 9,909 |
strings.Builder + Grow | 164,308 | 141,760 | 4,901 |
112 倍时间、1254 倍内存。原因:Go 的字符串不可变,每次 += 都要分配新串并复制整个前缀,总复制量是 O(n²);5000 行产生约 177MB 临时对象,GC 被迫频繁运行。
package main
import (
"fmt"
"strconv"
"strings"
)
type Row struct {
Name string
N int
}
// ❌ O(n²):每次 += 都分配新串、复制整个前缀
func slow(rows []Row) string {
var s string
for _, r := range rows {
s += r.Name + "," + strconv.Itoa(r.N) + "\n"
}
return s
}
// ✅ O(n):一次分配,追加写入
func fast(rows []Row) string {
var b strings.Builder
b.Grow(len(rows) * 24)
for _, r := range rows {
b.WriteString(r.Name)
b.WriteByte(',')
b.WriteString(strconv.Itoa(r.N))
b.WriteByte('\n')
}
return b.String()
}
func main() {
rows := make([]Row, 5000)
for i := range rows {
rows[i] = Row{Name: "sku-" + strconv.Itoa(i), N: i}
}
fmt.Println(len(slow(rows)) == len(fast(rows))) // true:两种写法输出长度一致
}
其他实现反模式
| 反模式 | 后果 | 改法 |
|---|---|---|
热循环里 defer | 每次几纳秒到几十纳秒,百万次就是几十毫秒 | 移出循环或显式调用 |
| 热路径 JSON 反射 | 序列化常占 CPU 30%–50% (视 payload) | 代码生成/预编译 schema |
| 正则回溯 | 一个恶意输入让 CPU 100% (ReDoS) | 限制长度、用 RE2、避免嵌套量词 |
| 锁内做 IO | 一个 50ms 慢查询让所有请求排队 | 缩小临界区,先查后锁 |
| 频繁小分配 | GC 占用 20%–30% CPU | 对象池、预分配、减少逃逸 |
map[string]any 到处传 | 类型断言 + 装箱 + 缓存不友好 | 强类型结构体 |
| 同步写日志 | 每请求 10 次磁盘写,IOPS 打满 | 异步/批量/采样 |
| 忙等/短轮询 | CPU 空转,延迟固定为轮询间隔 | 事件通知/channel |
无界 append | 扩容复制 + 内存峰值翻倍 | make([]T, 0, n) 预分配 |
四、数据/IO 类:索引、批量、连接与缓存
| 反模式 | 数字/后果 | 改法 |
|---|---|---|
| 缺索引 | 200 万行全表扫 ~1.2s vs 索引 120 行 ~5ms | 按查询模式建组合索引(44.8 索引原理) |
| 列上套函数 | WHERE DATE(created_at)=? 索引失效 | 改成范围查询 |
| 隐式类型转换 | WHERE user_id='123' 导致索引失效 | 参数类型与列一致 |
SELECT * | 传 200 列而不是 5 列,网络与序列化按列数放大 | 只查需要的列 |
| 深分页 | OFFSET 100000 扫 10 万行 | 游标/keyset 分页 |
| 逐条插入 | 1000 行 1.15s vs 批量 100 行 66ms | 批量写入 (34.4 优化手段) |
| 大事务 | 锁持有时间长,复制延迟,回滚代价大 | 拆小事务,异步补偿 |
| 连接池过小 | 等待连接 120ms,P99 翻倍 | 按 L=λW 配池 (8.3 连接复用) |
| 连接池过大 | 下游连接数打满,内存和切换开销上升 | 池大小 ≤ 下游承载 |
| 缓存无 TTL | 数据永久陈旧,内存只涨不降 | TTL + 失效 + 容量上限 |
| 热 key | 单分片/单连接打满,其他空闲 | 本地缓存、key 打散、读副本(41.6 热点 key 与分片) |
五、组织类:性能变成「上线前一周的事」
| 反模式 | 症状 | 后果 | 改法 |
|---|---|---|---|
| 性能是最后一步 | 上线前一周才压测 | 架构已定,只能临时加机器/加缓存 | 设计阶段定延迟预算 |
| 没有性能预算 | 每个服务「只加 10ms」 | 10 个依赖加起来 100ms,预算被吃光 | 端到端预算分解到每个依赖 |
| 没有回归门禁 | 这版慢 20%,下版再慢 20% | 半年后整体慢一倍 | CI benchmark + P99 回归告警 |
| 只优化自己的服务 | 本服务 5ms,下游 500ms | 用户端到端没变 | 按端到端 SLI 优化 |
| 优化不测量 | 「感觉快了」 | 改错地方,甚至更慢 | before/after profile + 压测 |
| 压测造假 | 只报最好的一次 | 容量决策建立在假数据上 | 固定模型、多次、全量报告 |
| 容量拍脑袋 | 「按去年流量 ×2」 | 大促翻车或资源浪费 | 拐点 × 70% + 峰值系数 |
| 告警只看均值 | 平均 CPU 60%,P99 已爆 | 告警不响,事故扩大 | 按 P99/饱和度/队列告警 |
性能预算:把「快」写成可验收的数字
端到端 200ms 的接口,预算可以这样分:
| 环节 | 预算 | 超了怎么办 |
|---|---|---|
| 客户端/网络 | 20ms | CDN、连接复用、就近接入 |
| 网关 | 10ms | 减少鉴权/日志同步开销 |
| 认证/鉴权 | 10ms | 本地缓存 token、异步审计 |
| 业务计算 | 30ms | 算法/批处理/减少重复计算 |
| 缓存访问 | 10ms | 本地缓存、Pipeline |
| 数据库 (3 次) | 60ms | 索引、批量、读写分离 |
| 序列化/传输 | 15ms | 裁剪字段、二进制协议 |
| 排队/抖动 | 15ms | 限流、隔离、预留容量 |
| 余量 | 30ms | 不允许任何单环节吃掉 |
🔑 性能预算的作用不是「算得准」,而是在评审时能说「这个方案会超预算 40ms,不接受」。没有预算,每个团队都觉得自己只花了 10ms。
三条铁律
🔑 ① 没有度量,不优化。 先 profile/压测,再改代码;否则你优化的是自己的偏好。
🔑 ② 优化瓶颈,不优化非瓶颈。 火焰图最宽的函数不一定在关键路径上;按 Amdahl,非瓶颈优化收益接近 0。
🔑 ③ 一次只改一个变量,改完复测。 一次改五处,P99 没降你都不知道该回滚哪一处;P99 降了也不知道是哪一处的功劳。
反模式速查表
| 症状 | 最可能反模式 | 第一动作 |
|---|---|---|
| 平均延迟正常,P99 很高 | 只看平均值 / 长尾未拆 | 按接口/下游/分片拆 P99 |
| 压测很好,上线超时 | 闭环 CO / 数据太小 / 单接口 | 开环 + 混合流量 + 生产数据 |
| CPU 不高,延迟很高 | 排队/锁/IO 等待 | block/off-CPU profile、池等待 |
| CPU 100%,QPS 不涨 | 热点函数/GC/锁自旋 | CPU profile + heap profile |
| DB CPU 高,慢查询多 | N+1 / 缺索引 / SELECT * | 强制流定律 + EXPLAIN |
| 加机器吞吐不涨 | 串行/共享瓶颈 (USL) | 找 σ/κ,减少共享 |
| 内存缓慢上涨 | 泄漏/无界缓存/goroutine 堆积 | heap inuse + goroutine profile |
| 周期性地抖动 | GC/定时任务/缓存集中失效 | trace + GC 日志 + TTL 抖动 |
| 大促前临时扩容 | 容量无模型 | 拐点 × 70% + 峰值系数 |
| 每次发布都慢一点 | 无性能门禁 | CI benchmark + P99 回归 |
自检
Q1 一个首页并行调用 50 个下游,每个下游 P99 是 100ms (1% 请求超过),首页 P99 大概在什么量级?为什么「每个下游都达标」不等于「首页达标」?
▸ 答案:请求级慢概率 = 1 - 0.99^50 ≈ 39.5%。无法只凭这些数字算出精确 P99,但能确定 39.5% 的请求至少踩到一个慢下游,所以首页 P99 必然落在慢尾里,量级接近最慢下游的 P99 (远高于 100ms)。原因是尾部概率的乘法:只要有一个下游慢,整个请求就慢;并行只把「延迟相加」变成「取最大值」,并不能消除任一下游的长尾。对策:限制扇出、对每个下游设独立超时与降级、把非关键下游改为异步。
Q2 列表页返回 100 条记录,每条查一次作者,DB 每次 0.2ms,入口 100 QPS,DB 4 核。DB 利用率是多少?加索引和改批量,哪个收益大?
▸ 答案:V=101,X_db=10100 QPS,U = 10100×0.0002/4 = 50.5%。改批量 (V=2) 后 U=1%,降 50 倍;加索引把 0.2ms 降到 0.02ms 只降 10 倍。先减少次数,再降低单次成本;两者叠加才是 500 倍。
Q3 一个接口平均 30ms,P99 2s,QPS 500。老板说「平均很快,不用管」。你怎么用数字反驳?优先查什么?
▸ 答案:P99=2s 意味着每秒有 5 个请求等 2 秒,按 8 小时高峰算约 14.4 万个请求体验极差;平均 30ms 只是被 99% 的快请求拉低了。优先查长尾构成:① 按下游/分片/机器拆 P99;② 对比网关端到端与服务端耗时的差值 (排队/网络);③ 查 GC、连接池等待、缓存集中失效、慢 SQL;④ 看是否周期性出现。先定位那 1% 的共同特征,通常一处修复就能把 P99 拉下来。
Q4 为什么「循环里 s +=」在 5000 行时能慢 100 倍?给出复杂度和内存两个角度。
▸ 答案:字符串不可变,每次 += 都分配新串并复制旧前缀。第 i 次复制 i 个字符,总复制量 1+2+...+n = O(n²);5000 行约 177MB 临时分配,触发频繁 GC。strings.Builder 预分配后只做追加,总复制 O(n),内存降到 142KB 量级。这是常数因子被写成了数量级:数据量翻倍,慢 4 倍而不是 2 倍。
Q5 你们有 5 个串行依赖,每个 99.9% 可用、100ms。端到端可用性和延迟是多少?如果老板要求 99.99%,怎么办?
▸ 答案:延迟 5×100=500ms;可用性 0.999^5≈99.5%,即每月约 3.6 小时不可用,达不到 99.99% (每月约 4.3 分钟)。三条路:① 减少串行依赖 (并行化/合并/异步);② 降级:非关键依赖失败时返回默认值,不计入可用性;③ 冗余:关键依赖多副本/多活,把单点可用性提到 99.99%。只靠「让每个服务更稳」无法从 99.5% 到 99.99%,必须改结构。