本文转载自微信公众号「后端研究所」, 作者大白斯基 . 转载本文请联系后端研究所公众号.
缘起
前几天写了个小需求, 本来以为很简单, 但是上线之后却发现出了 bug.
需求大概是这样的:
上游调用我的服务来获取全量信息, 上游的数据包虽然是 JSON 但是结构不确定
我的服务使用 Go 语言开发, 所以就使用了原生的 JSON 包来进行反序列化
拿到唯一 ID 从 DB 拉取数据, 并返回给上游调用方
就是这么简单的过程, 让我栽了个跟头, bug 的现象是这样的:
上游给的唯一 ID 一直在数据库查不到结果
上游给的唯一 ID 一定是真实有效的
乖乖, 这就矛盾了, 于是我祭出了日志大法, 在测试环境跑了一下, 发现了个神奇的现象:
下游服务收到的 JSON 字符串中的唯一 ID 是没问题的, 和上游一致
下游服务经过 JSON.unmarshal 反序列化之后唯一 ID 发生了变化, 和上游不一致
究竟发生了什么?
难道我被智子给监控了吗?
我不理解 我不明白......
任何不合理现象背后一定有个合理的解释, 千万不要像我这样被玄学占领了高地.
分析
我决定看看究竟是谁在搞鬼, 现在的矛头指向了 JSON.unmarshal 这个反序列化的动作, 于是我写了个小 demo 复现一下:
- package main
- import (
- "encoding/json"
- "fmt"
- "reflect"
- )
- func main() {
- var request = `{"id":7044144249855934983,"name":"demo"}`
- var test interface{}
- err := JSON.Unmarshal([]byte(request), &test)
- if err != nil {
- fmt.Println("error:", err)
- }
- obj := test.(map[string]interface{})
- dealStr, err := JSON.Marshal(test)
- if err != nil {
- fmt.Println("error:", err)
- }
- id := obj["id"]
- // 反序列化之后重新序列化打印
- fmt.Println(string(dealStr))
- fmt.Printf("% v\n", reflect.TypeOf(id).Name())
- fmt.Printf("% v\n", id.(float64))
- }
跑一下看看结果如下:
- {
- "id":7044144249855935000,"name":"demo"
- }
- float64
- 7.044144249855935e+18
果然复现了:
原始输入字符串:
'{"id":7044144249855934983,"name":"demo"}'
处理后的字符串:
'{"id":7044144249855935000,"name":"demo"}'
id 从 7044144249855934983 变成了 7044144249855935000, 从有效数字 16 位之后变为 000 了, 所以这个 id 无法从 db 获取数据.
于是我谷歌了一波, 原来是这样的:
在 JSON 的规范中, 对于数字类型是不区分整形和浮点型的.
在使用 JSON.Unmarshal 进行 JSON 的反序列化的时候, 如果没有指定数据类型, 使用 interface{}作为接收变量, 其默认采用的 float64 作为其数字的接受类型
当数字的精度超过 float 能够表示的精度范围时就会造成精度丢失的问题
到这里, 我基本清楚了为什么会出现 bug:
上游的 JSON 字符串格式不确定无法使用 struct 来做反序列化, 只能借助于 interface{}来接收数据
上游的 JSON 所传的 id 是数值类型, 换成字符串类型则没有这种问题
上游的 JSON 所传的 id 数值比较大, 超过了 float64 的安全整数范围
解决方案有两种:
上游将 id 改为 string 传给下游
下游使用 JSON.number 类型来避免对 float64 的使用
- package main
- import (
- "encoding/json"
- "fmt"
- "strings"
- )
- func main() {
- var request = `{"id":7044144249855934983}`
- var test interface{}
- decoder := JSON.NewDecoder(strings.NewReader(request))
- decoder.UseNumber()
- err := decoder.Decode(&test)
- if err != nil {
- fmt.Println("error:", err)
- }
- objStr, err := JSON.Marshal(test)
- if err != nil {
- fmt.Println("error:", err)
- }
- fmt.Println(string(objStr))
- }
事情到这里基本已经清晰了, 改完上线就修复 bug, 但是我心中仍然有很多疑惑:
为什么 JSON.unmarshal 使用 float64 来处理就可能出现精度缺失呢?
缺失的程度是怎样的?
什么时候出现精度缺失?
里面有什么规律吗?
反序列化时 decoder 和 unmarshal 如何选择呢?
虽然问题解决了, 但是没搞清楚上面这些问题, 相当于并没有什么收获, 于是我决定探究一番.
探究
float64 作为双精度浮点型严格遵循 IEEE754 的标准, 因此想要搞清楚为什么 float64 可能出现精度缺失, 就必须要搞清楚二进制科学计算法和 IEEE754 标准的基本原理.
二进制的科学计数法
在聊 float64 之前, 我们先回忆下十进制的科学计数法.
我们为了便于记忆和直观表达, 采用科学记数法来编写数字的方法, 它可以容纳太大或太小的值, 在科学记数法中, 所有数字都是这样编写的: x = y*10^z, 此时的底数是 10.
比如 2000000=2*10^6, 确实更加直观简便, 同样的这种简化类的需求在二进制也存在, 于是出现了基于二进制的科学计数法.
二进制 1010010.110 表示为 1.010010110 * (2 ^ 6), 我们后面要说的 IEEE754 标准本质上就是二进制科学计数法的工程标准定义.
IEEE754 标准的诞生
在 20 世纪六七十年代, 各家电脑公司的各个型号的电脑, 有着千差万别的浮点数表示, 却没有一个业界通用的标准.
在 1980 年, 英特尔公司就推出了单片的 8087 浮点数协处理器, 其浮点数表示法及定义的运算具有足够的合理性, 先进性, 被 IEEE 采用作为浮点数的标准, 于 1985 年发布.
IEEE754(ANSI/IEEE Std 754-1985)是 20 世纪 80 年代以来最广泛使用的浮点数运算标准, 为许多 CPU 与浮点运算器所采用, 标准规定了四种表示浮点数值的方式: 单精确度 (32 位), 双精确度(64 位), 延伸单精确度(43 位以上很少使用) 与延伸双精确度(79 位以上).
威廉. 墨顿. 卡韩(英语: William Morton Kahan,1933 年 6 月 5 日 -), 生于加拿大安大略多伦多, 数学家与计算机科学家, 专长于数值分析, 1989 年图灵奖得主, 1994 年被提名为 ACM 院士, 现为加州大学柏克莱分校计算机科学名誉教授, 被称为浮点数之父.
老爷子已经近 90 岁了, 这是 1968 年到加州大学伯克利分校任数学与计算机科学教授时的照片.
IEEE754 的基本原理
int64 是将 64bit 的数据全部用来存储数据, 但是 float64 需要表达的信息更多, 因此 float64 单纯用于数据存储的位数将小于 64bit, 这就导致了 float64 可存储的最大整数是小于 int64 的.
理解这一点非常关键, 其实也比较好理解, 64bit 每一位都非常重要, 但是 float64 需要拿出其中几位来做别的事情, 这样存储数据的 range 就比 int64 小了许多.
IEEE754 标准将 64 位分为三部分:
sign, 符号位部分, 1 个 bit 0 为正数, 1 为负数
exponent, 指数部分, 11 个 bit
fraction, 小数部分, 52 个 bit
32 位的单精度也分为上述三个部分, 区别在于指数部分是 8bit, 小数部分是 23bit, 同时指数部分的偏移值 32 位是 127,64 位是 1023, 其他的部分计算规则是一样的.
IEEE754 标准可以认为是二进制的科学计数法, 该标准认为任何一个数字都可以表示为:
特别注意, 图片中的指数部分 E 并没有包含偏移值, 偏移值是 IEEE754 转换为浮点数二进制序列时使用的.
有效数字 M 的约束
M 的取值为 1≤M<2,M 可以写成 1.xxxxxx 的形式, 其中 xxxxxx 表示小数部分. IEEE 754 规定, 在计算机内部保存 M 时, 默认这个数的第一位总是 1, 因此可以被舍去, 只保存后面的 xxxxxx 部分, 在恢复计算时加上 1 即可.
指数 E 的约束
E 为一个无符号整数也就是都是>=0, 在 32 位单精度时取值范围为 0~255, 在 64 位双精度时取值范围为 0~2047. 当数字是小数时 E 将是负数, 为此 IEEE754 规定使用科学计数法求的真实 E 加上偏移值才是最终表示的 E 值.
看到这里读者会有疑问: 如果真实 E 值超过 128, 那么加上偏移值岂不是要超过 255 发生越界了?
没错, 当指数部分 E 全部为 1 时, 需要看 M 的情况, 如果有效数字 M 全为 0, 表示 ± 无穷大, 如果有效数字 M 不全为 0, 表示为 NaN.
NaN(Not a Number 非数)是计算机科学中数值数据类型的一类值, 含义为未定义或不可表示的值.
数据表示规则
前面了解了 IEEE754 的基本原理, 接下来就是实际应用了.
一般来说 10 进制场景下存在三种情况转换为浮点型:
纯整数转换为浮点数 比如 10086
混合小数转换为浮点数 比如 123.45
纯小数转换为浮点数 比如 0.12306
就分为两种情况将 10 进制全部转换为 2 进制就可以了, 比如整数部分 123 就辗转除 2 取余数再逆向书写就好, 小数部分则是辗转乘 2 取整再顺序书写就好.
偷个懒从菜鸟教程网站上 copy 个例子, 将 10 进制 173.8625 转换为 2 进制的做法:
十进制整数转换为二进制整数采用 "除 2 取余, 逆序排列" 法
十进制小数转换成二进制小数采用 "乘 2 取整, 顺序排列" 法
合并两部分
(173.8125)10=(10101101.1101)2
特别注意, 在某些情况下小数部分的乘 2 取整会出现无限循环, 但是 IEEE754 中小数部分的位数是有限的, 这样就出现了近似值存储, 这也是一种精度缺失的现象.
安全整数范围
我们之前有疑问: 任何整数经过 float64 处理后都有问题吗? 还是说有个安全转换的数值范围呢?
我们来分析下 float64 可以表示的数据范围是怎样的:
尾数部分全部为 1 时就已经拉满了, 再多 1 位尾数就要向指数发生进位, 此时就会出现精度缺失, 因此对于 float64 来说:
最大的安全整数是 52 位尾数全为 1 且指数部分为最小 0x001F FFFF FFFF FFFF
float64 可以存储的最大整数是 52 位尾数全位 1 且指数部分为最大 0x07FEF FFFF FFFF FFFF
- (0x001F FFFF FFFF FFFF)16 = (9007199254740991)10
- (0x07EF FFFF FFFF FFFF)16 = (9218868437227405311)10
也就是理论上数值超过 9007199254740991 就可能会出现精度缺失.
10 进制数值的有效数字是 16 位, 一旦超过 16 位基本上缺失精度是没跑了, 回过头看我处理的 id 是 20 位长度, 所以必然出现精度缺失.
decoder 和 unmarshal
我们知道在 JSON 反序列化时是没有整型和浮点型的区别, 数字都使用同一种类型, 在 go 语言的类型中这种共同类型就是 float64.
但是 float64 存在精度缺失的问题, 因此 go 单独对此给出了一个解决方案:
使用 JSON.Decoder 来代替 JSON.Unmarshal 方法
该方案首先创建了一个 jsonDecoder, 然后调用了 UseNumber 方法
使用 UseNumber 方法后, JSON 包会将数字转换成一个内置的 Number 类型(本质是 string),Number 类型提供了转换为 int64,float64 等多个方法
UseNumber causes the Decoder to unmarshal a number into an interface{} as a Number instead of as a float64
我们来看看 Number 类型的源码实现:
- // A Number represents a JSON number literal.
- type Number string
- // String returns the literal text of the number.
- func (n Number) String() string { return string(n) }
- // Float64 returns the number as a float64.
- func (n Number) Float64() (float64, error) {
- return strconv.ParseFloat(string(n), 64)
- }
- // Int64 returns the number as an int64.
- func (n Number) Int64() (int64, error) {
- return strconv.ParseInt(string(n), 10, 64)
- }
从上面可以看到 JSON 包的 NewDecoder 和 unmarshal 都可以实现数据的解析, 那么二者有何区别, 什么时候选择哪种方法呢?
https://stackoverflow.com/questions/21197239/decoding-json-using-json-unmarshal-vs-json-newdecoder-decode
其中的高赞答案给出了一些观点:
JSON.NewDecoder 是从一个流里面直接进行解码, 代码更少, 可以用于 http 连接与 socket 连接的读取与写入, 或者文件读取
JSON.Unmarshal 是从已存在与内存中的 JSON 进行解码
小结
到这里大部分问题已经搞清楚, 但是仍然一些疑问没有搞清楚:
为什么 JSON.unmarshal 没有直接只用类似于 decode 方案中的 Number 类型来避免 float64 带来的精度损失?
JSON.unmarshal 反序列化过程的详细原理是怎样的?
这两个疑问或许存在某些关联, 等我研究明白再写吧!
来源: http://developer.51cto.com/art/202112/697019.htm