当前位置: 首页 > 科技观察

深入理解 Go Json.Unmarshal 精度丢失之谜

时间:2023-03-19 20:08:13 科技观察

深入理解GoJson.Unmarshalprecisionloss的奥秘??本文转载自微信公众号“后端研究院”,作者大白鸡。转载本文请联系后端研究院公众号。元棋前几天写了一个小请求。原以为很简单,上线后发现了一个bug。需求大致是这样的:上游调用我的服务获取全量信息。上游数据包虽然是json,但是结构不确定。我的服务是用Go语言开发的,所以我使用原生的json包进行反序列化。就是这样一个简单的过程,唯一ID从DB中拉取数据返回给上游调用者,让我跌跌撞撞。bug的现象是这样的:上游给的唯一ID已经在数据库中找不到结果了。上游给的唯一ID必须是真实有效的,这是矛盾的,所以我牺牲了日志的方式,在测试环境中运行,发现一个神奇的现象:下游服务接收到的json字符串中的唯一ID没问题的,跟上游一致。json.unmarshal反序列化后,下游服务的唯一标识发生了变化。和上游不一致是怎么回事?我被Sophon监视了吗?不明白,不明白……任何不合理的现象背后,都必须有合理的解释。不要像我一样,被形而上学所占据。分析我决定看看是谁在搞鬼,现在矛头指向了json.unmarshal的反序列化动作,于是我写了一个小demo来复现:packagemainimport("encoding/json""fmt""reflect")funcmain(){varrequest=`{"id":7044144249855934983,"name":"demo"}`vartestinterface{}err:=json.Unmarshal([]byte(request),&test)iferr!=nil{fmt.Println("error:",err)}obj:=test.(map[string]interface{})dealStr,err:=json.Marshal(test)iferr!=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"}float647.044144249855935e+18果然转载:原始输入字符串:'{"id":7044144249855934983,"name":"demo"}'处理后的字符串:'{"id":7044144249855935000,"name":"demo"}'id从7044144249855934983到7044144249855935000,从16位到000,所以这个id无法从db中获取数据,于是google了一下,结果是这样的:在json规范中,数值类型不区分整型和浮点型。使用json.Unmarshal反序列化json时,如果不指定数据类型,则使用interface{}作为接收变量,当数字的精度超出精度范围时,默认使用float64作为数字的接受类型那float可以表示到这里,我就基本明白为什么会出现bug了:上游json字符串的格式不确定,无法使用struct反序列化,只能借助interface{}接收上游json传递的id是数值类型,换成字符串类型就没有这个问题。上游json传过来的id值比较大,超过了float64的安全整数范围。下游使用json.number类型来避免使用float64packagemainimport("encoding/json""fmt""strings")funcmain(){varrequest=`{"id":7044144249855934983}`vartestinterface{}decoder:=json.NewDecoder(strings.NewReader(request))decoder.UseNumber()err:=decoder.Decode(&test)iferr!=nil{fmt.Println("error:",err)}objStr,err:=json.Marshal(测试)iferr!=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标准诞生于1960年代和70年代。各个计算机公司的各种类型的计算机具有不同的浮点表示法,但业界没有统一的标准。1980年,英特尔公司推出了单片机8087浮点数协处理器。它的浮点数表示和定义的操作是足够合理和先进的。它被IEEE采用为浮点数标准并于1985年发布。IEEE754(ANSI/IEEEStd754-1985)是自1980年代以来使用最广泛的浮点运算标准。它被许多CPU和浮点运算单元所采用。该标准规定了四种表示浮点值的方式:单精度精度(32位)、双精度(64位)、扩展单精度(43位以上很少使用)和扩展双精度(79位及以上)。威廉·莫顿·卡汉(英语:WilliamMortonKahan,1933年6月5日-),出生于加拿大安大略省多伦多市,数学家和计算机科学家,擅长数值分析,1989年图灵奖获得者,1994年获得ACM提名Fellow,现任加州大学伯克利分校计算机科学名誉教授,被称为浮点数之父。老人年近九十。这是他1968年在加州大学伯克利分校任数学与计算机科学教授时的照片。IEEE754的基本原理是int64使用全部64bit的数据来存储数据,但是float64需要表达更多的信息,所以float64用于数据存储的位数将小于64bit,这导致float64可以存储的最大整数小于int64。理解这一点很重要,其实更容易理解。64bit的每一位都很重要,但是float64需要拿出其中的几位去做其他的事情,所以存储数据的范围要比int64小很多。IEEE754标准将64位分为三部分:符号,符号位部分,1位0为正数,1为负数指数,指数部分,11位小数,小数部分,52位32位单精度也分为以上三部分的区别在于指数部分是8bit,小数部分是23bit。同时指数部分的偏移值32位为127,64位为1023。其他部分计算规则相同。IEEE754标准可以被认为是二进制科学记数法。标准认为任何数都可以表示为:特别注意,图中的指数部分E不包含偏移值,偏移值是IEEE754在使用中转换为浮点数二进制序列时的值。有效数M的约束M的取值为1≤M<2,M可以写成1.xxxxxx的形式,其中xxxxxx表示小数部分。IEEE754规定,在计算机内部保存M时,这个数的第一位默认总是1,所以可以舍弃,只保存后面的xxxxxx部分,恢复计算时可以加1。索引E的约束E为无符号整数,即所有>=0,取值范围为0~255,32位单精度,64位0~2047双精度。当数字为小数时,E将为负数。为此,IEEE754规定,用科学计数法计算出的真实E加上偏移值即为最终表示的E值。看到这里,读者会有疑问:如果真实的E值超过128,那么加上偏移值会超过255而越界吗?是的,当索引部分E全为1时,需要看M的情况,如果有效数M全为0,表示±无穷大,如果有效数M不全为0,则为表示为NaN。NaN(NotaNumber)是计算机科学中数值数据类型中的一种值,表示未定义或不可表示的值。数据表示规则之前学习了IEEE754的基本原理,接下来就是实际应用了。一般来说,十进制场景转浮点数有三种情况:纯整数转浮点数如10086混合小数转浮点数如123.45纯小数转浮点数如0.12306可以分为两种情况:10所有的碱基都可以转化为二进制。比如整数部分123可以除以2取余数倒过来写,小数部分可以乘以2按顺序写。偷懒,从菜鸟教程网站上复制一个十进制173.8625转二进制的例子:十进制整数转二进制整数,用“余数除以2,倒序排列”的方法,十进制小数转二进制小数点。乘以2向上舍入,依次排列”方法合并两部分(173.8125)10=(10101101.1101)2特别注意,有些情况下小数部分舍入2会造成死循环,但是数IEEE754中小数部分的位数是有限的,所以有一个近似值存储,这也是一种不精确的现象安全整数范围之前我们有一个疑问:整数经过float64处理后会不会有问题?或者有没有一个安全的转换取值范围呢?我们来分析一下float64可以表示的数据范围:当尾数部分全为1时就已经满了,如果多了一个尾数就进位到指数,精度损失此时会发生,所以对于float64:最大安全整数是52位尾数全为1且指数部分最小0x001FFFFFFFFFFFFFfloat64可以存储52位尾数全为1的最大整数且指数部分是最大的0x07FEFFFFFFFFFFFFF(0x001FFFFFFFFFFFFFF)16=(9007199254740991)10(0x07EFFFFFFFFFFFFF)16=(9218868437227405407)10表示941的理论值超过92可能会出现精度损失。十进制值的有效位数为16位。一旦超过16位,精度不足的问题就基本没有了。回过头来看,我处理的id是20位的长度,肯定是精度不够。解码器和解组我们知道,在反序列化json时,整数和浮点数类型没有区别。数字使用相同的类型。在go语言的类型中,这种常见的类型是float64。但是float64存在精度不够的问题,所以go单独针对这个给出了解决方案:使用json.Decoder代替json.Unmarshal方法。该解决方案首先创建一个jsonDecoder,然后调用UseNumber方法。使用UseNumber方法后,json包中的数字会被转为内置的Number类型(本质上是string),Number类型提供了多种转int64、float64等方法,UseNumber导致Decoder对一个数字进行unmarshalintoaninterface{}asaNumber而不是float64我们来看看Number类型的源码实现://ANumberrepresentsaJSONnumberliteral.typeNumberstring//String返回number的文字文本.func(nNumber)String()string{returnstring(n)}//Float64returnsthenumberasafloat64.func(nNumber)Float64()(float64,error){returnstrconv.ParseFloat(string(n),64)}//Int64returnsthenumberasanint64.func(nNumber)Int64()(int64,error){returnstrconv.ParseInt(string(n),10,64)}从上面可以看到json包NewDecoder和unmarshal都可以实现数据解析,那么两者有什么区别,什么时候选择哪种方式呢?https://stackoverflow.com/questions/21197239/decoding-json-using-json-unmarshal-vs-json-newdecoder-decode的高度赞扬的答案给出了一些意见:json.NewDecoderdecodesdirectlyfromastream,withlesscode,并可用于读写http连接和socket连接,或者文件读取Pickjson.Unmarshal是对内存中已经存在的json解码的总结。到这里大部分问题已经搞清楚了,但是还有一些问题没有搞清楚:为什么json.unmarshal不直接使用类似decode方案的Number类型来避免float64带来的精度损失?json.unmarshal反序列化过程的详细原理是什么?这两个问题可能有些联系,等我明白了再写!