读源码学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类型的数据的。

参考

github decimal源代码