索引修改的大体规则:
- 对表的任何修改操做(UDI),总会对表上的非汇集索引执行等价的操做。某些更新操做除外。
- 对表的任何修改操做,都会先修改堆或者汇集索引,而后再修改非汇集索引。
- 若是修改的数据行,正是过滤索引过滤掉的行(过滤索引的叶级页不包含的行),则不会对过滤索引产生任何操做。
插入数据行
对于汇集和非汇集索引的插入,新行(不论是数据行仍是索引行)所包含的索引
键列值就决定了它将被插入的位置。插入操做的可能来源有:
- 直接的INSERT命令
- UPDATE致使的行移动(原来的地方已经容不下被更新后的行),内部使用先DELETE,再INSERT的UPDATE策略。
- UPDATE致使的索引键列变动。索引行是有序的,行的索引键值变动会致使行在索引中的位置变动,从而须要移动到新位置。一样是先DELETE,再INSERT。
若是当前
索引的叶级(叶级在汇集索引中是数据页,非汇集索引中是索引页)没有空间存放插入的新行,则索引会发生页拆分(Page Split)。行在索引中的位置是有序的,因此当新行将要被插入的
某个特定页没有可用空间时,就须要分配新页给索引。会先从已经分配的区中找是未使用的页,若是没有,则会分配一个新的统一区给索引,而后再使用新区中的页。
页拆分
获得新页以后,SQL Server会尽可能按照”对半分“原则,拆分原来页上的一半数据行到新页。第一次拆分是基于页上偏移阵列(Offset Array)来计算的。每次索引页拆分,还要向B+树中的父级页添加一行。有时须要屡次页拆分才能将新行保存下来。页拆分发生的越多,新页也载多,须要向你级页添加的行数载多,颇有可能同时致使父级页也发生页拆分。
索引树的查找方式是从根节点向叶节点进行的,因此Insert致使的页拆分也是从根节点向下发生的。这样在Insert致使的拆分未完成前,索引树须要使用闩锁(Latch)对索引进行保护,以防止索引被其它的操做修改。当从磁盘上读/写页时或者对数据页进行操做时(如页拆分),为了保护页中的数据的物理完整性,须要对页加上闩锁进行保护。当子节点的拆分完成而且再也不须要对父节点进行更新时,索引树中父节点的闩锁才会被释放。
在父节点的闩锁释放前,SQL Server会检测父节点页中是否还能容纳两行新数据。若是不能,则拆分它。这种状况只会当查找索引,而且须要向索引页中添加新行时才会发生。这样作的目的是当因为子级页发生页拆分而须要向父级页插入新行时,父级页老是有空间存放这些新行。
页拆分的类型由发生拆分的页的类型决定
根页拆分
当根页发生拆分时,会分配两个新页给索引。原来根页的数据会被插入到这两个新页中。原来的根页仍然是索引的根页,它上面只有两行数据,分别指向两个新页。原来的根页被保留,能够避免修改系统目录中指向根页的指针值。根页拆分会致使索引增长新的一级索引层次(深度增长一级)。这种拆分不多发生。
中间级页拆分
中间索引页发生拆分时,会增长一个新页,而后根据索引键的中间点(Midpoint)将一半的行拆分到新页,再往父级页中插入一行指向新页。这种拆分也不多发生。
叶级页拆分
这是最多见,也是最须要关注的拆分类型。汇集索引数据页和非汇集索引叶级页的拆分机制是同样的。虽然数据页拆分只会发生在对汇集索引表执行Insert操做时,可是也多是Update操做致使的内部Insert操做。前文提过了,当Update不是原地更新时,会执行先Delete再Insert的操做。
叶级页的拆分与中间级页的方式相似。可是须要索引管理器决定两页中的谁来接收后续的新行,还要处理两个页面谁也存不下的大型行(Large Row)。数据页拆分不会改变汇集索引键,因此相关的非汇集索引不会受到影响。
下面经过例子观察一下叶级页拆分。建立一个表,定义并插入大型行,使得一个页只能存放5行数据,而后插入第6行数据后,观察页拆分的状况。注意第6行的汇集键小于第5行。
USE test
go
CREATE TABLE bigrows
(
a int primary key,
b varchar(1600)
);
GO
/* Insert five rows into the table */
INSERT INTO bigrows
VALUES (5, REPLICATE('a', 1600));
INSERT INTO bigrows
VALUES (10, replicate('b', 1600));
INSERT INTO bigrows
VALUES (15, replicate('c', 1600));
INSERT INTO bigrows
VALUES (20, replicate('d', 1600));
INSERT INTO bigrows
VALUES (25, replicate('e', 1600));
GO
--get the data page id
select allocated_page_file_id as PageFID,allocated_page_page_id as PagePID,page_type_desc
from sys.dm_db_database_page_allocations(db_id('test'),object_id('bigrows'),null,null,'Detailed')
go
dbcc traceon(3604)
dbcc page(test,1,168,1)
go
--OFFSET TABLE:
--Row - Offset
--4 (0x4) - 6556 (0x199c)
--3 (0x3) - 4941 (0x134d)
--2 (0x2) - 3326 (0xcfe)
--1 (0x1) - 1711 (0x6af)
--0 (0x0) - 96 (0x60)
INSERT INTO bigrows
VALUES (21, replicate('f', 1600));
GO
select allocated_page_file_id as PageFID,allocated_page_page_id as PagePID,page_type_desc
from sys.dm_db_database_page_allocations(db_id('test'),object_id('bigrows'),null,null,'Detailed')
go
dbcc traceon(3604)
dbcc page(test,1,168,1)
dbcc page(test,1,172,1)
go
--Page 168
--OFFSET TABLE:
--Row - Offset
--2 (0x2) - 3326 (0xcfe)
--1 (0x1) - 1711 (0x6af)
--0 (0x0) - 96 (0x60)
--Page 172
--OFFSET TABLE:
--Row - Offset
--2 (0x2) - 1711 (0x6af)
--1 (0x1) - 3326 (0xcfe)
--0 (0x0) - 96 (0x60)
View Code
经过偏移阵列,能够看出页拆分后原来的页和新页各有三行。经过观察DBCC PAGE的输出的行内容,能够看到:并发
- 原来的页(PID:168)保存着汇集索引键值为5,10和15的行,新页(PID:172)保存着20,21和25的行。
- 在新页上,21这行存储在Slot1的位置上。根据Offset的值,21实际的物理位置倒是在25以后。
21的逻辑位置在25以前,物理位置在25以后。能够看出:
行的汇集索引键顺序是由Slot编号表示的,而不是行的物理位置。也就是
汇集索引表中,某行的Slot编号小于另外一行,则它的汇集索引键值也小于它。这实际上是一种优化的设计结果:当页上发生数据修改时,只须要修改页上偏移阵列的值来保证行的顺序,而不须要物理性的移动数据行位置来保证顺序。极大地减小数据修改的开销。因此“索引中行的物理存储顺序老是与它的索引键值的顺序是同样的。”这种说法是不正确的。实际上,只要偏移阵列提供了正确的逻辑顺序,行能够存储页的任意位置。
页拆分的代价是很大的。页拆分过程当中对旧页、新页和父页的修改操做,都须要完整写入事务日志。最小化业务高峰期发生页拆分的办法,一般有:
- 选择一个更合理的汇集索引键。好比,让新行插入到表的末尾,而不是像GUID那样随机插入。
- 对于更新变长列引发的页拆分,能够经过减小索引的填充因子(Fill Factor),在页上保留多一些可用空间给变长列更新使用。
删除数据行
删除数据行时,须要同时考虑数据页和索引页的变化。汇集索引表中删除行与非汇集索引叶级中删除行是同样的方式。
叶级中删除行
当索引叶级的行被删除时会被标记为幻影行(Ghost Record)。行被删除后,行头的一个位(Bit)被修改,行就标记为幻影,可是行仍是保留于页上。页头的元数据m_ghostRecCnt表示当前页的幻影行的数量。幻影行的用途有:
- 快速回滚。当行没有被物理删除时,回滚Delete操做只须要修改行头的表示幻影行的位便可。
- 键值范围锁定(key-Range Locking)和其它锁定的并发优化
- 用于行版本控制
幻影行何时被清除,由系统负载决定。SQL Server有一个叫作ghost-cleanup的后台线程,用于清理那些再也不须要被活动事务和其它功能使用的幻影行。幻影行可能很快被ghost-cleanup线程清除掉。因此为了观察幻影行,能够将Delete包裹在未被提交或者回滚的用户事务中,或者使用末公开的(Undocumented)跟踪标记661来禁用幻影行清理功能。可使用存储过程sp_clean_db_free_space清除整个库的幻影行,也能够用sp_clean_db_fie_free_space清除库中指定数据文件中的所有幻影行。
下面的例子,删除汇集索引表中的一行,观察幻影行。
USE test
GO
IF object_id('dbo.smallrows') IS NOT NULL
DROP TABLE dbo.smallrows;
GO
CREATE TABLE dbo.smallrows
(
a int IDENTITY PRIMARY KEY,
b char(10)
);
GO
INSERT INTO dbo.smallrows
VALUES ('row 1');
INSERT INTO dbo.smallrows
VALUES ('row 2');
INSERT INTO dbo.smallrows
VALUES ('row 3');
INSERT INTO dbo.smallrows
VALUES ('row 4');
INSERT INTO dbo.smallrows
VALUES ('row 5');
GO
--get data page id
select allocated_page_file_id as PageFID,allocated_page_page_id as PagePID,page_type_desc
from sys.dm_db_database_page_allocations(db_id('test'),object_id('smallrows'),null,null,'Detailed')
go
DELETE FROM dbo.smallrows
WHERE a = 3;
GO
dbcc traceon(3604)
dbcc page(test,1,174,1)
go
View Code
截取DBCC PAGE输出中与幻影行相关的内容。
- 页头中(Page Header)中的 m_ghostRecCnt = 1,表示当前页中幻影行数量是1。
- Slot2中的Record Type = GHOST_DATA_RECORD,表示当前行是幻影行。
- 偏移阵列中,Slot2的偏移量没有变,其它Slot的偏移理也没有变化。表示没有行发生移动。
OFFSET TABLE:
Row - Offset
4 (0x4) - 180 (0xb4)
3 (0x3) - 159 (0x9f)
2 (0x2) - 138 (0x8a)
1 (0x1) - 117 (0x75)
0 (0x0) - 96 (0x60)
能够经过sys.dm_db_index_physical_stats查看表中的幻影行总数。
中间级中删除行
中间级中删除行与从堆表中删除行相似。中间级页中被删除行,不会被标记为幻影行,所占用的页空间也不会马上释放,当有新索引行须要页面上的空间时才被释放和重用。
页回收
数据页上全部行被删除后,这个页会被ghost-cleanup线程回收(Dealocated)。堆表是个例外。若是表
只有一个数据页,此页也不会被回收。当数据页被删除,指向这些数据页的索引行会被删除。中间级页上索引行全被删除,不会立刻回收,而是会在页上保留一行,这一行稍后会被移动到邻近的有空闲空间的页上,而后回收原来的空页。
更新行
SQL Server会自动选择最优的数据更新策略。基于受影响行数,访问数据的方式和是否须要修改索引键来选择最优的策略。更新实现方式包括:直接将旧值原地修改成新值;删除旧行再插入新行。
行移动
发生行移动的两种状况:
当行中的变长列被更新后,原来位置没法再存储它时
由于行的逻辑顺序由索引键决定,因此当汇集索引或者非汇集索引的键列发生修改后,行的逻辑顺序发生改变时。例如当在lastname列上创建汇集索引,lastname为Abel的行存储在接近表开始的位置,若是将Abel修改成Zek,则将会被移动到接近表结束的位置。
非汇集索引的叶级中包含指向表中每一行的行定位器。汇集索引表中,行定位器就是汇集索引键。因此仅当汇集索引键被修改时,非汇集索引才会被修改。所以选择汇集索引键列时,尽可能选择非易失性的列(数据修改率极低,如Identity)。汇集索引表中,就算表的物理位置发生改变,也不会致使索引键改变,所心非汇集索引也不会被修改。
堆表中,行定位器是行的物理地址。行移动不会致使非汇集索引修改,由于它在行的原地址放置一个转发指针指向新地址,非汇集索引仍然引用原来的行地址,经过转发指针作重定向而已。可是堆表物理位置改变,会致使全部非汇集索引被修改。
原地更新
原地更新行是SQL Server的更新规则。每个原地更新操做都会在向事务日志写入一行,除非表上有更新触发器或者被标记为复制时。
若是原地更新须要修改索引键,则每一个操做会向事务日志先写入一条DELETE记录,而后再写入同样INSERT记录。
原地更新的场景:
- 更新堆,被更新的页有足够的空间存放更新后的行。
- 更新汇集索引表,且汇集索引列没有被更新。
- 更新汇集索引表的汇集索引列,可是更新后的行不会发生移动。
非原地更新
非原地更新发生在更新汇集索引的索引键时。更新会变成先删除再插入
两个操做。更新索引键也有多是混合更新,即有些行是原地更新,其它行是非原地更新。更新汇集索引键时,SQL Server会生成一个包含删除和插入操做涉及到的全部行的列表。这个列表较小就存在内存,较大就存在tempdb。而后根据键值和操做符(删除或者插入)对列表排序。接下来分种状况:
- 若是索引键值非惟一,则先删除再插入。
- 若是索引键值惟一,则会将删除和插入这两个操做合并成一个更新操做。这样更高效。
表级修改vs.索引级修改
在多索引的表上修改多行数据时,SQL Server提供两种索引维护策略:表级修改和索引级修改。表级修改也叫作"一次一行"(row-at-a-time),索引级修改也叫作”一次一索引“(index-at-a-time)。
在表级修改中,每一行数据被修改时,全部的索引都须要被维护一次。若是更新流是无序的,则SQL Server每更新一行就须要访问一次索引,这样就增长不少的随机访问。若是更新流是有序的,由于只能按一种条件排序,因此最多有一个索引不须要随机访问。
在索引级修改中,SQL Server将全部将要被修改的行汇总起来,并针对索引进行排序(有几个索引,就会有几回排序)。而后将全部的修改汇总再应用到每一个索引上。能够看出,这个过程当中每一个索引页最多被访问一次。
修改大表和索引上中的不多部分数据,SQL Server通常采用表级修改。若是修改的量很是大,则通常会选择索引级修改。经过执行计划能够看出采用的哪一种修改方式:每一个受影响的索引前都有一个UPDATE操做符,则是索引级。若是只有UPDATE操做符,则是表级。