深蓝黑背景封面:左侧大标题「上线前,先拍出数量级」,副标题「扫描行数 × 访问方式 × IO 常数」;右侧原创示意图——一棵三层 B+ 树索引,根到叶子一条查找路径高亮标注「3 次页 IO」,旁边一把标尺刻度从 微秒、毫秒 到 秒,指针落在毫秒档;左上标注「面试经典 · 数据库」,右上标注「Day 05 · 11-02」 图 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,差一万倍以上

估算流水线图:上方四步流程——第一步表行数定基数(百万行还是亿行);第二步索引高度定查找代价(B+ 树每层一次页 IO,千万行约三层);第三步访问方式定扫描形态(索引点查只碰几个页,范围扫描看区间行数,全表扫描行数即页数级别);第四步乘 IO 常数得时间量级(随机读与顺序读代价差一个量级,内存命中再快几个量级)。下方两个对照例子:左「索引点查」= 3 次页 IO → 毫秒级;右「千万行全表扫描」= 数十万页顺序读 → 秒级,中间用一根粗箭头标注「差 3 个数量级」。底部总结:估算的价值不在精确,在于上线前发现「慢一个量级」的设计错误;拍完量级再上 EXPLAIN 验证 图 2:行数定基数、高度定查找、方式定形态、常数定时间。

可能的追问。

「常数哪来的?」页大小 16KB 是官方文档写死的默认值;随机读 0.1ms、顺序读每秒几百 MB 是当代 SSD 的常识量级,机械盘则互换角色——随机读比顺序读慢两个量级,结论只会更夸张。「二级索引点查呢?」多一次回表:先走二级索引拿到主键,再走一遍主键索引取行,页 IO 大致翻倍,仍是毫秒级。「缓冲池命中怎么算?」热数据在内存里,一次页访问是微秒级,比落盘快两三个量级——所以估算时先分清工作集是否装得进内存。「估算和 EXPLAIN 什么关系?」先后关系:先拍数量级做设计决策(这个接口的 SLA 敢不敢定 50ms),再用 EXPLAIN 验证假设。估算抓的是「方向性错误」,EXPLAIN 抓的是「执行细节」。

这套「先拍量级再验证」的基本功,在 AI 工程里同样救命:给特征库扩容、给检索接口定 SLA 之前,先算清楚扫描行数和访问方式差几个量级,就不会有「上线才发现慢一千倍」的事故。回到面试现场,建议的答题节奏:一行公式开场,页大小与树高两个常数垫后,三种访问方式的对照收尾,最后点一句与 EXPLAIN 的配合关系。你遇到过「上线才发现慢一个量级」的查询吗?当时是哪个常数拍错了?欢迎吐槽。