堆的修改需要使用到PFS页(PageFreeSpace)。PFS记录着数据页的空间使用情况。PFS页上使用1个字节(Byte)表示一个页的使用情况。一个PFS页可以表示8088个数据页,于是每8088个数据页就会有一个PFS页。一个数据文件的第二个页就是PFS页。PFS页上1个字节的结构:
- Bit 1:是否被分配并使用。比如,分配给对象的统一区,并不是区内所有的页都被使用。此位就用标示已分配区中页是否被真正使用。
- Bit2:表示页是否来自混合区
- Bit3:表示页是否是一个IAM页(Index Allocation Map)
- Bit4:表示页中是否有幻影行(Ghost Record)。后台的幻影行清理进程就需要用到这个位了。只有删除索引中的数据时才会产生幻影行。
- Bit5-7:表示页的空间被使用的情况。取值如上图所示。
插入数据行
堆表插入新的数据行时,新行会被分配到任何有可用空间的地方。也就是说插入位置可能是表的任何位置。如果没有页有可用空间,则会从已经分配给表的统一分区中寻找未被使用的页来存储数据。如果所有区的所有页都没有空闲空间,则会分配新的统一分区给表来存储数据。
删除数据行
直接删除之,删除方式跟删除索引中的非叶级页一样但跟。
从堆表删除数据行后,被删除行所在的数据页并不会马上重组数据页以释放空间,只会标示这些空间可用。当插入新行需要连续的可用空间时,才会被回收利用。下面的示例从页中间删除一行:
CREATE TABLE smallrows ( a int identity, b char(10) ); GO INSERT INTO smallrows VALUES (‘row 1‘); INSERT INTO smallrows VALUES (‘row 2‘); INSERT INTO smallrows VALUES (‘row 3‘); INSERT INTO smallrows VALUES (‘row 4‘); INSERT INTO smallrows VALUES (‘row 5‘); go --get the data page id for dbcc page SELECT allocated_page_file_id, allocated_page_page_id, page_type_desc FROM sys.dm_db_database_page_allocations (db_id(‘test‘), object_id(‘smallrows‘), NULL, NULL, ‘DETAILED‘); go dbcc traceon(3604) dbcc page(test,1,146,1) go --delete the row a=3 DELETE FROM smallrows WHERE a = 3; GO dbcc traceon(3604) dbcc page(test,1,146,1) go 观察两次dbcc page输出的OFFSET TABLE: Row - Offset 4 (0x4) - 180 (0xb4) 3 (0x3) - 159 (0x9f) 2 (0x2) - 138 (0x8a) 1 (0x1) - 117 (0x75) 0 (0x0) - 96 (0x60) Row - Offset 4 (0x4) - 180 (0xb4) 3 (0x3) - 159 (0x9f) 2 (0x2) - 0 (0x0) 1 (0x1) - 117 (0x75) 0 (0x0) - 96 (0x60)
可以看出:
- 删除前后其它行的偏移量没有变化,也就是说没有行被移动。
- 删除后,被删除行(slot2)偏移量变成了0,表示该slot未被使用。
其实使用DBCC PAGE(TEST,1,146,2)仍然可以看到row3这一行。
清空堆表数据页上的数据,它也不会自动的释放这些页。可以通过sys.dm_db_partition_statst和sys.dm_db_
database_page_allocations观察到页的使用和分配信息是不会有变化的。要想清空页的数据并回收空间:
- delete时使用表锁:数据页会被释放,但IAM页保留
- truncate table:这个是针对清空表,会释放所有页,包括IAM页
- 创建并删除一个聚集索引
- 使用alter table ...rebuild
更新数据行
SQL Server会自动选择最优的数据更新策略。基于受影响行数,访问数据的方式和是否需要修改索引键来选择最优的策略。更新实现方式包括:直接将旧值原地修改为新值和插入新值后删除旧值。
堆表中的数据行移动
堆表中数据行的变长列的数据更新为更大尺寸的数据后,原来的数据页不能再存储它,就会发生数据行移动。数据行被移动到新页时,原来的位置上会放置一个转发指针(Forwarding Pointer)。这个指针指向行的现在的地址。这样的好处,就是当发生数据行移动时不需要移动页上所有的数据,只需要移动特定行并生成转发指针即可。
下面的示例,创建包含变长列的表,然后更新一行的变长列,使得超出原来页的容量。然后观察页的转发情况。
if OBJECT_ID(‘bigrows‘) is not null drop TABLE bigrows go CREATE TABLE bigrows ( a int IDENTITY , b varchar(1600), c varchar(1600)); GO INSERT INTO bigrows VALUES (REPLICATE(‘a‘, 1600), ‘‘); INSERT INTO bigrows VALUES (REPLICATE(‘b‘, 1600), ‘‘); INSERT INTO bigrows VALUES (REPLICATE(‘c‘, 1600), ‘‘); INSERT INTO bigrows VALUES (REPLICATE(‘d‘, 1600), ‘‘); INSERT INTO bigrows VALUES (REPLICATE(‘e‘, 1600), ‘‘); GO SELECT allocated_page_file_id, allocated_page_page_id, page_type_desc FROM sys.dm_db_database_page_allocations (db_id(‘test‘), object_id(‘bigrows‘), NULL, NULL, ‘DETAILED‘); go UPDATE bigrows SET c = REPLICATE(‘x‘, 1600) WHERE a = 3; GO SELECT allocated_page_file_id, allocated_page_page_id, 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,163,1) go
然后观察Slot2的内容,发现记录类型为9个字节的转发存根(Forwarding Stub)。
Slot 2, Offset 0xcfe, Length 9, DumpStyle BYTE Record Type =FORWARDING_STUB Record Attributes = Record Size = 9 Memory Dump @0x000000000B4AACFE 0000000000000000: 04a50000 00010000 00
转发存根的16进制内容可心划分成4个部分,每部分代表的含义如下:
04-a5000000-0100-0000
转发存根标志字节位-转记录所在的页号-文件号-Slot编号
由于SQL Server采用的是Little-Endian字节序来组织字节存放的(也就说看到就是它在内存中存放的顺序),所以我们要看到数据本来的顺序和内容,还需要将之转换成Big-Endian的字节序组织的样子,即做一次高低位转换,然后才能转成10进制表示的内容。
高低位转换后:04-000000a5-0001-000
转10进制后:4-165-1-0
4就是表示这是一条转发存根,转发后记录的位置是:文件1中165号页内的Slot0上。也可以DBCC PAGE查看165号页Slot0长什么样。可以看到Record Type = FORWARDED_RECORD,表示这是一条转发记录。行内容是大量的c和x字符。
Slot 0, Offset 0x60, Length 3229, DumpStyle BYTE Record Type =FORWARDED_RECORD Record Attributes = NULL_BITMAP VARIABLE_COLUMNS Record Size = 3229 Memory Dump @0x000000000B4AA060
转发指针只存在于堆表上。一个转发指针不会指向另一个转发指针,如果转发记录再次被转发,则原来的转发指针会指向转发记录的新地址。一旦一个转发指针被生成,它会一直存在。有些情况会转发指针会被清除:
- 转发记录尺寸缩小了,并且原来的页能够存放得下它,它就会回到原来的页,转发指针被清除
- 收缩数据库。数据文件收缩不会产生任何新的转发指针,它会重新分配书签,并且会删除一些数据页。如果这些页包含转发记录和存根,会被重新组织到其它页,从而消除转发。
- 使用ALTER Table Rebuild重建堆表
- 转发行被删除
- 建立聚集索引,变成聚集索引表
原地(In Place)更新
原地更新是SQL Server的更新规则。原地更新只在原来的位置上修改受影响的字节内容。每更新一行就会向事务日志写一条记录。如果表有更新触发器或者行被标记为复制,则更新一行,会写两条事务日志记录。先写一条删除,再写一条插入记录。原地更新发生的两种情况:
- 不需要用到转发指针的堆表更新
- 不需要修改聚集键的聚集索引
聚集索引键存放是有序的,当修改聚集索引键的值,但不影响其排序位置时,也会是原地更新。比如某表的聚集索引列Name的值包括:Allen,Bill,Charlie。如果将Bill更新为Bily,是原地更新,将Bill更新为David,就是非原地升级(可能新行还会存在当前的数据页上)。
非原地更新
非原地更新发生在更新聚集索引的索引键时。更新会变成先删除再插入两个操作。更新索引键也有可能是混合更新,即有些行是原地更新,其它行是非原地更新。更新聚集索引键时,SQL Server会生成一个包含删除和插入操作涉及到的所有行的列表。这个列表小的话就存在内存,大的话就存在tempdb。然后根据键值和操作符(删除或者插入)对列表排序。接下来分种情况:
- 如果索引键值非唯一,则先删除再插入。
- 如果索引键值唯一,则会将删除和插入这两个操作合并成一个更新操作。这样更高效。
总结
1. 本文大部分理论基础和例子都参考和引用的《Microsoft SQL Server 2012 Internal》
2. 有一段时间,经常被人问到”我要修改一下表需要你帮忙评估一下影响”,然后就做了一些基础知识的总结。