DB中多字段索引时B+树构建和查询逻辑

在存在多字段(联合索引/复合索引)的情况下,B+ 树的构建和范围查询逻辑会变得稍微复杂一些,因为这里牵扯到了数据库里非常经典的一个规则——最左前缀原则

我们用 (w_id, name) 复合索引为例,来看看它是如何建树并处理 w_id < 600 AND name > 'bztyhnmj' 这个范围查询的。


1. 复合索引是如何建树的?

在 B+ 树中,联合索引的键值是一个复合元组。建树的核心规则是:先按第一个字段排序;如果第一个字段相同,再按第二个字段排序。

假设我们有以下几条数据,B+ 树的叶子节点在排列它们时,会严格按照 (w_id, name) 的字典序(即先比 w_id,再比 name)连接起来:

(500, 'abc') -> (500, 'xyz') -> (600, 'aaaa') -> (600, 'bztyhnmj') -> (600, 'zzzz') -> (700, 'abc')

  • 看到没?只有当 w_id 都是 500 或者 600 时,name 的排序才有意义。
  • 整体来看,w_id 是绝对有序的(500 -> 600 -> 700),但 name 在全局来看是无序的('abc' -> 'xyz' -> 'aaaa' ... 并不是严格的 A-Z 顺序)。

2. 面对 w_id < 600 AND name > 'bztyhnmj' 数据库如何查询?

对于这个具体的 SQL 条件,情况有点特殊。因为你的第一个条件是 w_id < 600(一个范围查询),这会导致一个现象:最左前缀原则在 w_id 处断了,后面的 name 无法直接利用 B+ 树的结构进行快速定位(二分查找)。

它的具体执行步骤如下:

第一步:定位 w_id 的范围起点(或终点)

因为条件是 w_id < 600,B+ 树会直接利用最左列 w_id,从根节点垂直向下,找到 w_id 满足条件的边界。

  • 既然是 < 600,数据库通常会直接找到最左边、最小的那个叶子节点(比如从 w_id 最小的数据开始)。

第二步:沿着链表向右“水平扫街”并过滤

找到最左侧的起点后,B+ 树开始沿着叶子节点的双向链表向右顺序扫描。

在扫描的过程中,对于每一条数据,数据库需要同时判断两个条件:

  1. 检查 w_id 是否小于 600:只要 w_id < 600,就继续往右读;一旦看到 w_id = 600,扫描立刻停止(因为后面的 w_id 只会更大)。
  2. **检查 name > 'bztyhnmj'**:在 w_id < 600 的这批数据里,逐条过滤出 name 大于 'bztyhnmj' 的记录。

关键点:这里的 name 索引失效了吗?

在这个查询中,name 字段无法用来在 B+ 树的非叶子节点中带路(无法减少扫描的叶子节点范围)。因为在 w_id < 600 的范围内,name 的取值是狗牙交错、全局无序的,数据库必须把所有 w_id < 600 的叶子节点记录都扫一遍。

不过,现代数据库(如 MySQL 5.6+)引入了 ICP(Index Condition Pushdown,索引下推) 技术:

  • 虽然 name 不能用来缩减扫描范围,但由于 name 的数据就在索引树的叶子节点上,数据库在扫树时,会直接在索引层把不满足 name > 'bztyhnmj' 的数据过滤掉,而不是傻傻地把所有 w_id < 600 的行数据都回表去查一遍。这极大地减少了回表(聚簇索引)的次数。

工程上复合键是什么形式

数据库底层(存储引擎层)非常经典的一种索引键实现形式:定长/紧凑排布的二进制字节数组(Packed Binary Key Buffer)

索引键并不是以我们熟悉的 structclass 或多级指针的形式存在,而是直接利用 memcpy 将各个字段的原始数据按顺序、无缝拼接在一块连续的内存空间中(即 low_keyup_key 所指向的 char* 缓冲区)。

下面为这种索引键的具体形式、内存布局以及它在处理你之前提到的范围查询时的妙处。


1. 内存中的具体布局形式

由于复合索引在 B+ 树中需要严格按字段顺序比较,这种形式的索引键会把多个字段“打包”成一个单一的扁平字节流。

假设你的复合索引包含两个字段:(w_id INT, name CHAR(8))

  • w_id 是整数,长度(col_len)为 4 字节。
  • name 是固定长度字符串,长度(col_len)为 8 字节。

当调用 generate_index_key 后,low_keyup_key 的内存布局就会变成一个 12 字节长的连续内存块

偏移量 (offset) 0 ~ 3 字节 (4 bytes) 4 ~ 11 字节 (8 bytes)
存储内容 w_id 的原始二进制数据 name 的原始二进制数据

关键点: 字段之间没有分隔符,完全依赖于索引元数据(index_meta_.cols[i].len)记录的长度来计算偏移量(offset)并切分字段。


2. 代码中的 low_keyup_key 代表什么?

在 B+ 树进行范围查询时,数据库需要确定扫描的左边界(下限)右边界(上限)

  • low_key:搜索区间的起始键(Lower Bound),B+ 树用它垂直向下定位到扫描的起点。
  • up_key:搜索区间的结束键(Upper Bound),B+ 树在水平扫描时,用它来判断何时该停下来。

3. 结合你之前的例子:未命中索引的后续列如何处理?

代码中最后的那个 for 循环完美解释了你之前问的 w_id < 600 AND name > 'bztyhnmj' 为什么后面的列无法用于树的层级定位:

// 复合索引后缀列:未出现在 index_conds_ 中的列用全范围 [min, max]
for (; i < index_meta_.col_num; ++i) {
    injectLowKey(col_name, low_key, offset, col_len);
    injectHighKey(col_name, up_key, offset, col_len);
    ...
}

当你输入 w_id < 600 AND name > 'bztyhnmj' 时,优化器通常只会把 w_id < 600 放入 index_conds_ 数组中。第一个循环处理完 w_id 后,遇到了范围查询,第一个循环就结束了(此时 i = 1)。

接下来进入第二个循环,处理没能用来构建精确索引范围的 name 列:

  • injectLowKey 会在 low_keyname 对应位置填入该数据类型的最小值(例如全 0x00)。
  • injectHighKey 会在 up_keyname 对应位置填入该数据类型的最大值(例如全 0xFF)。

最终生成的两个 Key 形式如下:

  • low_key (起点) = [ 类型的极小值, name的极小值(0x00...) ] (代表从整个索引的最开始起步)
  • up_key (终点) = [ 600, name的极大值(0xFF...) ] (代表一旦遇到 w_id >= 600 的数据就立刻停止)

至于 name > 'bztyhnmj' 这个条件,则交给了代码注释里提到的 check_cons(上层过滤条件/索引下推)在扫描过程中去逐条肉搏判断。


4. 为什么要采用这种二进制拼接的形式?

这种设计在像 MySQL (InnoDB)、RocksDB (MyRocks) 等工业级数据库的存储引擎中非常普遍,原因有两点:

  1. 极其高效的比较性能(Memcmp 搞定一切)
    当 B+ 树在非叶子节点进行二分查找时,它需要比较两个复合键的大小。如果把键做成这种按字段顺序紧凑拼接的二进制字节流,数据库可以直接使用 C 语言标准库的 memcmp(key1, key2, total_len) 来一口气比较完整个复合索引,而不需要写复杂的循环去分别转成 int 或 string 挨个比。
  2. 便于统一持久化和传输
    无论是写到磁盘、在内存中移动,还是作为 B+ 树节点的 Key,定长的扁平字节数组都是最容易管理和序列化的结构。

总结:如果把条件反过来呢?

对比一下,加深理解:

  • 当前情况 (w_id < 600 AND name > 'bztyhnmj'):先导字段是范围,name 的有序性在全局被打破,name 只能起到过滤作用。
  • 假设情况 (w_id = 600 AND name > 'bztyhnmj'):先导字段是等值。此时在 w_id = 600 的小圈子里,name 是严格有序的!B+ 树可以先精准定位到 (600, 'bztyhnmj') 这个位置,然后直接向后扫描。这种情况下,两个字段都完美用上了索引的定位能力。
posted @ 2026-05-26 15:39  韩熙隐ario  阅读(7)  评论(0)    收藏  举报