聚集索引即基于数据行的键值在表内排序和存储这些数据行。每个表只能有一个聚集索引,因为数据行本身只能按一个顺序存储。
从某种程度上,聚集索引即数据,这句话是有道理的;但正如同其他索引一样,聚集索引也是按 B 树结构进行组织的。既然是 B 树组织,那么就有叶子结点和非叶子节点之分。聚集索引 B 树的顶端节点称为根节点;聚集索引中的底层节点称为叶节点。在根节点与叶节点之间的任何索引级别统称为中间级。在聚集索引中,叶节点包含基础表的数据页。根节点和中间级节点包含存有索引行的索引页。每个索引行包含一个键值和一个指针,该指针指向 B 树上的某一中间级页或叶级索引中的某个数据行。每级索引中的页均被链接在双向链接列表中。
因此可以这么说,聚集索引的叶子结点存储的是按聚集索引顺序排列的数据本身,而中间结点和根节点则在维护索引和其层级。
对于某个聚集索引, sys.system_internals_allocation_units 中的 root_page 列指向该聚集索引某个特定分区的顶部。 SQL Server 将从索引中向下移动以查找与某个聚集索引键对应的行。为了查找键的范围, SQL Server 将在索引中移动以查找该范围的起始键值,然后用向前或向后指针在数据页中进行扫描。为了查找数据页链的首页, SQL Server 将从索引的根节点沿最左边的指针进行扫描。
drop table testUniqueCluster drop table testNonUniqueCluster CREATE TABLE testUniqueCluster ( name CHAR ( 900), remark CHAR ( 1100) ) CREATE UNIQUE CLUSTERED INDEX ix_testUniqueCluster ON testUniqueCluster ( name ) INSERT INTO testUniqueCluster VALUES ( 'B' , 'BBB1' ) INSERT INTO testUniqueCluster VALUES ( 'A' , 'AAA1' )
CREATE TABLE testNonUniqueCluster ( name CHAR ( 900), remark CHAR ( 1100) ) CREATE CLUSTERED INDEX ix_testNonUniqueCluster ON testNonUniqueCluster ( name )
INSERT INTO testNonUniqueCluster VALUES ( 'B' , 'BBB2' ) INSERT INTO testNonUniqueCluster VALUES ( 'B' , 'BBB1' ) INSERT INTO testNonUniqueCluster VALUES ( 'A' , 'AAA1' )
SELECT c . name , a . type_desc, total_pages , used_pages , data_pages , testdb . dbo . f_get_page ( first_page ) first_page_address , testdb . dbo . f_get_page ( root_page ) root_address , testdb . dbo . f_get_page ( first_iam_page ) IAM_address FROM sys . system_internals_allocation_units a , sys . partitions b , sys . objects c WHERE a . container_id = b . partition_id and b . object_id = c . object_id AND c . name in ( 'testUniqueCluster' , 'testNonUniqueCluster' )
TRUNCATE TABLE tablepage ; INSERT INTO tablepage EXEC ( 'DBCC IND(testdb,testUniqueCluster,1)' ); INSERT INTO tablepage EXEC ( 'DBCC IND(testdb,testNonUniqueCluster,1)' ); SELECT b . name table_name , CASE WHEN c . type = 0 THEN ' 堆 ' WHEN c . type = 1 THEN ' 聚集 ' WHEN c . type = 2 THEN ' 非聚集 ' ELSE ' 其他 ' END index_type , c . name index_name , PagePID , IAMPID , ObjectID , IndexID , Pagetype , IndexLevel , NextPagePID , PrevPagePID FROM tablepage a , sys . objects b , sys . indexes c WHERE A . ObjectID = b . object_id AND A . ObjectID = c . object_id AND a . IndexID = c . index_id |
Name |
Type_desc |
Used_pages |
Data_pages |
First_page_address |
Root_address |
IAM_Address |
testUniqueCluster |
IN_ROW_DATA |
2 |
1 |
1:233 |
1:233 |
1:234 |
testNonUniqueCluster |
IN_ROW_DATA |
2 |
1 |
1:235 |
1:235 |
1:236 |
下面我们用 dbcc 命令介绍一下聚集索引的构造。
DBCC TRACEON ( 3604) DBCC PAGE ( testDB , 1, 233, 1) m_type = 1 5E3BC060: 1000d407 42202020 20202020 20202020 ?....B .... 5E3BC3E0: 20202020 20202020 42424231 20202020 ? BBB1 ... 5E3BC830: 20202020 0200fc 10 00d407 41 20202020 ? .......A ... 5E3BCBB0: 20202020 20202020 20202020 20202041 ? A 5E3BCBC0: 41413120 20202020 20202020 20202020 ?AA1 ... 5E3BD000: 20202020 20202020 20202002 00fc 0000 ? .....
OFFSET TABLE: Row - Offset 1 (0x1) - 96 (0x60) 0 (0x0) - 2103 (0x837)
DBCC PAGE ( testDB , 1, 235, 1) 5E3BC060: 1000d407 42202020 20202020 20202020 ?....B ... 5E3BC3E0: 20202020 20202020 42424232 20202020 ? BBB2 ... 5E3BC830: 20202020 0300f 8 30 00d407 42 20202020 ? ...0...B ... 5E3BCBB0: 20202020 20202020 20202020 20202042 ? B 5E3BCBC0: 42423120 20202020 20202020 20202020 ?BB1 ... 5E3BD000: 20202020 20202020 20202003 00f80100 ? ..... 5E3BD010: df070100 0000 1000 d407 4120 20202020 ?..........A ... 5E3BD390: 20202020 20202020 20202020 20204141 ? AA 5E3BD3A0: 41312020 20202020 20202020 20202020 ?A1 ... 5E3BD7E0: 20202020 20202020 20200300 f 8 000021 ? .....!
OFFSET TABLE: Row - Offset 2 (0x2) - 2103 (0x837) 1 (0x1) - 96 (0x60) 0 (0x0) - 4118 (0x1016) |
其中红颜色的部分为每行的行头部分,蓝颜色部分为每行的结尾部分。
大家可以看到 m_type=1 即数据页面,大家应该很奇怪吧,为什么明明是聚集索引,却是数据页面呢?正如上面所提到,聚集索引的叶子页面即数据页面。因为这个表只有 2~3 条记录,所以 root 页面还达不到需要分为 B 树的程度,所以该 root 页面也是叶子页面。
我们首先来看一下 1000d407 的行头部如何解释
第 0 位 |
第1-3 位 |
第4 位 |
第5 位 |
第6-7 位 |
1 个字节 |
2 个字节 |
0 |
000 |
1 |
0 |
00 |
00 |
d407 |
10 |
00 |
2004 |
||||
始终为 0 |
0 表示主记录 |
存在NULL 位图 |
存在变长字段 |
保留 |
状态B 保留 |
字段长度 |
即该行为不存在变长字段的主记录,且字段长度为 2004 个字节。
那 30 00d407 该如何解释呢?即 00001100 即存在变长字段的主记录,我们的 testNonUniqueCluster 怎么会存在变长字段呢?
在该非唯一聚集索引表中,我们首先插入记录 B 、 BBB2 记录,再插入 B 、 BBB1 记录,这个时候对于非唯一索引如何去识别呢? SQL Server 在重复行的行尾增加了 8 个额外的字节,稍后我们再分析行尾。
在 testUniqueCluster 表中正常的行尾为 0200fc ,其解释如下 0200 表示该表有 2 个字段, fc 则为 1111 1100 ,即前 2 个字段不为空。
而对于 testNonUniqueCluster 表正常的行尾应为 0300 f8 ,其解释如下 0300 表示该表有 3 个字段, f8 则为 1111 1000 ,即前 3 个字段不为空;很显然 SQL Server 把非唯一索引的标识符也当做字段了;但的的确确因为 B 、 BBB2 和 A 、 AAA1 在插入的时候是唯一的,所以不需要这个字段。
我们接下来看看 B 、 BBB1 行的尾部 03 00f8 0100 df070100 0000 , 0300f 8 解释同上, 0100 即 1 表示该表一共有 1 个变长字段, df07 即 2015 变长字段结束的位置,最后四个字节 0100 0000 为非唯一索引的标识符,换算成 10 进制即 1 。
从页面中记录的顺序我们其实可以看得出来,聚集索引的行的物理顺序与行的实际存储没有太大关系,而是与记录槽的顺序的有关。
既然我们再谈论聚集索引,那就不能不说聚集索引的中间节点和根节点了,
为了简化处理,我们使用 testUniqueCluster 来做进一步的研究。
该表包含 2 个定长字段,合计 2000 字节,加上相应的头部的 4 个管理字节和尾部的 3 个管理字节,共计 2007 个字节,页头还需要 96 个字节,每行的偏移量需要 2 个字节,所以单页 8192 字节只能容纳大概 4 条记录。也就是说当我们完成第五条记录时就应该产生分页现象了。
INSERT INTO testUniqueCluster VALUES ( 'C' , 'CCC1' ) INSERT INTO testUniqueCluster VALUES ( 'D' , 'DDD1' ) INSERT INTO testUniqueCluster VALUES ( 'E' , 'EEE1' ) TRUNCATE TABLE tablepage ; INSERT INTO tablepage EXEC ( 'DBCC IND(testdb,testUniqueCluster,1)' );
SELECT b . name table_name , CASE WHEN c . type = 0 THEN ' 堆 ' WHEN c . type = 1 THEN ' 聚集 ' WHEN c . type = 2 THEN ' 非聚集 ' ELSE ' 其他 ' END index_type , c . name index_name , PagePID , IAMPID , ObjectID , IndexID , Pagetype , IndexLevel , NextPagePID , PrevPagePID FROM tablepage a , sys . objects b , sys . indexes c WHERE A . ObjectID = b . object_id AND A . ObjectID = c . object_id AND a . IndexID = c . index_id |
以下为该表的详细页面分布
index_name |
PagePID |
IAMPID |
IndexID |
Pagetype |
IndexLevel |
NextPagePID |
PrevPagePID |
… |
234 |
NULL |
1 |
10 |
NULL |
0 |
0 |
… |
233 |
234 |
1 |
1 |
0 |
248 |
0 |
… |
239 |
234 |
1 |
2 |
1 |
0 |
0 |
… |
248 |
234 |
1 |
1 |
0 |
0 |
233 |
我们再用 sys.system_internals_allocation_units 来看一下该表的页面概要信息。
name |
total_pages |
used_pages |
data_pages |
first_address |
root_address |
IAM_address |
testUniqueCluster |
4 |
4 |
2 |
1:233 |
1:239 |
1:234 |
从以上两个表格,我们可以看出 IAM 页面未发生变化,仍旧是第 234 页面。
根节点页面发生了变化,现在是第 239 页面, pagetype=2 ,即索引页面,新增加了一个数据页面第 248 页面,第 233 页面仍继续存在;同时在第 248 和 233 个页面之间存在着互链的关系。
同时观察一下数据,发现在第 233 页中存在 A 、 AAA1 ; B 、 BBB1 ; C 、 CCC1 ; D 、 DDD1 等 4 条记录,而第 248 页中则存在 E 、 EEE1 记录,也就是说对于 SQL Server 来说索引的分裂应该是以最小代价进行,而不是完全均衡策略。
再让我们用 DBCC PAGE(1,testDB,239,3) 观察一下根节点的内容。
FileId |
PageId |
Row |
Level |
ChildFileId |
ChildPageId |
name (key) |
KeyHashValue |
1 |
239 |
0 |
1 |
1 |
233 |
NULL |
(6f4251ce1f81) |
1 |
239 |
1 |
1 |
1 |
248 |
E |
(201c8aeace10) |
因为这是个索引的非叶子节点,所以连表现形式都简化了。
FieldId 为当前页面的文件 ID
PageId 为当前页面的页面 ID
Row 表示为当前的 slot 槽
Level 为 1 表示为当前为非叶子节点
ChildFieldId 表示为插槽号指向的页面的文件 ID
ChildPageId 表示为插槽号指向的页面的页面 ID
Name 表示为当前索引的键值
KeyHashValue 为 SQL Server 键值的内部表示的 hash 值。
即 E 右侧的数据指向第 248 页面,而左侧的则指向第 233 页面。
那么再让我们插入 4 条记录看看根页面的变化。
INSERT INTO testUniqueCluster VALUES ( 'C' , 'CCC1' ) INSERT INTO testUniqueCluster VALUES ( 'D' , 'DDD1' ) INSERT INTO testUniqueCluster VALUES ( 'E' , 'EEE1' ) DBCC PAGE(1,testDB,239,3) |
FileId |
PageId |
Row |
Level |
ChildFileId |
ChildPageId |
name (key) |
KeyHashValue |
1 |
239 |
0 |
1 |
1 |
233 |
NULL |
(6f4251ce1f81) |
1 |
239 |
1 |
1 |
1 |
248 |
E |
(201c8aeace10) |
1 |
239 |
2 |
1 |
1 |
249 |
I |
(201cbd800c11) |
现在我们可以看到在根节点上又增加了一个新的键值 I ,凡是大于等于 I 的记录均指向第 249 页;结合前面的描述,我们可以得到下面的索引结构变化示意图。