一份实测记录说"优化后查询从 900ms 降到 120ms"。复现时在服务器上跑了 780ms——因为作者的"优化"里有一半收益来自"本机是 NVMe + 数据只有 20 万行",而生产是网络存储 + 800 万行。
本机与服务器的差异不是"配置高低",而是几个会改变结论方向的变量:磁盘类型、数据规模、网络距离、核数与并发、以及本机上"没有别人在跑"。这六项之外还有一个"元差异":本机的结论是给你一个人看的,服务器的结论会被别人引用。所以本地测速可以粗糙,而写进文档的数字必须在会被人拿去用的环境里测。
这些差异不消除,本地结论就不能外推。
1. 六项会改变结论的差异
| 差异 | 影响方向 |
|---|---|
| 磁盘类型(NVMe vs 网络存储) | IO 型查询差一个数量级 |
| 数据规模 | 索引与全扫的交叉点不同 |
| 网络距离 | 每跳增加往返 |
| 核数与超线程 | 并发与 GC 线程数 |
| 其它负载 | 缓存与 CPU 争抢 |
| 运行时与参数 | 默认值可能不同 |
第一项最容易被低估:本地 NVMe 的随机读比网络存储快百倍,于是"加了索引快 10 倍"在本地成立、在服务器上可能只快 2 倍——因为瓶颈从 CPU 换到了 IO。
第五项常被完全忽略:本机只有你一个进程,服务器上有 20 个。第六项(运行时与参数)最隐蔽:本机可能跑的是 JDK 17 默认参数,服务器跑了 12 个 -XX。同一个程序在两处的 GC 行为可以完全不同,而"版本相同"这句话不足以说明可比性。
缓存命中率、CPU 时间片都不同。
2. 数据规模:最常被"凑合"的一项
| 做法 | 是否可用 |
|---|---|
| 生产数据脱敏导出 | 可用 |
| 按比例缩小(1/10) | 谨慎(分布会变) |
| 造数(随机) | 只用于功能验证 |
| 用生产从库 | 可用且推荐 |
第一项(生产脱敏导出)要注意"脱敏是否改变了分布":把手机号替换成随机串,会让唯一索引的选择性变化,于是计划可能不同。脱敏要做"保分布"的替换(如按原值哈希)。
第二项的风险是"交叉点被移动":索引在全扫更便宜时不会被选中,于是"缩小 10 倍的数据"测出的计划与生产不同。
第四项是实践中的最优:在从库上跑只读查询,规模与分布都是真的,代价是要拿到访问权限。
// 数据规模的三个必写数字
rows: 8,120,000
avg_row_bytes: 420
index_count: 5
// 缺"行数"时,任何耗时数字都无法判断适用性
// 缺"分布"时,无法判断是否包含倾斜
3. 本机"快"的三种假象
| 假象 | 机制 |
|---|---|
| 缓存全在内存 | 重复跑同一查询 |
| 编译/预热已完成 | JIT 已编译 |
| 无并发争抢 | 独占 CPU |
第一项最普遍:同样的查询跑第二遍,数据页已在 OS 缓存里第一项的量化方式很简单:同一查询连续跑 5 次,记录每次耗时。如果第 1 次是 800ms、第 2–5 次是 120ms,那 120ms 是"缓存命中"的数,不是"生产可用"的数。
,于是"第二遍比第一遍快三倍"——而这个收益在生产上不存在(因为数据量大得多)。
第二项在 JVM 上明显:首次运行是解释执行,而稳态是编译后的代码。所以"第一次慢"是本机特有现象,除非你显式预热。
// 消除三种假象的最小做法
// 1. 清 OS 缓存(需要权限)或换数据文件路径
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
// 2. 预热:先跑 3 轮丢弃
for i in 1 2 3; do run_query > /dev/null; done
// 3. 记录"第几轮"与"是否清缓存"
还有一项"看不见"的差异:文件系统与挂载参数。本机可能是 ext4 直连,服务器可能是 overlayfs + 网络挂载,而后者的小文件随机写在某些参数下会慢几十倍——这类差异在"明明硬件更好"的机器上尤其让人困惑。
// 确认磁盘与挂载类型(差异的源头)
df -T /var/lib/mysql # 文件系统类型
mount | grep -E ' / | /var' # 挂载参数(noatime? relatime?)
// 对比两边的随机写能力
fio --name=r --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30 --time_based
4. 网络距离的量化
| 场景 | 典型 RTT |
|---|---|
| 同机 | < 0.05ms |
| 同机房 | 0.2–0.5ms |
| 跨可用区 | 1–2ms |
| 跨地域 | 20–100ms |
第四项的差异不是"加法"而是"乘法":一次请求若串行 10 次跨地域调用,就是 200ms–1s 的固定开销,与代码优化无关。
这里有个"够不够"的判断:当网络开销占比超过 20% 时,端到端数字已经不能反映代码质量。此时应当报告"服务端处理耗时"而不是端到端。
所以本机测的"端到端耗时"必须减去这部分,或者明确标注"不含网络"。
一个实用的折中:在本地做"参数化测量",把结论写成关于变量的函数。比如"耗时 ≈ 20ms + 行数 × 0.08ms"——这样在服务器上只要代入真实行数,就能估算,而不必每次都复测。
// 参数化结论的写法(比单个数字有用得多)
实测:2 万行 22ms,20 万行 38ms,100 万行 121ms
拟合:T ≈ 20 + rows × 0.0001 (ms) ← 线性段
修正:超过 50 万行后斜率变大(索引失效),需分段
// 用法:服务器 800 万行 → 落在"斜率变大"段,不能直接外推
5. 怎么让本地结论可外推
| 做法 | 说明 |
|---|---|
| 在等规模环境复测 | 最可靠 |
| 只报告"相对改善"并注明环境 | 有限可用 |
| 用服务器跑基准(而非本机) | 推荐 |
| 分别报告 CPU 时间与墙钟时间 | 剔除网络 |
第四项很实用:CPU 时间不受网络与排队影响第三项(用服务器跑基准)的实践要点是"不要在生产实例上跑":用一台同规格的空闲机器,或在从库上跑只读。在有流量的实例上测,得到的是"带负载的数字",而它无法归因。
,于是"CPU 时间从 800ms 降到 110ms"是一个更可外推的结论。
// 两边都报告的写法
local: wall 120ms, cpu 110ms, rows 200k, nvme, no concurrent load
server: wall 780ms, cpu 140ms, rows 8.1M, network disk, 20 concurrent
// 结论:算法改善成立(cpu 110 vs 原 800),但墙钟收益主要被 IO 吃掉
最后一条实践建议:把"环境差异"写成一份清单,随实测记录一起提交。清单不需要长,六行就够——而它的价值在于让以后读这份记录的人立刻知道"这个数字能不能用"。
// 环境差异清单(附在实测记录末尾)
disk: local=nvme server=network-ssd
rows: local=200k server=8.1M
rtt: local=0 server=0.4ms
cores: local=8 server=16 (ht on)
load: local=none server=20 concurrent
runtime: local=jdk17-default server=jdk17-12-XX-flags
6. 排错:五种"本地快服务器慢"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 本地 120ms 服务器 780ms | IO 类型不同 | 在服务器测 |
| 第二遍快三倍 | OS 缓存 | 清缓存或标注轮次 |
| 并发一上来就崩 | 无争抢假设 | 加并发压测 |
| 服务器上计划不同 | 数据规模/统计 | 等规模复测 |
| 换机器结果又变 | 参数/版本不同 | 打印实际配置 |
第三项(并发一上来就崩)的根源是"本机没有并发":连接池、线程池、GC 线程数这些参数在单请求下都不起作用,而它们恰恰是高并发下的主要瓶颈。
第四项最典型:本机 20 万行时优化器选了索引,服务器 800 万行时选了全扫。这不是"优化失效",而是"优化只在特定规模下成立"。
7. 边界:什么情况本地结论足够
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯算法复杂度对比 | 与 IO/网络无关 | 本地够 |
| 数量级差异(10 倍以上) | 噪声不影响方向 | 本地够 |
| 功能与正确性验证 | 不涉及性能 | 本地够 |
| 已明确瓶颈在 CPU | 本机 CPU 可代表 | 本地够,但注核数 |
判断口径一句话:这次结论会不会被用来决定"上不上生产"。最后一条与"记录"有关的边界:记录里必须写"在哪测的",而不只是"什么机器"。因为"本机"与"同规格空闲机"在很多团队里被混称,而它们的结论强度完全不同。
会,必须在等规模环境复测;只用来判断"方向对不对",本地够。
8. 自测
- 六项差异里,哪一项最容易造成"一个数量级"的误判?为什么它会把瓶颈从 CPU 换到 IO?
- 为什么"按比例缩小数据"会移动交叉点?最优替代是什么?
- 本机"快"的三种假象分别是什么?怎么消除它们?
- 网络距离为什么是"乘法"而不是"加法"?
- "分别报告 CPU 时间与墙钟时间"为什么让结论更可外推?
- 判断"本地结论是否足够"的那个问题是什么?