《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

时间:2023-03-09 09:02:34
《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

《Pro SQL Server Internals》

《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

作者: Dmitri Korotkevitch 
出版社: Apress
出版年: 2016-12-29
页数: 804
定价: USD 59.99
装帧: Paperback
ISBN: 9781484219638

索引的设计与调整

索引不可能在任何地方都适用。每个系统都是独特的,并且需要基于工作负载、业务需求和许多其他因素的自己的索引方法。然而,有几个设计考虑和指导方针可以应用在每个系统中。

当我们优化现有系统时也是如此。虽然优化是一个迭代过程,在每种情况下都是唯一的,但是有一组技术可用于检测每个数据库系统中的低效率。

在本章中,我们将介绍在设计新索引和优化现有系统时需要牢记的几个重要因素。

聚集索引设计考虑

每次更改聚集索引键的值时,都会发生两件事。首先,SQL Server将行移动到聚集索引页面链和数据文件中的不同位置。第二,它更新行ID,这是聚集索引键。行ID被存储并需要在所有非聚集索引中更新。在I/O方面,这可能是昂贵的,特别是在批量更新的情况下。此外,它可以增加聚集索引的碎片,并且在行id大小增加的情况下,增加非聚集索引的碎片。因此,最好有一个静态的聚集索引,其中键值不改变。

所有非聚集索引都使用聚集索引键作为行id。过宽的聚集索引键会增加非聚集索引行的大小,并且需要更多的空间来存储它们。因此,在索引或范围扫描操作期间,SQL Server需要处理更多的数据页,这使得索引的效率较低。

在非唯一非聚集索引的情况下,row-id还存储在非叶索引级别,这反过来又减少了每页的索引记录数量,并可能导致索引中额外的中间级别。尽管非叶索引级别通常缓存在内存中,但是每次SQL Server遍历非聚集索引B-Tree时,都会引入额外的逻辑读取。

最后,较大的非聚集索引在缓冲池中使用了更多的空间,并且在索引维护期间引入了更多的开销。显然,不可能提供定义可应用于任何表的键的最大可接受大小的通用阈值。然而,作为一般规则,最好使用窄的聚类索引键,索引键尽可能小。

将聚集索引定义为唯一的也是有益的。这很重要的原因并不明显。考虑这样一个场景,其中表没有唯一的聚集索引,并且您希望运行在执行计划中使用非聚集索引查找的查询。在这种情况下,如果非聚集索引中的row-id不是唯一的,SQL Server将不知道在键查找操作期间要选择哪个聚集索引行。

SQL Server通过向非唯一聚集索引添加另一个称为唯一性的可空整数列来解决这些问题。对于键值的第一次出现,SQL Server使用NULL填充非平衡符,对于插入到表中的每个后续副本自动递增它。

注释每个聚类索引键值可能的重复数受整数域值的限制。使用相同的聚集索引键不能超过2147483648行。这是一个理论上的极限,并且创建具有如此差的选择性的指标显然是个坏主意。

让我们来看看在非唯一聚集索引中由唯一符引入的开销。清单7-1所示的代码创建了三个具有相同结构的不同表,并且分别用65536行填充它们。表DBO.UNIQECI是唯一定义了唯一聚集索引的表。表dBo.NoNoviCeNoDUP不具有任何重复的键值。最后,表dBo.NoNuxCudiDUPS在索引中有大量重复。

列表7-1非唯一聚集索引:表创建

create table dbo.UniqueCI

(

    KeyValue int not null,

    ID int not null,

    Data char() null,

    VarData varchar() 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() null,

    VarData varchar() 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() null,

    VarData varchar() not null

        constraint DEF_NonUniqueCIDups_VarData

        default 'Data'

); 

create /*唯一*/ clustered index IDX_NonUniqueCIDups_KeyValue

on dbo.NonUniqueCIDups(KeyValue);

-- 填充数据

;with N1(C) as (select  union all select ) --  rows

,N2(C) as (select  from N1 as T1 cross join N1 as T2) --  rows

,N3(C) as (select  from N2 as T1 cross join N2 as T2) --  rows

,N4(C) as (select  from N3 as T1 cross join N3 as T2) --  rows

,N5(C) as (select  from N4 as T1 cross join N4 as T2) -- , 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 % , 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'), , 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'), , 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'), , null

        ,'DETAILED');

《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

7-1。非唯一聚集索引:聚集索引行大小

尽管dbo.NonUniqueCINoDups表中没有重复的键值,但是该行中仍然添加了两个额外的字节。SQL Server在数据的可变长度部分中存储了一个非平衡符,这两个字节通过可变长度数据偏移数组中的另一个条目添加。

在这种情况下,当聚集索引具有重复的值时,非均衡器又添加四个字节,这导致总共6字节的开销。

值得一提的是,在某些边缘情况下,非平衡器使用的额外存储空间可以减少可以放入数据页的行数。我们的例子说明了这种情况。正如您所见,DBO.UNIQECI使用的数据页比其他两个表少大约15%个。

现在,让我们看看UNIQUE是如何影响非聚集索引的。清单7- 3所示的代码在所有三个表中创建非聚集索引。图7-2显示了这些索引的物理统计。

列表7:3非唯一聚集索引:检查非聚集索引的行大小

create nonclustered index IDX_UniqueCI_ID 

on dbo.UniqueCI(ID);

create nonclustered index IDX_NonUniqueCINoDups_ID

on dbo.NonUniqueCINoDups(ID);

create nonclustered index IDX_NonUniqueCIDups_ID

on dbo.NonUniqueCIDups(ID);

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'), 2, 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'), 2, 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'), 2, null

        ,'DETAILED');

《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

7-2。非唯一聚集索引:非聚集索引的行大小

dBo.NoMeNeCuNoDUPS表中的非聚集索引没有开销。您还记得,SQL Server不会在存储NULL数据的尾随列的可变长度偏移数组中存储偏移信息。然而,唯一性在DBO.NoNuxEcIdUPS表中引入了八字节的开销。这8个字节由一个4字节的非平衡器值、一个2字节可变长度数据偏移数组条目和一个存储行中可变长度列数量的2字节条目组成。

我们可以通过以下方式总结非平衡器的存储开销。对于具有_作为NULL的未均衡器的行,如果索引具有至少一个存储非空值的可变长度列,则存在两个字节的开销。该开销来自未均衡器列的可变长度偏移数组条目。否则就没有开销。

在填充了非均衡器的情况下,如果有存储NOT NULL值的可变长度列,则开销是6字节。否则,开销是8字节。

提示如果期望聚集索引值中有大量重复,则可以将整数标识列作为最右侧的列添加到索引,从而使其唯一。这给每行增加了4字节可预测的存储开销,相比之下,非整理器引入的不可预测高达8字节的存储开销。当通过行的所有聚集索引列引用该行时,这也可以提高单个查找操作的性能。

以尽量减少插入新行导致的索引碎片的方式设计聚集索引是有益的。实现这一点的方法之一是使聚集索引值不断增加。标识列上的索引就是一个这样的例子。另一个例子是在插入时用当前系统时间填充的日期时间列。

然而,不断增长的指标存在两个潜在的问题。第一个涉及统计。正如您在第3章中了解到的,当直方图中没有参数值时,SQL Server中的传统基数估计器会低估基数。除非您正在使用新的SQL Server 2014-2016基数估计器,否则您应该将这种行为考虑到系统的统计维护策略中,该估计器假定直方图之外的数据具有与表中其他数据的分布类似的分布。

下一个问题更复杂。随着索引的不断增加,数据总是插入到索引的末尾。一方面,它防止页面分裂并减少碎片。另一方面,它可能导致热点,即当多个会话试图修改相同的数据页和/或分配新的页或区段时发生的序列化延迟。SQL Server不允许多个会话更新相同的数据结构,而是序列化那些操作。

热点通常不是问题,除非系统以非常高的速率收集数据,并且索引每秒处理数百个插入。我们将讨论如何在第27章“系统故障排除”中检测到这样的问题。

最后,如果一个系统具有一组频繁执行的重要查询,那么考虑一个聚集索引(对它们进行优化)可能是有益的。这消除了昂贵的密钥查找操作,并提高了系统的性能。

即使这样的查询可以通过使用覆盖非聚集索引来优化,但它并不总是理想的解决方案。在某些情况下,它要求您创建非常宽的非聚集索引,这将消耗磁盘和缓冲池中的大量存储空间。

另一个重要因素是经常修改列。向非聚集索引添加频繁修改的列需要SQL Server在多个地方更改数据,这会对系统的更新性能产生负面影响并增加阻塞。

尽管如此,设计满足所有这些指导方针的聚类索引并不总是可能的。此外,你不应该认为这些准则是绝对的要求。您应该分析系统、业务需求、工作负载和查询,并选择对您有利的聚集索引,即使它们违反了一些指导原则。

恒等式、序列和单义标识符

人们通常选择身份、序列和独特的标识符作为聚集索引键。一如既往,这种方法有其自身的利弊。

在这些列上定义的聚集索引是唯一的、静态的和狭窄的。此外,身份和序列不断增加,从而降低索引碎片。其中一个最理想的用例是目录实体表。例如,可以考虑存储客户、文章或设备列表的表。这些表存储数千行,甚至可能几百万行,尽管数据是相对静态的,因此,热点不是问题。此外,这些表通常由外键引用并用于连接。整数列或大数列上的索引非常紧凑和高效,这将提高查询的性能。

注释我们将在第8章“约束”中更详细地讨论外键约束。

身份或序列列上的聚集索引在事务表的情况下效率较低,事务表以非常高的速率收集大量数据,这是由于它们引入的潜在热点。

另一方面,对于聚集索引和非聚集索引,唯一标识符很少是一个好的选择。用NeWess()函数生成的随机值大大增加索引碎片。此外,单标识符上的索引降低了批处理操作的性能。让我们看一个示例,并创建两个表:一个表在标识列上具有聚集索引,另一个表在唯一标识符列上具有聚集索引。在下一步中,我们将在两个表中插入65536行。您可以在清单4-4中看到这样做的代码。

列表7:4。唯一标识符:创建表

create table dbo.IdentityCI

(

    ID int not null identity(1,1),

    Val int not null,

    Placeholder char(100) null

); 

create unique clustered index IDX_IdentityCI_ID

on dbo.IdentityCI(ID);

create table dbo.UniqueidentifierCI

(

    ID uniqueidentifier not null

        constraint DEF_UniqueidentifierCI_ID

        default newid(),

    Val int not null,

    Placeholder char(100) null,

);

create unique clustered index IDX_UniqueidentifierCI_ID

on dbo.UniqueidentifierCI(ID)

go

;with N1(C) as (select 0 union all select 0) -- 2 rows

,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows

,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows

,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows

,N5(C) 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(C) as (select 0 union all select 0) -- 2 rows

,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4 rows

,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16 rows

,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256 rows

,N5(C) 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。在表中插入数据:执行统计

 

读数

执行时间(毫秒)

标识

158438

173毫秒

唯一标识符

181879

256毫秒

《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

73。在表中插入数据:执行计划

如您所见,在唯一标识符列上的索引的情况下,还有另一个排序操作符。SQL Server在插入之前对随机生成的唯一标识符值进行排序,这会降低查询的性能。

让我们将另一批行插入到表中,并检查索引碎片。在清单7到5中显示了这样做的代码。图7- 4显示查询的结果。

列表7-5。唯一标识符:插入行并检查碎片

;with N1(C) as (select 0 union all select 0) -- 2 行

,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4行

,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16行

,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256行

,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536行

,IDs(ID) as (select row_number() over (order by (select null)) from N5)

insert into dbo.IdentityCI(Val)

    select ID from IDs;

;with N1(C) as (select 0 union all select 0) -- 2行

,N2(C) as (select 0 from N1 as T1 cross join N1 as T2) -- 4行

,N3(C) as (select 0 from N2 as T1 cross join N2 as T2) -- 16行

,N4(C) as (select 0 from N3 as T1 cross join N3 as T2) -- 256行

,N5(C) as (select 0 from N4 as T1 cross join N4 as T2) -- 65,536行

,IDs(ID) as (select row_number() over (order by (select null)) from N5)

insert into dbo.UniqueidentifierCI(Val)

    select ID from IDs;

select page_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');

select page_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');

《Pro SQL Server Internals, 2nd edition》中CHAPTER 7 Designing and Tuning the Indexes中的Clustered Index Design Considerations一节(译)

7-4。碎片索引

如您所见,唯一标识符列上的索引被严重分割,与标识列上的索引相比,它使用大约多40%的数据页。

批量插入到唯一标识符列上的索引中,在数据文件的不同位置插入数据,这对于大型表来说,会导致沉重的、随机的物理I/O。这可以显著地降低操作的性能。

个人经验

前段时间,我参与了一个系统的优化,该系统有一个250GB的表,具有一个聚集索引和三个非聚集索引。非聚集索引之一是唯一标识符列上的索引。通过删除这个索引,我们能够将50000行的批量插入从45秒加速到7秒。

有两个常见的用例,用于在唯一标识符列上创建索引。第一个是支持跨多个数据库的值的唯一性。考虑一个分布式系统,其中可以将行插入到每个数据库中。开发人员经常使用单一标识符来确保每个键值是唯一的系统范围。

这样的实现中的关键元素是如何生成键值。正如您已经看到的,使用NEWID()函数或在客户机代码中生成的随机值对系统性能有负面影响。但是,可以使用NEWSEQUENTIALID()函数,该函数生成唯一且通常不断增加的值(SQL Server不时重置它们的基值)。使用NEWSEQUENTIALID()函数生成的唯一标识符列上的索引类似于标识和序列列上的索引;但是,应该记住,与4字节整型或8字节自增长数据类型相比,唯一标识符数据类型使用16字节的存储空间。

作为替代解决方案,可以考虑创建具有两列的复合索引(InstallationId,Unique_Id_Within_Installation)。这两列的组合保证跨多个安装和数据库的唯一性,并且使用比唯一标识符更少的存储空间。可以使用整数标识或序列生成Unique_Id_Within_Installation值,这将减少索引的分段。

在需要跨数据库中的所有实体生成唯一键值的情况下,可以考虑跨所有实体使用单个序列对象。这种方法满足了要求,但使用的数据类型比UNIQUIGIDENER小。

另一个常见的用例是安全性,其中唯一标识符值用作安全令牌或随机对象ID。不幸的是,在这个场景中不能使用NEWSEQUENTIALID()函数,因为可以猜测该函数返回的下一个值。

此场景中的一个可能的改进是使用CHECKSUM()函数创建计算列,随后对其进行索引,而不在唯一标识符列上创建索引。代码显示在清单7- 6中。

列表7:6使用校验SUME():表结构

create table dbo.Articles

(

    ArticleId int not null identity(1,1),

    ExternalId uniqueidentifier not null

        constraint DEF_Articles_ExternalId

        default newid(),

    ExternalIdCheckSum as checksum(ExternalId),

    /* 其他列*/

); 

create unique clustered index IDX_Articles_ArticleId

on dbo.Articles(ArticleId);

create nonclustered index IDX_Articles_ExternalIdCheckSum

on dbo.Articles(ExternalIdCheckSum);

提示可以在不保留它的情况下对计算列进行索引。

即使IDX_Articles_ExternalIdCheckSum索引将被严重分割,但是与唯一标识符列上的索引(4字节密钥与16字节)相比,它将更加紧凑。它还提高了批处理操作的性能,因为排序速度更快,也需要更少的内存来处理。

您必须记住的一点是,CHECKSUM()函数的结果不能保证是唯一的。您应该将两个谓词都包含到查询中,如列表7-7所示。

列表7:7使用校验SUME():选择数据

select ArticleId /* 其他列*/

from dbo.Articles

where checksum(@ExternalId) = ExternalIdCheckSum and ExternalId = @ExternalId

提示在需要索引大于900/1700字节的字符串列的情况下,可以使用相同的技术,这是非群索引键的最大大小。即使这样的索引不支持范围扫描操作,它也可以用于点查找。