5.5 数据集设计
上一节:5.4 环境隔离清单 | 下一节:5.6 统计基础 配套代码:05-experiment-design/05-dataset-design
一句话结论
数据集的「规模」决定执行计划,数据集的「分布」决定缓存命中率与锁竞争。 这两件事都会让结论完全翻转——用均匀分布压测出的「达标」,在真实的幂律流量下可能完全不达标。
一、用「试衣模特」理解数据集
服装品牌用标准身材的模特试衣服,然后量产。问题在于:
| 模特 | 真实顾客 |
|---|---|
| 身高 175、三围标准 | 身高 150–195,各种体型 |
| 只是一个人 | 分布很重要 |
如果只用模特试,你只能确认「这件衣服在标准身材上好看」——不能确认它适合大多数人。
数据集完全一样:
| 数据集维度 | 如果做错了 | 后果 |
|---|---|---|
| 规模 | 用 1 万行测,线上 1 亿行 | 执行计划不同(索引 vs 全表扫描) |
| 分布 | 用均匀分布测,线上是幂律 | 缓存命中率虚高、锁竞争虚低 |
| 冷热 | 只测热缓存 | 上线后冷启动的 P99 是热态的几十倍 |
二、规模:为什么「同数量级」不够,要「同级」
执行计划会随数据量改变
| 数据量 | PostgreSQL 可能选择 |
|---|---|
| 1 千行 | 全表扫描(反正很快) |
| 10 万行 | 索引扫描 |
| 1 亿行 | 索引扫描 + 更复杂的计划(并行查询、位图扫描) |
关键:执行计划的转折点不是线性的。 你可能在 10 万行时看到「走索引、P99 = 5 ms」,在 1 亿行时完全一样;但也可能在 100 万行时突然因为统计信息或选择性变化而改走全表扫描。
规模的经验法则
测试数据量 ≥ 线上数据量的 10%
为什么不是 100%:
| 原因 | 说明 |
|---|---|
| 成本 | 灌 1 亿行数据要几十分钟到几小时 |
| 增长 | 线上数据也在增长,追不上 |
| 边际收益 | 从 10% 到 100%,执行计划通常不会改变(但要验证) |
验证方法:在 10%、50%、100% 三个规模上跑同样的查询,确认执行计划(EXPLAIN)没有变化。如果变了,说明你选的规模不够。
三、分布:最容易被忽略、影响最大的一项
均匀分布 vs 幂律分布
| 均匀分布 | 幂律(齐夫)分布 | |
|---|---|---|
| 访问模式 | 每个 key 等概率 | 少数 key 承担大部分访问 |
| 缓存命中率 | 低(key 太分散) | 高(热点集中) |
| 锁竞争 | 低(分散) | 高(热点集中) |
| 数据访问范围 | 覆盖全表 | 集中在少数行 |
| 真实业务 | 少见 | 绝大多数 |
真实业务的形态:
社交网络:1% 的网红占 80% 的访问
电商:1% 的热门商品占 60% 的销量
短链:1% 的热门链接占 40% 的点击
用幂律分布能发现什么
| 发现 | 为什么均匀分布测不出 |
|---|---|
| 热点 key 的锁竞争 | 均匀分布下竞争被分散了 |
| 热点 key 的缓存失效(击穿) | 均匀分布下缓存命中率本来就低,看不出"击穿" |
| 热点分片的 CPU 不均 | 均匀分布下负载均衡 |
| 缓存实际命中率 | 均匀分布下命中率虚低(过度悲观)或虚高(取决于缓存大小) |
注意方向:均匀分布不一定"更乐观"或"更悲观"——它只是不真实。它可能让你:
- 过度乐观:热点 key 的竞争被分散,看不到争用;
- 过度悲观:缓存命中率虚低,P99 虚高。
两种偏差都可能让你的结论失效。
怎么生成幂律分布
/** 近似齐夫分布:alpha 越大越集中 */
fun zipfSample(n: Int, alpha: Double, rnd: Random): Int {
val u = rnd.nextDouble()
return max(1, (n * Math.pow(u, alpha)).toInt() + 1)
}
alpha = 0:退化为均匀分布。alpha ≈ 1:接近真实齐夫。alpha = 3:更集中(前 1% 的 key 承担绝大多数访问)。
怎么确定 alpha:从生产访问日志统计——画一个「key 访问频次」的双对数图(log-log plot),斜率就是 alpha 的近似值。
最真实的做法:直接从生产日志提取真实的 ID 访问分布,而不是用公式模拟。
四、冷热:别忘了「冷启动」这条路径
| 场景 | P99(相对热缓存) |
|---|---|
| 热缓存(跑了几分钟) | 1× |
| 缓存半热(刚发布) | 3–10× |
| 冷缓存(刚启动) | 10–50× |
所以必须明确你的 SLO 覆盖哪个阶段:
| SLO 覆盖 | 需要测什么 |
|---|---|
| 只覆盖稳态 | 预热后测(常规压测) |
| 覆盖发布期 | 必须测冷启动(清空缓存 + 重启服务) |
| 覆盖故障恢复后 | 测「缓存全失效 + 高负载」的恢复过程 |
测试冷启动的方法:
# ① 清空 Redis 缓存
redis-cli FLUSHDB
# ② 重启服务(清空本地缓存)
scripts/restart-app.sh
# ③ 立即开始压测(不预热!)
k6 run loadtest/profile-constant.js
注意:冷启动压测的风险是可能压垮数据库(所有请求都穿透到数据库)。这正是要测的——但要在有防护的环境做,并准备好终止条件。
五、数据集检查清单
## 数据集检查
### 规模
- [ ] 与线上同数量级(≥ 10%)
- [ ] 验证过执行计划在各规模下未变化(`EXPLAIN` 对比)
- [ ] 记录实际行数
### 分布
- [ ] 使用了幂律分布(不是均匀随机)
- [ ] 记录了 alpha 值
- [ ] 或者:从生产日志提取的真实分布
- [ ] 有明确的热点比例(例如「前 1% 的 key 占 40% 访问」)
### 内容
- [ ] 字段长度接近真实(不是全填 "test")
- [ ] 有边界值(空字符串、极长字符串、NULL)
- [ ] 关联关系真实(外键指向真实存在的行)
### 状态
- [ ] 缓存状态明确(冷 / 半热 / 热)
- [ ] 连接复用已启用
- [ ] 统计信息已更新(`ANALYZE`)
### 隔离
- [ ] 写操作有数据隔离方案(避免污染后续实验)
- [ ] 有数据重置脚本
六、三个真实的教训
| 教训 | 原因 |
|---|---|
| 「测试环境 P99 = 5 ms,上线后 = 120 ms」 | 只用了 1 万行数据(10 万行时执行计划才变化) |
| 「均匀分布测出缓存命中率 30%,优化了缓存策略收效甚微」 | 真实流量是幂律分布,命中率本来就有 85%,优化空间很小 |
| 「上线后每小时出现几次尖刺」 | 只测了热缓存,没测热点 key 集中失效的场景 |
共同点:都是数据集不真实导致的,而不是代码问题。
七、本节小结
- 规模决定执行计划:测试数据量应 ≥ 线上的 10%,并用
EXPLAIN验证计划未变。 - 分布决定缓存与锁行为:真实业务几乎都是幂律分布,均匀分布既不真实也可能双向偏差。
- 最真实的数据集是从生产日志提取的真实分布,而不是公式模拟。
- 冷热状态必须明确:冷启动的 P99 可能是热态的 10–50 倍,SLO 覆盖发布期就必须测。
- 数据集要清单化检查(规模、分布、内容、状态、隔离)。
- 很多「上线后才出现」的问题,根因是数据集不真实,而不是代码缺陷。
八、自测
- 你用 1 万行数据测出某查询 P99 = 3 ms,线上是 5000 万行,实测 P99 = 800 ms。请解释可能的原因,以及你怎么在测试环境复现这个问题。
- 一个团队用均匀随机 ID 压测,得到缓存命中率 35%。他们据此认为「缓存效果不好,不如去掉」。这个推理有什么问题?
- 你的服务 SLO 写的是「P99 < 200 ms」。请说明在什么情况下必须测冷启动,以及不测会有什么后果。
- 最可能的原因是执行计划发生了变化:1 万行时 PostgreSQL 优化器选择全表扫描(快,因为数据少),或者统计信息显示索引没有选择性;5000 万行时数据量与统计信息完全不同,可能触发了全表扫描(如果没索引)或选择了不同的连接算法。怎么复现:① 把测试数据量提到 50 万–500 万行(≥ 线上的 10%);② 关键一步是对比两个规模下的
EXPLAIN (ANALYZE, BUFFERS)输出,确认计划是否相同;③ 如果计划相同但耗时差很多,检查缓存命中率(Buffers: shared readvsshared hit)——大表可能无法全部装进内存;④ 还要检查统计信息(last_analyze时间、行数估计 vs 实际),估计偏差会让优化器选错计划;⑤ 另外注意:大表上的索引维护、写放大、以及 autovacuum 行为都与小表不同。 - 问题在于均匀分布不代表真实流量。真实业务的访问是幂律的:少数热点 key 承担大部分请求。用均匀随机 ID 压测时,key 分散在很大范围里,缓存自然命中率低——但这不代表生产环境的命中率也低。所以「缓存效果不好」这个结论不能从这份数据得出。正确做法:① 改用幂律分布(或从生产日志提取真实 ID 分布)重测;② 极可能发现命中率在 80% 以上,缓存其实效果很好;③ 但另一面也要注意:幂律分布会暴露均匀分布看不到的问题——热点 key 的锁竞争、缓存击穿(热点 key 失效瞬间)、分片不均。所以真实分布既可能让"缓存命中率"这个指标变好,又会暴露新的风险。
- 必须测冷启动的情况:① SLO 是「全时段」的,包含发布期(滚动发布)与自动扩容期;② 服务有本地缓存或依赖 Redis,发布后缓存为空;③ 有自动扩缩容,新实例上线时是冷的;④ Serverless/按需启动的场景。不测的后果:稳态压测显示 P99 = 120 ms 达标,但发布后新实例的 P99 可能是 1–3 秒——如果负载均衡在此时把大量流量切过去,会拖垮整个集群(甚至因为上游重试而放大)。更隐蔽的是:这种问题只在发布时出现,等你去看监控时缓存已经热了,问题"自己消失"了,根本抓不到。正确做法:单独做一次「冷启动测试」(清缓存 + 重启 + 立即压测),并把「启动到稳态」的时间作为指标记录下来(第 2 章 2.4 节的冷启动度量)。