欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  数据库

解剖SQLSERVER 第十四篇 Vardecimals 存储格式揭秘(译)

程序员文章站 2022-06-02 07:58:42
...

解剖SQLSERVER 第十四篇 Vardecimals存储格式揭秘(译) http://improve.dk/how-are-vardecimals-stored/ 在这篇文章,我将深入研究vardecimals 是怎麽存储在磁盘上的。 作为一般的介绍vardecimals 是怎样的,什么时候应该使用,怎样使用,参考这篇文章 vard

解剖SQLSERVER 第十四篇 Vardecimals 存储格式揭秘(译)

http://improve.dk/how-are-vardecimals-stored/

在这篇文章,我将深入研究vardecimals 是怎麽存储在磁盘上的。

作为一般的介绍vardecimals 是怎样的,什么时候应该使用,怎样使用,参考这篇文章

vardecimal 存储格式启用了吗?

首先,我们需要看一下vardecimals 是否已经开启了,因为他会完全改变decimals 的存储方式。Vardecimal 不是独立的一种数据类型,所有使用decimals 的列都会使用vardecimals方式来存储并使用相同的system type(106)。注意在SQLSERVER里面,numeric 跟decimal是完全一样的。无论我在哪里我提到decimal, 你都可以使用numeric来替代并且会得到相同的结果

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)


你可以执行下面语句来查看给定的表的vardecimal 是否开启

SELECT OBJECTPROPERTY(OBJECT_ID('MyTable'), 'TableHasVarDecimalStorageFormat')

如果你没有权限运行上面语句,或者不想使用OBJECTPROPERTY函数,你可以查询sys.system_internals_partition_columns DMV 获取同样的信息。

USE test
GO

SELECT
    COUNT(*)
FROM
    sys.system_internals_partition_columns PC
INNER JOIN
    sys.partitions P ON P.partition_id = pc.partition_id
INNER JOIN
    sys.tables T ON T.object_id = P.object_id
WHERE
    T.name = 'test_vardecimal' AND
    P.index_id  1 AND
    PC.system_type_id = 106 AND
    PC.leaf_offset  0


固定长度变为可变长度
正常的decimal列在记录里面使用固定长度来存储。这意味着存储的是真正的数据。他不需要保存存储的字节数的长度信息这个长度信息用来计算并存储在元数据里。

一旦你打开vardecimals, 所有的decimals 不再使用固定长度来存储,进而用可变长度代替。

将decimal 作为可变长度字段来存储有一些特别含义
1、我们再也不能使用静态的方法来计算一个给定的值的所需字节数
2、会有两个字节的开销用来存储偏移值在可变长度偏移数组里
3、如果先前行记录没有可变长度列,那么开销实际上是4个字节因为我们也需要存储可变长度列的列数量
4、decimal 的实际值变成了可变数量的字节 这需要我们去解读

vardecimal 值由哪些部分组成
一旦我们开始解析行记录然后在可变长度部分检索vardecimal 的值,我们需要解析里面的数据。

正常的decimals 基本能存储一个巨大无比的整数(范围是根据元数据定义的decimal的位置)
vardecimals 的存储使用科学计数法。使用科学计数法,我们需要存储三个不同的值
1、符号(正数/负数)
2、指数
3、(对数的) 尾数

使用这三个组成部分,我们可以使用下面的公式计算出实际值

(sign) * mantissa * 10sup>exponentsup>

例子

假设我们有一个vardecimal(5,2)列,我们存储的值是123.45。在科学记数法里,将表示为1.2345 * 102
在这种情况下我们有正数符号(1),一个尾数1.2345和一个指数2。SQL Server知道尾数总是有一个固定小数点在第一个数字后面,正因为如此,这会简单的存储整数值12345作为尾数,

当指数是2,SQLSERVER知道我们的范围由指数2来定义,数值向右移动指数的长度,存储0作为实际的指数

一旦我们读取这个数,我使用下面的公式计算尾数(注意我们在这里不关心如果尾数是正的还是负的--我们会稍后将他保存到account里)

mantissa / 10sup>floor(log10(mantissa))sup>

将我们的值带入进去,我们得到

12345 / 10sup>floor(log10(12345))sup>

通过简化我们得到

12345 / 10sup>4sup>

使用科学计数法得到最终的尾数

1.2345

到目前为止一切顺利,我们现在得到尾数的值。在这里我们需要做两件事
1、加上符号位
2、根据指数值将decimal的小数点移动到右边正确的位置。当SQLSERVER知道范围是2,他将2替代4,并减去指数指定的范围--允许我们忽略范围并且只需要直接计算数字

因此我们获得了我们最终需要计算的最后数字

(sign) * mantissa * 10sup>exponentsup> => (1) * 1.2345 * 10sup>2sup> => 1.2345 * 10sup>2sup> = 123.45

读取符号位和指数
第一个字节包含符号和指数。在先前的例子里面,这些值占用4个字节(包含额外的2个字节的偏移数组)

0xC21EDC20

如果我们观察第一个字节,并且将他转换为binary,我们获得下面信息

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

最重要的那个位是最左边的那个位,或者从右开始数第7位(位置编号从0开始)那个位是符号位。如果设置为1表示正数,如果为0表示负数,我们看到是1表示正数

位0 - 6是一个7位的包含指数的值。一个常规的无符号7位值可以包含的范围是0~127。当decimal 数据类型需要表示一个范围 –1038+1到 1038-1,我们就需要存储负数。

我们可以使用7位的其中一位作为符号位,在其余的6位里存储值,允许的范围是–64 到 63。然而SQLSERVER会用尽7位去存储本身的数值,

不过存储的是一个偏移值64。因此,指数0会被存储为64 (64-64=0)。指数-1 会存储63 (63-64=-1),指数1会存储65 (65-64=1)如此类推。

在我们的例子里,读取位0-6 将会得到下面的值

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

66减去64这个偏移值就得出 指数2
66-64=2(指数)


尾数的chunk存储
余下的字节包含尾数值。我们把他转换为二进制

Hex: 1E       DC       20

Bin: 00011110 11011100 00100000

尾数存储在10位的chunks里,每一个chunk显式尾数的3个数字(记住,尾数是一个很大的整数,直到后来我们开始把他作为一个十进制的指针数值 10的n次幂)

把这些字节切割放进去chunks里面 会得到如下的组别

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

在这种情况下,一个字节8位 SQLSERVER在这里会浪费4位使用这种chunk大小的话。问题来了,为什么要选择一个chunk大小为10位?那10位 需要显示所有可能的三位整数(0-999)。

如果我们使用一个chunk的大小来表示一个数字会怎样?

在这种情况下,一个字节8位 SQLSERVER在这里会浪费4位使用这种chunk大小的话。问题来了,为什么要选择一个chunk大小为10位?那10位 需要显示所有可能的三位整数(0-999)。如果我们使用一个chunk的大小来表示一个数字会怎样?

刚才那样的情况,我们需要展示数值0-9.那总共需要4个位(0b1001 = 9)。然而,使用4个位的时候我们实际可以展示的最大范围是0-15(0b1111 = 15) --意味着我们浪费了6个值的空间(15-9=6)这些值永远不需要的。从百分比来讲,我们浪费了6/16=37.5%

让我们试着画出不同的chunk大小对应浪费的百分比的图:

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

我们看到chunk大小选择4和选择7 相比起选择chunk大小为10 有较大的浪费。
在chunk大小为20时,对于0浪费相当接近,但是他还是有2倍浪费率相比起10来说

现在,浪费不是最重要的。对于压缩来说,最理想的情况是对于绝对必要的数字我们不想使用多余的数字。在chunk大小为10的情况下,可以显示3个数字,我们浪费了2个数字空间范围是0-9。

然而,我们只关注范围100-999。如果我们的chunk大小选择20个位,每个chunk显示6个数字,我们浪费了了一些字节 值从0-99999,当我们只关注值1000000-999999。

基本上,这是一个折衷方案,浪费就越少 而且也越好。我们继续看图表,粒度越来越少。很明显 选择10个位作为chunk的大小是最好的选择 --这个选择的浪费是最小的 并且有合适的粒度大小 3个数字


在我们继续之前还有一些细节。想象一下我们需要存储的尾数值为4.12,有效的整数值是412

Dec: 412
Bin: 01100111 00
Hex: 67       0

在这种情况下,我们会浪费8位在第二个字节,因为我们只需要一个块,但我们需要两个字节来表示这10位。在这种情况下,鉴于过去两位不设置,SQL Server会截断最后一个字节。因此,如果你正在读一块,你的磁盘上,你可以假设其余部分不设置。


在这种情况下,我在第二个字节浪费8个位,因为我们只需要一个chunk,不过我们需要两个字节来显示这个位。在这种情况下,多余的两个位不会进行设置,SQLSERVER会简单的截断最后一个字节。因此,如果你读取一个chunk并且位数已经超出了磁盘的范围,你可以假设剩余的位并没有设置

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

解析一个vardecimal值
最后,我们准备去解析一个vardecimal 值(使用C#实现)!我们将使用先前的例子,存储123.45值使用decimal(5,2)列。在磁盘上,我们读取下面的字节数组根据调用的顺序

Hex: C2       1E       DC       20
Bin: 11000010 00011110 11011100 00100000

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

读取符号位
读取符号位相对来说比较简单。我们将只需要在第一个字节上读取:

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

通过位运算符我们将右移7位,剩下的位是最重要的位。这意味着我们将得到值1 表示正数的符号位,如果是0表示负数

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

decimal sign = (value[0] >> 7) == 1 ? 1 : -1;

读取指数
下面(技术上来讲这7个位是紧跟着符号位的)的7位包含了指数值

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

将十六进制值0b1000010 转换为进制值得到的结果是66.我们知道指数总是有偏移值64,我们需要将存储的值减去64从而得到实际值:

Exponent = 0b1000010 – 0n64  Exponent = 6664 = 2

读取尾数
接下来就是尾数值。前面提到,我们需要读取一个10个位的chunk,并且需要注意那些被截断的部分

首先,我们需要知道有多少有用的位。这样做很简单,我们只需简单的将尾数的字节数(除了第一个字节之外的所有字节)乘以8

int totalBits = (value.Length - 1) * 8;

一旦我们知道有多少位是可用的(在这个例子里 2个chunk 24位=3个字节* 8位),我们可以计算chunks的数目

int mantissaChunks = (int)Math.Ceiling(totalBits / 10d);

因为每个块占用10位,我们只需要的比特总数除以10。如果有填充最后,匹配一个字节边界,它将是0的,不会改变最终的结果。因此为2字节尾数我们将有8位备用,将非标准都是0。

对于一个3字节尾数我们将有4位,再次添加0尾数总额。

因为每个chunk占用10个位,我们只需要除以将位的总数除以10。如果在结尾有占位,为了匹配位的边界,SQLSERVER会填充0但是这个不会影响上面公式得出的结果。

因此,对于一个2个字节的尾数我们会有8bit(这里作者是不是错了?应该是6bit吧) 是剩余的,这些剩余位都会被无意义的0填充。对于一个3字节 的尾数我们会有4bit 剩余,

再一次在总的尾数值上填充0

这里我们准备读取chunk的值。在读取之前,我们需要分配两个变量

decimal mantissa = 0;
int bitPointer = 8;

可变长的尾数值是由尾数值进行累加的,每次我们读取一个新10-bit chunk值就累加一次。bitPointer 是一个指针指向当前读取到的位。
我们不准备读取第一个字节,我们会从第8位开始读取(从0开始数,因此第8位 =第二个字节的第一位)

看一下这些位 他们可以简单的看成是一条long stream --我们只需要从左到右进行读取,对吧?不完全是,你可否记得,最右边的位是最重要的,
因此最右边的位应该是我们最先要读取的。然而,我们需要一次读取一个字节。同样,整体的方向是按chunk为单位的话是从左到右读取。

一旦我们达到chunk的位置,我们每次就需要一个字节一个字节地读取。bits1-8 在第一个字节里读取,bits9-10 在第二个字节里读取,

下面图片中橙色的箭头(最大的那个箭头指示字节读取顺序(chunkwise)从左向右,而每个独立的字节内部的读取顺序是小箭头那个 从右向左)

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

为了方便访问所有的bits,和避免做太多的人工的位移操作,我实例化一个BitArray 类 ,这个类包含了所有的数据位:

var mantissaBits = new BitArray(value);

使用这个类,你必须知道bit 数组如何跟字节进行映射。形象的描述,他会像下面那样,mantissaBits 数组指针在图片的上面:

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

解剖SQLSERVER 第十四篇    Vardecimals 存储格式揭秘(译)

我知道这看起来很复杂,不过所有这些复杂的事情只不过是需要知道指针的指向。我们的代码里面是字节数组。
我们访问每一个独立的bits的方式是通过mantissaBits 数组,这个数组只是一个很大的指向独立的bits的数组指针。

看一下第一个8 bits,manitssaBits 数组按照我们的读取方向很好地排列。第一个条目(mantissaBits[0])指向
第一个字节的最右一位。第二个条目指向第二个字节,以此类推。因此,第一个8 bits是直接读取的。然而,后面的两个,在manitssaBits 数组里面他们需要
我们跳过6个条目以至于我们读取条目14和15,他们指向下一个字节的最后两个bits(因为是从右向左读取的)。

读取第二个chunk,我们必须回去读取bit 条目8-13 然后跳到条目20-23,忽略条目16-19 因为他们只是不相关的占位padding。
这是相当棘手的。幸运的是我们可以*选择从重要的位到不重要的位进行读取,或者相反

让我们看一下代码实现

for (int chunk = mantissaChunks; chunk > 0; chunk--)
{
    // The cumulative value for this 10-bit chunk
    decimal chunkValue = 0;

    // For each bit in the chunk, shift it into position, provided it's set
    for (int chunkBit = 9; chunkBit >= 0; chunkBit--)
    {
        // Since we're looping bytes left-to-right, but read bits right-to-left, we need
        // to transform the bit pointer into a relative index within the current byte.
        int byteAwareBitPointer = bitPointer + 7 - bitPointer % 8 - (7 - (7 - bitPointer % 8));

        // If the bit is set and it's available (SQL Server will truncate 0's), shift it into position
        if (mantissaBits.Length > bitPointer && mantissaBits[byteAwareBitPointer])
            chunkValue += (1  chunkBit);

        bitPointer