Mysql在页内是怎么查找数据的?
我们知道 B+ 树像一本层层递进的目录。现在,这段话描述的是这本字典的“最后一层”——也就是 具体的一页“正文页”(叶子节点) 内部,是怎么快速找到你想要的“词条”的。
想象一下,你翻到了字典的某一页,这一页密密麻麻写满了词。你不可能从头到尾一个一个字地看,对吧?这一页(16KB大小)内部也需要一个“迷你目录”来快速定位。这个“迷你目录”就是“页目录结构”。
它通过把页内的数据分成一个个小“组”(槽),并记录每个组的最大词条,然后利用二分查找快速定位到词条可能所在的“组”,最后再在这个小“组”里进行极短的顺序查找。
为什么需要“页目录结构”?——底层原理与效率考量
我们知道,B+ 树的每个节点(不管是内部节点还是叶子节点)在磁盘上都对应一个“数据页”(Page),默认是 16KB。
-
一个页能装多少数据?
如果每条记录很小(比如几十字节),一个 16KB 的页就能装下成百上千条记录!
例如,如果一条记录 100 字节,一个 16KB 的页就能装下 16 * 1024 / 100 ≈ 160 条记录。
如果只有键值和指针,非叶子节点能装得更多。 -
如何在页内快速查找?
- 当数据库把一个 16KB 的页从磁盘读到内存后,现在的问题是如何在内存中这 16KB 的数据里,快速找到我们想要的那条记录。
- 如果只是把记录简单地堆在一起,要找某条记录就得一条一条地遍历,这是 O(N) 的线性查找,效率太低了。
- 虽然数据在页内是按照键值排序的,我们可以进行二分查找 (O(logN)),但直接对所有记录进行二分查找,需要不断地计算偏移量和比较,仍然不够“优雅”和快速。
-
“页目录”的解决方案:二分查找 + 局部遍历
为了解决这个问题,InnoDB 在每个数据页(包括叶子节点和非叶子节点)的内部,又额外维护了一个精简的“页目录”(Page Directory)。这个页目录:
- 把页内的所有记录逻辑上划分成若干个小的“组”(
slot
或record group
)。 - 页目录本身存储的是指向每个组 最后一条记录的偏移量(或最大键值)。
- 由于这些组是按键值顺序排列的,所以页目录里的条目也是有序的。
这样,查找一条记录就分成了两步:
- 在页目录中进行二分查找: 快速定位到目标记录所在的“组”。因为页目录条目少,这个二分查找非常快。
- 在找到的组内进行顺序遍历: 由于每个组的记录数量非常少(后面会讲到具体规则),在这个小范围内进行顺序查找也非常快。
- 把页内的所有记录逻辑上划分成若干个小的“组”(
这种组合方式,既利用了二分查找的效率,又避免了复杂的数据结构(如红黑树)在页内维护的开销。
详细解析:图文结合与模拟
我们用一个简化的例子来模拟一个叶子页的内部结构和查询过程。
假设我们一个叶子页,里面有按主键排序的记录:1, 2, 3, 4, 5, 6, 7, 8, 9, 10
。
根据 InnoDB 的规定,第一个组只有一条记录,中间组 4-8 条,最后一个组 1-8 条。为了方便演示,我们假设中间组都是 4 条记录。
原始记录序列(已排序):
[1, Record1], [2, Record2], [3, Record3], [4, Record4], [5, Record5], [6, Record6], [7, Record7], [8, Record8], [9, Record9], [10, Record10]
这里,页目录的每个“槽”存储的是其对应组中最大记录的“指针”(或者说偏移量),并且这些“槽”自身是按键值大小有序排列的。
模拟查询过程:查找主键为 3 的记录
假设我们要在这个页中查找主键为 3
的记录。页目录有 4 个槽(low=0, high=3
):
- 槽 0 指向最大键 1
- 槽 1 指向最大键 5
- 槽 2 指向最大键 9
- 槽 3 指向最大键 10
模拟过程解释:
-
二分查找页目录:
- 初始
low = 0
,high = 3
。 - 第一次
mid = (0 + 3) / 2 = 1
。槽 1 的最大键是5
。因为3 <= 5
,说明目标键可能在槽 1 对应的组或更靠前的组中。所以high = mid = 1
。 - 现在
low = 0
,high = 1
。 - 第二次
mid = (0 + 1) / 2 = 0
。槽 0 的最大键是1
。因为3 > 1
,说明目标键不在槽 0 对应的组,而在更靠后的组。所以low = mid + 1 = 1
。 - 现在
low = 1
,high = 1
。high - low = 0
,二分查找结束。我们找到了槽1
。
- 初始
-
在组内顺序遍历:
- 定位到槽 1 对应的组。这个组包含了从主键
2
到主键5
的记录(因为槽 0 的最大键是 1,槽 1 的最大键是 5)。 - 我们从这个组的起始记录(即主键 2 的记录)开始,一条一条地往后找:
- 遇到记录
2
,不是3
。 - 遇到记录
3
,Bingo!找到了。
- 遇到记录
- 返回记录 3。
- 定位到槽 1 对应的组。这个组包含了从主键
这个例子中的“槽 1 入手拿到主键 2 的记录”是关键。因为槽 1 指向的是组内最大键 5 的记录,它实际上代表的是 (max_key_of_previous_slot, max_key_of_current_slot]
这个范围。所以,找到槽 1,意味着我们找到了 (1, 5]
这个组。我们从这个组的第一条记录(主键 2)开始往后遍历,直到找到主键 3。
InnoDB 的具体规定
文中提到的 InnoDB 规定,是为了确保组内查找的效率:
- 第一个分组只有一条记录: 这条记录通常是页中最小的那条,或者是一个特殊的“infimum”记录。
- 中间的分组 4-8 条记录: 这是核心。将组大小限制在 4-8 条,意味着即使进行顺序遍历,最多也只需要比较 8 次,这是非常快的。
- 最后一个分组 1-8 条记录: 和中间分组类似,保证末尾组的效率。
“因此不必担心遍历很长的链表导致性能问题。” 这句话就是对上面这些规则的总结。它强调的是,虽然页内的记录是通过链表组织(为了插入删除方便),但页目录的存在和严格的组大小限制,使得查找时不需要遍历整个链表,而是在极短的链表片段上进行遍历。
核心思想与总结
- 分而治之: 就像 B+ 树将整个数据文件分成多个页,页目录又将一个页内的记录分成多个组。
- 层次查找: 从 B+ 树的根节点,通过二分查找(或类似方式)定位到正确的子节点,最终找到目标数据所在的叶子节点。一旦页被加载到内存,页目录结构 再次使用二分查找定位到页内的小组,然后在这个小组内进行快速的顺序遍历。
- 兼顾读写: 记录通过单向链表在页内串联,方便插入和删除,而页目录则主要优化了查找性能。
- 极致优化磁盘I/O和CPU效率: B+ 树保证了少量的磁盘I/O次数,而页目录则确保了加载到内存后的数据页的CPU处理效率。
所以,当你说 SELECT * FROM table WHERE id = X;
时,MySQL 经历了:
- B+ 树全局定位: 从根节点出发,层层下降,每次读取一个节点(对应一次磁盘I/O),最终找到目标数据所在的叶子节点。
- 页内局部定位: 叶子节点(16KB)被加载到内存后,利用其内部的“页目录”,通过一次快速的二分查找,迅速定位到包含目标记录的小组。
- 小组内精确查找: 在这个极小的记录小组内进行简单的顺序遍历,找到最终的记录。
这个“页目录结构”就是 B+ 树能在大数据量下依然保持高效查询的关键细节之一。它展现了数据库系统在设计时,对各个层次性能瓶颈的细致考量。