MySQL + go 如何安全处理 decimal 类型数据
在电商或者金融相关的场景中,商品价格等数据都会涉及到小数的表示或者计算,如果使用编程语言内置的浮点数类型,会有精度丢失的风险。在应用领域,decimal
类型应运而生,MySQL 数据库中内置支持 decimal
数据类型,而程序设计上,一般编程语言都会有标准库或者第三方库对 decimal
类型提供实现。本文快速展示下如何实现全链路对 decimal
类型数据的读取处理,而不用担心会丢失数据的精度。
数据库层 - MySQL
在 MySQL 层,decimal
类型的值使用二进制表示,其大致转换过程是:
- 将待存储的数据按照整数和小数部分一分为二,比如
1234567890.1234
,分为1234567890
和1234
; - 针对整数部分,从低位到高位,按照每 9 位数字为一组,进行分割,比如
1234567890
将分为1
和234567890
; - 使用最短字节序列分别表示每个分组的整数,上面的
1
即0b00000001
,而234567890
则对应0x0D-FB-38-D2
; - 对于小数部分,使用类似的分组(从高位到低位)处理方式,即 1234 表示为
0x04D2
; - 最后,将最高位置反,得到
0x81 0D FB 38 D2 04 D2
,也就是使用了 7 个字节来表示这个数字。
Bonus: 如果是小数,比如 -1234567890.1234
,则只需要将上面第 5 步的所有位置反即可,也就是 0x7E F2 04 C7 2D FB 2D
小结
MySQL 通过设计巧妙的可变长度的二进制转换,实现了对严格要求精度的小数的表示。
网络传输层 - MySQL
存储在 MySQL 底层存储上的 decimal,我们知道是二进制了之后,也就对精度问题的持久化存储放心了,但是,又带来两个问题:
- 数据经过二进制转换之后,如果将这个字节序列给客户端,客户端显然是不能理解的,而且耦合了转换逻辑,显然是需要 MySQL 服务器做一次反向的从二进制数据到真实小数的转换;
- 转换后的数据在数据库连接中的传输,MySQL 又是如何保证安全呢?
答案很简单:纯文本。
通过对 MySQL 连接进行抓包,可以确认这一点,截图是通过 Wireshark 抓包 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
接口的实现,以确认它确实是安全的:
首先,我在源码处加了两行代码,以方便我确认底层数据的类型,确认反序列前,是一个字节序列:
之后,我跟踪了代码的执行,可以看到 decimal 包按照字符串的方式对数据直接进行了反序列化:
网络传输层 - protobuf
考虑到整数的溢出以及浮点数精度损失风险,我在对外服务的协议规范上,也都统一使用字符串类型。
message Order {
string order_no = 1;
string purchase_amount = 2;
status int32 = 3;
}
总结
- MySQL 底层使用可变长度的二进制表示 decimal 类型数据;
- MySQL 在网络传输中使用纯文本表示 decimal 类型数据;
- Golang 程序中借助
shopspring/decimal
实现 decimal 类型数据的处理; -
shopspring/decimal
底层使用了科学计数法表示 decimal,但是本文就不展开了; - 应用对外服务协议使用字符串表示 decimal 类型数据。
思考
- 使用字符串表示 decimal 类型数据可能带来更多的字节数量。