引言
引言有点长,听我娓娓道来。。。(不想看可直接跳过引言看正文)
之前有个需求,甲方想要一个简单的发红包的微信小程序,大概流程就是 生成个小程序红包码,扫码后,过几秒就会到账到微信零钱里。
于是需要对接 微信支付商户的商家付款到零钱接口。
大概看了下接口,需要传浮点值或字符串的转账金额,
做到用户扫码的同时,
调接口扣商家运营账户里的钱
然后发给领取钱的用户到微信钱包里
流程很简单,也就没当回事
写完后交付了
不出意外的话,顺利交付,万事大吉,吃鸡吃鸡
结果不出意外的出意外了!
甲方批量生成并打印的红包码中,有数个红包无法扫码,
且金额都是5.9,9.9云云,而其他的金额又能正常领取,那肯定是后端接口的问题了
于是我想搞清楚,究竟是哪个问题?
经过排查后发现,是从数据库拿到红包应发的金额后,拿着这个值拼好参数,直接调用了微信的接口,微信返回的报错,而保存信息直接是参数异常,但经排查,参数没错啊?
从数据库取到的金额是小数点只有两位的浮点小数,怎么会异常呢?小数从数据库直接打印也是正常的只有后两位的数,那到底异常在哪?
经过排查,是因为这个金额看着是有限两位小数,打印也是有限两位小数,其实是无限位小数,位数多到直到浮点溢出为止!
因为平常涉及计算都用了bc系列函数做高精度转换成字符串输出,而这回不需要计算,直接偷懒取出来就塞给了接口,才导致了这个bug?
那这个bug的原因又是什么呢?好好的有限小数为什么变成了无限小数了?
不同进制表达的局限性
我们知道,计算机底层实际计数方式以及加减乘除的运算实际都是用二进制来算的,cpu不认识我们熟悉的10进制,但我不想在这里说什么二进制是 逢1 进位,每进一位是两倍关系。以及什么2进制和10进制之间的转换等来解释 这种偏学术的表达!
我想尝试简单的来解答这个问题!它的漏洞其实非常的明显且浅显易懂!
接下来你要记住以下两点假设:
一:假设我们不知道2进制,只知道十进制!那是我们熟悉的1,2,3,4,5,6,7,8,9,10,11。。。
二:同时假设我们不知道有分数这个东西,也就是没办法用几分之一这个语言来描述小数!
重点来了!
那我问你:
你怎么来用小数表达 三分之一 ? 请记住,你不能用分式,只能用小数去表达!(就像计算机的内存只能用来存二进制数)
假设我猜的不错的话,你肯定写的是无限循环小数0.33333333......=>∞
也就是说我们得出了结论
如果没有分数,光靠我们熟悉的10进制小数,是表达不出三分之一的。
所以就必须套用定义中的 无限循环小数去强行解释这个10进制无法表达的三分之一!
可以暂停想想我所说的意思
那难道小数就真的无法表示三分之一了么?
一定就是类似0.3333333。。。无限循环小数去解释去解释他么?
其实不然!
想想看,
假设你忘掉10进制
从出生起接触的就是3进制,
什么意思呢,在你的世界里,只有 0,1,2 没有数字3,4,5,6,7,8,9
3进制只有 0,1,2 !
3进制只有 0,1,2 !
3进制只有 0,1,2 !
好洗脑完成,继续往下看:
如果你还无法在大脑里想象出进制,不妨这样想:
一把尺子
平均分布了10个刻度,就是10进制
并且如果1-2(厘米)刻度 之间的空白 ,想直接再刻更细的刻度(毫米),也强制性必须刻满10个(1厘米 = 10毫米)
如果把1厘米刻度当成是整数 1 ,那么1毫米 就是 0.1 ,
这个刻度就是在说十进制的小数!
想想看,如果再把每一毫米 再按等比细分成10分,那是不是更大的刻度的 十分之一倍?
按十等分细分,0.1 就是十分之一
再按十等分细分,0.001 就是十分之一的 十分之一
再按十等分细分,0.0001 就是十分之一的 十分之一 的 十分之一
刻度越分越细,但定死的规矩就是只能十等分
这就是10进制
再看三进制
一把尺子
平均分布了三个刻度,就是3进制!如下图(忘掉刻在基因里的十进制),假设尺子就长这样
并且如果1-2刻度 之间的空白 ,想直接再刻更细的刻度,也强制性必须刻满3个
这个刻度就是在说三进制的小数!
想想看,如果再把每一毫米 再按等比细分成3分,那是不是更大的刻度的 三分之一倍?
按三等分细分,0.1 就是三分之一
再按三等分细分,0.001 就是三分之一的 三分之一
再按三等分细分,0.0001 就是三分之一的 三分之一 的 三分之一
刻度越分越细,但定死的规矩就是只能三等分
这就是3进制!
你看看尺子上标的
此时三进制的0.1 ,是不是就是十进制苦苦表达不出来的三分之一啊?
是不是?
也就是说
10进制无法表达的三分之一,被迫表达成了0.333333333的无限循环小数
而在三进制的刻度下,0.1就能表达这个三分之一
并且,非无线循环
看到没
十进制的0.1表达的是10分之一
而三进制的0.1表达的是3分之一
这说明了一件事!
就是,不同进制之间
一定会存在一些数,这个进制能用整张有限小数表达,而在那个进制之下就成了无线小数
我的意思是说
我们用10进制的刻度 能精确表达的某个比例(小数),
在2进制这个刻度下是无法表达出来的,只能靠不断的二分刻度(尺子上不断细分),来接近或近似十进制这个刻度表达的那个比例(数学上看就是无限小数)
而我们计算机虽然底层是用二进制存储的,但又必须用10进制去显示(因为我们肉眼习惯看10进制的数而非2进制),所以哪怕内存存储的小数是小数位溢出的无限小数,但展示在屏幕时,计算机也需要显示成10进制才行!
那么问题来了!!!
如果计算机存的二进制数是个无限小数而其真实的10进制其实是正常刻度精度的有限小数呢?
我这个二进制无限小数怎么正常显示10进制正常的有限小数呢?
很简单
将二进制无限小数用“四舍五入”法保留N位小数(计算机超时数据类型会溢出,相当于自动舍弃了),
舍弃后面的精度,四舍五入后再转换成10进制数,
比如十进制0.333333333.。。转出0.33333333334 ,
如果计算机用三进制显示(现实是10进制,这只是比喻),
此时转成3进制可能是0.10000000005 ,
此时命令行打印时如果直接是浮点类型,会自动四舍五入显示成0.1 ,
也就是说,实际内存存的是0.10000000005,但其实我要的值是0.1!
这就是那个bug产生的原因
只是发生的不是10进制 转化 3进制情况
而是2进制 转化 10进制
其实不管是谁转谁
只要是涉及小数间的进制转换,其本质就是:
那个刻度下的某个比例,用这个刻度去表示,其必然会存在无法用当前刻度表示的数
就好比是10进制小数无法表示3分之一
3进制无法表示4分之一
等等不胜枚举。。。
所以涉及到带小数的计算,切记一定要四舍五入!用这门语言提供好的计算库去计算浮点!
比如PHP的bc系列函数(bcadd() ,bcsub() ......)
否则类似0.10000000005 的问题会一直折磨你