PostgreSQL: Numeric类型介绍 ——PostgreSQL源码分析课程作业

本文详细解析了PostgreSQL中numeric数据类型的设计,包括数据结构、高精度实现方法、numeric_in和round_var函数的工作原理。通过深入剖析,读者能理解其设计精妙,提升C语言编程和数据库开发技能。
摘要由CSDN通过智能技术生成

摘要

通过PostgreSQL课程一学期的学习,我掌握了通过调试PostgreSQL来辅助阅读源码的技能。我选取了PostgreSQL中的numeric数据类型进行深入研究,接下来主要会介绍数据结构的定义、高精度的实现方法、以及numeric一些重要函数,主要是numeric_in、round_var等函数。通过本文的分析,读者不但可以了解到numeric的基本实现方式和特性,更可以从这些看似简单的定义和实现中感受到设计的精巧,并从中得到C语言系统开发的一些技能和经验。从numeric这一数据类型中,我们也可以了解到PostgreSQL中数据类型实现的一些共性。

关键词:numeric,数据结构,位运算,精度

一、引言

在C语言中,可以表示数值的数据类型有char、short、int、long、float、double等,而这些表示的精度有限,即使是double也最多只有15位十进制数字精度。在PostgreSQL中,numeric是精度最高的一种数值类型,其精度可以达到小数点前 131072 位,小数点后 16383 位。所以,numeric几乎可以看作是一个任意精度的数字,在科学计算中起着重要作用。
一个numeric类型的标度(scale)是小数部分的位数,精度(precision)是全部数据位的数目,也就是小数点两边的位数总和。要声明一个字段的类型为numeric,可以用以下三种语法:NUMERIC(precision, scale)意为同时指定标度和精度,精度必须为正数,标度可以为零或者正数。另外,NUMERIC(precision)选择了标度为0,不带任何精度与标度的声明。NUMERIC则创建一个可以存储一个直到实现精度上限的任意精度和标度的数值。如果一个要存储的数值的标度比字段声明的标度高,那么系统将尝试圆整(四舍五入)该数值到指定的小数位。然后,如果小数点左边的数据位数超过了声明的精度减去声明的标度,那么将抛出一个错误。
除了普通的数字值之外,numeric类型允许用特殊值NaN表示"不是一个数字"。任何在NaN上面的操作都生成另外一个NaN。

二、数据结构

Numeric的数据结构在磁盘上和在内存中是不同的。在磁盘上存储效率较高,而在内存中读取效率较高。每次从磁盘加载到内存需要先进行结构的转换,存储时也要进行转换。因此,我们将分别对两种数据结构进行说明。

1.内存中的实现

在内存中的数据结构如图1所示:
图 1 Numeric在内存中的数据结构——NumericVar
图 1 Numeric在内存中的数据结构——NumericVar

首先要知道比较重要的一个宏定义NBASE,定义值为10000,表示单个digit的范围为0-9999。由于用两个字节(int16)可以覆盖这个范围,所以digits的类型NumericDigit使用了int16。
然后来看数据结构中其他部分,ndigits表示数字占用的digit个数,ndigits的值为一个数字去掉前导0和末尾0剩下的有效digit位数。weight为数字最高位表示的权重,简单理解为小数点的偏移,以NBASE为基本单位,这个值可以为正,可以为负,也可以为0。例如weight为1,表示最高位的digit要乘以10000,如果weight为-1,表示最高位的digit要乘以10-4。sign表示正负符号,为0则表示数字为正,为1则表示数字为负。dscale表示小数位数,也即引言中提到的标度,通过ndigts和dscale可以确定数字中有效位数。buf该数组不直接访问,一般先开一个到两个元素的空间作为缓冲,下文将具体分析其作用。digits作为一个柔型数组,在结构体的末尾,是存储digit的数组空间,其空间是动态开辟的,使用起来类似于一个指针。

2.磁盘上的实现

磁盘上的实现分为两种,一种是short型,一种是long类型,short类型与long类型的结构相似,但占用空间长度要短。在精度要求不是特别高的情况下,用short类型可以节约存储空间。
在这里插入图片描述
图 2 磁盘上数据结构

FLEXIBLE_ARRAY_MEMBER是宏定义,值为空,所以此处的n_data也为柔性数组。两种不同的数据结构头部每个位的表示含义如下。
在这里插入图片描述
图 3 两种数据结构头部含义

NumericShort中的n_header存储符号位sign、小数位数display scale、权重weight,以及最高位的两位有特殊含义,含义如表1所示。NumericLong和NumericShort的区别在于,多花了两个字节的空间存储。联系两个结构体的方式是有一个联合体存在,联合体可以根据n_header的最高两位来判断numeric的类型,用不同的方式去读取后面实际存储的数据。

表 1 n_header最高两位的含义

最高两位表示含义
00类型为long,且符号为正
01类型为long,且符号为负
10类型为short,符号看第3位
11类型为NaN

除了上文提到的数据结构,还有一个数据结构,包含了一个头部和联合体,如图4所示。vl_len_是头部,值为(4+2+ndigits2)<<2,4是vl_len_自身的字节数,2位n_header的字节数,ndigits2为n_data的字节数,左偏两位是由于varlena的特殊结构,需要空出两位去存储其他信息。Numeric被定义为NumericData*,即一个指针指向存储的位置。
在这里插入图片描述
图 4 NumericData的实现方式

3.数据举例

我们来看一个数字,通过其在两种数据结构下的实际存储来直观感受numeric的实现方式。对于数字12345.06789,其有效数字有10位,小数有5位,在内存中用NumericVar实现的方式为:
ndigits: 4
weight: 1
sign: 0
dscale: 5
digits: 0001 2345 0678 9000
由于整数部分有5位,所以最高位的digit为1,权重weight为1,表示1的权重为104,第二个digit存储数字2345,这两位一起表示整数部分。小数部分有5位,所以dscale为5,用两位digit表示,分别为0678和9000。一共使用了4位digits,所以ndigits为4,sign为0表示这个数字为正数。
这个数字在磁盘上实现的格式应该是:
n_header: 33409(1000 0010 1000 0001)2
n_data: 0001 2345 0678 9000
n_header最高二位的10表示这是一个NumericShort类型,第三位sign为0,接下来为000101,表示dscale为5,0000001表示weight为1,之所以没有存储ndights是因为这个数字可以根据结构体的大小(因为含有柔型数组)减去头部大小而计算得到,在实际的代码中也是这样做的。n_data即为NumericVar中的digits直接复制而来,完全相同。在这里,vl_len_的值为56,也即(4+2+4*2)<<2。

4.精度分析

根据numeric的数据结构,我们可以由此分析一下文档中所写的高精度实现原理。小数点前 131072 位、小数点后 16383 位是被dscale和weight限制的,超出精度范围会报错。
对于long,dscale用14位表示,所以小数点后的位数位16383位(11 1111 1111 1111),也即14位2进制的最大值。weight用一个short表示,最大值为32767,因此最多可以有(32767+1)*4=131072位。
这种表示方法是一种程序员和机器理解、效率和精度之间的平衡。用十进制方式表示,效率并不高,本来一个signed short类型的变量可以表示0~32767,但只用来表示0~9999。不过相对于二进制表示程序员和代码阅读者便于理解,使用方便。用十进制另一个好处是可以精确表示,不会出现精度损失的问题。
但是,这个表示效率高于之前用字符串表示大整数或者大浮点数的方法,因为之前的方法8位只能表示0~9,也即32位才能表示0~9999。注意这里weight可以为负数,如果把weight当作无符号整数,表示范围会更大。但这样的话weight没有负数的表示范围,如果数字很小需要很多0占用digits的空间,可能效率会很低。

三、numeric_in函数

1.总体介绍

这个函数的主要功能是将一个字符串类型的小数转换为numeric类型。在每一次使用numeric类型时,数据库要把SQL代码中涉及的小数字符串通过这个函数转换为可以计算和使用的numeric类型。传入的字符串可能如“1.345000”、“-0.00023”、“1E-6”等,相应地可以均转换为numeric类型并返回。下面我们要大致介绍一下numeric_in中转换的大致过程。
首先通过PG_GETARG_CSTRING获取第一个参数,读取方式为cstring。PG_GETARG_CSTRING是一个特殊的函数,可以从传入的参数PG_FUNCTION_ARGS以一定的格式获得具体的参数值。同理,下面PG_GETARG_INT32就是用int32的格式获取第三个参数。首先要去除字符串一开始的空格,如图5所示。
在这里插入图片描述
图 5 numeric_in代码1

接下来,如图6所示,该函数要判断这个字符串是否表示NaN。如果前三个字符为“NaN”,就可以构造一个表示NaN的numeric对象了。596行之后是判断NaN之后是否有其他非空字符,如果有,则说明字符串格式有误,无法转换成numeric。
在这里插入图片描述
图 6 numeric_in代码2

如果前三个字符不是“NaN”,则当作一般的小数去转换。转换过程主要经过以下三步:1. 开辟NumericVar的内存空间并把字符串转为NumericVar类型;2. 检查精度以及四舍五入;3. 把NumericVar类型转换为Numeric类型并返回。这个过程如图7所示。
在这里插入图片描述
图 7 numeric_in代码3

接下来我们具体去看一下这个过程中涉及到的几个函数。涉及到的主要函数有set_var_from_str、apply_typmod、make_result三个函数。

2.set_var_from_str函数

set_var_from_str函数的功能从函数名可以看出,把一个字符串转换为NumericVar类型。返回值为一个指针,指向字符串中满足小数格式的末尾的位置再加1,这个返回值主要用于检查末尾的空间和垃圾。在函数的一开始,首先检查数字的符号以及是否直接以小数点开头(如果整数部分为0时,可以忽略0以小数点开头),如图8代码所示。
在这里插入图片描述
图 8 set_var_from_str代码1——判断符号

接下来要对每一位进行遍历,小数点前的当作整数部分,小数点后的当作小数部分,算出小数和整数的位数,如图9所示。如果遇到一位不是数字,则直接退出。最终dweight存储整数位数,dscale存储小数位数。如果发现小数是用科学计数法表示的,需要调整整数和小数的位数。
在这里插入图片描述
图 9 set_var_from_str代码2——遍历每一位

接下来,如图10所示,就是根据上面计算得到的dweight和dscale计算得到weight和ndigits两个重要的数据。
在这里插入图片描述
图 10 计算得到weight和ndigits

如图11所示,根据之前字符串中存储的有效数字,计算每一位digits的值,4位为一组。
在这里插入图片描述
图 11 计算得到digits

最后有一个strip_var函数,主要是去掉前面和后面的0,这个是通过修改weight和ndigits来减少前面或者后面的0,从而节省存储空间。如果前面有大量的0,例如0.000078,本来应该是{ndigits: 3,weight: 0,dscale: 6,digits: 0000 0000 7800},转换之后变成{ndigits: 1,weight: -2,dscale: 6,digits: 7800},weight减小了2,但digits减少了两个0,节约了4字节的空间。12300000初步转换之后应该是{ndigits: 2,weight: 1,dscale: 0,digits: 1230 0000},经过图11的代码转换之后为{ndigits: 1,weight: 1,dscale: 0,digits: 1230},ndigits减小了1,digits少了1位,节约了两个字节。

3.apply_typmod函数

这个函数主要是读取typmod参数并且进行四舍五入和精度检查。首先根据typmod计算numeric的整数位和小数位,接下来进行四舍五入。四舍五入之后再根据精度调整0的个数。
这个函数的核心部分是round_var。首先根据di = (var->weight + 1) * DEC_DIGITS + rscale这个公式,计算出要保留的位数。如果这个值小于0,结果为0。如果这个值等于0,即为只保留1位有效位数,最后的数字为0或者100……00。如果这个值大于0,首先把该舍去的位舍去,然后判断是否需要进位。
四舍五入的过程是先判断多余的位数上的数字,然后判断是否需要进位,如果需要则进位,如果不需要则直接去掉多余的位数。如果进位,需要考虑最高位进位的情况,这样数字就会增加一位,需要进行更加复杂的判断。
下面用一个例子来看一下这个过程:

对于9999.99,其用NumericVar格式表示的格式为:
{ndigits: 2
weight: 0
dscale: 2
digits: 9999 9900};
如果要四舍五入到小数点后1位,结果应该为10000.0,用NumericVar表示为:
{ndigits: 1
weight: 1
dscale: 1
digits: 0001};

顺着代码手动执行这个过程,传入的参数rscale为1,计算得到的di为5,表示一共有5位有效数字,ndigits计算应该为2。
在这里插入图片描述
图 12
在这里插入图片描述
图 13

di对DEC_DIGITS取模显然为rscale取模,这个决定了表示小数的digits的最后一位有几个有效数字,当di为1时,pow10值为1000,当di为2时,pow10值为100,当di为3时,pow10值为10,用pow10对四舍五入之后的digits的最后一位取模剩下的值,就是多余要舍去的值(extra)。如果extra大于pow10的一半,说明要向上入。向上入分为两种情况:一种是入到上一位即可,比如extra为600,pow10为1000,digit为8000,这种情况下向上入一位digit变成了9000,不会继续影响上面的位数,另一种情况位digit位9000,入一位之后,不但影响了当前的digit,而且影响上一个digit,所以当前的digit变成了0000,carry为1,代表要继续向上入。我们的例子是第二种情况,所以最终末尾的digits会变成0000。这一过程对应的代码如图14所示。
在这里插入图片描述
图 14

接下来就是根据carry进位的问题,同样分为两种情况:一是进位一次就结束,比如上一个digit是5000,carry为1的情况下会变成5001,另一种是持续进位,比如digit为9999,carry为1的情况下,会让上一个digit变成0000,carry依然为1,进入下一次循环,再去影响更高位的digit。比较特殊的情况在于,如果持续进位一直到最高位的digit,例如最高的一位digit已经是9999了,需要加1,由于NumericVar结构体初始化在指定digits时前面有一位buf的多余空间,所以可以利用这个空间存储多出来的1,其他digits保持不变,最后让digits指针指向的开始位置减1,指向buf原本的空间(现在存储了最高位的digits,也即1),然后修改weight和ndigits。这样提高了计算效率,但并没有浪费太多内存,如图15所示。我们的例子依然是第二种情况,carry为1会导致digits[0]变成0000,然后carry依然为1,接下来就是digits[-1](即为buf[0])被置为1,然后digits指向buf[0],weight从0增加到1,ndigits增加1。
在这里插入图片描述
图 15

注意这个时候还没有到最后的状态,此时的ndigits为3,digits为0001 0000 0000,此时要再次经历上一节提到的strip_var函数,最终去掉尾部的0,ndigits变成1,digits变成0001。最终变成目标数据{ndigits: 1,weight: 1,dscale: 1,digits: 0001},完成四舍五入的过程。

4.make_result函数

make_result函数的作用是根据NumericVar创建一个Numeric数据。在转换过程中,首先判断数字是否为NaN,如图16所示。
在这里插入图片描述
图 16

接下来,依旧需要对NumericVar进行一次去掉前置0和末尾0的操作,如图17所示。虽然在前面的转换函数已经做过这样的操作,但考虑到作为一个单独的函数的功能完整性,PG依然重复做了这一事情。
在这里插入图片描述
图 17

对于数字0,PG强行规定符号为正,在处理的时候单独处理,如图18所示。
在这里插入图片描述
图 18

下面是最核心的代码部分——构造Numeric,如图19所示。首先判断其大小和精度是否可以被short类型表示。接下来的代码含义都比较相似,首先计算结构的长度,主要是头部大小+Digits的大小。然后通过位运算的方式去对头部赋值,把sign、weight、dscale的信息存入n_header之中。最后将Digits的数据复制到Numeric类型中的n_data区域,完成整个过程。
在这里插入图片描述
图 19

5.对buf作用的分析

Buf是NumericVar中作用比较难以理解的一个指针。在官方文档的描述中,buf的作用在于当调整digits和weight时无需重新分配空间,但我们从不通过buf访问数据。
Buf实际上只是一个指针,在开辟空间之后,一般会设置digits指向buf的后一位空间,相当于在实际存储数字的空间前开了一段缓冲区,通过numeric_in中对buf的使用情况,可以归结为两点:

  1. 如果digits指针自增(一般是因为前面开头0比较多),可以方便地释放之前的空间。例如对于数字0.000078,初步转换之后为:
    {ndigits: 3,
    weight: 0,
    dscale: 6,
    digits: 0000 0000 7800},
    去除前置的0转换之后为:
    {ndigits: 1
    weight: -2
    dscale: 6
    digits: 7800},
    实际上的操作出了修改ndigits和weight的值外,就是digits指针向右移两个位置。如果没有buf指针指向缓冲区,digits右移之后空余出来的空间可能会丢失成为垃圾,或者需要移动后面实际存储的数据,代价都比较大。
  2. 如果digits指针自减(相当于是进位,位数增加了一位),无需重新分配空间。还是之前9999.99四舍五入为10000.0的例子,总位数多了一位,如果没有提前空余出来buf的空间,就需要把digits中所有的数据后移,但由于有buf的存在,可以直接利用之前没有用到的空间,而不用移动整个数据到新的位置。

四、numeric_out函数

1.总体介绍

这个函数的功能与numeric_in恰好相反,是把Numeric类型转换为字符串类型。当不表示NaN时,通过两个函数,一个是init_var_from_num,一个是get_str_from_var,逐步转为字符串类型。
除了这个函数外,还有一个numeric_out_sci函数,功能为把Numeric类型转为科学计数法形式的字符串类型,与numeric_out函数的核心功能类似,所以不单独做说明。

2.init_var_from_num

如图20所示,这个函数主要是用一个Numeric来初始化NumericVar类型,也即把Numeric类型转换为NumericVar类型。这个函数看似简单,但大量使用了宏定义。宏定义的好处一是简单易懂,二是代码重用的同时没有函数调用的额外开销。
在这里插入图片描述
图 20

来看其中一个宏定义的实现,如图21。本质上这是一个三目表达式,首先判断这个numeric是short类型还是long类型,接下来根据二者的不同,去选择读取不同的位作为dscale。如果是short类型,则留取n_header的第4位到9位并且右移7位之后的数字作为dscale(见图3中的实现方式),如果是long类型,则n_sign_dscale除了最高的两位,剩下的均为dscale的内容。读取weight的方式也大致类似,需要先判断是否位short类型,然后再去读取不同的数据。
在这里插入图片描述
图 21

3.get_str_from_var

这个函数接受一个NumericVar作为输入的参数,并返回一个字符串。主要的实现过程有三步:

  1. 判断符号;
  2. 判断小数点;
  3. 把digit转为字符串数字。

判断符号的过程是直接读取sign来判断,如果是负数,需要在前面加一个“-”号。然后要判断weight是否小于0,如果小于0,则说明整数部分为0,需要在字符串上加一个0和小数点,后面再去填充小数部分,如图22所示。
在这里插入图片描述
图 22

否则,先填充整数部分,然后加上小数点,然后再读取小数部分。在填充每一位的时候需要注意,由于NumericVar在存储时会去掉末尾的0,仅仅用weight和ndigit来表示数字的位数,到底是实际读取内存中的值还是读取一个0出来,这取决于ndigits和当前已经读取位数的关系。当已经读取的位数小于ndigits时,要读取实际存储的值,并且将这个值转换为四位的数字字符串,当已经读取的位数大于等于ndigits并且还没有达到weight或者dscale要求的位数时,直接读取一个0出来,并且转换为字符串中恰当个数的0。

五、总结

本文通过对PostgreSQL源码中numeric.c代码的阅读,了解了一个数据结构在PostgreSQL中需要实现的功能有哪些。作为PostgreSQL中的一种数据类型,其需要实现numeric_in、numeric_out等函数来完成对命令的解析。对于这种高精度数值类型,其需要参与科学计算,所以需要实现的函数主要有比较类函数、与精度有关的函数、与数学四则运算有关的函数、与高级数学运算(例如开方、指数、对数等)的函数、与统计有关的函数、对常用聚合函数的重载、与常见数据类型之间转换的函数等。

本文重点关注了所有数据类型都有的in和out函数在numeric这一复杂数据类型上的实现,并针对代码实现进行了优劣分析,了解了PostgreSQL在设计numeric这一类型上的一些细节,对于更加精准地把握numeric类型的特性和性能以及其他数据结构在实现上的共性有很大的提升和帮助,同时提升了我的一些数据库的开发及调试技能。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

薛钦亮

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值