4. 索引存储结构:页、区、段的概念

聊索引优化,很多人上来就揪着 B+ 树不放。但说实话,如果你不理解数据在磁盘上到底是怎么放的,优化往往就是瞎猫碰死耗子。我个人习惯,讲索引之前,先讲清楚三个东西:页(Page)、区(Extent)、段(Segment)。搞懂了它们,你才能明白为什么有些索引建了比不建还慢。

4.1 页(Page):最小的 I/O 单元

页是数据库和磁盘打交道的「基本单位」。操作系统读磁盘按块(Block),数据库读磁盘按页。你想想看,一条记录才几百字节,如果每次只读一条,磁盘寻道时间就足以把性能拖垮。所以数据库把多条记录打包成一页,一次 I/O 读一整页。

在 SQL Server 里,页的大小是 8KB。MySQL 的 InnoDB 默认也是 16KB。这不是拍脑袋定的,是经过大量 I/O 性能测试的折中选择。

页的核心特性:

  • 页是数据库 I/O 的最小单位,一次读写至少一页。
  • 页内数据按行存储,每行有固定的「行偏移数组」来管理。
  • 页头(Page Header)记录页的元信息,比如页号、上一页、下一页、空闲空间等。
  • 页尾(Page Footer)有一个校验和(Checksum),用来检测页损坏。

我在项目中遇到过一件事:有个同事建了索引,但查询还是很慢。我一看,索引键选的是 VARCHAR(4000)——这导致一页只能放 2 行索引记录。每查 100 条就要读 50 页,I/O 瞬间爆炸。

避坑指南:索引键越短越好。键越短,一页里能放的索引行就越多,查询需要的 I/O 次数就越少。我曾经见过有人把 GUID 做主键,一页只能放 50 多个索引行,结果 B+ 树高度直接飙到 5 层。

页的结构大致是这样的:

页 (8KB / 16KB)
├── 页头 (96 bytes / 200 bytes)
│   ├── 页号 (Page ID)
│   ├── 上一页指针
│   ├── 下一页指针
│   ├── 页类型 (数据页、索引页、溢出页等)
│   └── 空闲空间偏移量
├── 数据行区域
│   ├── 行 1
│   ├── 行 2
│   ├── ...
│   └── 行 N
├── 行偏移数组 (从页尾向前生长)
│   ├── 行 1 偏移
│   ├── 行 2 偏移
│   └── ...
└── 页尾 (Checksum)

嗯,这里要注意:行偏移数组是从页尾往前长的,数据行是从页头往后长的。两者在中间相遇,页就满了。这种设计的好处是——删除行时只需要更新偏移数组,不需要物理搬数据。

4.2 区(Extent):管理的核心

页是 I/O 单位,但数据库不可能一页一页地管理空间。太零碎了。所以有了区——区是空间分配的基本单位。

区和页的关系很简单:一个区包含固定数量的连续页。在 SQL Server 里,一个区是 64KB(8 页)。在 InnoDB 里,区的大小是 1MB(64 页)。

你想想看,如果没有区,新创建一张表时,数据库要分别去磁盘上找 64 个空闲页。这些页可能不连续,分散在各个地方。读写时磁头就得来回跳,性能大打折扣。

区的出现解决了两个问题:

  1. 分配效率:一次性分配 64 页(1MB),比分配 64 次快得多。
  2. 空间连续性:区内的页物理连续,顺序扫描时性能极好。

注意:并不是所有表都直接用区来分配。小表(小于 64KB 的)会用「混合区」——多个小表共享同一个区。只有表增长到一定大小后,才会转为使用「统一区」——一个区专属于一个对象。这种机制避免了大量小表浪费空间。

4.3 段(Segment):逻辑的容器

到了段这个层面,就不再是物理概念了。段是逻辑容器,用来归组相关的区。一个表或索引在逻辑上就是一个段。

每个段包含三种类型的区:

区类型 说明 典型场景
数据区 存储表数据的页 表扫描、范围查询
索引区 存储 B+ 树非叶子节点的页 索引查找、排序
溢出区 存储大对象(LOB)或变长列的溢出部分 TEXT、BLOB、大 VARCHAR

举个例子。一张表有聚簇索引,那它的段结构就是这样的:

  • 数据段:存储所有行的实际数据(叶子节点本身)。
  • 索引段:存储 B+ 树的非叶子节点(路由信息)。
  • 溢出段(可选):如果某行有超大字段,放不下一页,就把溢出部分单独扔到这里。

每个段内部又维护一个「区链表」。数据库在分配新区时,直接从链上取一个空闲区,不需要去全局元数据里搜索。这也是为什么频繁增删的表,区链表会变得碎片化。

核心思想:段让数据库能够为不同的访问模式做优化。比如扫描表时只需要顺序读数据区,不需要读索引区。又比如重建索引时只需要替换索引区,数据区完全不受影响。

为了让你更直观地理解这三层的关系,我画了一张图。

页、区、段结构关系图 索引存储结构:页 → 区 → 段 段(Segment)— 逻辑容器 • 一个表或索引对应一个段 • 段内部维护区链表 • 段分为:数据段 / 索引段 / 溢出段 • 段不是物理对象,无固定大小 区 1 (1MB) 区 2 (1MB) 区 3 (1MB) • 区是空间分配的基本单位 • 1 个区 = 64 个连续页 (InnoDB) • 区内的页物理连续 • 混合区 vs 统一区 页1 页2 页3 …64 页1 页2 页3 …64 页1 页2 页3 …64 • 页是 I/O 最小单位 (8KB / 16KB) • 页内包含行数据 + 页头 + 行偏移数组 • 页通过链表连接成逻辑序列 • 页头部记录上下页指针 物理连续 → 顺序扫描快 | 逻辑非连续 → 随机读慢 | 碎片化 → 区链表分裂

4.4 三层关系如何影响性能

搞清楚了页、区、段是什么,接下来才是重点——它们怎么影响索引性能。我直接给结论:

  • 页太小 → 每页记录数少 → 索引树变高 → I/O 次数变多。
  • 页太大 → 读进来的无用数据多(I/O 浪费)→ 缓存命中率下降。
  • 区不连续 → 顺序扫描变成随机读 → 性能断崖式下跌。
  • 段链表长 → 分配区时要遍历大量链表节点 → 写性能下降。

实战经验:我调过一张日志表,数据量 50GB,全表扫描要 40 秒。查 sys.dm_db_index_physical_stats 发现碎片率 78%。重建聚簇索引后,扫描降到 5 秒。原因就是区不连续了,顺序读变成了大量随机 I/O。

4.5 避坑清单

最后给个清单,这些是我在项目里踩过的坑:

问题现象 根因 解决方案
全表扫描越来越慢 页碎片化 / 区不连续 重建索引或整理碎片
频繁插入时性能波动 段链表碎片 / 页拆分频繁 调整 Fill Factor,预留空闲空间
索引重建很慢 溢出段过大 / LOB 页分散 单独处理 LOB 列,或使用在线索引重建
小表占用过多空间 混合区未回收 定期收缩数据库或重建小表

说白了,页、区、段就是数据库的「三级存储架构」。从最小的 I/O 单位,到分配管理单元,再到逻辑容器——每一层都有明确的职责。你不一定要背下每个参数,但理解这个层级关系之后,再看执行计划里的 Page Reads、Extent Contention、Segment Fragmentation 这些指标,心里就有谱了。