聚集索引设计注意事项
每次更改聚集索引键的值时,都会发生两件事。第一件事是SQL Server将行移动到聚集索引页链和数据文件中的其他位置。第二件事是它会更新行ID,即聚集索引键。行ID已经存储,并且需要在所有非聚集索引中更新。这在I/O方面可能会很昂贵,特别是在批量更新的情况下。此外,它还可以增加聚集索引的碎片,在行ID大小增加的情况下,还可以增加非聚集索引的碎片。因此,最好有一个键值不会改变的静态聚集索引。
所有非聚集索引都使用聚集索引键作为行ID。太宽的聚集索引键会增加非聚集索引行的大小,而且这需要更多的空间来存储它们。因此,在索引或范围扫描操作期间,SQL Server需要处理更多的数据页,这会降低索引的效率。
对于非唯一非聚集索引,行ID也存储在非叶索引级别,这反过来减少了每页索引记录的数量,并可能导致索引中出现额外的中间级别。尽管非叶索引级别通常缓存在内存中,但每次SQL Server遍历非聚集索引B-Tree时,都会引入额外的逻辑读取。
最后,较大的非聚集索引在缓冲池中使用更多的空间,并在索引维护期间引入更多的开销。显然,不可能提供一个通用阈值来定义可以应用于任何表的键的最大可接受大小。然而,作为一般规则,最好使用窄聚集索引键,并且索引键尽可能小。
将聚集索引定义为唯一索引也是有益的。原因很重要但是并不明显。考虑有这样一种情况:表没有唯一的聚集索引,并且您希望运行一个在执行计划中使用非聚集索引查找的查询。在这种情况下,如果非聚集索引中的行ID不唯一,SQL Server将不知道在键查找操作期间要选择什么聚集索引行。
SQL Server通过将另一个名为uniquifier的可以为空的整数列添加到非唯一的聚集索引中来解决这些问题。SQL Server会在第一次出现键值时用空值填充uniquifiers,并自动为插入到表中的每个后续重复项增加它。
注意:每个聚集索引键值可能重复的数目受整型域值的限制。具有相同聚集索引键的行不能超过2147483648行。这是一个理论上的限制,显然创建选择性如此差的索引是一个坏主意。
让我们看看uniquifiers在非唯一聚集索引中引入的开销。清单7-1中所示的代码创建了三个具有相同结构的不同表,并用65536行填充它们。表dbo.UniqueCI是唯一定义了唯一聚集索引的表。表 dbo.NonUniqueCINoDups没有任何重复的键值。最后,表dbo.NonUniqueCDups在索引中有大量重复项。
列表7-1。非唯一聚集索引:表创建
create table dbo.UniqueCI
(
KeyValue int not null,
ID int not null,
Data char(986) null,
VarData varchar(32) not null
constraint DEF_UniqueCI_VarData
default ‘Data‘
);
create unique clustered index IDX_UniqueCI_KeyValue
on dbo.UniqueCI(KeyValue);
create table dbo.NonUniqueCINoDups
(
KeyValue int not null,
ID int not null,
Data char(986) null,
VarData varchar(32) not null
constraint DEF_NonUniqueCINoDups_VarData
default ‘Data‘
);
create /唯一/ clustered index IDX_NonUniqueCINoDups_KeyValue
on dbo.NonUniqueCINoDups(KeyValue);
create table dbo.NonUniqueCIDups
(
KeyValue int not null,
ID int not null,
Data char(986) null,
VarData varchar(32) not null
constraint DEF_NonUniqueCIDups_VarData
default ‘Data‘
);
create /唯一/ clustered index IDX_NonUniqueCIDups_KeyValue
on dbo.NonUniqueCIDups(KeyValue);
– 填充数据
;with N1© as (select 0 union all select 0) – 2 rows
,N2© as (select 0 from N1 as T1 cross join N1 as T2) – 4 rows
,N3© as (select 0 from N2 as T1 cross join N2 as T2) – 16 rows
,N4© as (select 0 from N3 as T1 cross join N3 as T2) – 256 rows
,N5© as (select 0 from N4 as T1 cross join N4 as T2) – 65,536 rows
,IDs(ID) as (select row_number() over (order by (select null)) from N5)
insert into dbo.UniqueCI(KeyValue, ID)
select ID, ID from IDs;
insert into dbo.NonUniqueCINoDups(KeyValue, ID)
select KeyValue, ID from dbo.UniqueCI;
insert into dbo.NonUniqueCIDups(KeyValue, ID)
select KeyValue % 10, ID from dbo.UniqueCI;
现在,让我们看看每个表的聚集索引的物理统计信息。列表7-2中显示了这个代码,结果如图7-1所示。
列表7-2。非唯一聚集索引:检查聚集索引的行大小
select index_level, page_count, min_record_size_in_bytes as [min row size]
,max_record_size_in_bytes as [max row size]
,avg_record_size_in_bytes as [avg row size]
from?sys.dm_db_index_physical_stats(db_id(), object_id(N‘dbo.UniqueCI‘), 1, null ,‘DETAILED‘);
select index_level, page_count, min_record_size_in_bytes as [min row size]
,max_record_size_in_bytes as [max row size]
,avg_record_size_in_bytes as [avg row size]
from
sys.dm_db_index_physical_stats(db_id(), object_id(N‘dbo.NonUniqueCINoDups‘), 1, null
,‘DETAILED‘);
select index_level, page_count, min_record_size_in_bytes as [min row size]
,max_record_size_in_bytes as [max row size]
,avg_record_size_in_bytes as [avg row size]
from
sys.dm_db_index_physical_stats(db_id(), object_id(N‘dbo.NonUniqueCIDups‘), 1, null
,‘DETAILED‘);
7 - 1所示。非唯一聚集索引:聚集索引的行大小
即使表dbo.NonUniqueCINoDups中没有重复的键值,表中仍然有两个额外的字节添加到行中。SQL Server在数据的可变长度部分存储一个uniquifier,这两个字节由可变长度数据偏移数组中的另一个条目添加。
在这种情况下,当集群索引具有重复值时,uniquifiers将再添加4个字节,这将导致总共6个字节的开销。
值得一提的是,在某些边缘情况下,uniquifier使用的额外存储空间可以减少数据页上可以容纳的行数。我们的示例演示了这种情况。如你所见,表dbo.UniqueCI与其他两个表相比,UniqueCI使用的数据页少了大约15%。
现在,让我们看看uniquifier如何影响非集群索引。清单7-3所示的代码在所有三个表中创建了非集群索引。图7-2显示了这些索引的物理统计信息。
清单7。非唯一聚集索引:检查非聚集索引的行大小
createnonclustered index IDX_UniqueCI_ID
ondbo.UniqueCI(ID);
createnonclustered index IDX_NonUniqueCINoDups_ID
ondbo.NonUniqueCINoDups(ID);
createnonclustered index IDX_NonUniqueCIDups_ID
ondbo.NonUniqueCIDups(ID);
selectindex_level, page_count, min_record_size_in_bytes as [min row size]
,max_record_size_in_bytes as [max row size]
,avg_record_size_in_bytes as [avg row size]
from
sys. dm_db_index_physical_stats(db_id(), object_id(N’dbo.UniqueCI’), 2, null ,‘DETAILED’);
selectindex_level, page_count, min_record_size_in_bytes as [min row size]
,max_record_size_in_bytes as [max row size]
,avg_record_size_in_bytes as [avg row size]
from
sys. dm_db_index_physical_stats(db_id(), object_id(N’dbo.NonUniqueCINoDups’), 2, null ,‘DETAILED’);
selectindex_level, page_count, min_record_size_in_bytes as [min row size]
,max_record_size_in_bytes as [max row size]
,avg_record_size_in_bytes as [avg row size]
from
sys. dm_db_index_physical_stats(db_id(), object_id(N’dbo.NonUniqueCIDups’), 2, null ,‘DETAILED’);
图7-2 不唯一聚集索引:非聚集索引大小
dbo.NonUniqueCINoDups表中的非聚集索引没有开销。可能还记得,SQL Server不会将偏移量信息存储在可变长度偏移数组中,以便跟踪列存储空数据。 尽管如此,标识符介绍八个字节在dbo.NonUniqueCIDups表中的开销。 这八个字节包括一个四字节的标识符值,两个字节的可变长度数据的偏移阵列条目,和两个字节的条目中存储的行中的可变长度的列数的。
我们可以总结的标识符的存储开销以下列方式。 对于具有标识符为空的行,如果索引至少有一个存储不为空值的可变长度列,则会产生两字节开销。该开销来自标识符列的可变长度偏移数组条目。 否则没有开销。在填充标识符的情况下,如果存在存储不为空的值的可变长度列,则开销为六个字节。否则,开销是8个字节
注意:如果期望集群索引值中有大量重复,可以添加整数
标识列作为索引的最右列,从而使其惟一。这为每一行增加了4字节的可预测存储开销,而uniquifier引入了最多8字节的不可预测存储开销。当您通过该行的所有聚集索引列引用该行时,这还可以提高单个查找操作的性能。
以最小化插入新行导致的索引碎片的方式设计聚集索引是有益的。实现这一点的方法之一是使聚集索引值不断增加。标识列上的索引就是这样一个例子。另一个例子是datetime列,其中填充了插入时的当前系统时间。
然而,索引不断增长存在两个潜在问题。第一个与统计有关。正如您在第3章中了解到的,当直方图中没有参数值时,SQL Server中的遗留基数估计器会低估基数。您应该将这种行为考虑到系统的统计维护策略中,除非您使用新的SQL Server 2014-2016基数估计器,该估计器假定直方图之外的数据具有与表中其他数据相似的分布。
下一个问题更复杂。随着索引的增加,数据总是插入索引的末尾。一方面,它可以防止页面分裂,减少碎片。另一方面,它可能导致热点,即当多个会话试图修改相同的数据页和/或分配新的页或区段时发生的序列化延迟。SQL Server不允许多个会话更新相同的数据结构,而是序列化这些操作。
热点通常不是问题,除非系统以非常高的速率收集数据,并且索引每秒处理数百次插入。我们将在第27章“系统故障排除”中讨论如何检测此类问题。
最后,如果系统有一组频繁执行的重要查询,那么考虑集群索引可能是有益的,它可以优化这些查询。这消除了昂贵的键查找操作,并提高了系统的性能。
尽管可以通过使用覆盖非集群索引来优化此类查询,但它并不总是理想的解决方案。在某些情况下,需要创建非常宽的非集群索引,这将占用磁盘和缓冲池中的大量存储空间。
另一个重要因素是修改列的频率。将经常修改的列添加到非集群索引需要SQL Server在多个位置更改数据,这会对系统的更新性能产生负面影响,并增加阻塞。
尽管如此,设计满足所有这些指导原则的聚集索引并不总是可能的。此外,您不应该认为这些指导方针是绝对的需求。您应该分析系统、业务需求、工作负载和查询,并选择对您有利的集群索引,即使它们违反了某些指导原则。
标识、序列和唯一标识符
人们通常选择标识符、序列和唯一标识符作为集群索引键。与往常一样,这种方法有它自己的优缺点。在这些列上定义的聚集索引是惟一的、静态的和窄的。此外,恒等式和序列不断增加,这减少了索引碎片。它们的理想用例之一是编目实体表。例如,您可以考虑表格,其中存储了客户、文章或设备的列表。这些表存储了数千甚至数百万行,尽管数据相对静态,因此热点不是问题。而且,这些表通常由外键引用,并在连接中使用。integer或bigint列上的索引非常紧凑和高效,这将提高查询的性能。
我们将在第8章“约束”中更详细地讨论外键约束。
在事务性表中,身份或序列列上的聚集索引效率较低,事务性表以非常高的速率收集大量数据,这是由于它们引入的潜在热点造成的。
另一方面,对于聚集索引和非聚集索引,唯一标识符很少是一个好的选择。使用NEWID()函数生成的随机值极大地增加了索引碎片。此外,唯一标识符上的索引会降低批处理操作的性能。让我们看一个示例并创建两个表:一个表在标识列上具有集群索引,另一个表在唯一标识符列上具有集群索引。下一步,我们将在两个表中插入65,536行。您可以在清单7-4中看到执行此操作的代码。
清单7-4 标识符:表创建
(
ID int not null identity(1,1),
Val int not null,
Placeholder char(100) null
);
create unique clustered index IDX_IdentityCI_ID
ondbo.IdentityCI(ID);
create table dbo.UniqueidentifierCI
(
ID uniqueidentifier not null
constraintDEF_UniqueidentifierCI_ID
defaultnewid(),
Val int not null,
Placeholder char(100) null,
);
create unique clustered index IDX_UniqueidentifierCI_ID
ondbo.UniqueidentifierCI(ID)
go
;with N1© as (select 0 union all select 0) – 2 rows
,N2© as (select 0 from N1 as T1 cross join N1 as T2) – 4 rows
,N3© as (select 0 from N2 as T1 cross join N2 as T2) – 16 rows
,N4© as (select 0 from N3 as T1 cross join N3 as T2) – 256 rows
,N5© as (select 0 from N4 as T1 cross join N4 as T2) – 65,536 rows
,IDs(ID) as (select row_number() over (order by (select null)) from N5)
insert into dbo.IdentityCI(Val)
select ID from IDs;
;with N1© as (select 0 union all select 0) – 2 rows
,N2© as (select 0 from N1 as T1 cross join N1 as T2) – 4 rows
,N3© as (select 0 from N2 as T1 cross join N2 as T2) – 16 rows
,N4© as (select 0 from N3 as T1 cross join N3 as T2) – 256 rows
,N5© as (select 0 from N4 as T1 cross join N4 as T2) – 65,536 rows
,IDs(ID) as (select row_number() over (order by (select null)) from N5)
insert into dbo.UniqueidentifierCI(Val)
select ID from IDs;
我的计算机上的执行时间和读取次数如表7-1所示。 图7-3显示了两个查询的执行计划。
表7-1 将数据插入表:执行统计
表7 - 3。将数据插入表中:执行统计信息
如您所见,在唯一标识符列上的索引的情况下,还有另一个排序操作符。SQL Server在插入之前对随机生成的唯一标识符值进行排序,这会降低查询的性能。让我们向表中插入另一批行并检查索引碎片。执行此操作的代码如清单7-5所示。图7-4显示了查询的结果。
清单7 - 5。惟一标识符:插入行并检查碎片
;with N1© as (select 0 union all select 0) – 2 rows
,N2© as (select 0 from N1 as T1 cross join N1 as T2) – 4 rows
,N3© as (select 0 from N2 as T1 cross join N2 as T2) – 16 rows
,N4© as (select 0 from N3 as T1 cross join N3 as T2) – 256 rows
,N5© as (select 0 from N4 as T1 cross join N4 as T2) – 65,536 rows
,IDs(ID) as (select row_number() over (order by (select null)) from N5)
insert into dbo.IdentityCI(Val)
select ID from IDs;
;with N1© as (select 0 union all select 0) – 2 rows
,N2© as (select 0 from N1 as T1 cross join N1 as T2) – 4 rows
,N3© as (select 0 from N2 as T1 cross join N2 as T2) – 16 rows
,N4© as (select 0 from N3 as T1 cross join N3 as T2) – 256 rows
,N5© as (select 0 from N4 as T1 cross join N4 as T2) – 65,536 rows
,IDs(ID) as (select row_number() over (order by (select null)) from N5)
insert into dbo.UniqueidentifierCI(Val)
select ID from IDs;
selectpage_count, avg_page_space_used_in_percent, avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id(),object_id(N’dbo.IdentityCI’),1,null,‘DETAILED’);
selectpage_count, avg_page_space_used_in_percent, avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id(),object_id(N’dbo.UniqueidentifierCI’),1,null ,‘DETAILED’);
图7-4。 索引碎片
正如您所看到的,uniqueidentifier列上的索引非常分散,与identity列上的索引相比,它使用的数据页多了大约40%。在uniqueidentifier列上的索引中进行批量插入,会在数据文件的不同位置插入数据,对于大型表,这会导致大量随机物理I/O。这样会大大降低手术的效果。
个人经验
不久前,我参与了一个系统的优化,该系统有一个250gb的表,其中有一个集群索引和三个非集群索引。其中一个非集群索引是uniqueidentifier列上的索引。通过删除这个索引,我们能够将50,000行的批插入从45秒提高到7秒。
当您希望在uniqueidentifier列上创建索引时,有两个常见的用例。第一个是支持跨多个数据库的值的唯一性。考虑一个分布式系统,其中行可以插入到每个数据库中。开发人员经常使用uniqueidentifier来确保每个键值在系统范围内是唯一的。
这种实现中的关键元素是如何生成键值。正如您已经看到的,NEWID()函数或客户机代码中生成的随机值会对系统性能产生负面影响。但是,您可以使用NEWSEQUENTIALID()函数,该函数生成惟一且通常不断增长的值(SQL Server不时重置它们的基值)。使用NEWSEQUENTIALID()函数生成的uniqueidentifier列上的索引类似于标识列和序列列上的索引;但是,您应该记住,与4字节的int或8字节的bigint数据类型相比,uniqueidentifier数据类型使用16字节的存储空间。
作为一种替代解决方案,您可以考虑创建一个包含两列的复合索引
(InstallationIdUnique_Id_Within_Installation)。这两列的组合保证了跨多个安装和数据库的唯一性,并且比惟一标识符使用更少的存储空间。您可以使用整数标识或序列来生成Unique_Id_Within_Installation值,这将减少索引的碎片。
在需要跨数据库中所有实体生成惟一键值的情况下,可以考虑跨所有实体使用单个sequence对象。这种方法满足了需求,但是使用了比惟一标识符更小的数据类型
另一个常见的用例是安全性,惟一标识符值用作安全令牌或随机对象ID。不幸的是,在此场景中不能使用NEWSEQUENTIALID()函数,因为可以猜测该函数返回的下一个值。
在这个场景中,一个可能的改进是使用CHECKSUM()函数创建一个计算列,然后索引它,而不需要在uniqueidentifier列上创建索引。代码如清单7-6所示。
清单7-6。 使用CHECKSUM():表结构
create table dbo.Articles
(
ArticleIdint not null identity(1,1),
ExternalIduniqueidentifier not null
constraintDEF_Articles_ExternalId
defaultnewid(),
ExternalIdCheckSum as checksum(ExternalId),
/* Other Columns */
);
create unique clustered index IDX_Articles_ArticleId
ondbo.Articles(ArticleId);
createnonclustered index IDX_Articles_ExternalIdCheckSum
ondbo.Articles(ExternalIdCheckSum);
提示:可以索引计算列而不保留它。
尽管IDX Articles ExternalIdCheckSum索引将会严重碎片化,但与uniqueidentifier列上的索引(4字节密钥与16字节)相比,它将更加紧凑。 它还提高了批处理操作的性能,因为更快的排序,这也需要更少的内存来进行。
您必须记住的一件事是CHECKSUM()函数的结果不保证是唯一的。 您应该在查询中包含两个谓词,如清单7-7所示。
清单7-7。 使用CHECKSUM():选择数据
selectArticleId /* Other Columns */
fromdbo.Articles
where checksum(@ExternalId) = ExternalIdCheckSum and ExternalId = @ExternalId
注意:在需要索引大于900 / 1,700字节的字符串列的情况下,可以使用相同的技术,这是非聚簇索引键的最大大小。 即使这样的索引不支持范围扫描操作,它也可以用于点查找。
非聚集索引设计注意事项
当单非聚簇索引查找和键查找操作时,很难找到连接多个非聚簇索引比使用更有效的转折点。当索引选择性很高并且SQL Server估计索引查找操作将返回少量行时,键查找成本将相对较低。 在这种情况下,没有理由使用另一个非聚集索引。或者,当索引选择性较低时,索引查找会返回大量行,而SQL Server通常不会使用它,因为效率不高。
让我们看一个示例,我们将创建一个表并用1,048,576行填充它。 Col1在列中存储50个不同的值,Col2存储150个值,Col3存储200个值。 最后,我们将在表上创建三个不同的非聚簇索引。 执行此操作的代码如清单7-8所示。
清单7-8.多个非聚簇索引:表创建
create table dbo.IndexIntersection
(
Id int not null,
Placeholder char(100),
Col1 int not null,
Col2 int not null,
Col3 int not null
);
ondbo.IndexIntersection(ID);
;with N1© as (select 0 union all select 0) – 2 rows
,N2© as (select 0 from N1 as T1 cross join N1 as T2) – 4 rows
,N3© as (select 0 from N2 as T1 cross join N2 as T2) – 16 rows
,N4© as (select 0 from N3 as T1 cross join N3 as T2) – 256 rows
,N5© as (select 0 from N4 as T1 cross join N4 as T2) – 65,536 rows
,N6© as (select 0 from N3 as T1 cross join N5 as T2) – 1,048,576 rows
,IDs(ID) as (select row_number() over (order by (select null)) from N6)
insert into dbo.IndexIntersection(ID, Col1, Col2, Col3)
select ID, ID % 50, ID % 150, ID % 200 from IDs;
createnonclustered index IDX_IndexIntersection_Col1
ondbo.IndexIntersection(Col1);
createnonclustered index IDX_IndexIntersection_Col2
ondbo.IndexIntersection(Col2);
createnonclustered index IDX_IndexIntersection_Col3
ondbo.IndexIntersection(Col3);