图 1:本文封面,估算的目标不是精确,是数量级。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题目问「如何预估一条 MySQL 语句的性能」。EXPLAIN 谁都会跑,这道题考的是不跑任何命令、只靠几个数字拍出响应时间的量级——这个能力决定了你敢不敢在上线前说「这条查询没问题」。
估算公式只有一行:扫描行数 × 访问方式 × IO 常数。
第一步,表行数定基数。一百万行和一亿行是两个世界,先搞清楚量级。第二步,把行数换算成页数。InnoDB 官方文档写明默认页大小是 16KB;假设单行约 200 字节,一页装 80 行上下,一千万行的表就是十几万页、约 2GB 数据。
第三步,索引高度定点查代价。B+ 树每走一层就是一次页 IO。非叶子页里一个「键 + 指针」约 16 字节,扇出按 1000 估:根一页、第二层覆盖 1000 个指针、第三层覆盖 100 万个——而十几万叶子页两层就够定位,所以一千万行的表,索引只有 3 层。主键点查 = 3 次页 IO,全部落盘也不过零点几毫秒;若上层页缓存在内存里,实际只读一次叶子页。
第四步,访问方式定扫描形态。同样是这张千万行的表:全表扫描要把十几万页从头到尾顺序读一遍,按 SSD 顺序读每秒几百 MB 的吞吐量级算是秒级;范围扫描命中几千行,是「3 次随机 IO 定位起点 + 几十页顺序读」,毫秒级;点查,零点几毫秒。同一个表,三种访问方式跨了三四个数量级——这才是这道题真正要你看见的东西。
用代码把账算一遍。
下面这个估算器把上面四步公式化,用 Node.js 22.15.0 直接运行(node sqlcost.js):
'use strict';
// SQL 代价数量级估算器:行数 × 访问方式 × IO 常数
const PAGE_BYTES = 16384; // InnoDB 默认页大小 16KB
const ROW_BYTES = 200; // 假设单行约 200 字节
const RANDOM_IO_MS = 0.1; // SSD 随机读一页约 0.1ms 量级
const SEQ_READ_MB_S = 500; // SSD 顺序读吞吐约每秒数百 MB 量级
function tablePages(rows) {
const rowsPerPage = Math.floor(PAGE_BYTES / ROW_BYTES);
return Math.ceil(rows / rowsPerPage);
}
// B+ 树高度:非叶子扇出约 1000,逐层覆盖到叶子页数
function treeHeight(rows) {
const leafPages = tablePages(rows); // 叶子层就是数据页本身
let internal = 0, capacity = 1;
while (capacity < leafPages) { internal++; capacity *= 1000; }
return internal + 1; // 内部层 + 叶子层
}
function pointQuery(rows) {
const ios = treeHeight(rows); // 根到叶子,每层一次页 IO
return { ios, ms: ios * RANDOM_IO_MS };
}
function fullScan(rows) {
const pages = tablePages(rows);
const ms = (pages * PAGE_BYTES / 1e6) / SEQ_READ_MB_S * 1000; // 顺序读
return { ios: pages, ms };
}
function rangeScan(rows, matched) {
const lookup = treeHeight(rows); // 定位区间起点
const leafPages = tablePages(matched); // 叶子层顺序扫过的页
return { ios: lookup + leafPages,
ms: lookup * RANDOM_IO_MS + (leafPages * PAGE_BYTES / 1e6) / SEQ_READ_MB_S * 1000 };
}
const rows = 10_000_000;
console.log(`表规模:${rows.toLocaleString()} 行,行宽 ${ROW_BYTES}B,页 ${PAGE_BYTES / 1024}KB`);
console.log(`表总页数:${tablePages(rows).toLocaleString()} 页 ≈ ${(tablePages(rows) * PAGE_BYTES / 1e6).toFixed(0)}MB`);
console.log(`B+ 树高度:${treeHeight(rows)} 层(扇出按 1000 估)`);
const p = pointQuery(rows);
console.log(`主键点查:${p.ios} 次随机页 IO ≈ ${p.ms.toFixed(1)}ms -> 毫秒级`);
const f = fullScan(rows);
console.log(`全表扫描:${f.ios.toLocaleString()} 页顺序读 ≈ ${(f.ms / 1000).toFixed(1)}s -> 秒级`);
const r = rangeScan(rows, 5000);
console.log(`范围扫描(命中 5000 行):${r.ios} 页 ≈ ${r.ms.toFixed(1)}ms -> 毫秒级`);
console.log(`点查毫秒级 vs 全表秒级:${p.ms.toFixed(1)}ms 对 ${(f.ms / 1000).toFixed(1)}s,差一万倍以上`);
实测输出:
表规模:10,000,000 行,行宽 200B,页 16KB
表总页数:123,457 页 ≈ 2023MB
B+ 树高度:3 层(扇出按 1000 估)
主键点查:3 次随机页 IO ≈ 0.3ms -> 毫秒级
全表扫描:123,457 页顺序读 ≈ 4.0s -> 秒级
范围扫描(命中 5000 行):65 页 ≈ 2.3ms -> 毫秒级
点查毫秒级 vs 全表秒级:0.3ms 对 4.0s,差一万倍以上
图 2:行数定基数、高度定查找、方式定形态、常数定时间。
可能的追问。
「常数哪来的?」页大小 16KB 是官方文档写死的默认值;随机读 0.1ms、顺序读每秒几百 MB 是当代 SSD 的常识量级,机械盘则互换角色——随机读比顺序读慢两个量级,结论只会更夸张。「二级索引点查呢?」多一次回表:先走二级索引拿到主键,再走一遍主键索引取行,页 IO 大致翻倍,仍是毫秒级。「缓冲池命中怎么算?」热数据在内存里,一次页访问是微秒级,比落盘快两三个量级——所以估算时先分清工作集是否装得进内存。「估算和 EXPLAIN 什么关系?」先后关系:先拍数量级做设计决策(这个接口的 SLA 敢不敢定 50ms),再用 EXPLAIN 验证假设。估算抓的是「方向性错误」,EXPLAIN 抓的是「执行细节」。
这套「先拍量级再验证」的基本功,在 AI 工程里同样救命:给特征库扩容、给检索接口定 SLA 之前,先算清楚扫描行数和访问方式差几个量级,就不会有「上线才发现慢一千倍」的事故。回到面试现场,建议的答题节奏:一行公式开场,页大小与树高两个常数垫后,三种访问方式的对照收尾,最后点一句与 EXPLAIN 的配合关系。你遇到过「上线才发现慢一个量级」的查询吗?当时是哪个常数拍错了?欢迎吐槽。