SQL Server 中的聚集索引、非聚集索引和 B+ Tree
先别急着看索引
刚开始理解索引时,很容易直接跳到“聚集索引、非聚集索引、B+ Tree”这些词。这样学会比较累,因为这些概念看起来像数据库强行发明出来的名词。
我觉得更自然的入口是先问一个问题:
1 | SELECT * |
数据库到底怎么找到这行数据?
如果没有索引,答案很直接:一页一页扫。
SQL Server 里数据不是按“表格视图”那样存的,而是放在数据页里。一个数据页通常是 8KB。表里的行会被塞进很多页:
1 | Users 表 |
如果数据库不知道 taki@example.com 在哪一页,就只能从头扫到尾。表小的时候没什么感觉,表一大,慢的不是 SQL 语句本身,而是它读了太多不该读的数据页。
索引要解决的就是这个问题:让数据库少读页。
B+ Tree 是一套分层目录
SQL Server 里常见的行存索引,本质上是一棵 B+ Tree。可以先把它理解成一本书的多级目录。
如果书只有 10 页,目录没意义;如果书有 100 万页,你不可能从第一页翻到最后一页找一个词。目录要分层:
1 | 根节点 |
查一个值时,数据库不是从头扫,而是一路缩小范围:
1 | 根节点 -> 中间节点 -> 叶子节点 -> 找到目标 |
B+ Tree 的几个特点刚好适合数据库:
- 树比较矮。哪怕数据很多,通常也只要读几层页面。
- 叶子节点有序。等值查询、范围查询、排序都能受益。
- 每个节点能放很多 key。数据库页是块状读取,B+ Tree 能很好地利用这种 IO 模型。
这里要注意一个细节:数据库里的 B+ Tree 不是“一整个数据库一棵树”。更准确地说:
1 | 一个 B+ Tree 类型的索引,大致对应一棵树。 |
如果一张表有 1 个主键索引、2 个普通索引,那这张表就可能有 3 棵 B+ Tree。不同索引按不同列排序,服务不同查询。
表本身怎么放:Heap 和聚集索引
在 SQL Server 里,表数据常见有两种组织方式:
1 | Heap |
Heap 就是没有聚集索引的表。数据行大致是哪里有空间就放哪里,本身没有按某个业务键组织。
1 | Users Heap |
这不是说 heap 完全没规则,而是对查询来说,它没有一条“按 Id 排好”的主路径。你按 Id = 7 查,如果没有索引,还是得扫。
聚集索引就不一样了。
1 | CREATE CLUSTERED INDEX CX_Users_Id |
有了聚集索引后,表数据本身会按这棵 B+ Tree 来组织。这里最关键的一句话是:
1 | SQL Server 聚集索引的叶子层就是数据行本身。 |
可以粗略画成这样:
1 | CX_Users_Id |
上层页面负责导航,叶子页面就是完整数据行。查 Id = 5 时,数据库沿着 Id 这棵树走到叶子层,就拿到了整行数据。
这也是为什么一张表只能有一个聚集索引。因为数据行本身只能按一种主要方式组织。你不能让同一份数据既按 Id 排,又按 Email 排,还按 CreatedAt 排。其他排序需求只能交给非聚集索引。
主键不一定等于聚集索引
很多人会把主键和聚集索引混在一起,因为 SQL Server 创建主键时,默认经常会创建聚集索引。
比如:
1 | CREATE TABLE Users ( |
在 SQL Server 里,这个主键默认通常会变成:
1 | PRIMARY KEY CLUSTERED (Id) |
但这不是概念上的必然关系。
主键是约束,回答的是:
1 | 哪一列能唯一标识一行? |
聚集索引是存储组织方式,回答的是:
1 | 数据行按哪棵 B+ Tree 组织? |
也可以显式写成非聚集主键:
1 | CONSTRAINT PK_Users PRIMARY KEY NONCLUSTERED (Id) |
只是日常 OLTP 表里,用自增 Id 做聚集主键很常见,因为它窄、稳定、递增,插入时对页面比较友好。
非聚集索引是一条额外查找路径
假设 Users 表已经按 Id 做了聚集索引,但经常按邮箱查用户:
1 | SELECT * |
如果只有 Id 聚集索引,这个查询还是不舒服。因为数据按 Id 排,不按 Email 排。数据库没法直接根据邮箱走到某个位置。
这时可以建非聚集索引:
1 | CREATE NONCLUSTERED INDEX IX_Users_Email |
这会额外维护一棵按 Email 排序的 B+ Tree:
1 | IX_Users_Email |
这棵树的叶子层不是完整用户数据行,而是索引键加行定位信息。
如果表有聚集索引,非聚集索引叶子层里通常保存的是聚集索引键。上面的例子里就是 Id。
所以查询过程变成:
1 | 1. 走 IX_Users_Email,找到 Email = taki@example.com |
第 3 步通常叫 Key Lookup,也就是常说的回表。
如果表是 heap,没有聚集索引,非聚集索引叶子层里保存的就不是聚集键,而是 RID,可以理解成:
1 | FileId + PageId + SlotId |
也就是“第几个文件、第几页、第几个槽位”。数据库拿这个位置再回到 heap 里取行。
回表为什么有时很贵
回表不是一定坏。查一两行时,先走非聚集索引再回表通常很快。
麻烦出现在返回行数很多的时候。
比如:
1 | SELECT * |
如果 Status = 'Paid' 命中了 80% 的订单,那么数据库即使用了 IX_Orders_Status,也要对大量行做 Key Lookup。结果可能是:
1 | 先读一遍 Status 索引 |
这种随机访问很多时,可能还不如直接扫聚集索引。
所以索引不是“建了就一定用”。优化器会估算成本:如果走索引再回表比扫描还贵,它就可能选择扫描。
这也是为什么低选择性字段不一定适合单独建索引。像 Gender、IsDeleted、Status 这种列,要看数据分布和查询条件,不是看到 WHERE 里出现就建。
覆盖索引:让查询不用回表
如果查询需要的列都在非聚集索引里,就不用回表。
比如:
1 | SELECT Id, Email |
IX_Users_Email 里有 Email,叶子层也有聚集键 Id,这个查询可以只读非聚集索引。
如果还要返回 Name:
1 | SELECT Id, Email, Name |
但 Name 不在索引里,就要回表。可以用 INCLUDE 把它放进叶子层:
1 | CREATE NONCLUSTERED INDEX IX_Users_Email |
这时可以理解成:
1 | IX_Users_Email 叶子层 |
Name 不参与索引排序,只是跟在叶子层,方便查询直接拿到结果。这就是覆盖索引的基本思路。
覆盖索引很实用,但也不能滥用。INCLUDE 列越多,索引越大,写入维护成本也越高。读快了,写会变重。
B+ Tree 对范围查询也很友好
B+ Tree 不只适合等值查询,也适合范围查询。
比如订单表:
1 | CREATE INDEX IX_Orders_CreatedAt |
查询:
1 | SELECT * |
数据库可以先沿着 B+ Tree 找到 2026-05-01 附近的叶子页,然后顺着叶子页往后读,直到超过 2026-06-01。
1 | 2026-04-29 -> 2026-04-30 -> 2026-05-01 -> 2026-05-02 -> ... -> 2026-06-01 |
这比扫描全表自然要舒服很多。
排序也是类似道理。如果查询的 ORDER BY 正好和索引顺序匹配,数据库可能不需要额外排序。
1 | CREATE INDEX IX_Orders_UserId_CreatedAt |
这个索引适合:
1 | SELECT TOP 20 * |
因为它先按 UserId 分组,再在同一个用户下面按 CreatedAt DESC 排好。数据库可以直接拿前 20 条,而不是把这个用户所有订单找出来再排序。
聚集键选错会影响所有非聚集索引
SQL Server 里有一个很容易忽略的点:如果表有聚集索引,非聚集索引叶子层会带着聚集键。
这意味着聚集键会出现在很多非聚集索引里。
如果聚集键很宽,比如拿一个很长的字符串做聚集索引,那么每个非聚集索引都会变大。索引页能放下的记录变少,树可能更高,缓存效率也变差。
所以聚集键一般推荐:
- 窄
- 稳定
- 唯一或接近唯一
- 尽量递增
自增整数、BIGINT IDENTITY 这类字段经常被用作聚集键,就是因为它们符合这些特征。
随机 GUID 做聚集键就要小心。它不是不能用,而是插入位置太随机,容易造成页分裂和碎片。SQL Server 里如果必须用 GUID,可以考虑 NEWSEQUENTIALID() 或者把 GUID 主键设为非聚集,再单独选择合适的聚集键。
最后串一次查询过程
如果只看定义,聚集索引、非聚集索引和 B+ Tree 还是有点散。把它们放进一次查询里会清楚很多。
假设 Users 表按 Id 建了聚集索引,又按 Email 建了非聚集索引:
1 | CREATE CLUSTERED INDEX CX_Users_Id |
现在查一行用户:
1 | SELECT Id, Email, Name |
数据库大致会这样走:
1 | 1. 去 IX_Users_Email 这棵 B+ Tree 里找 taki@example.com |
这里三件事刚好对上:
- B+ Tree 负责把“从头扫”变成“沿目录往下找”。
- 非聚集索引负责提供一条按
Email查找的路径。 - 聚集索引负责组织真实数据行,最后从它的叶子层拿到完整记录。
如果 IX_Users_Email 里已经包含 Name,第 3 步就可以省掉:
1 | CREATE NONCLUSTERED INDEX IX_Users_Email |
这时查询只读 Email 这棵索引就够了。索引优化很多时候就是在减少这种额外跳转:少扫一些页,少回一次表,少做一次排序。
所以这篇文章真正想说明的是:索引不是一个抽象的“加速开关”。它是一套真实存在的数据结构。聚集索引决定数据行主要按什么方式放,非聚集索引提供额外查找路径,B+ Tree 则是这些路径背后的目录结构。