存储引擎揭秘:基本结构之一——记录
最后编辑:2010-10-21
原文地址:
http://www.sqlskills.com/BLOGS/PAUL/post/Inside-the-Storage-Engine-Anatomy-of-a-record.aspx
本周我将发表一系列 SQL SERVER 中用来存储数据和跟踪分配的基本结构。大部分文章其实当初我在 TechEd2006 上开博时便发表过,但是现在我想更清晰地描述它,并使用 DBCC PAGE 来检查各种结构。
那么,什么是记录?简单地说,一条记录就是物理存储的表或索引的一行。当然,实际上比这复杂得多 …
数据记录
- 数据记录存放在数据页中。
- 数据记录存储着一个堆或者聚集索引叶节点中的行。
- 一个数据记录总是保存着一个表的所有列——或者是值本身或者是一个指向值的指针。
- 如果数据行是 LOB 类型(大对象数据类型, text, ntext, image 或者是 SQL SERVER 2005 中新介绍的 varchar(max), nvarchar(max), varbinary(max),XML ) , 那么数据记录中是保存的一个指向其他页(一个用来保存 LOB 值的松散树的根节点)上的文本记录的指针。例外的情况是若设置架构为存储 LOB 列到行内,此时若 LOB 列值足够小以至于适合放在一条数据记录,系统便将 LOB 列也放在数据记录中,这样便可以通过查询 LOB 列而不需要另外的 IO 来读取文本记录从而提高了性能。
- 在 SQL SERVER 2005 中,非 LOB 变长列(比如 ,varchar, sqlvariant )也可以因为一行数据超过 8060 字节而使用行溢出特性将列存储在行外。此时,存储格式和 LOB 的格式是一样的——一个指向文本记录的指针。
- 堆和聚集索引的存放的列之间的不同,我以后专门写文章说。(译注:非唯一聚集索引中若键相同,系统会自动在最后增加一个 4 字节的唯一标识符列)
被前转记录( Forwarded ) / 前转记录 (Forwarding)
- 技术上它们属于数据记录,并且只是存在于堆中。
- 一个前转过记录是堆中更新过的数据记录,因为太大不能适合在原来页中的位置上,所以被转移到另一个页。被前转过记录中有一个向后指向前转记录的指针。
- 一个前转记录被放置到记录原来的位置上,指向记录的新的位置。有时它也被称为“转移桩”,因为它只是包含实际记录的位置。
- 这样可以避免更新任何非聚集索引,因为它直接指向记录的原来的物理位置。
- 尽管这可以优化更新时非聚集索引的维护,但它也会造成查询期间另外的 IO 。这是因为非聚集索引记录指向记录旧的位置,所以需要一个另外的 IO 才会读到实际的数据行的位置。这又给堆和聚集索引之间的讨论添了一把火,这有利于聚集索引。
索引记录
- 索引记录存放在索引页中。
- 有两种类型的索引记录(差别在它们保存哪些列)
- 存储于非聚集索引叶节点中的非聚集索引行。
- 用来组成聚集索引和非聚集索引的 B 树(即聚集索引和非聚集索引 的叶节点以上层)的结点页中的行。
- 我将在以后的文章更详细地介绍它们的差别,因为它们确实很复杂(尤其是 SQL SERVER 2000 和 2005 之间的差别) , 必须得另文介绍。
- 通常情况下索引记录不包含表的所有列的值的(即使是那些所谓的覆盖索引)。
- SQL SERVER 2005 中,非聚集索引可以包括 LOB 值作为包含列(其存储结构和数据记录完全一致),也可以包括行溢出数据(同样的,其存储结构和数据记录完全一致)。
文本记录
- 文本记录存放在文本页中。
- 用来存放 LOB 值的树结构是由多种类型的文本记录组成的,并存放在两类文本页中。我将在以后的文章中介绍它们是如何运作并连接在一起的。
- 它们也被用来存放因“行溢出”而被挤出数据或索引记录的变长列的值。
幽灵记录
- 幽灵记录是那些逻辑上删除而物理上并没有从页中删除的记录。其原因比较复杂,但最基本是因为幽灵记录可以有助于简化键范围锁( key-range locking )和事务的回滚。
- 这种记录用 1 位来标明该记录为幽灵记录。幽灵记录直到置其为“幽灵”的事务被提交才能被物理删除。一旦事务被提交,该记录或者会被一后台异步程序(幽灵记录清除任务程序)删除或者因为又新插入一组相同键而将幽灵记录转回为正常记录。
其他记录类型
- 还有一些记录用来保存各种分配位图、排序操作的中间结果、文件和数据库的元数据(比如每个数据文件的文件头页或启动页)。同样的,我将在以后的文章中介绍它们(所以,以后还要写一系列的文章 J )。
记录的结构
不管什么类型及用途,所有的记录都有相同的结构,只是列的数量和类型有所不同而已。 比如,一个有着复杂架构的表可能有着上百个不同类型的列而一个分配位图只有一个列,充满整个页。
记录结构如下:
- 记录头部
- 4 字节长
- 前两个字节为记录元数据(记录类型等)
- 后两字节用来向前指向 NULL 位图。
- 记录的固长部分,包含所有固长数据类型列(比如, bigint, char(10), datetime )
- NULL 位图
- 两个字节用来存储记录中列的数目。
- 一个变长字节的 NULL 位图:不管列可否为 NULL ,每一列都有 1 位。(这比 SQL SERVER 2000 简单, 2000 中只有可 NULL 值的列才有 1 位。)
- 这可以优化 NULL 值列的读取。
- 变长列的偏移数组
- 2 个字节表示变长列的数目。
- 每个变长列都有 2 个字节用来表示列值结束的偏移。
- 版本标志
- 这仅在 SQL SERVER 2005 才有。它是一个 14 字节的的结构,其包含:一个时间戳 和一个指向存储在 tempdb 中的版本的指针 。
NULL 位图优化
为什么说可通过 NULL 位图优化呢?
首先,使用 NULL 位图可以省略为定长数据类型的保存特殊的“ NULL ”值。如果没有 NULL 位图,你怎么能知道一列是否为 NULL 值呢?对于定长列,你得定义一个特殊的“ NULL ”值,这将限制定长类型的有效范围。对于 varchar 列,它的值可能是 0 长度的空字符串,所以通过检查列长度并不管用——你必须也定义特殊的“ NULL ”值;当然其他变长类型可以通过检查长度来判断是否为 NULL 值。总之,我们需要 NULL 位图。
其次,它可以节省 CPU 周期。如果没有 NULL 位图,就必须为定长和变长列执行额外的指令。
对定长列:
- 读取存储的列值(可能会有一次 cpu 的 cache miss (高速缓存缺省))
- 取出为这个类型预定义的“ NULL ”值。(可能会有一次 cpu 的 cache miss ,但是在多行读取中只有第一次读时会发生)
- 在上面取出两个值之间进行比较
对变长列:
- 计算出变长数组的偏移
- 读出变长列的数目(可能会有一次 cpu 的 cache miss )
- 计算需要读取的变长偏移数组的位置
- 读取列的偏移值(可能会有一次 cpu 的 cache miss )
- 将下一列的偏移值也读出(如果 4 的偏移正好是缓存边界,可能会有另一次 cpu 的 cache miss )
- 比较两值是否相等
但是如果有 NULL 位图,你只要做下面的事情就行了:
- 读取 NULL 位图的偏移(可能会有一次 cpu 的 cache miss )
- 计算出你要读取的 NULL 位的附加的偏移(你要读的列对应的位)
- 读出它(可能会有一次 cpu 的 cache miss )
所以这和查询了一个定长列差不多,但是对于变长列和多行查询来说, NULL 位图的优势是非常明显的。
使用 DBCC IND 和 DBCC PAGE 详细检查一条数据
先创建一个用来查询的例子表:
USE MASTER ;
GO
IF DATABASEPROPERTY ( N 'recordanatomy' , 'Version' ) > 0 DROP DATABASE recordanatomy ;
GO
CREATE DATABASE recordanatomy ;
GO
USE recordanatomy ;
GO
CREATE TABLE example ( destination VARCHAR ( 100 ), activity VARCHAR ( 100 ), duration INT );
GO
INSERT INTO example VALUES ( 'Banff' , 'sightseeing' , 5 );
INSERT INTO example VALUES ( 'Chicago' , 'sailing' , 4 );
GO
使用 DBCC IND 来获得需要查看的页:
DBCC IND ( 'recordanatomy' , 'example' , 1 );
GO
输出显示数据页是( 1:143 ),我们我们将使用 DBCC PAGE 来列出该页,使用选项 3 可以完整列出每一条记录。
DBCC TRACEON ( 3604 );
GO
DBCC PAGE ( 'recordanatomy' , 1 , 143 , 3 );
GO
记住我们需要打开跟踪标志以便让 DBCC PAGE 输出结果至控制台而不是错误日志里。输出结果大致如下:
Slot 0 Offset 0x60 Length 33
Record Type = PRIMARY_RECORD Record Attributes = NULL_BITMAP VARIABLE_COLUMNS
Memory Dump @0x5C76C060
00000000: 30000800 05000000 0300f802 00160021 †0..............!
00000010: 0042616e 66667369 67687473 6565696e †.Banffsightseein
00000020: 67†††††††††††††††††††††††††††††††††††g
Slot 0 Column 0 Offset 0x11 Length 5
destination = Banff
Slot 0 Column 1 Offset 0x16 Length 11
activity = sightseeing
Slot 0 Column 2 Offset 0x4 Length 4
duration = 5
让我们使用上面我列出的记录结构来看看这些结构是如何保存的:
- 字节 0 是记录元数据的 TagA 字节。
- 值为 0x30 , 它对应于 0x10 ( 位 4) 和 0x20 ( 位 5) 。位 4 表示有一个 NULL 位图,位 5 表示记录中有变长列。如果也置了 0x40 (位 6 ),则表示该记录有一个版本标志。如果也置了 0x80 (位 7 ),则表示字节 1 中有值。
- 字节 0 的位 1-3 表示记录的类型。可能值如下:
- 0 = 主记录。 堆中没有被前转的数据记录或者是聚集索引叶节点中的数据记录。
- 1 = 前转过记录
- 2 = 前转记录
- 3 = 索引记录
- 4 = BLOB 碎片
- 5 = 幽灵索引记录
- 6 = 幽灵数据记录
- 7 = 幽灵版本记录。这是个在一些特殊情况下(比如:“幽灵”了的一个版本化的 BLOB 记录)会用到的 15 字节的记录: 1 个字节的记录头部加上 14 字节的版本标志。
- 在我们的例子中,没有置任何位,这表示这记录是个主记录。如果记录是索引记录,字节 0 的值为 0x36 。记住记录类型是从位 1 开始的,而不是位 0 。所以上面的列举出的记录类型值需要向左移一位(即乘以 2 )才能获得字节中的值。
- 字节 1 是记录元数据的 TagB 字节。它可以是 0x00 或者 0x01 。如果是 0x01 ,则表示记录类型是幽灵向前记录;如果是 0x00 ,则表示我们期望 TagA 字节。
- 字节 2 和 3 是记录中 NULL 位图的偏移。这里是 0x0008 ,表示从第 4 个字节开始的固定长度部分为 4 字节。因为我们知道表架构,所以这是我们期望的结果。
- 字节 4 至 7 是定长部分。再一次,因为我们知道表的架构,所以我们知道它是一个 4 字节的整数。如果不知道架构的话,你就只好猜了。这个值是 0x00000005 ,这就是我们想看到的列的值。
- 字节 8 和 9 是记录中列的数目。这里 0x0003 是正确的。这表示有 3 列,每列 1 位的 NULL 位图占用 1 个字节。
- 字节 10 为 NULL 位图。这里值是 0XF8 ,我们想把它变成二进制看看它值的意义。 0xF8 = 11111000 . 这就有意义了。位 0-2 代表列 1-3 ,都是 0 ,表示这些列都不是 NULL 值。位 3-7 表示不存在的列,它们被置为 1 。
- 字节 11 和 12 是记录中变长列的数目。这里值是 0X0002 ,我们知道这是正确的。这表示有在变长列偏移数组中 2 个两字节条目:字节 13-14 中值为 0X0016; 字节 15-16 中值为 0X0021 。记住 NULL 位图条目指向的列值的尾部——这样我们就知道每个列有多长,而不需要专门保存列长度。
- 最后的偏移是字节 15 和 16 ,这表示第一个变长列的开始偏移为 17 (或 16 进制 0X11 ),这和 DBCC PAGE 输出一致。第 1 个变长列结束字节是 0X16 ,所以第一个变长列(从字节 17 至 21 )的值为 0x42616E6666 。我们从表元数据中得知只是第一个 varchar 列 destination 。转换成 ASCII 就是“ Banff ”。同理,第二个变长列(从字节 22 至 32 )的值为“ sightseeing ”,这和我们期望值是相同的。
分析至此。
SQL SERVER 2008 的一些特性会改变一些的记录结构。