1. 随机磁盘 I/O#

1.1. 随机磁盘 I/O 的定义#

B + Tree 索引的时候总是提到 随机磁盘 IO, 这是什么意思呢?

既然查询的主要事件就是 随机 IO 的时间, 一次查询怎么估算进行的随机 IO 的次数呢?

读取或写入彼此不连续、位置不可预测的物理块(页、扇区)时,就叫随机(Random)I/O

  • 访问顺序跳来跳去 → 每次都要重新定位磁头或重新建立闪存地址映射

  • 在数据库语境里,随机 I/O 通常指 “把单个 4 KB/8 KB/16 KB 页从持久化介质搬进内存” 这一原子动作

  • 与之对立的是顺序(Sequential)I/O:按连续 LBA 逐块读写,一次“拉”大量数据,定位成本只付一次

1.2. 为什么“随机”比“顺序”慢得多?#

介质 顺序 I/O 吞吐 随机 I/O 吞吐 随机单次延迟主要来源
机械硬盘 (HDD) 200 MB/s ≈ 1 MB/s (≈ 200×差) 寻道 + 旋转等待 (ms)
SATA SSD 550 MB/s 40 MB/s 闪存块映射、队列对齐 (µs)
NVMe SSD 3 GB/s 500 MB/s 同上,但控制器更快 (µs)

随机 I/O 把定位成本均摊到 16 KB 这种很小的 payload 上, 顺序 I/O 则把定位成本摊到数百 KB ~数 MB, 故单位数据成本悬殊

1.3. 数据库为何经常出现随机 I/O?#

  1. B⁺Tree 把整张表拆成很多 16 KB 页, 键值排序后物理位置不再按主键连续

  2. 一次查询只需要极少量行 ⇒ 只读几个离散页

  3. 并发事务各跑各的语句 ⇒ 访问模式高度交错

1.4. 随机 I/O 在执行计划中的角色#

  • Optimizer 估算“若某条索引路径需 n 个未命中的页” ⇒ 代价 = n × (随机 I/O 单价)
  • 高度 h = 3 的 B⁺Tree:理论最坏 3 次随机读即可命中目标叶子 ⇒ 成本大幅低于全表顺序扫(需读很多页)
  • Buffer Pool 命中 ⇒ 把“随机 I/O”降为“逻辑读”,代价由毫秒/微秒→纳秒级

1.5. 典型日志/指标中如何看到随机 I/O#

  • SHOW STATUS LIKE 'Innodb_buffer_pool_reads'; → 物理随机读次数

  • Performance Schema 表 ⁠file_summary_by_instance.* → 按文件统计随机 I/O 次数与平均延迟

1.6. 一次二级索引等值查找#

  1. 索引根页不在缓存 → 随机读取 16 KB

  2. 内部页 miss → 再随机读取 16 KB

  3. 叶子页 miss → 第三次随机读取

如果页面已缓存, 上述步骤变成 0 次随机 I/O, 仅逻辑读

取到主键 → 去聚簇索引重走 1~3 步 ⇒ 最多再 3 次随机 I/O

因此常说 “随机 I/O 次数 ≈ 树高(+回表)”

2. 磁盘随机 IO 次数怎么计算的#

下面只讨论 InnoDB(8.x)+ B⁺Tree 的情形, 重点说明

  • 随机 I/O “怎么数”

  • 影响它的典型因素与估算方法

2.1. 概念对齐#

名词 含义
随机 I/O 次数 查询执行过程中不得不从磁盘把某个页读取进 Buffer Pool 的次数(若页已命中缓冲,则记作逻辑读,不计入随机 I/O)
树高 h 自根页 → 目标叶子页所需经过的层数;根也算 1
回表 (bookmark lookup) 二级索引命中后, 再去聚簇索引找整行所触发的额外路径

2.2. 单条记录查询时的估算公式#

假设页都不在缓冲区(最坏情形)

查询类型 随机 I/O 估算
PRIMARY KEY 等值 h(PK)
二级索引等值(唯一) h(Secondary) + h(PK)
二级索引非唯一,匹配 k 条 h(Secondary) + k × h(PK) + (k – 1)(额外叶子页)

说明

  1. 根页通常常驻内存,可把最顶层减 1;但在“最坏估算”里仍按 h 计算
  2. 如果同一叶子页内包含多条目标记录,只算 1 次随机 I/O

2.3. Range-Scan / 全表扫描#

场景 随机 I/O 估算
主键顺序扫整表 页数 = ⌈行数 ÷ 每页行数⌉ → 近似顺序 I/O,成本常按“顺序读”估算而非随机
二级索引范围 (l ≈ r) h(Secondary) + #命中叶子页 + 回表成本(同上)

2.4. 多表 JOIN 时如何累加#

优化器使用**块嵌套循环(BNL)或索引嵌套循环(BKA)**等模型;以下给最常见的“嵌套循环 + 内表走索引”场景:

随机 I/O ≈ outer_rows_not_in_cache × outer_row_cost + outer_rows × (inner_index_height + inner_pk_height)

其中

  • outer_row_cost 指外表行本身的读取成本(可能为 0,若外表已全缓冲)
  • inner_index_height 与 inner_pk_height 计算规则同上

2.5. Optimizer 如何拿到这些数字#

  1. 统计信息 (⁠ANALYZE TABLE) 告诉它行数、基数、平均每页记录数
  2. ⁠innodb_page_size 决定页大小;结合记录长度可推算每页能放几条
  3. 根页、最热的几层常常假设缓存命中 → 物理 I/O 被扣掉
  4. 剩余部分按一次访问 = 一次随机 I/O的假设累加
SHOW STATUS LIKE 'InnoDB_buffer_pool_read%';
变量 含义
Innodb_buffer_pool_read_requests 逻辑读(包括命中)
Innodb_buffer_pool_reads 物理随机读(未命中)
Innodb_buffer_pool_pages_flushed 物理写

2.6. 例子 二级索引等值查询#

SELECT age 
FROM   users 
WHERE  name = 'Grace';

已知

  • h(Secondary)=3h(PK)=3

  • 'Grace' 唯一

随机 I/O 估算

  1. 二级索引:3 页

  2. 回表:再次走聚簇 3 页

==> 总计 6 次(若根页 & 内部页已缓存,可能只剩 1~2 次实际磁盘 I/O)

2.7. 例子 索引嵌套循环连接 INLJ#

假设所有目标页最开始都不在 Buffer Pool(最坏路径), 介质为 NVMe(一次随机 I/O≈ 20 µs), 页大小 16 KB

主键聚簇索引 辅助索引 行数 (N) 平均行长 行 / 页 (估) 页数
orders (id) (customer_id, id) 1 000 000 150 B ≈100 ≈10 000
order_items (id) (order_id, id) 5 000 000 120 B ≈120 ≈41 700
  • 两棵 B⁺Tree(聚簇 + 二级)高度都为 3:Root→Internal→Leaf
  • 根页常驻内存,但在“最坏估算”里仍计 1 次 I/O
  • 每遇到一层,可能触发一次随机 I/O;一次 miss = 1 次 I/O

查询

SELECT  o.id, oi.product_id
FROM    orders       o
JOIN    order_items  oi  ON oi.order_id = o.id
WHERE   o.customer_id = 123;

场景参数(假设)

名称 数值 说明
orders.customer_id = 123 的订单数 100 行 外表有 100 行
一张订单的明细数 5 行 内表平均值 5 行
B+Tree 高度 3 根 + 内部 + 叶子
查询开始时 Buffer Pool 为空 最坏路径;所有碰到的页都会 miss

访问外表 orders 的随机 I/O

步骤(一次性完成) 随机 I/O 次数
a. 走二级复合索引 ⁠(customer_id, id) 3 页
b. 回到聚簇索引拿整行 3 页
c. 继续扫描叶子页把 100 行都读完 1 页(100 行刚好在同一叶子)
小计 7 次

说明

  • “走索引 3 页”= 根 1 + 内部 1 + 叶子 1
  • “回表再 3 页” 同理
  • 最后那张叶子页已经在内存,往后读同页不再算随机 I/O

查询 customer_id = 123 的所有订单及其订单中包含的商品信息, 这种查询场景 orders 是外表, order_items oi 是内表:

  • 查询从 orders 表(外表)开始

  • WHERE o.customer_id = 123 条件会先筛选出 orders 表中所有 customer_id = 123 的记录

    • 一次成本 IO 次数: 7 次, 这 7 次 加载了一页数据, 假设这一页包含了 100 个订单数据,
    • 如果 100 个订单需要两页, 则需要 7 + 1 = 8 次 IO, 而不是 7 * 2 = 14, 第二个叶子页只有 1 I/O,而不是再付 7 I/O
      • 路径上的根页、内部页已经在内存 → 不再 miss
      • 只需把第二个叶子页从磁盘搬进来 → 1 I/O
  • 数据库将上一步的 orders 表结果与 order_items 表(内表)进行 INNER JOIN, 根据 ON oi.order_id = o.id 条件匹配

    • 一次成本 IO 次数: 同上 7 次, 这只是 对于一个订单, 找出其所有 items 的查询, 平均 5 个 items, 一页肯定放得下, 所以就是 7 次, 不是 上面的那种 可能为 7 + 1 次 或者 7 * 2 次, 即 不会是 2 页, 三页 只用来放了 5 个 item, 当然也有可能,除非 item 很大, 一个就超过 8kb, 2 * 8 >= 16 kb > 一页的大小
    • 因为有 100 个订单, 查询 100 次, 所以 100 * 7 = 700 次 IO
      • 这么计算是错的, 因为 根页、内部页只在第一次 miss, 当处理第 2 ~ 100 张订单时:
      • 二级索引根 / 内部页 已经在 Buffer Pool
      • 聚簇索引根 / 内部页 也已在 Buffer Pool
      • 因此后 99 次只剩:
      • 索引叶子 1 次 IO + 聚簇叶子 1 次 IO = 2 次, 即 2 IO / 订单 而不是 7
    • 第一张订单 7 I/O + 后面 99 张 × 2 I/O ≈ 205 I/O, 远小于 700
  • 总计 内表 order_items 表进行了 205 次 IO + 外表 7 次 IO = 212 次IO

可见 真正决定数量级的是:根/内部页是否命中, 优化器在成本模型里正是用“根页大概率命中、内部页可能命中、叶子易 miss”这套概率,去预估 I/O 量