2. SQL Server存储引擎与数据组织

理解 SQL Server 如何把一行行数据真正落到磁盘上,是索引设计与查询优化的起点。很多性能问题并不是 SQL 语句写得不好,而是数据在物理层面被组织成了不适合当前访问模式的结构。本节从最小的存储单元开始,逐层向上梳理页、区、堆表和聚集索引的物理结构,并说明它们如何决定一次查询的读取代价。

一、页:SQL Server 的最小 I/O 单元

SQL Server 读写磁盘的最小单位不是“行”,而是页(Page)。每个页固定为 8 KB,页是数据文件内部的基本容器。无论查询只需要一行还是几百行,只要这一行所在的页没有被读入内存,引擎就必须把整个 8 KB 的页读进来。这就是为什么“返回行数少”并不等于“读取代价小”。

页有多种类型,常见的有:数据页(Data Page,存放堆表或聚集索引的叶级行数据)、索引页(Index Page,存放非聚集索引的行定位信息)、IAM 页(Index Allocation Map,记录某个对象使用了哪些区)、PFS 页(Page Free Space,记录页的可用空间)、GAM/SGAM 页(记录区的分配情况)等。日常优化中最关心的是数据页和索引页,因为它们承载真正的业务数据。

一个数据页内部由三部分组成:96 字节的页头(Page Header)、中间的行数据区、以及页尾的行偏移数组(Row Offset Array)。页头记录页号、对象 ID、页类型、空闲空间等元信息;行偏移数组从页尾向前增长,每个槽位 2 字节,指向对应行在页内的起始位置。行数据区从页头之后开始向后增长。当两者相遇时,页就满了。因此单页可容纳的行数取决于行长:假设一行约 200 字节,则一页大约能放 40 行左右;若一行只有 50 字节,则能放 150 行以上。这个估算直接决定了表的大小和扫描成本。

二、区:页的分配单位

页不是逐个向磁盘申请的,而是以区(Extent)为单位分配。一个区包含 8 个连续的页,即 64 KB。区分为两种:

  • 统一区(Uniform Extent):整个区只属于一个对象(一张表或一个索引),适合数据量较大的对象。
  • 混合区(Mixed Extent):区内的 8 个页可以分给最多 8 个不同的对象,适合小表,避免小对象一上来就占用 64 KB。

SQL Server 的分配策略是:对象较小时优先使用混合区,当对象增长到一定程度后转为统一区。理解这一点有助于解释“为什么小表查询有时反而更慢”——小表的数据页可能分散在多个混合区中,物理连续性差,预读效率低。

三、堆表:没有聚集索引的表

如果一张表没有聚集索引,它就是一个堆表(Heap)。堆表中的数据行不按任何键排序,插入时被放到“有空闲空间的任意页”中。引擎通过 IAM 页链来跟踪堆表使用了哪些区,扫描时按 IAM 记录的区顺序读取。

堆表的关键特征是:行没有稳定的物理顺序,因此无法通过“定位到某个键值”直接找到目标行。当你在堆表上建非聚集索引时,索引叶级的行定位符是RID(Row Identifier),由文件号、页号、槽号组成。这意味着通过非聚集索引查找一行,需要先读索引页拿到 RID,再回表读对应的数据页,形成一次额外的随机 I/O。

堆表并非一无是处。对于只做全表扫描、不做范围查询、且插入非常频繁的日志型表,堆表可以避免聚集索引维护带来的页分裂开销。但一旦出现“按某列查少量行”的需求,堆表的随机回表代价就会明显上升。

四、聚集索引:数据即索引

聚集索引(Clustered Index)不是独立于数据存在的结构,它就是数据本身。聚集索引的叶级页存放的是完整的行数据,并且这些行按照聚集键的顺序物理排列。中间层和根层是索引页,只存放键值和指向下层页的指针。

这种“数据按聚集键有序”的特性带来两个直接好处:第一,按聚集键做范围查询时,引擎可以顺序读取连续的页,预读效率高;第二,通过聚集键定位单行时,从根页到叶页只需 2~3 次页读取(B 树高度通常为 3 层左右)。

聚集键的选择因此非常关键。理想的聚集键应满足:窄(键越窄,非聚集索引叶级存放的定位信息越小)、唯一(避免引擎自动添加 4 字节 uniquifier)、静态(避免更新导致行在页间移动)、递增(避免随机插入造成页分裂)。这也是为什么自增整数列常被用作聚集键,而 GUID 作为聚集键则容易引发大量页分裂和碎片。

当聚集索引发生页分裂时,新页会从区中申请,原有页约一半的行被移动到新页。频繁的随机插入会让页的填充度下降、碎片增加,最终表现为扫描同样的行数却要读更多的页。可以通过 sys.dm_db_index_physical_stats 查看 avg_fragmentation_in_percent 和 avg_page_space_used_in_percent 来判断是否需要重建或重组索引。

SQL Server 数据组织层次:页 → 区 → 堆表 / 聚集索引 数据页(8 KB) 页头 Page Header(96 字节) 行数据区(从页头后向后增长) 行1 | 行2 | 行3 | … 行N 行偏移数组(从页尾向前增长,每槽 2 字节) 区(Extent = 8 个连续页 = 64 KB) 统一区:整区属于同一对象 混合区:8 页可分给最多 8 个对象 IAM 页记录对象占用了哪些区 堆表(Heap) 无聚集索引,行不按任何键排序 数据页 P1 数据页 P5 数据页 P9 非聚集索引叶级定位符 = RID RID = 文件号 : 页号 : 槽号 查找路径:索引页 → RID → 回表读数据页 代价:额外一次随机 I/O 聚集索引(Clustered Index) 叶级即数据,按聚集键物理有序 根页(Root) 中间层(Intermediate) 叶级 = 完整行数据(有序) 定位单行:根 → 中间 → 叶,约 2~3 次页读

五、从物理结构看查询代价

把上面的结构串起来,就能解释日常优化中的几个典型现象。第一,逻辑读不等于返回行数。一次范围扫描即使只返回 10 行,如果这 10 行分散在 10 个不同的页上,逻辑读就是 10 次页访问。第二,聚集键决定范围扫描效率。按聚集键过滤时,页是连续的,预读可以一次拉入多个页;按非聚集键过滤时,若选择性不高,优化器可能放弃索引直接扫描聚集索引。第三,堆表的回表是随机 I/O,当返回行数较多时,优化器往往选择全表扫描而非“索引查找 + 回表”。

实际判断时,可以用下面的方式观察物理结构对查询的影响:

-- 查看表的聚集索引与堆表类型
SELECT OBJECT_NAME(object_id) AS 表名,
       type_desc AS 结构类型,
       index_id, name
FROM sys.indexes
WHERE object_id = OBJECT_ID('dbo.Orders');

-- 查看页数与碎片情况
SELECT index_id, index_type_desc,
       page_count,
       avg_page_space_used_in_percent AS 页填充率,
       avg_fragmentation_in_percent AS 碎片率
FROM sys.dm_db_index_physical_stats(
       DB_ID(), OBJECT_ID('dbo.Orders'), NULL, NULL, 'SAMPLED');

如果 type_desc 返回 HEAP,说明该表没有聚集索引;返回 CLUSTERED 则说明存在聚集索引。碎片率超过 30% 通常考虑重建,10%~30% 之间可考虑重组。页填充率过低意味着页内空间浪费,扫描同样行数需要读更多页。

聚集索引的叶级就是数据页,因此“表的大小”本质上等于聚集索引叶级的页数。估算表大小时,可用“行数 × 平均行长 ÷ 8096”粗略得到页数,再乘以 8 KB 得到数据量。这个估算在容量规划和索引取舍时非常实用。
不要把“有索引”等同于“查询快”。堆表上的非聚集索引需要 RID 回表,聚集索引上的非聚集索引需要聚集键回表,两者都是额外 I/O。真正决定性能的是:访问路径需要读多少页、这些页是否连续、以及是否命中缓存。

掌握页、区、堆表和聚集索引的物理结构后,后续讨论非聚集索引、覆盖索引、包含列和键列顺序时,就能从“引擎实际要读哪些页”的角度去判断设计是否合理,而不是停留在语法层面。