读源码学MYSQL系列(二)decimal存储转化函数decimal2bin
问题来源
高精度计算是计算机工程实践中非常重要的内容,在涉及到精确计算的项目中,思考过数据库的设计。因而比较好奇MYSQL中是如何实现对decimal的支持的。本文通过源码阅读,分析理解decimal的存储及各种运算转化。参考源代码:
https://github.com/google/mysql/blob/master/include/decimal.h
https://github.com/twitter-forks/mysql/blob/master/strings/decimal.c
基础准备
首先,在头文件decimal.h中定义了基本结构体和类型:
typedef int32 decimal_digit_t;
MYSQL采取4字节为一组来存储高精度小数,可以存储9位十进制数字。不足9位部分仍然使用4字节存储。
typedef struct st_decimal_t {
int intg, frac, len;
my_bool sign;
decimal_digit_t *buf;
} decimal_t;
其中,各个字段含义如下:
intg: 整数,十进制整数部分位数
frac: 整数,十进制小数部分位数
sign: 布尔,false表示正数,true表示负数
buf: int32类型数组,每个int32存储9位十进制数字
len: 数组buf的长度
还有一些其他的宏定义如下:
typedef decimal_digit_t dec1;
typedef longlong dec2;
#define DIG_PER_DEC1 9
#define DIG_MASK 100000000
#define DIG_BASE 1000000000
#define DIG_MAX (DIG_BASE-1)
#define DIG_BASE2 ((dec2)DIG_BASE * (dec2)DIG_BASE)
#define ROUND_UP(X) (((X)+DIG_PER_DEC1-1)/DIG_PER_DEC1)
static const dec1 powers10[DIG_PER_DEC1+1]={
1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000};
static const int dig2bytes[DIG_PER_DEC1+1]={0, 1, 1, 2, 2, 3, 3, 4, 4, 4};
static const dec1 frac_max[DIG_PER_DEC1-1]={
900000000, 990000000, 999000000,
999900000, 999990000, 999999000,
999999900, 999999990 };
存储转化函数decimal2bin
该函数将decimal_t类型的结构体转化为二进制形式。下面结合源代码说明,为了便于理解,将整个函数按照功能顺序拆分成几个模块分析。
思路说明
前文说过,decimal是采用4字节来存储9位整数的,该函数主要围绕着该思路进行。对于任意一个小数,可以进行如下拆分:
不足9位部分|9位整数(重复多次)|小数点|9位小数(重复多次)|不足9位部分
函数声明
int decimal2bin(decimal_t *from, uchar *to, int precision, int frac)
各个参数含义如下:
from: 待转化的decimal结构体
to: uchar数组,转化结果保存到该数组
precision: 声明decimal精度中的总位数
frac: 声明decimal精度中的小数位数
该函数返回执行结果的状态码:
E_DEC_OK/E_DEC_TRUNCATED/E_DEC_OVERFLOW
变量声明
dec1 mask=from->sign ? -1 : 0, *buf1=from->buf, *stop1;
int error=E_DEC_OK, intg=precision-frac,
isize1, intg1, intg1x, from_intg,
intg0=intg/DIG_PER_DEC1,
frac0=frac/DIG_PER_DEC1,
intg0x=intg-intg0*DIG_PER_DEC1,
frac0x=frac-frac0*DIG_PER_DEC1,
frac1=from->frac/DIG_PER_DEC1,
frac1x=from->frac-frac1*DIG_PER_DEC1,
isize0=intg0*sizeof(dec1)+dig2bytes[intg0x],
fsize0=frac0*sizeof(dec1)+dig2bytes[frac0x],
fsize1=frac1*sizeof(dec1)+dig2bytes[frac1x];
const int orig_isize0= isize0;
const int orig_fsize0= fsize0;
uchar *orig_to= to;
该函数声明的变量比较多,有一部分是在计算过程中使用到的,可以到时候再根据代码判断含义。在此主要说明初始化过的变量:
mask: 符号位,dec1类型,其实就是int类型
buf1: 指向from数据中的buf数组
intg: 计算出来的整数部分位数
intg0, frac0, intg0x, frac0x是根据类型精度参数decimal(precision, frac)计算出来的各部分长度。按照前文的拆分,可以表示如下:
intg0x | intg0 | 小数点 | frac0 | frac0x
相应的,isize0和fsize0分别表示计算出来的整数部分和小数部分的字节数。
frac1和frac1x表示实际传进来的from参数的小数部分:
整数部分 | 小数点 | frac1 | frac1x
fsize1表示小数部分需要的字节数。
那么此处为什么不计算整数部分呢?整数部分涉及到前导0的移除,相对要复杂一点,其计算放到了下面的代码中。
前置0处理
buf1= remove_leading_zeroes(from, &from_intg);
if (unlikely(from_intg+fsize1==0))
{
mask=0; /* just in case */
intg=1;
buf1=&mask;
}
intg1=from_intg/DIG_PER_DEC1;
intg1x=from_intg-intg1*DIG_PER_DEC1;
isize1=intg1*sizeof(dec1)+dig2bytes[intg1x];
remove_leading_zeros函数的功能通过名字就可以看出,移除前置0,该函数功能比较简单,只有十几行,可以自行查看。from_intg返回移除之后的整数部分位数,返回的buf1指向数组中从左到右第一个非0的元素位置。
为了理解if语句,需要解释一下unlikely。在mysql源码中,likely和unlikely都出现过,从用法上来说是一样的,if (likely(value))等价于if(value),unlikely类似。那有什么用处呢?其实它们是编译优化语句,likely表示if语句块有较大概率执行,unlikely表示else语句块有较大概率执行,方便编译器做指令优化用的。
条件from_intg+fsize1==0表示整数部分是0,同时小数部分也为0,此时做一些例外处理。因为这是一个较小概率的情形,所以使用了unlikely。
回答上一节的疑问,在这里,intg1和intg1x分别表示移除前置0后的整数部分中足9位和不足9位部分,isize1则表示相应的字节数。如下:
intg1x | intg1 | 小数点 | 小数部分
空间检查
整数部分
if (intg < from_intg)
{
buf1+=intg1-intg0+(intg1x>0)-(intg0x>0);
intg1=intg0; intg1x=intg0x;
error=E_DEC_OVERFLOW;
}
else if (isize0 > isize1)
{
while (isize0-- > isize1)
*to++= (char)mask;
}
先看if条件,intg是根据声明规格计算出来的整数部分位数,如果小于实际from_intg,报溢出错误。buf1指向实际存储数位的数组,intg1-intg0是足9位部分超出的个数。(intg1x>0)指实际数据中不足9位部分是否需要一个int元素,类似的,(intg0x>0)指声明规格中不足9位部分是否需要一个int元素。buf1加上这些超出部分,实际含义是跳过from中比声明规格多的部分。intg1和intg1x也要修正为声明规格。
在else分支中,如果根据规格计算出来的字节数isize0大于实际字节数isize1,移动to指针,同时将多出的字节设置成符号位mask。
小数部分
if (fsize0 < fsize1)
{
frac1=frac0; frac1x=frac0x;
error=E_DEC_TRUNCATED;
}
else if (fsize0 > fsize1 && frac1x)
{
if (frac0 == frac1)
{
frac1x=frac0x;
fsize0= fsize1;
}
else
{
frac1++;
frac1x=0;
}
}
首先,看外层if条件,如果规格计算值fsize0小于实际字节数fsize1,报溢出错误,并将frac1和frac1x修正为声明规格参数。注意,对比整数部分,此处不需要移动buf1指针。因为,超出的小数部分位于数组的末尾。
然后,在else分支中,如果规格计算值fsize0大于实际字节数且实际frac1x大于0(即小数部分有不足9位的剩余部分出现),为了方便后续计算,需要对位数进行一些调整,继续分两种情况。一是frac0==frac1,此时必有frac0x>frac1x,需要对frac1x进行修正。二是frac0>frac1,此时,将frac1x直接并入到frac1中一起处理。注意,因为fsize0>fsize1且frac1x>0,不可能有frac0<frac1。
为了便于后续小数部分的处理,在此做一个总结。经过上段代码后,小数部分的可能关系如下:
当fsize0 < fsize1时, frac1 = frac0, frac1x = frac0x
当fsize0 = fsize1时, frac1 = frac0, frac1x = frac0x or frac1 - frac0 = 1, frac1x = 0, frac0x=7/8 or frac0 - frac1 = 1, frac0x = 0, frac1x = 7/8
当fsize0 > fsize1时, frac0 >= frac1, frac0x >= frac1x
数值转换
根据拆分成9位部分和不足9位部分的思路,可以将要转化的数划分为以下结构:
intg1x | intg1 | 小数点 | frac1 | frac1x
转化过程分成这四部分展开:
intg1x
/* intg1x part */
if (intg1x)
{
int i=dig2bytes[intg1x];
dec1 x=(*buf1++ % powers10[intg1x]) ^ mask;
switch (i)
{
case 1: mi_int1store(to, x); break;
case 2: mi_int2store(to, x); break;
case 3: mi_int3store(to, x); break;
case 4: mi_int4store(to, x); break;
default: DBUG_ASSERT(0);
}
to+=i;
}
dig2bytes函数根据数字位数(intg1x)计算需要的字节数。此处,*buf1即是最高位的那个int整数,求余操作保证不会超出intg1x位数限制,最后与符号位mask进行了异或操作。
得到了要保存的数值后,依据字节数i,分别将x存储到to所指向的数组中。mi_int1store表示将x的一个字节存储到to地址中,其他类似。
intg1 + frac1
/* intg1+frac1 part */
for (stop1=buf1+intg1+frac1; buf1 < stop1; to+=sizeof(dec1))
{
dec1 x=*buf1++ ^ mask;
DBUG_ASSERT(sizeof(dec1) == 4);
mi_int4store(to, x);
}
接下来是整数和小数中的足9位部分,这个比较简单,一个一个复制int就可以了。参考上面的代码,每次复制一个int到to数组中,直到遍历完intg1和frac1次。值得注意的是,在保存数值的时候,同样的,与符号位mask进行了异或操作。
frac1x
/* frac1x part */
if (frac1x)
{
dec1 x;
int i=dig2bytes[frac1x],
lim=(frac1 < frac0 ? DIG_PER_DEC1 : frac0x);
while (frac1x < lim && dig2bytes[frac1x] == i)
frac1x++;
x=(*buf1 / powers10[DIG_PER_DEC1 - frac1x]) ^ mask;
switch (i)
{
case 1: mi_int1store(to, x); break;
case 2: mi_int2store(to, x); break;
case 3: mi_int3store(to, x); break;
case 4: mi_int4store(to, x); break;
default: DBUG_ASSERT(0);
}
to+=i;
}
对比intg1x部分的处理,前面多了一个while循环。这里的逻辑比较绕,while的作用是为了扩充frac1x,举个例子,0.345,frac1 = 0, frac1x = 3,此时,通过while循环可以把frac1x扩充为4,即0.3450,分别以34和50的形式存储到uchar数组中。那么,此处的lim是怎么计算的呢?参考上一行代码,这里有一个大前提,不能超过声明规格,即isize0 >= isize1。当frac1 < frac0时,frac1x的取值从0到DIG_PER_DEC1都有可能,所以lim = DIG_PER_DEC1。而当frac1 >= frac0时,必有frac1x <= frac0x,所以lim = frac0x。
收尾
if (fsize0 > fsize1)
{
uchar *to_end= orig_to + orig_fsize0 + orig_isize0;
while (fsize0-- > fsize1 && to < to_end)
*to++= (uchar)mask;
}
orig_to[0]^= 0x80;
/* Check that we have written the whole decimal and nothing more */
DBUG_ASSERT(to == orig_to + orig_fsize0 + orig_isize0);
return error;
if条件表示在to数组大于实际需要的情况下,使用符号位mask对to数组进行填充。
最后返回该函数执行的状态码。
总结
该函数虽然只有120多行,但有较多的细节,因为涉及到from数据和to数组之间的规格检测及空间填充问题。通过该函数,我们可以比较清楚的看到mysql是如何存储decimal类型的数据的。