为什么数据库需要索引:从二分查找到 B+Tree

50 阅读16分钟

摘要:当数据规模从几万、几百万增长到千万甚至上亿条时,数据库真正需要解决的问题,并不是“数据能不能保存下来”,而是“如何在如此庞大的数据中快速找到目标”。从最直接的全表扫描,到看起来已经足够高效的二分查找,再到真正面向数据库存储模型设计的 B+Tree,这背后并不只是数据结构的变化,而是一次从 算法 → Page → I/O → 数据访问模式 的完整转变。


一、没有索引,数据库如何找到数据?

在上一篇文章中,我们已经建立了一个最基本的数据库存储模型:

Database
   │
   ▼
Storage Engine
   │
   ▼
Heap / Data File
   │
   ▼
Page
   │
   ▼
Record

数据库最终还是要把 Record 存放到某种持久化存储中,而数据库访问数据时,通常并不是直接面对一条条 Record,而是通过 Page 这样的存储单位进行读取和管理。

但到这里还有一个非常现实的问题:

如果我知道自己想找哪一条数据,数据库怎么知道它在哪里?

于是,我们继续向上走一层:

             Index
               │
               ▼
Database → Storage → Page → Record

这一层,就是数据库索引。


假设现在有一张用户表:

User

id      name
----------------
1       张三
2       李四
3       王五
4       赵六
...
1000000000

执行:

SELECT *
FROM User
WHERE id = 900000000;

如果数据库没有任何能够帮助定位 id 的索引结构,那么最直接的方法就是从数据中逐步寻找:

Page 0
  ↓
Page 1
  ↓
Page 2
  ↓
...
  ↓
Page N

然后不断检查:

record.id == 900000000

这就是 Full Table Scan,全表扫描。

如果有 10 亿条记录,在最坏情况下就需要检查大量数据。

从算法角度看,它的时间复杂度是:O(N)

数据量越大,这种方式的代价就越明显。于是第一个问题出现了:能不能不从第一条数据开始找?


二、二分查找已经是 O(log N),为什么还不够?

如果我们能够保证数据按照 id 有序排列:

1
2
3
4
5
...
1000000000

那么事情就简单很多。

寻找:900000000 时,我们不需要逐条检查。

可以直接取中间位置:

                    500000000
                   /          \
                  /            \
        小于目标                大于目标
                                  \
                                   ...

不断缩小搜索范围。

这就是:Binary Search(二分查找)

它的时间复杂度是:O(log N)

对于 10 亿个有序元素来说,理论上只需要大约 30 次比较。

从纯粹的算法角度来看,这已经非常优秀。

那么数据库是不是直接把所有数据排好序,然后使用二分查找就可以了?

问题恰恰出在这里。


三、数据库真正需要考虑的,不只是算法复杂度

数据库中的数据,并不是简单地存在一块无限大的连续内存里。

我们之前已经知道,数据库通常会以 Page 作为数据管理和 I/O 的基本单位。

为了方便理解,假设:

1 Record = 100 Bytes

1 Page = 8 KB

那么一个 Page 大约可以容纳:

8192 / 100 ≈ 81 Records

10 亿条记录,就意味着需要大量 Page。

现在假设我们对这些数据进行二分查找。

逻辑上,我们可能会不断跳跃:

Page 500000
       ↓
Page 250000
       ↓
Page 375000
       ↓
...

从算法角度看,这仍然是:O(log N)

但数据库需要考虑另外一个问题:

每一次跳跃访问的数据,是否已经在内存中?

如果目标 Page 不在 Buffer Pool 中,数据库就可能需要从存储设备读取对应 Page。

于是,一个看似很小的“比较”背后,可能对应一次代价更高的数据访问。

这就是数据库与普通内存算法非常重要的区别。

数据库不能只看 CPU 上做了多少次比较,还必须考虑数据是如何被存储和访问的。

因此,数据库真正希望优化的,并不是简单地把:O(N)

变成:O(log N)

而是进一步考虑:

如何让一次查询尽可能少地访问 Page,并让每次 Page 访问获得尽可能多的有效信息?

这就是 B+Tree 出现的重要原因。


四、B+Tree:让树真正适合数据库的 Page

如果直接使用普通二叉搜索树:

                50
              /    \
            25      75
           /  \    /  \
         12   37  62   87

每个节点最多只有两个孩子。当数据规模不断增长时,树的高度也会不断增加。

如果每访问一层都需要处理一个节点,那么树越高,就意味着可能需要访问越多的节点。

B+Tree 的思路则完全不同。它不会把一个节点限制成只有两个孩子,而是让一个节点拥有大量的子节点:

                 [30 | 60]
                /    |    \
               /     |     \
              /      |      \
        [10 20]   [40 50]   [70 80]

一个节点可以拥有几十、几百甚至更多的子节点。这种结构被称为多路搜索树。

它最重要的效果就是:降低树的高度。


为什么数据库特别希望树变矮?

假设一个索引节点能够保存约 1000 个子指针。

那么理论上:

第一层:
1000

第二层:
1000 × 1000
= 1,000,000

第三层:
1000 × 1000 × 1000
= 1,000,000,000

也就是说,在这个简化模型中,只需要很少的层级,就可以覆盖非常大的数据空间。

这里的 1000 只是为了建立数量级上的直觉。真实数据库中的节点 fanout 取决于:

  • Page 大小
  • Key 大小
  • Pointer 大小
  • Page Header
  • Slot / Metadata
  • 具体的数据布局

但核心思想不变:

  1. 一个节点保存更多索引信息
  2. 一个节点拥有更多子节点
  3. 树的高度降低
  4. 查询需要经过的 Page 更少。

这才是 B+Tree 的第一个关键设计。


五、为什么 B+Tree 的节点可以和 Page 很好地结合?

到这里,我们还可以继续追问一个问题:

为什么一个 B+Tree 节点通常可以设计成一个数据库 Page?

因为数据库本身就是以 Page 为重要的数据管理单位。

于是可以形成这样的对应关系:

B+Tree Node
      ≈
Database Page

例如,一个索引 Page 可以大致理解成:

+--------------------------------+
|          Page Header           |
+--------------------------------+
| Key | Child Pointer            |
+--------------------------------+
| Key | Child Pointer            |
+--------------------------------+
| Key | Child Pointer            |
+--------------------------------+
| Key | Child Pointer            |
+--------------------------------+
|              ...               |
+--------------------------------+

当数据库读取这个 Page 时,一次就可以获得大量 Key 以及对应的导航信息。

因此,B+Tree 的多路结构和 Page 模型形成了非常自然的匹配:

                 B+Tree
                    │
                    ▼
              一个节点保存
              大量索引信息
                    │
                    ▼
              对应一个 Page
                    │
                    ▼
          一次访问获得大量信息
                    │
                    ▼
             降低树的高度
                    │
                    ▼
             减少访问 Page

这也是为什么学习数据库索引时,不能只从“树”本身去理解。

B+Tree 从来不是孤立的数据结构。

它和数据库的 Page、Buffer Pool、I/O 以及数据访问模式是联系在一起的。


六、为什么是 B+Tree,而不是普通的 B Tree?

B Tree 和 B+Tree 经常被放在一起讨论。两者最大的结构差异之一,是数据存放的位置。

B Tree

B Tree 中,数据可以出现在内部节点,也可以出现在叶子节点:

             [50]
            /    \
         [20]    [80]

也就是说,当搜索 50 时,有可能在内部节点就直接找到对应的数据。


B+Tree

B+Tree 则把内部节点和叶子节点的职责进一步分离。

可以简单理解为:

Internal Node
      ↓
只负责导航

Leaf Node
      ↓
保存索引项对应的数据定位信息

结构类似:

                 [50]
                /    \
               /      \
       [10 20 30]    [50 60 80]

需要注意的是,具体“叶子节点保存什么”与索引类型有关。

例如在某些数据库的聚簇索引中,叶子节点本身可以包含完整的行数据;而在二级索引中,叶子节点通常保存索引 Key 以及定位对应记录所需的信息。

因此,更准确的理解是:

B+Tree 的内部节点主要负责导航,而叶子节点保存最终的索引项。

这种设计带来的一个重要好处是:内部节点可以尽可能紧凑。

内部节点不需要保存完整的数据记录,就可以放下更多 Key 和 Child Pointer。

于是:

更多 Key
   ↓
更多 Child
   ↓
更大的 Fanout
   ↓
更低的树高
   ↓
更少的 Page 访问

这样又回到了我们最开始讨论的 Page 和 I/O。


七、为什么 B+Tree 的叶子节点还要连接起来?

B+Tree 的另一个非常重要的设计,是叶子节点通常按照 Key 的顺序连接起来:

[10 20 30]
     ↓
[40 50 60]
     ↓
[70 80 90]
     ↓
[100 110 120]

为什么需要这条链?

因为数据库并不只有:

WHERE id = 100

这样的等值查询。

还有大量范围查询:

SELECT *
FROM User
WHERE id >= 100
  AND id <= 200;

这时候,我们首先需要找到 100。

B+Tree 可以通过从 Root 到 Leaf 的路径快速定位到第一个位置:

Root
 ↓
Internal Page
 ↓
Leaf Page
 ↓
100

找到之后,不需要重新从 Root 开始寻找 110、120、130……

而是可以沿着叶子节点继续向后访问:

[100 110 120]
       ↓
[130 140 150]
       ↓
[160 170 180]
       ↓
[190 200]

所以 B+Tree 的优势并不只是:快速找到一个值。

它还提供了:快速定位起点 + 按顺序遍历后续数据。

这也是 B+Tree 特别适合数据库范围查询的重要原因。


八、一次 B+Tree 查询到底发生了什么?

现在把前面的知识全部串起来。

假设执行:

SELECT *
FROM User
WHERE id = 900;

假设索引结构如下:

                         Root Page
                     [500 | 800]
                     /    |     \
                    /     |      \
                   /      |       \
              Page A   Page B   Page C
                                  |
                                  ▼
                          [850 | 900 | 950]

查询开始。

第一步:访问 Root

数据库首先从索引的 Root 开始。

Root 中有:

500 | 800

因为:

900 > 800

所以继续进入对应的子节点。


第二步:访问下一层

进入目标 Internal Page。

继续比较 Key:

850 | 900 | 950

找到:

900

第三步:定位最终数据

索引项会提供进一步定位数据所需要的信息。

于是查询路径可以抽象成:

SQL
 ↓
B+Tree Root
 ↓
Internal Page
 ↓
Leaf Page
 ↓
Record Location
 ↓
Data Page
 ↓
Record

如果目标数据已经在 Buffer Pool 中,访问过程会进一步表现为内存中的 Page 查找。

如果所需要的 Page 不在内存中,则可能需要从存储设备读取。

因此,一次索引查询真正涉及的并不是简单的:

比较 Key

而是:

Key Comparison
       +
Page Navigation
       +
Buffer Management
       +
Data Access

这也是数据库索引和普通内存数据结构之间非常重要的区别。


九、B+Tree 为什么特别适合数据库?

到这里,可以把 B+Tree 的几个关键特性放到一起:

                 B+Tree
                    │
        ┌───────────┼───────────┐
        │           │           │
        ▼           ▼           ▼
     多路分支     有序 Key     叶子链
        │           │           │
        ▼           ▼           ▼
      低树高     快速定位      范围扫描
        │           │           │
        └───────────┼───────────┘
                    ▼
              适合数据库访问

它同时解决了几个数据库非常重要的问题:

  • 多路分支降低树高
  • 内部节点主要负责导航
  • Key 保持有序
  • 叶子节点支持顺序访问
  • 节点可以与 Page 很好地结合
  • 支持动态插入和删除
  • 支持等值查询和范围查询

所以 B+Tree 的优势不是某一个单独的特性。

真正强大的是这些特性组合在一起之后形成的效果。


十、索引并不是免费的

如果索引这么好,那么一个自然的问题就是:

为什么不把所有字段都建立索引?

因为索引本身也是数据。

假设 User 表拥有:

id
name
age
address
phone
email

如果为这些字段全部建立索引,那么数据库需要额外维护多棵索引树。

于是查询可能变快,但写入就会产生额外工作。

例如:

INSERT
UPDATE
DELETE

这些操作不仅需要修改数据本身,还可能需要修改对应的索引。

因此:

数据修改
   │
   ├── 修改 Data Page
   │
   └── 修改 Index Page

索引带来的成本主要可以从几个方面理解。

1. Storage Cost

索引本身需要额外的存储空间。

Data
+
Index

索引越多,占用的空间通常越大。


2. Write Cost

插入、删除和更新数据时,对应索引也可能需要维护。

因此:

INSERT
  ↓
Data Update
  +
Index Update

3. Maintenance Cost

B+Tree 需要保持自己的结构特性。

例如:

Page Split
Page Merge
Rebalance

这些操作都会带来额外的维护成本。

所以:索引不是越多越好,而是在查询收益和维护成本之间进行权衡。


十一、Page Split:B+Tree 写入的代价

假设一个 Page 已经接近或者达到容量上限:

[10 20 30 40]

现在插入:

25

如果当前 Page 没有足够空间,就需要进行结构调整。

简化来看,可以理解为:

        原 Page

   [10 20 25 30 40]

          ↓

       Page Split

       /        \
[10 20]      [25 30 40]

Split 之后,父节点还需要增加新的索引信息。

如果父节点本身也已经没有空间,那么父节点也可能发生 Split:

Leaf Split
    ↓
Parent Update
    ↓
Parent Split
    ↓
Continue Upward

在极端情况下,甚至可能导致 Root Split,使树的高度增加。

因此:B+Tree 的写入并不是简单地“找到位置,然后写进去”。

它还需要不断维护整个树的结构。

这也是 B+Tree 在高写入场景下需要重点考虑的问题。


十二、为什么 Hash Index 没有取代 B+Tree?

既然 Hash Table 的等值查询平均可以达到接近 O(1),那么一个自然的问题是:

为什么数据库还需要 B+Tree?

关键在于:数据库需要解决的不只是等值查询。

Hash 非常适合:

WHERE id = 100

但对于:

WHERE id >= 100
  AND id <= 200

Hash 就很难发挥同样的优势。

因为 Hash 的核心思想是:

Key
 ↓
Hash Function
 ↓
Bucket

它强调的是:根据一个 Key 快速定位。

而 B+Tree 强调的是:维护 Key 的有序关系。

因此:

特性HashB+Tree
等值查询优秀优秀
范围查询较弱优秀
有序遍历较弱优秀
排序相关操作较弱优秀
前缀 / 范围访问较弱较强
动态维护相对简单更复杂

因此:Hash 更接近“精确定位”的思维,而 B+Tree 更接近“有序定位”的思维。

数据库查询模式非常丰富,需要的不只是:

找到一个值

还需要:

找到一个范围
找到第一个值
找到下一个值
按照 Key 顺序遍历

这也是 B+Tree 能够长期成为数据库索引重要方案的原因之一。


十三、为什么 B+Tree 成为了数据库索引的重要方案?

现在可以回头重新看这个问题:

为什么数据库喜欢 B+Tree?

并不是因为 B+Tree 是一种优秀的数据结构。

而是因为它很好地匹配了数据库的实际工作方式:

                    B+Tree
                       │
       ┌───────────────┼───────────────┐
       │               │               │
       ▼               ▼               ▼
     多路分支         Key 有序        叶子链
       │               │               │
       ▼               ▼               ▼
     低树高          快速定位        范围扫描
       │               │               │
       └───────────────┼───────────────┘
                       ▼
                    Database
                       │
             ┌─────────┴─────────┐
             ▼                   ▼
           Page                 I/O

它不是只考虑 CPU 上的比较次数。

而是同时考虑:

数据结构
   +
Page
   +
Buffer Pool
   +
I/O
   +
数据访问模式

这其实是数据库设计中一个非常重要的思想:数据结构不能脱离它所运行的硬件和存储模型单独讨论。


十四、B+Tree 也不是终点

如果 B+Tree 这么优秀,那么为什么后来又出现了:

  • LevelDB
  • RocksDB
  • LSM Tree

等完全不同的存储设计?

答案来自 B+Tree 的另一个方面:写入。

一个典型的 B+Tree 写入过程可能涉及:

找到目标 Leaf
      ↓
修改 Page
      ↓
Page 可能已满
      ↓
Page Split
      ↓
更新 Parent
      ↓
可能继续向上 Split

如果数据库长期面对大量随机写入,这种维护方式可能产生较高的写放大和随机更新成本。

于是另一个非常重要的设计思想出现了:**既然随机更新 B+Tree 有成本,那么能不能把大量写入先变成更适合顺序写的形式?**这正是 LSM Tree 所关注的问题。

于是数据库存储系统出现了另一条非常重要的路线:

B+Tree
  │
  │  擅长:
  │  低树高
  │  有序查询
  │  范围访问
  │
  └──────────────┐
                 │
                 ▼
            另一种思路
                 │
                 ▼
              LSM Tree
                 │
                 ▼
        Sequential Write
                 +
             Compaction

这里不需要急着讨论 LSM Tree 的具体实现。

真正值得留下的问题是:当数据库从“读多写少”逐渐走向“写多读多”时,B+Tree 的设计是否仍然是最合适的?

这个问题,也正好引出了下一阶段对 LevelDB、RocksDB 和 LSM Tree 的学习。


十五、真正应该记住的是什么?

如果这篇文章能留下些什么,我希望是下面这条完整的逻辑:

数据规模变大
      ↓
全表扫描成本过高
      ↓
尝试二分查找
      ↓
O(log N)
      ↓
但数据库需要考虑 Page 和 I/O
      ↓
需要降低树高和 Page 访问次数
      ↓
B+Tree
      ↓
多路分支
      ↓
低树高
      ↓
Page 友好
      ↓
快速定位 + 有序遍历
      ↓
等值查询 + 范围查询

所以,B+Tree 最值得学习的并不是,它有几个节点、几个指针、怎么分裂。

这些当然重要,但那属于实现层面。

更重要的是理解它背后的设计过程:

当我们知道数据库的数据是以 Page 为单位管理,并且 I/O 成本远高于简单的 CPU 比较之后,就会开始思考:能不能让一个 Page 保存尽可能多的索引信息?能不能让树尽可能矮?能不能在快速定位之后继续顺序访问?

于是,B+Tree 的结构就不再显得神秘。它并不是凭空出现的一棵“高级树”。而是从数据库的实际约束中,一步一步推导出来的。