数据库系列(三):索引原理 — B+ 树、位图、倒排与四大数据库的实现差异

写在前面

承接前一篇存储引擎的内容,本文进入第二个子系统:索引。索引是数据库性能的核心——一个表加对索引可以让查询从秒级降到毫秒级,加错索引也能让写入变成灾难。

本文要回答的核心问题:

为什么几乎所有关系数据库的索引都是 B+ 树?PG 的 GIN/GiST/BRIN 是什么?为什么联合索引要最左前缀?


一、为什么是 B+ 树

1.1 一个朴素问题

100 万行的表,按 id 查询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
方案 1:顺序扫描(Seq Scan)
  平均读 50 万行 → 几百毫秒

方案 2:哈希索引(Hash Index)
  哈希一次 → O(1) 找到行
  限制:只支持等值查询(=、IN),范围查询不支持

方案 3:二叉搜索树(BST)
  高度 log2(100万) ≈ 20
  每次比较一次 + 读一个节点 → 20 次 I/O(如果每节点一页)
  限制:树太高,磁盘 I/O 多

方案 4:B 树(B-Tree)
  多叉树,每节点存多个 key + 数据
  高度 logm(N),m 通常 100+ → 100 万行高度 2~3
  限制:内部节点也存数据 → 节点能放的 key 数少 → 树高较高

方案 5:B+ 树(B+Tree)
  B 树的变种:所有数据只在叶子节点
  内部节点只放 key,叶子节点放完整数据
  叶子节点之间用双向链表连接
  → 内部节点能放更多 key,树高更低
  → 范围查询走叶子链表,极快

1.2 B+ 树的结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
                       [10│30│50│70]              ← 根(内部节点,只放 key)
                      /      |       |       \
              [1│3│5│8] [11│15│25│28] [31│38│45│48] [51│55│65│68] [71│75│85│88]
                ↑────────叶子节点链表────────↑
              叶子节点存完整数据(IOT)或行指针(二级索引)

特性:
  - 高度通常 2~4(千万级数据)
  - 范围查询:从根定位到叶子,沿链表扫描
  - 等值查询:从根定位到叶子
  - 每节点 = 一个 Page(默认 8K / 16K)

1.3 为什么 B+ 树胜出

数据结构 等值查询 范围查询 排序 树高(1亿行)
Hash ✅ O(1) ——
二叉 BST O(log n) ✅ 慢 ✅ 慢 27
红黑树 O(log n) ✅ 慢 ✅ 慢 27
跳表 O(log n) 27
B 树 O(logm n) 4~5
B+ 树 O(logm n) ✅ 极快 ✅ 极快 2~3
1
2
3
4
5
B+ 树的核心优势:
  1. 树矮:每节点放百级 key,3 层就能索引亿级行
  2. 范围查询强:叶子链表,连续 I/O
  3. 排序免费:B+ 树本身有序
  4. 节点 = Page:天然适配缓冲池

1.4 跳表的故事:为什么 Redis 用跳表而 MySQL 用 B+ 树

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Redis 用跳表(ZSet 底层):
  - 全内存,I/O 不是瓶颈
  - 跳表实现简单(无锁 CAS 友好)
  - 不在乎树高(27 层和 3 层在内存里差不多)

MySQL 用 B+ 树:
  - 数据在磁盘,I/O 是瓶颈
  - 必须让树矮(3 层就够)
  - 节点要按 Page 组织,匹配磁盘 I/O 单位

一句话:磁盘选 B+ 树,内存选跳表 / 哈希。

二、聚集索引 vs 非聚集索引

承接前一篇的 HEAP vs IOT,索引视角下要再讲一遍,因为这是四大数据库最大的术语混淆点

2.1 名词对照

概念 Oracle SQL Server MySQL InnoDB PostgreSQL
主键索引(数据本身) IOT Clustered Index Clustered Index (无,主键也只是索引)
二级索引 Normal Index Nonclustered Index Secondary Index Index
二级索引指向什么 rowid(堆表)/ PK(IOT) RID(堆)/ Clustered Key 主键值 ctid

2.2 二级索引的"回表"成本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
查询:SELECT name, balance FROM accounts WHERE name = 'Alice';

InnoDB:
  1. 走 name 二级索引(B+ 树)→ 找到主键值 id=1
  2. 走主键索引(B+ 树)→ 找到 id=1 的整行("回表")
  → 2 次 B+ 树查找

PostgreSQL:
  1. 走 name 索引 → 找到 ctid=(0,1)
  2. 走 Heap → 直接定位到行("回 heap")
  → 1 次索引 + 1 次随机读

差异:
  - InnoDB:二级索引查到的主键值,再到主键 B+ 树走一遍
  - PG:二级索引直接给出物理位置,一次定位到 heap
  - 单条查询 PG 通常更省 I/O,但需要二级索引足够"宽"

2.3 SQL Server 的两种表状态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 状态 1:堆表(Heap),无 Clustered Index
CREATE TABLE t_heap (id INT, name VARCHAR(50));
-- 二级索引指向 RID(FileID:PageID:SlotID,8 字节)

-- 状态 2:Clustered Table(有 Clustered Index)
CREATE TABLE t_clustered (
    id INT PRIMARY KEY,  -- 自动建 Clustered Index
    name VARCHAR(50)
);
-- 二级索引指向 Clustered Key(id 值)

2.4 Oracle 的 IOT 选项

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 默认堆表
CREATE TABLE t1 (id INT PRIMARY KEY, name VARCHAR2(50));
-- PK 是独立的二级索引,指向 rowid

-- 显式 IOT
CREATE TABLE t2 (
    id INT PRIMARY KEY,
    name VARCHAR2(50)
) ORGANIZATION INDEX;
-- 表本身就是按 id 排序的 B+ 树

2.5 设计原则

1
2
3
4
5
6
7
IOT / Clustered Key 的选择原则(适用 InnoDB / MSSQL Clustered / Oracle IOT):
  1. 短:让所有二级索引都跟着变小
  2. 单调递增:减少 B+ 树页分裂(如自增 ID)
  3. 不变:改主键 → 所有二级索引重建
  4. 唯一:Clustered Key 必须唯一(不唯一时引擎会加后缀)

PG 反而没这些约束:主键可以随意选,因为所有索引都指向 ctid

三、PG 的索引武器库

PostgreSQL 是四家里索引类型最丰富的。除了 B-tree,还有 GIN、GiST、BRIN、SP-GiST、Hash 五种。

3.1 B-tree(默认)

1
2
3
4
CREATE INDEX idx_name ON t(name);

-- 适用:等值、范围、排序、唯一约束
-- 不适用:JSON 文档检索、全文搜索、地理数据

3.2 GIN(Generalized Inverted Index,倒排索引)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
倒排索引:把"元素 → 行"的映射存起来
  适用:数组、JSONB、全文检索

示例表:tags 是 text[]
  id | tags
  ---|------
  1  | {a, b, c}
  2  | {b, c, d}
  3  | {a, c}

GIN 索引内部:
  a → [1, 3]
  b → [1, 2]
  c → [1, 2, 3]
  d → [2]

查询 WHERE 'b' = ANY(tags) → 直接用 GIN,O(log n)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 数组索引
CREATE INDEX idx_tags ON t USING GIN(tags);
SELECT * FROM t WHERE tags @> ARRAY['b'];

-- JSONB 索引
CREATE INDEX idx_data ON t USING GIN(data);
SELECT * FROM t WHERE data @> '{"city": "Shanghai"}';

-- 全文索引
CREATE INDEX idx_fts ON docs USING GIN(to_tsvector('english', body));
SELECT * FROM docs WHERE to_tsvector('english', body) @@ to_tsquery('postgres & index');
1
2
3
4
GIN 特点:
  - 查询快(O(log n))
  - 写入慢(每条记录要拆成多个元素入索引)
  - 适合"低频更新、高频查询"场景

3.3 GiST(Generalized Search Tree)

1
2
3
4
5
6
7
8
9
GiST 是一个"框架",可以塞各种数据类型:
  - 地理数据(PostGIS 核心依赖)
  - 范围类型(int4range、tstzrange)
  - 模糊匹配(pg_trgm)

示例:找"附近 1km 的店铺"
CREATE INDEX idx_location ON shops USING GIST(location);
SELECT * FROM shops
WHERE ST_DWithin(location, ST_Point(121.4, 31.2), 1000);

3.4 BRIN(Block Range Index)

1
2
3
4
5
6
BRIN:块范围索引
  适用:超大的、物理顺序与某列相关的表(如按时间追加的日志)

原理:
  每 128 个 Page 一组,记录这组的最小/最大值
  查询时根据 min/max 跳过大块
1
2
3
4
5
-- 时序日志场景
CREATE INDEX idx_log_ts ON logs USING BRIN(ts);

SELECT * FROM logs WHERE ts >= '2026-06-01' AND ts < '2026-06-02';
-- BRIN 让 PG 跳过 ts 不在范围的大块
1
2
3
4
BRIN 特点:
  - 索引极小(KB 级,不是 GB 级)
  - 适合"自然有序"的数据(时间序列)
  - 不适合随机写入的数据

3.5 SP-GiST(Space-Partitioned GiST)

1
2
SP-GiST:用于"非平衡"数据结构(如 trie 树、四叉树、k-d 树)
  适用:电话区号、IP 路由、字符串前缀匹配

3.6 Hash

1
2
3
4
5
6
7
PG Hash 索引:
  - 早期版本(< 10)有 bug,不推荐用
  - 10+ 修复,支持 WAL 复制
  - 只支持等值查询(=)
  - 比 B-tree 略快,但功能受限

CREATE INDEX idx_id ON t USING HASH(id);

3.7 PG 索引选型表

场景 索引类型
等值、范围、排序 B-tree(默认)
JSONB 查询、数组包含 GIN
地理、范围、模糊匹配 GiST
大表 + 时间范围扫描 BRIN
前缀匹配、路由 SP-GiST
仅等值 + 极致性能 Hash

四、Oracle 的索引特色

4.1 B-Tree Index(默认)

1
2
3
4
CREATE INDEX idx_name ON t(name);

-- Oracle 的 B-Tree 实际是 B+ 树
-- 叶子节点存:索引键 + rowid

4.2 Bitmap Index(位图索引)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
适用:低基数列(如性别、状态、地区)
原理:每个 key 值对应一个位图,每一位代表一行

  表(5 行):
  id | gender
  ---|-------
  1  | M
  2  | F
  3  | M
  4  | M
  5  | F

  Bitmap Index on gender:
    M → [1, 0, 1, 1, 0]
    F → [0, 1, 0, 0, 1]

查询 WHERE gender='M' AND region='East':
  M 位图 AND East 位图 → 一次位运算
1
2
3
4
CREATE BITMAP INDEX idx_gender ON users(gender);

-- 适合:低基数列 + 数据仓库(不频繁更新)
-- 灾难场景:高并发 OLTP 写入 → 锁整个位图段

4.3 Reverse Key Index(反向键索引)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
解决"热块"问题:
  场景:主键是顺序 ID(1, 2, 3, ...),所有插入都打到 B+ 树最右侧叶子
       → 该页成为热块,并发插入互相阻塞

反向键索引:把 key 字节反转
  原 key: 12345 → 反向: 54321
  原 key: 12346 → 反向: 64321
  → 插入分散到 B+ 树不同叶子

代价:范围查询失效(不再连续)
1
CREATE INDEX idx_id_reverse ON t(id) REVERSE;

4.4 Function-Based Index

1
2
3
4
5
6
7
-- 经典场景:大小写不敏感查询
CREATE INDEX idx_lower_name ON t(LOWER(name));
SELECT * FROM t WHERE LOWER(name) = 'alice';  -- 走索引

-- 计算列
CREATE INDEX idx_amount_usd ON t(amount * 7.2);
SELECT * FROM t WHERE amount * 7.2 > 100;

4.5 Oracle 索引特色总览

索引类型 适用场景
B-Tree 通用
Bitmap 低基数列 + 数仓
Reverse 热块消除
Function-based 函数查询
IOT 索引组织表
Domain 用户自定义(如 Spatial)

五、联合索引与最左前缀

5.1 联合索引的结构

1
2
3
4
CREATE INDEX idx_a_b_c ON t(a, b, c);

-- B+ 树叶子节点按 (a, b, c) 排序:
-- (1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 2, 3) (2, 1, 1) (2, 1, 5) ...

5.2 最左前缀原则

1
2
3
4
5
6
7
8
9
能用上 idx_a_b_c 的查询:
  WHERE a = 1                       ✅ 用 a
  WHERE a = 1 AND b = 2             ✅ 用 a, b
  WHERE a = 1 AND b = 2 AND c = 3   ✅ 用 a, b, c
  WHERE a = 1 AND c = 3             ✅ 用 a(c 用不上,跳过 b)
  WHERE a > 1 AND b = 2             ✅ 用 a(范围后断)
  WHERE b = 2                       ❌ 不用(缺最左 a)
  WHERE b = 2 AND c = 3             ❌ 不用
  WHERE a = 1 ORDER BY b, c         ✅ 用 a + 排序免费

5.3 为什么这样

1
2
3
4
5
6
B+ 树是有序的,按 (a, b, c) 排序:
  (1, 1, 1) < (1, 1, 2) < (1, 2, 1) < (2, 1, 1)

WHERE a = 1:从 (1, ?, ?) 段开始扫
WHERE a = 1 AND b = 2:进一步缩到 (1, 2, ?) 段
WHERE b = 2:B+ 树不是按 b 排序的,无法定位 → 退化为全索引扫描

5.4 范围查询"截断"后续列

1
2
3
4
5
6
7
WHERE a > 1 AND b = 2:
  a 是范围 → B+ 树定位到 a > 1 的范围,但 b 在这个范围内无序
  → 只能用 a,b 不能用索引

如何绕过:
  方案 1:调整索引列顺序(按"等值在前,范围在后"排)
  方案 2:把范围改成 IN 列表(a IN (2, 3, 4) AND b = 2)

5.5 设计建议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
联合索引列顺序的优先级:
  1. 等值查询的列(最左)
  2. 排序列(中间,利用 B+ 树天然有序)
  3. 范围查询列(最右,避免截断)

反例:
  WHERE status = 'A' AND create_time > '2026-06-01'
  → idx(status, create_time):先等值定位 status,再范围扫 create_time ✅
  → idx(create_time, status):先范围扫 create_time,再过滤 status ❌(无效)

观察执行计划验证:
  MySQL: EXPLAIN SELECT ... → key_len 看用了几列
  PG: EXPLAIN (BUFFERS) SELECT ... → "Index Cond" 看用了哪些列
  Oracle: EXPLAIN PLAN → "Access Predicates" vs "Filter Predicates"
  SQL Server: SET SHOWPLAN_TEXT ON → Seek Predicate 是真用,Predicate 是过滤

六、覆盖索引、索引下推、条件下推

6.1 覆盖索引(Covering Index)

1
2
3
4
5
6
7
如果 SELECT 的所有列都在索引里 → 不需要回表

InnoDB 示例:
  CREATE INDEX idx_name_age ON users(name, age);
  SELECT name, age FROM users WHERE name = 'Alice';
  → 命中 idx_name_age,叶子节点存 (name, age, id)
  → 所有需要的列都在 → "Using index"(不回表)
1
2
3
4
5
6
7
-- MySQL 验证
EXPLAIN SELECT name, age FROM users WHERE name = 'Alice';
-- Extra: Using index → 覆盖索引生效

-- 故意触发回表
EXPLAIN SELECT name, age, email FROM users WHERE name = 'Alice';
-- Extra: <空> → 需要回表取 email

6.2 MySQL 的"覆盖"延伸:包含列

1
2
3
4
5
6
-- MySQL 8.0+
CREATE INDEX idx_name ON users(name) INVISIBLE;
ALTER TABLE users ADD INDEX idx_name_age_email (name) INCLUDE (age, email);
-- INCLUDE 列只放在叶子节点,不参与排序

-- Oracle / SQL Server / PG 都有类似 INCLUDE 子句

6.3 索引下推(Index Condition Pushdown, ICP)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
MySQL 5.6+ 引入,针对联合索引:

  CREATE INDEX idx_a_b ON t(a, b);
  SELECT * FROM t WHERE a = 1 AND b LIKE '%abc%';

无 ICP(5.6 之前):
  1. 走 idx_a_b 找到 a = 1 的所有行(比如 1000 行)
  2. 全部回表取整行
  3. 在 Server 层过滤 b LIKE '%abc%'
  → 1000 次回表

有 ICP:
  1. 走 idx_a_b 找到 a = 1 的所有行(1000 行)
  2. 在存储引擎层直接用 b LIKE '%abc%' 过滤(剩 10 行)
  3. 只回表 10 次
  → 减少 100 倍回表
1
2
EXPLAIN SELECT * FROM t WHERE a = 1 AND b LIKE '%abc%';
-- Extra: Using index condition → ICP 生效

6.4 条件下推到存储层

1
2
3
4
5
6
PG 和 Oracle 都有类似的下推机制:
  PG:把 WHERE 条件推到 Scan 节点(Index Scan 的 Index Cond)
  Oracle:谓词下推到 Table Access
  SQL Server:Seek Predicate(下推)vs Predicate(残留过滤)

判断方法:执行计划里"过滤发生得越早越好"

七、索引失效与"隐形索引"

7.1 索引失效的常见原因

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1. 函数包装列
   WHERE LOWER(name) = 'alice'     -- 不走 idx_name
   → 解决:建函数索引 或 改写为 name = 'Alice' COLLATE ...

2. 隐式类型转换(经典坑)
   WHERE phone = 13800000000       -- phone 是 VARCHAR,传 INT
   → MySQL:函数 CAST(phone) → 不走索引
   → PG:报错(更严格)
   → 解决:传字符串 '13800000000'

3. LIKE 前缀通配
   WHERE name LIKE '%abc'          -- 不走索引
   WHERE name LIKE 'abc%'          -- 走索引
   → 解决:用全文索引(PG GIN / MySQL FULLTEXT)

4. OR 中部分列无索引
   WHERE a = 1 OR b = 2           -- 如果 b 没索引,整条不走索引
   → 解决:UNION ALL 或给 b 加索引

5. 计算 / 表达式
   WHERE id + 1 = 100              -- 不走索引
   → 改写为 WHERE id = 99

6. NULL 不被索引(部分数据库)
   Oracle:单列索引不存 NULL
   → 解决:建复合索引包含 NOT NULL 列

7.2 隐形索引(Invisible Index)

1
2
3
4
5
6
7
8
用途:临时禁用索引,观察是否真的没用,再决定删

MySQL 8.0+:
  ALTER TABLE t ALTER INDEX idx_name INVISIBLE;
  -- 优化器看不见 → 如果性能没变差 → 可以 DROP
  -- 如果变差 → 改回 VISIBLE

Oracle / PG(PG 12+ 也支持)都有类似机制
1
2
3
4
5
-- PG 用 ALTER INDEX ... ALTER COLUMN ... SET (attndims = 0) 不直接
-- 推荐用 extension:hypopg(虚拟索引,模拟)
CREATE EXTENSION hypopg;
SELECT * FROM hypopg_create_index('CREATE INDEX idx_test ON t(name)');
-- 不真建索引,只让优化器"以为"有 → 看 EXPLAIN 是否选择

7.3 索引选择性

1
2
3
4
5
6
7
8
索引值不值得建?看选择性:
  选择性 = 不同值的数量 / 总行数

  低选择性(< 0.1):性别、状态、布尔 → 一般不建
  中选择性(0.1 ~ 0.5):地区、年龄段 → 看场景
  高选择性(> 0.5):用户 ID、订单号 → 必建

  PG:SELECT 1.0 * COUNT(DISTINCT col) / COUNT(*) FROM t;

八、索引维护代价

8.1 写入放大

1
2
3
4
5
6
7
8
9
每加一个索引,每次 INSERT 都多写一份:
  原表 + N 个索引 = N + 1 次写入

  UPDATE / DELETE 也要同步维护索引

经验值:
  - 一张表索引数建议 ≤ 5 个
  - 高写入表 ≤ 3 个
  - 数仓表(只读为主)可以多

8.2 页分裂与碎片

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
B+ 树插入时,如果目标页满了:
  → 把页分裂成两页,各放一半
  → 物理位置不连续,磁盘 I/O 变慢

  顺序插入(自增主键):基本不分裂
  随机插入(UUID 主键):频繁分裂

碎片化监控:
  MySQL: ANALYZE TABLE t; SHOW TABLE STATUS LIKE 't';
  PG: VACUUM (VERBOSE) t; 看 free space
  SQL Server: SELECT * FROM sys.dm_db_index_physical_stats(...)
  Oracle: ANALYZE INDEX idx VALIDATE STRUCTURE;

8.3 索引重建

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- MySQL(在线 DDL,8.0+)
ALTER TABLE t ENGINE=InnoDB;       -- 重建表(含所有索引)
ALTER TABLE t DROP INDEX idx, ADD INDEX idx (...);  -- 单独重建

-- PostgreSQL(CONCURRENTLY 不阻塞写)
REINDEX INDEX CONCURRENTLY idx_name;
REINDEX TABLE CONCURRENTLY t;

-- SQL Server
ALTER INDEX ALL ON t REBUILD;
ALTER INDEX ALL ON t REORGANIZE;   -- 轻量整理

-- Oracle
ALTER INDEX idx_name REBUILD ONLINE;

8.4 索引统计信息

1
2
3
4
5
6
7
8
优化器依赖统计信息决定走不走索引:
  行数、基数、最值、直方图

定期收集:
  MySQL: ANALYZE TABLE t;  (8.0 默认自动采样)
  PG: ANALYZE t;           (autovacuum 自动)
  Oracle: DBMS_STATS.GATHER_TABLE_STATS(...)
  SQL Server: UPDATE STATISTICS t;  (自动)

九、小结

本文学习了索引原理:

  • 为什么 B+ 树是关系数据库的标准索引
  • 聚集 vs 非聚集(Clustered vs Secondary)的术语差异
    • InnoDB 主键即数据
    • PG/Oracle/SQL Server 都是堆表 + 二级索引
  • PG 的索引武器库:B-tree / GIN / GiST / BRIN / SP-GiST / Hash
  • Oracle 的特色:Bitmap / Reverse / Function-based
  • 联合索引的最左前缀原则与范围截断
  • 覆盖索引、索引下推、条件下推
  • 索引失效的六大原因与"隐形索引"调试法
  • 索引维护的代价:写入放大、页分裂、统计信息
1
2
3
4
记住三句话:
  1. 关系数据库索引绝大多数是 B+ 树——因为磁盘 I/O 模型
  2. InnoDB 二级索引存"主键值",PG/Oracle 存"行位置"——这是回表成本的根源
  3. 索引不是免费的——每加一个,写入代价线性增加

下一篇将进入事务主题:ACID 怎么实现?原子性靠 Undo,持久性靠 Redo,分布式事务怎么做?