SQL Server索引进阶 第十一篇:索引碎片分析与解决

2016-3-3 164+ 分享:

本文是SQL Server索引进阶系列(Stairway to SQL Server Indexes)的一部分。

相关有关索引碎片的问题,大家应该是听过不少,也许也很多的朋友已经做了与之相关的工作。那我们今天就来看看这个问题。

为了更好的说明这个问题,我们首先来普及一些背景知识。

 

知识普及

 

我们都知道,数据库中的每一个表要么是堆表,要么就是包含聚集索引的表,或者我们称之为有序表。如果表是一个堆表,那么在使用非聚集索引查询数据的时候,会使用书签查找去底层的数据表中去检索需要的数据,这个书签查找会通过每一个索引中包含的行标识(RID)去定位每一个底层数据表的数据行。如果表上面有聚集索引,那么在使用非聚集索引查找其他需要数据的时候,就会使用聚集索引键去定位底层的数据行。

 

我们也知道,索引是由索引页组成的,索引中的每一个条目包含在页中。每8个页组成一个块。

 

索引的层级是从底向上的,就是一个树结构,最下面的就是第0层,也是叶节点。索引中的根节点处于整个索引的最上层。

 

如果要扫描整个索引,那么就意味着必须要读取页节点中的每一个页(要么是数据页,要么是索引页)。其中,每个页都包含着一个指向它前面的页和一个指向它后面也的指针。之前,我们也提过:如果单看某一层节点,其实就是一个双向链表。

我们应该知道:页(不管是数据页,还是索引页 ,还是其他的类型的页)处于的逻辑顺序和它的物理顺便不一定就是一样的,也就说,在A页中的指针指向了它的下一个页B,也就说A和B页在逻辑上面是一起的,但是它们在物理上面可能不一样,甚至B页和A页在物理上相隔几百个页。

如果在逻辑上面相连的页在物理存储级别相隔的越近,那么在读取这些页的时候所花的I/O成本也就越小,因为产生磁盘的磁头移动带来的延迟。相反,如果他们的物理存储顺序和逻辑顺序一致,那么SQL Server在读取的时候,就可以一次读取,因为每次会读取一个块(8个页)。

好了,普及知识之后,我们就来看看什么是碎片。

 

什么是索引碎片

 

索引碎片可以分为两类:内部索引碎片和外部索引碎片。下面我们就来具体的看看而这之前的区别以及如何检查。

 

内部索引碎片

 

每一个索引页中都包含一些索引的条目(就类似数据页包含很多的数据行一行),这一点我们在之前讲过了的。但是,很多的时候,不是每个页都包含了最大的条数。例如,一个页的大小8k,也就是4096字节,除去一些页头,页脚等,还剩下8000多字节,如果每个索引条目的大小事100字节,那么这个索引页最大就可以包含80个条目,但是很多的情况下,却没有包含这么多。

 

也就说,很多的时候,索引页并没有完全的填满,或者这是问题,或许这么我们特意这样的,我们后续会提到。当我们谈到索引碎片的时候,我们往往就是指这些索引页没有完全填满。或者说的更加明白一点就是:我们原本是希望页都被填满的,但是随着数据的增删改,使得索引中的数据没有填满。

 

我们可以使用
sys.dm_db_index_physical_stats来查看相关的内部碎片的情况,执行查询如下:

SELECT IX.name AS 'Name'
     , PS.index_level AS 'Level'
     , PS.page_count AS 'Pages'
     , PS.avg_page_space_used_in_percent AS 'Page Fullness (%)'
  FROM sys.dm_db_index_physical_stats( 
           DB_ID(), 
           OBJECT_ID('Sales.SalesOrderDetail'), 
           DEFAULT, DEFAULT, 'DETAILED') PS
  JOIN sys.indexes IX
    ON IX.OBJECT_ID = PS.OBJECT_ID AND IX.index_id = PS.index_id 
  WHERE IX.name = 'PK_SalesOrderDetail_SalesOrderID_SalesOrderDetailID';
GO

 

执行结果如图:

SQL Server索引进阶 第十一篇:索引碎片分析与解决

我们可以看到每个索引的页面的填充情况。这是一个针对聚集索引的查询。因此,这个索引的叶子层的入口是表中的行。层级为0的叶子层有1234页,每页的平均密度达到99%以上,说明这个表只有非常小的内部碎片。

下面,我们再来讲讲外部索引碎片。

 

外部索引碎片

 

理解了上面的问题,这个外部索引碎片就好理解了,最简单的说法就是:索引中的索引页的逻辑顺序和物理顺序不一致。我们通过个图对比的来看看。

 

 

在上图中,一个索引包含了16个页。但是这16页不是包含在2个相连的块中的,而是分布在不同的地方,因为它们之前中的一些块被其他的对象占用了。这样就导致了16个页在物理上面不连续,这就是碎片。在读取的时候,就会消耗额外的I/O。

 

和之前一样,我们可以使用
sys.dm_db_index_physical_stats来查看外部碎片的情况。但是这里的参数值可能要发生变化了:之前在sys.dm_db_index_physical_stats最后一个参数值是'DETAILED',这里我们的值是LIMITED或者Default。因为外部碎片关注的是索引页之前的连续性问题,不关注每一个页中的数据,此时只是部分的扫描,没有必要全部的扫描。大家可以参看MSDN的去进一步的理解这些参数的含义。

 

查询如下:

SELECT IX.name AS 'Name'
, PS.index_level AS 'Level'
, PS.page_count AS 'Pages'
, PS.avg_fragmentation_in_percent AS 'External Fragmentation (%)'
, PS.fragment_count AS 'Fragments'
, PS.avg_fragment_size_in_pages AS 'Avg Fragment Size'
FROM sys.dm_db_index_physical_stats(
DB_ID(),
OBJECT_ID('Sales.SalesOrderDetail'),
DEFAULT, DEFAULT, 'LIMITED') PS
JOIN sys.indexes IX
ON IX.OBJECT_ID = PS.OBJECT_ID AND IX.index_id = PS.index_id
WHERE IX.name = 'PK_SalesOrderDetail_SalesOrderID_SalesOrderDetailID';

结果如下:

SQL Server索引进阶 第十一篇:索引碎片分析与解决

 

除了使用脚本之外,我们还可以在SQL Server管理器中查看,在某个索引上面右键,属性,如下:

 SQL Server索引进阶 第十一篇:索引碎片分析与解决

 

在这里要说明一下,因为原英文版本在理解上面可能会有些困难,为了使得大家更好的理解原文,我们这里特意的加入了一些其他的内容,帮助朋友们进行一个过渡。

因为索引碎片分析涉及到了页拆分的一些知识,页拆分发生在某个页上的数据已经填满而没有多余的空间给新增的数据而产生的动作,同时,向已经填满数据的页上面加入新的数据还可能会导致另外一个操作,所以,我们这里也随便的讲一个,使得大家更好的理解。

 

我们之前已经提到过,SQL Server在数据库中把任何的信息都是保存在基于8KB的页(不管是何种类型的页,我们这里不考虑大对象的数据页)上面的。如果记录(不管是底层的数据行记录还是索引中的条目等)的大小总和加起来小于8KB,那么SQL Server可能就会在一个页上面存放多条记录。如果大于了8KB,那么肯定就需要更多的页来进行记录的保存,此时SQL Server必须改变每一个页上面的记录。SQL Server主要基于两种方法来实现这个改变:记录转发与页拆分。

 

备注:记录-我们这里一个对数据的统称,例如数据页上面的每一条数据是一个记录,索引页上面的一个条目是一个记录。

 

记录转发

 

当记录的大小已经超过了一个页的容量的时候,第一种存放记录的方式就是“记录转发”。

 

这个方法只有当底层的数据表是堆的时候才采用。如果某一行的数据记录被修改,使得此时所在的数据页已经无法存放其修改的行所有的数据,SQL Server将会把这条记录移动到一个新的数据页上面去,同时会增加两个指针。第一个指针将会表明这个数据行现在新的位置,通常这个指针称之为“记录转发指针”,而第二个指针将会放在新的数据页上面,指向这个记录原先数据页,这个指针称之为“回指指针”。熟悉数据结构的朋友,其实可以把这个过程想成在一个链表中加入一个节点。

 

为了使得大家更好的明白上面的讲述,我们还是来看一个例子。在例子中,我们将会带着大家一起来看看记录转发这个过程是如何进行的。如下图:

 

假设图中的页,编号为100,这个页处于一个堆表中。在这个页中包含了4条数据,而且每一条数据大小约2K,加起来就是8KB。如果此时第二条数据被更新了,使得它的数据大小变为了2.5KB,此时这个数据页肯定就无法存放所有的数据,此时SQL Server就会再去分配一个新的页,假设编号为101。那么,第二条数据就会被移到新分配的数据页上面去,而且在原先的页(编号100)上面加上一个指针指向第二条数据的新位置。那么原先存放第二条记录的地方此时就放置了指针。

 

另外,在新的页101中,也有一个指针回指向页100。在图中没有画出来。

 

记录转发的问题在于,它使得一条数据在一个表中存在两个位置:一个位置存放指针,一个位置存放真实的数据。随着记录的不断变多,会增加更多的额外的磁盘空间,特别是读取数据时额外的I/O操作,因为可能存在这样的情况:某些记录通过不断的修改,使得它们不在适合存放在当前页,从而放在新页上,做第一次的记录转发,然后再修改,然后再次进行第二次的记录转发….如下图:

 

大家应该可以体会到,此时原本的数据A已经通过多次的转发,而在其他的页上面保留的仅仅只是它转发过程中下一个页的位置,这样,要想找到A数据,那么就要经过多次的指针查找,直到最后。

 

页拆分

 

对于页拆分,相信是很多朋友听的比较多的一个词了。下面,我们就来看看这个话题。页拆分发生在包含有索引的表中,要么有聚集索引,要么有非聚集索引。同时,页拆分不仅仅发生在数据页上,也发生在索引页上。

 

页拆分的过程基本是这样的: 如果一个记录的大小更新(或者增加),使得原来的页不在适应数据的大小,此时SQL Server无法将变化的数据写入,那么它就会把原先页上面的一半的记录移到新的页上面去。之后,SQL Server再次尝试去把数据写入,如果不行,那么再次分页,直到最后可以把数据写入。

 

我们还是通过一个例子来讲解这个问题。我们主要通过一个更新的操作来讲述。还是看到下面的图:

 

在页100上有4条记录,每一个的大小约2KB,此时刚好把一个页占满。如果此时对第二条数据进行修改,使得它的大小变为2.5KB,那么此时就会进行页的拆分。那么原先的4条数据,就会被分为2部分放在不同的页上,同时,SQL Server会在原先的页100上面放置一个指向新页的指针,然后SQL Server再次去更新第二条记录。

 

好,说完了上面两种情况之后,我们就来看看,它们对索引的碎片有什么影响。

 

其实谈到碎片问题,只要是发生在页拆分操作上,特别是当索引的B树结构发生页拆分的时候。

 

下面,我们就要细化这个过程。

 

如果此时,表上已经有了索引,如果在数据表中增加一行数据,那么,这行数据肯定要反应到索引结构中(除非采用了过滤的索引),从而使得索引结构开始发生调整。

如果增加到索引结构中的这个条目可以加入到某个索引页中,换句话说,索引页中的空闲的空间可以容纳新的索引条目的大小,这个过程算是结束。

 

如果空间不足,那么此时,肯定要去分配新的页面,此时还不确定这个新的页面和旧的页面是否在物理空间上面连续,那么这就产生外部索引碎片,同时把原先页中的索引记录分布在两个页上,使得这个两个页有了比之前更多的空闲的空间,这就增加了内部索引碎片。

 

但是内部的碎片,可能会随着索引记录的不断增加而将其空闲的填充而减少。但是外部的碎片只有等到我们维护索引的时候才消失。

 

 

其实,大家可以看出来,不仅仅是索引碎片,底层数据页的碎片也可以采用同样的分析方法。