同样是 B+Tree,为什么 SQLite、PostgreSQL 和 InnoDB 的实现不同?

13 阅读7分钟

数据库真正有意思的地方,并不是“知道它用了 B+Tree”,而是: 同一种数据结构,为什么不同数据库会做出完全不同的实现?

上次我们认识了 B+Tree。

今天开始从“数据结构”进入“数据库实现”。

我们重点比较三个非常经典的数据库:

  • SQLite
  • PostgreSQL
  • MySQL InnoDB

它们都大量使用 B-Tree 家族索引,但存储模型并不一样。


一、先看一个最重要的问题

假设有一张表:

User

id    name    age
1     张三     20
2     李四     25
3     王五     30

现在:

SELECT *
FROM User
WHERE id = 2;

数据库最终必须完成一件事情,找到 id = 2 对应的数据,但这里存在两种完全不同的设计。


设计 A:索引只保存“数据在哪里”

例如:

B+Tree

        2
        |
        ↓
    Page 10
    Slot 3

找到:

Page 10 + Slot 3

然后再去读取真正的数据。

也就是:

Index
  ↓
Record Location
  ↓
Data

设计 B:索引本身就是数据

例如:

B+Tree

       2
       |
       ↓
+-------------+
| id=2        |
| name=李四    |
| age=25      |
+-------------+

找到索引节点:

数据本身也就找到了。


这两个设计看起来只是一个小区别。

实际上,它会影响整个数据库的:

  • 存储结构
  • 查询路径
  • 主键性能
  • 二级索引
  • 更新方式
  • Page 组织
  • 磁盘空间
  • Buffer Pool 使用

而 SQLite、PostgreSQL、InnoDB 恰好体现了不同的设计思想。


二、SQLite:B-Tree 就是数据组织的核心

先看最简单、最紧凑的 SQLite。

SQLite 与 MySQL、PostgreSQL 最大的不同之一:它不是一个独立的数据库服务器。

它通常直接嵌入应用程序:

Application
     |
     ↓
  SQLite
     |
     ↓
database.db

数据库本身就是一个文件。


三、SQLite 的核心:B-Tree Page

SQLite 内部大量数据都组织在 B-Tree 中。

可以简单理解成:

Database File

+---------+
| Page 1  |
+---------+
| Page 2  |
+---------+
| Page 3  |
+---------+
| Page 4  |
+---------+

这些 Page 并不是简单的数据块。

它们可以作为:

B-Tree Internal Page

或者:

B-Tree Leaf Page

SQLite 的一个重要特点

SQLite 的表本身可以组织成一种 B-Tree。

例如:

Table B-Tree

          Root
           |
     +-----+-----+
     |           |
   Page A      Page B
     |           |
   Records     Records

因此 SQLite 的存储模型相对紧凑。

它没有简单地采用:

Heap
+
Separate Primary Index

这种模型。


四、SQLite 为什么适合这样设计?

因为 SQLite 的目标非常明确:小型、可靠、嵌入式、零配置。

它需要:

  • 一个文件
  • 一个进程
  • 很少的后台组件
  • 尽可能简单的部署方式

所以 SQLite 的存储设计非常集中。

你可以把它理解为:

SQLite
   |
   +-- B-Tree
   |
   +-- Pager
   |
   +-- WAL(预写日志)
   |
   +-- File

它的很多数据库能力最终都会落到这个文件和 Page 管理体系上。


五、PostgreSQL:数据和索引分开

接下来来看 PostgreSQL。它采用了一个非常重要的设计:表数据和索引通常是分开的。

例如:

Table
  |
  ↓
Heap
  |
  +-- Page 0
  +-- Page 1
  +-- Page 2

另外:

Index
  |
  ↓
B-Tree
  |
  +-- Index Page
  +-- Index Page
  +-- Index Page

两者是独立的。


六、PostgreSQL 的索引保存什么?

假设:

User

id=100
name=张三

Heap 中:

Page 5

+----------------+
| Tuple          |
| id=100         |
| name=张三     |
+----------------+

B-Tree 索引中:

100
 |
 ↓
Tuple Location

这个位置通常通过:**TID(Tuple Identifier)**来定位。

可以把它理解为:

TID
 |
 +-- Block Number
 |
 +-- Tuple Offset

也就是:

Index
  |
  ↓
某个 Heap Page
  |
  ↓
某条 Tuple

七、为什么 PostgreSQL 要这样设计?

因为 PostgreSQL 的设计非常强调:表数据本身的独立性。

一张表可以拥有多个索引:

                 Table
                   |
                 Heap
              /    |    \
             /     |     \
            ↓      ↓      ↓
         Index1  Index2  Index3

例如:

PRIMARY KEY(id)
INDEX(name)
INDEX(age)
INDEX(email)

这些索引都指向:

Heap Tuple

因此 PostgreSQL 可以非常灵活地增加不同类型的索引。


八、这带来一个重要问题:Index Scan 不是终点

假设:

SELECT *
FROM User
WHERE id = 100;

PostgreSQL:

SQL
 ↓
B-Tree Index
 ↓
TID
 ↓
Heap Page
 ↓
Tuple

因此一次查询可能至少涉及:

Index Page
+
Heap Page

这被称为:Index Scan + Heap Fetch

这也是 PostgreSQL 查询性能分析中非常重要的一件事情。


九、InnoDB:完全不同的思路

现在进入 MySQL 的 InnoDB。这里是今天最重要的部分。

InnoDB 使用 Clustered Index(聚簇索引),作为表数据的核心组织方式。


十、什么叫 Clustered Index?

假设:

User

id    name
1     张三
2     李四
3     王五

InnoDB 可以把主键 B+Tree 组织成:

             Root
              |
        [1 | 2 | 3]
              |
              ↓
+-------------------------+
| id=1 | 张三             |
| id=2 | 李四               |
| id=3 | 王五             |
+-------------------------+

叶子节点:不只是索引。而是:直接保存整行数据。

所以:

Primary Key
     |
     ↓
Clustered B+Tree
     |
     ↓
Row Data

这就是聚簇索引。


十一、为什么 InnoDB 要这么做?

核心目标之一:让主键访问非常高效。

例如:

SELECT *
FROM User
WHERE id = 100;

InnoDB:

Root
 ↓
Internal Page
 ↓
Leaf Page
 ↓
Row

到了 Leaf Page,数据已经在那里,不需要再:

Index
 ↓
Heap
 ↓
Tuple

多走一步。


十二、三种数据库放在一起

现在我们可以画出一张非常重要的图。

SQLite

B-Tree
  |
  ↓
Table Data

PostgreSQL

B-Tree Index
      |
      ↓
    TID
      |
      ↓
    Heap
      |
      ↓
   Tuple

InnoDB

Primary Key B+Tree
       |
       ↓
   Row Data

十三、那么二级索引呢?

这里 InnoDB 又出现一个非常有意思的设计。

假设:

PRIMARY KEY(id)
INDEX(name)

主键索引:

id
 ↓
完整Row

但是 name 索引不能直接保存整行,否则每个二级索引都会非常巨大。

因此二级索引通常保存:

name
 +
Primary Key

例如:

name Index

张三 → id=100
李四   → id=200
王五 → id=300

查询:

SELECT *
FROM User
WHERE name = '李四';

路径:

Secondary Index
      |
      ↓
   Primary Key
      |
      ↓
Clustered Index
      |
      ↓
    Row

于是发生了第二次 B+Tree 查询。

这个过程通常被称为:回表(Table Lookup)


十四、三种数据库的核心差异

现在可以正式比较:

特性SQLitePostgreSQLInnoDB
数据组织B-TreeHeapClustered B+Tree
主键索引B-Tree结构独立索引聚簇索引
索引与数据高度融合分离主键融合
二级索引依实现而定指向Heap Tuple指向主键
查询路径B-TreeIndex → HeapClustered Index
设计倾向简洁、嵌入式灵活、扩展性OLTP、高效主键访问

这里最重要的不是记住表格。

而是理解:B+Tree 只是工具,真正决定数据库行为的是“B+Tree + 数据组织方式”。


十五、为什么 PostgreSQL 的 Heap 不像 InnoDB 那样直接放到 B+Tree?

这是一个非常值得思考的问题。

如果把数据直接放进 B+Tree:

B+Tree
 |
 +-- Row
 +-- Row
 +-- Row

看起来非常高效。

但是:

数据的位置与主键绑定得更加紧密。

而 PostgreSQL:

Heap
 |
 +-- Tuple
 +-- Tuple
 +-- Tuple

Index
 |
 +-- Index
 +-- Index

数据和索引相对独立,这样做可以带来更大的灵活性。

例如:

一张表可以拥有:

B-Tree
Hash
GiST
GIN
BRIN

等不同索引类型。它们都可以针对同一份 Heap 数据。


十六、为什么数据库没有一种“完美”的索引设计?

因为数据库面临的需求不同。

如果目标是:主键查询极快。

Clustered Index 非常优秀。

如果目标是:一个表需要多个不同类型的索引。

Heap + Index 非常灵活。

如果目标是:极简、单文件、嵌入式。

SQLite 的设计非常合适。

所以:

数据库设计
      ≠
寻找最优秀的数据结构

数据库设计
      =
针对目标场景做权衡

这是整个数据库内核学习过程中非常重要的思想。


十七、再看一个更新问题

假设:

id=100
name=张三

现在:

UPDATE User
SET name='Alexander'
WHERE id=100;

如果新的数据长度发生变化:

张三

变成:

Alexander

数据可能无法继续放在原来的空间。

这时候数据库需要考虑:

  • 原位置是否足够?
  • 是否需要移动 Tuple?
  • 是否产生空闲空间?
  • 索引是否需要更新?
  • 其他事务看到什么版本?

于是:**存储结构开始与事务系统产生联系。**这就是下一阶段非常重要的内容。


十八、应该建立的知识体系

我们已经从:

Database

走到了:

Database
   |
   ↓
Storage Engine
   |
   ↓
Page
   |
   ↓
Data Organization
   |
   ├── Heap
   |
   └── B+Tree
          |
          ↓
        Index

而现在又进一步发现:

B+Tree
   |
   +── SQLite
   |
   +── PostgreSQL
   |
   └── InnoDB

虽然名字相似 B-Tree / B+Tree,但是背后的数据组织方式完全可以不同。


十九、重要的三个结论

① Index 不等于 Data

PostgreSQL 中:

Index → Heap → Tuple

索引只是定位工具。


② Clustered Index 可以同时承担 Data + Index

InnoDB:

B+Tree
  ↓
Row

索引本身就是数据组织结构。


③ 数据库设计的核心不是“选择哪个算法”

而是:根据访问模式、数据规模、写入方式、事务需求和硬件特性,决定数据应该如何组织。