MySQL + go 如何安全处理 decimal 类型数据

02/Mar/2022 · 1 minute read

在电商或者金融相关的场景中,商品价格等数据都会涉及到小数的表示或者计算,如果使用编程语言内置的浮点数类型,会有精度丢失的风险。在应用领域,decimal 类型应运而生,MySQL 数据库中内置支持 decimal 数据类型,而程序设计上,一般编程语言都会有标准库或者第三方库对 decimal 类型提供实现。本文快速展示下如何实现全链路对 decimal 类型数据的读取处理,而不用担心会丢失数据的精度。

数据库层 - MySQL

在 MySQL 层,decimal 类型的值使用二进制表示,其大致转换过程是:

  1. 将待存储的数据按照整数和小数部分一分为二,比如 1234567890.1234,分为 12345678901234
  2. 针对整数部分,从低位到高位,按照每 9 位数字为一组,进行分割,比如 1234567890 将分为 1234567890
  3. 使用最短字节序列分别表示每个分组的整数,上面的 10b00000001,而 234567890 则对应 0x0D-FB-38-D2
  4. 对于小数部分,使用类似的分组(从高位到低位)处理方式,即 1234 表示为 0x04D2
  5. 最后,将最高位置反,得到 0x81 0D FB 38 D2 04 D2,也就是使用了 7 个字节来表示这个数字。

Bonus: 如果是小数,比如 -1234567890.1234,则只需要将上面第 5 步的所有位置反即可,也就是 0x7E F2 04 C7 2D FB 2D

小结

MySQL 通过设计巧妙的可变长度的二进制转换,实现了对严格要求精度的小数的表示。

网络传输层 - MySQL

存储在 MySQL 底层存储上的 decimal,我们知道是二进制了之后,也就对精度问题的持久化存储放心了,但是,又带来两个问题:

  1. 数据经过二进制转换之后,如果将这个字节序列给客户端,客户端显然是不能理解的,而且耦合了转换逻辑,显然是需要 MySQL 服务器做一次反向的从二进制数据到真实小数的转换;
  2. 转换后的数据在数据库连接中的传输,MySQL 又是如何保证安全呢?

答案很简单:纯文本。

通过对 MySQL 连接进行抓包,可以确认这一点,截图是通过 Wireshark 抓包 MySQL 服务器返回的 decimal 数据:

screenshot
MySQL 服务器响应的 decimal 类型数据是纯文本

小结

因为使用了纯文本传输数据,所以不用担心小数在传输过程中会有精度问题

应用层 - Golang

在我的应用程序里,我使用了 golang 来开发程序,依赖了 shopspring/decimal 包来处理 decimal 类型,而且它同时实现了 sql.Scanner 接口,也就意味着我可以直接用它完成对数据库查询返回数据的反序列化。比如我的代码里:

// Order ...
type Order struct {
	OrderNo          string
	PurchaseAmount   decimal.Decimal
	Status           uint8
}

order := new(Order)
db.Where("order_no = ?", orderNo).First(order).Error

不需要额外的逻辑,PurchaseAmount 能够精确地反序列化 decimal 类型的数据。

尽管如此,我还是看了下 shopspring/decimal 包里对 Scanner 接口的实现,以确认它确实是安全的:

首先,我在源码处加了两行代码,以方便我确认底层数据的类型,确认反序列前,是一个字节序列:

screenshot
decimal 获取的value的类型是字节序列

之后,我跟踪了代码的执行,可以看到 decimal 包按照字符串的方式对数据直接进行了反序列化:

screenshot
decimal 按照处理字符串的方式完成反序列化

网络传输层 - protobuf

考虑到整数的溢出以及浮点数精度损失风险,我在对外服务的协议规范上,也都统一使用字符串类型。

message Order {
    string order_no = 1;
    string purchase_amount = 2;
    status int32 = 3;
}

总结

思考