SQL Server 中的聚集索引、非聚集索引和 B+ Tree

taki

先别急着看索引

刚开始理解索引时,很容易直接跳到“聚集索引、非聚集索引、B+ Tree”这些词。这样学会比较累,因为这些概念看起来像数据库强行发明出来的名词。

我觉得更自然的入口是先问一个问题:

1
2
3
SELECT *
FROM Users
WHERE Email = 'taki@example.com';

数据库到底怎么找到这行数据?

如果没有索引,答案很直接:一页一页扫。

SQL Server 里数据不是按“表格视图”那样存的,而是放在数据页里。一个数据页通常是 8KB。表里的行会被塞进很多页:

1
2
3
4
5
6
7
8
9
10
11
12
Users 表

Page 101
Id=1, Name=Taki, Email=taki@example.com
Id=2, Name=Tom, Email=tom@example.com

Page 102
Id=3, Name=Jack, Email=jack@example.com
Id=4, Name=Lucy, Email=lucy@example.com

Page 103
Id=5, Name=Mike, Email=mike@example.com

如果数据库不知道 taki@example.com 在哪一页,就只能从头扫到尾。表小的时候没什么感觉,表一大,慢的不是 SQL 语句本身,而是它读了太多不该读的数据页。

索引要解决的就是这个问题:让数据库少读页。

B+ Tree 是一套分层目录

SQL Server 里常见的行存索引,本质上是一棵 B+ Tree。可以先把它理解成一本书的多级目录。

如果书只有 10 页,目录没意义;如果书有 100 万页,你不可能从第一页翻到最后一页找一个词。目录要分层:

1
2
3
4
5
6
7
8
9
10
11
12
13
根节点
A - M -> 中间节点 1
N - Z -> 中间节点 2

中间节点 1
A - D -> 叶子页 10
E - H -> 叶子页 11
I - M -> 叶子页 12

叶子页 11
Email = eva@example.com -> 数据位置
Email = frank@example.com -> 数据位置
Email = grace@example.com -> 数据位置

查一个值时,数据库不是从头扫,而是一路缩小范围:

1
根节点 -> 中间节点 -> 叶子节点 -> 找到目标

B+ Tree 的几个特点刚好适合数据库:

  • 树比较矮。哪怕数据很多,通常也只要读几层页面。
  • 叶子节点有序。等值查询、范围查询、排序都能受益。
  • 每个节点能放很多 key。数据库页是块状读取,B+ Tree 能很好地利用这种 IO 模型。

这里要注意一个细节:数据库里的 B+ Tree 不是“一整个数据库一棵树”。更准确地说:

1
一个 B+ Tree 类型的索引,大致对应一棵树。

如果一张表有 1 个主键索引、2 个普通索引,那这张表就可能有 3 棵 B+ Tree。不同索引按不同列排序,服务不同查询。

表本身怎么放:Heap 和聚集索引

在 SQL Server 里,表数据常见有两种组织方式:

1
2
Heap
Clustered Index

Heap 就是没有聚集索引的表。数据行大致是哪里有空间就放哪里,本身没有按某个业务键组织。

1
2
3
4
5
Users Heap

Page 101: Id=3, Id=8
Page 102: Id=1, Id=5
Page 103: Id=2, Id=7

这不是说 heap 完全没规则,而是对查询来说,它没有一条“按 Id 排好”的主路径。你按 Id = 7 查,如果没有索引,还是得扫。

聚集索引就不一样了。

1
2
CREATE CLUSTERED INDEX CX_Users_Id
ON Users(Id);

有了聚集索引后,表数据本身会按这棵 B+ Tree 来组织。这里最关键的一句话是:

1
SQL Server 聚集索引的叶子层就是数据行本身。

可以粗略画成这样:

1
2
3
4
5
6
7
8
9
10
CX_Users_Id

Root Page
[ Id <= 3 | Id > 3 ]
/ \
/ \
Leaf Page 101 Leaf Page 102
Id=1, Name=... Id=4, Name=...
Id=2, Name=... Id=5, Name=...
Id=3, Name=... Id=6, Name=...

上层页面负责导航,叶子页面就是完整数据行。查 Id = 5 时,数据库沿着 Id 这棵树走到叶子层,就拿到了整行数据。

这也是为什么一张表只能有一个聚集索引。因为数据行本身只能按一种主要方式组织。你不能让同一份数据既按 Id 排,又按 Email 排,还按 CreatedAt 排。其他排序需求只能交给非聚集索引。

主键不一定等于聚集索引

很多人会把主键和聚集索引混在一起,因为 SQL Server 创建主键时,默认经常会创建聚集索引。

比如:

1
2
3
4
5
CREATE TABLE Users (
Id BIGINT IDENTITY(1,1) NOT NULL,
Email NVARCHAR(200) NOT NULL,
CONSTRAINT PK_Users PRIMARY KEY (Id)
);

在 SQL Server 里,这个主键默认通常会变成:

1
PRIMARY KEY CLUSTERED (Id)

但这不是概念上的必然关系。

主键是约束,回答的是:

1
哪一列能唯一标识一行?

聚集索引是存储组织方式,回答的是:

1
数据行按哪棵 B+ Tree 组织?

也可以显式写成非聚集主键:

1
CONSTRAINT PK_Users PRIMARY KEY NONCLUSTERED (Id)

只是日常 OLTP 表里,用自增 Id 做聚集主键很常见,因为它窄、稳定、递增,插入时对页面比较友好。

非聚集索引是一条额外查找路径

假设 Users 表已经按 Id 做了聚集索引,但经常按邮箱查用户:

1
2
3
SELECT *
FROM Users
WHERE Email = 'taki@example.com';

如果只有 Id 聚集索引,这个查询还是不舒服。因为数据按 Id 排,不按 Email 排。数据库没法直接根据邮箱走到某个位置。

这时可以建非聚集索引:

1
2
CREATE NONCLUSTERED INDEX IX_Users_Email
ON Users(Email);

这会额外维护一棵按 Email 排序的 B+ Tree:

1
2
3
4
5
6
7
8
9
IX_Users_Email

Root / Intermediate Pages
按 Email 导航

Leaf Pages
Email = jack@example.com -> Id = 3
Email = taki@example.com -> Id = 1
Email = tom@example.com -> Id = 2

这棵树的叶子层不是完整用户数据行,而是索引键加行定位信息。

如果表有聚集索引,非聚集索引叶子层里通常保存的是聚集索引键。上面的例子里就是 Id

所以查询过程变成:

1
2
3
1. 走 IX_Users_Email,找到 Email = taki@example.com
2. 得到聚集索引键 Id = 1
3. 再走 CX_Users_Id,找到完整 Users 行

第 3 步通常叫 Key Lookup,也就是常说的回表。

如果表是 heap,没有聚集索引,非聚集索引叶子层里保存的就不是聚集键,而是 RID,可以理解成:

1
FileId + PageId + SlotId

也就是“第几个文件、第几页、第几个槽位”。数据库拿这个位置再回到 heap 里取行。

回表为什么有时很贵

回表不是一定坏。查一两行时,先走非聚集索引再回表通常很快。

麻烦出现在返回行数很多的时候。

比如:

1
2
3
SELECT *
FROM Orders
WHERE Status = 'Paid';

如果 Status = 'Paid' 命中了 80% 的订单,那么数据库即使用了 IX_Orders_Status,也要对大量行做 Key Lookup。结果可能是:

1
2
先读一遍 Status 索引
再一行一行回聚集索引取完整数据

这种随机访问很多时,可能还不如直接扫聚集索引。

所以索引不是“建了就一定用”。优化器会估算成本:如果走索引再回表比扫描还贵,它就可能选择扫描。

这也是为什么低选择性字段不一定适合单独建索引。像 GenderIsDeletedStatus 这种列,要看数据分布和查询条件,不是看到 WHERE 里出现就建。

覆盖索引:让查询不用回表

如果查询需要的列都在非聚集索引里,就不用回表。

比如:

1
2
3
SELECT Id, Email
FROM Users
WHERE Email = 'taki@example.com';

IX_Users_Email 里有 Email,叶子层也有聚集键 Id,这个查询可以只读非聚集索引。

如果还要返回 Name

1
2
3
SELECT Id, Email, Name
FROM Users
WHERE Email = 'taki@example.com';

Name 不在索引里,就要回表。可以用 INCLUDE 把它放进叶子层:

1
2
3
CREATE NONCLUSTERED INDEX IX_Users_Email
ON Users(Email)
INCLUDE (Name);

这时可以理解成:

1
2
IX_Users_Email 叶子层
Email = taki@example.com -> Id = 1, Name = Taki

Name 不参与索引排序,只是跟在叶子层,方便查询直接拿到结果。这就是覆盖索引的基本思路。

覆盖索引很实用,但也不能滥用。INCLUDE 列越多,索引越大,写入维护成本也越高。读快了,写会变重。

B+ Tree 对范围查询也很友好

B+ Tree 不只适合等值查询,也适合范围查询。

比如订单表:

1
2
CREATE INDEX IX_Orders_CreatedAt
ON Orders(CreatedAt);

查询:

1
2
3
4
SELECT *
FROM Orders
WHERE CreatedAt >= '2026-05-01'
AND CreatedAt < '2026-06-01';

数据库可以先沿着 B+ Tree 找到 2026-05-01 附近的叶子页,然后顺着叶子页往后读,直到超过 2026-06-01

1
2
2026-04-29 -> 2026-04-30 -> 2026-05-01 -> 2026-05-02 -> ... -> 2026-06-01
↑ 从这里开始读

这比扫描全表自然要舒服很多。

排序也是类似道理。如果查询的 ORDER BY 正好和索引顺序匹配,数据库可能不需要额外排序。

1
2
CREATE INDEX IX_Orders_UserId_CreatedAt
ON Orders(UserId, CreatedAt DESC);

这个索引适合:

1
2
3
4
SELECT TOP 20 *
FROM Orders
WHERE UserId = 1001
ORDER BY CreatedAt DESC;

因为它先按 UserId 分组,再在同一个用户下面按 CreatedAt DESC 排好。数据库可以直接拿前 20 条,而不是把这个用户所有订单找出来再排序。

聚集键选错会影响所有非聚集索引

SQL Server 里有一个很容易忽略的点:如果表有聚集索引,非聚集索引叶子层会带着聚集键。

这意味着聚集键会出现在很多非聚集索引里。

如果聚集键很宽,比如拿一个很长的字符串做聚集索引,那么每个非聚集索引都会变大。索引页能放下的记录变少,树可能更高,缓存效率也变差。

所以聚集键一般推荐:

  • 稳定
  • 唯一或接近唯一
  • 尽量递增

自增整数、BIGINT IDENTITY 这类字段经常被用作聚集键,就是因为它们符合这些特征。

随机 GUID 做聚集键就要小心。它不是不能用,而是插入位置太随机,容易造成页分裂和碎片。SQL Server 里如果必须用 GUID,可以考虑 NEWSEQUENTIALID() 或者把 GUID 主键设为非聚集,再单独选择合适的聚集键。

最后串一次查询过程

如果只看定义,聚集索引、非聚集索引和 B+ Tree 还是有点散。把它们放进一次查询里会清楚很多。

假设 Users 表按 Id 建了聚集索引,又按 Email 建了非聚集索引:

1
2
3
4
5
CREATE CLUSTERED INDEX CX_Users_Id
ON Users(Id);

CREATE NONCLUSTERED INDEX IX_Users_Email
ON Users(Email);

现在查一行用户:

1
2
3
SELECT Id, Email, Name
FROM Users
WHERE Email = 'taki@example.com';

数据库大致会这样走:

1
2
3
4
1. 去 IX_Users_Email 这棵 B+ Tree 里找 taki@example.com
2. 在 Email 索引的叶子层拿到对应的 Id
3. 用这个 Id 再去 CX_Users_Id 这棵 B+ Tree 里找完整数据行
4. 返回 Id、Email、Name

这里三件事刚好对上:

  • B+ Tree 负责把“从头扫”变成“沿目录往下找”。
  • 非聚集索引负责提供一条按 Email 查找的路径。
  • 聚集索引负责组织真实数据行,最后从它的叶子层拿到完整记录。

如果 IX_Users_Email 里已经包含 Name,第 3 步就可以省掉:

1
2
3
CREATE NONCLUSTERED INDEX IX_Users_Email
ON Users(Email)
INCLUDE (Name);

这时查询只读 Email 这棵索引就够了。索引优化很多时候就是在减少这种额外跳转:少扫一些页,少回一次表,少做一次排序。

所以这篇文章真正想说明的是:索引不是一个抽象的“加速开关”。它是一套真实存在的数据结构。聚集索引决定数据行主要按什么方式放,非聚集索引提供额外查找路径,B+ Tree 则是这些路径背后的目录结构。

评论