背景 #
事情的起因是,正当我悠闲的品尝一杯Java Caffe的时候,突然飞书一个加急信息铺面而来,“小张啊,你快看下,线上有个用户用优惠券少付一分钱“
啥,我瞬间大惊,最近优惠券系统也没有更新,难道是支付宝降本增笑,偷偷的给我们发福利,我仔细检查日志,我们给支付宝传入的金额就是少了一分钱
那支付宝没问题,我开始检查我们系统来了,我仔细检查入参,我们传入数据都对,但是进入优惠券系统后金额就是少了一分!!!
原来是你 #
这个优惠券系统18年就已经上线了,上线后也一直稳定运行,也没人来投诉过少钱了啊,在仔细查看代码之后,我发现了一个疑点,存储折扣的字段是 Float
计算优惠核心代码是: `
Float discount = fromDB();
Integer price = fromCommoidty();
Float discountPrice = discount * price;
Integer newPrice = discountPrice.intValue();
就这么一段简单代码怎么会有问题呢,我手动执行了一下,我的天当 price 为 100的时候,折扣为0.53的时候,计算出来的价格却是 52 分,看到这里你可能会吃惊,不信你在java中执行下面Float.valueOf(0.53f * 100).intValue()
代码
我用我的计算机按了几遍,0.53 * 100
就是等于53,离了谱了,会不会是精度不够呢,假如折扣使用Double 来存储,诶好了,可以了 Double.valueOf(0.53 * 100).intValue()
结果打印出来了我想要的53
好,正当我以为解决了这个bug的时候,我突然想,真的万无一失了吗,我记得金融方面推荐使用 BigDecimal
, 使用Double 真的能解决这个问题吗,我再试几个
试到0.29 的时候出问题了, 0.29 * 100 = 28,what ,浓眉大眼的Java 也给我整这出,我用脚也算的出来是等于29啊,没方法了,有请 BigDecimal
老祖
很快啊,三下五除二我就写完了代码,我直接将折扣转换器成 BigDecimal
,只需要将上面代码第三行换成下面的
new BigDecimal(discount).multiply(new BigDecimal(amount))
大功告成,BigDecimal
也用上了,这下总不会算错了吧,我来验证一下,首先验证一下100 * 0.53 ,等效代码为 (new BigDecimal(0.53f).multiply(new BigDecimal(100)).intValue()
)
what,结果竟然还是 52,好家伙网上说的都是骗人的啊,难道只能使出最后一招了吗,将折扣存成字符串或者读取的时候类型定义为 BigDecimal
但是这样一来,所有依赖的这个接口的都得改,Java 中最恐怖的就是修改类字段的某个类型了,所有上下游都得改
不可能,肯定还有方法,接下来,我们从源头分析这个问题的原因
浮点数 #
我们都知道整数,整数很简单,小时候就能从1,2,3,4数到100,我们从小就知道1+1=2,10+10=20,我们将所以数以10为一组,10个10就是百,十个百就是千,对于小数来说是相反的,我们将1个单位切分为10份,每份为1/10,假如1/10不够,那再将1/10分成10份,每份就是1/100
这种分法带来一个问题,就是假如我想1分成3份,我们会得到一个 0.333333 无限循环小数
假如我们将三个这样循环小数加起来,我们会发现,我们使用十进制来表达的3个0.333(后面无数个0)加起来会得到 0.999(后面无限个0),似乎永远也得不到最终的1,总差那么一点点
这就是问题的根源,我们目前使用的十进制,有的时候不能表示别的进制(比如三进制)中一些基本的单位(如1/3),人类目前使用的是十进制,但是计算机目前使用的是二进制,从计算机的角度上看,他使用二进制,不能表达,我们十进制中的某个比如0.01
我们使用 new BigDecimal(0.01f).toPlainString()
打印出计算机存储0.01 的实际值
0.00999999977648258209228515625
你会惊奇的发现,竟然得到的一个结果是0.009999的这样的结果,让我们以我们上面熟悉的例子来举例,这个0.01 就好比在十进制中我们想表达1/3 (3进制 )这个数,我们只能用 0.3333(后面无限个3)来表达,而且你发现为了节约字符长,0后面我只写出来4个3,理论上只要后面的3足够多,那么我们就越接近真实值
假设我们目前只让你用4位小数去表达(1/3),你发现在10进制,距离(1/3) 的只有两个数,要么是0.3333 要么是0.3334 ,其中0.3333更加接近真实值,所以我们选择了0.3333,对于浮点数来也是一样的,基于IEEE754 定义,浮点数其实就是一个二进制版本的科学计数法,转换成我们熟悉的十进制科学计数法来说,就是使用 K.xxxxx * 10 (n 次方) 来表达一个数
让我们回到真实的业务中,当运营输入一个折扣 0.01 ,由于我们系统采用的是浮点数去存储,我们只能在浮点数的有效小数部分去表达这个0.01(由于0.01 无法被 2的n次方整除),那么就会有两个很小很小的数,你可以打开这个转换网站输入 0.01
十进制 (exact): 0.00999999977648258209228515625
二进制: 0 01111000 01000111101011100001010
你可以看到,从这个二进制就可以看到,省略前面数字(1010,1011)这两个数是离0.01最近的二进制数,由于1010要比1011更接近0.01,所以最后浮点数选择了1010,而且由于是舍弃了部分值,所以最后这个二进制要比十进制中的少了那么一点点
[!note]
IEEE754 : 会针对超过其进度的小数做一个类似四舍五入的操作,和四舍五入的操作有点区别的地方在于,当最后一位是偶数的时候,5的话也不进位,一个是为了平衡浮点数进位概率,二是为了避免全部进位(比如0.999999 万一进位了直接到1了,前面全白算的)耗费CPU时间
浮点数这么不可靠吗 #
看到这里,你会想为啥我平常使用浮点数很正常,我存储到数据库是0.01,但是当我从数据库拿出来,打印出来的时候还是0.01,你可以试下 打印new BigDecimal(0.01f).floatValue()
,你会发现欸,打印出来的还是0.01,为什么不是数据库存储的是 0.0099(省略后面小数) 呢
简单来说就是当我们将浮点数转换成字符串的时候,我们会调用一个 FloatingDecimal.toJavaFormatString
静态方法将 这个 0.00999999977648258209228515625
“四舍五入” 成 0.01 去了,当然实际算法不是这个通过这个算法(想了解可以看我这篇 [[Java 如何将浮点转换成字符串]] ),我们可以把之前输入到数据库中的数字“还原”回来
所以理论上只要是你输入的值在Float能表示的范围内,存数据库啥样取出来之后,只要我们转换一下,就可以完整把之前输入的小数表达出来,像我们正常一般就到小数点四五位,所以我们一般不涉及到计算部分,使用浮点也没问题
这就可以解释 new BigDecimal(0.53f).multiply(new BigDecimal(100)).intValue()
还是得到一样的结果,因为我放进去的是0.52999(省略后面小数),所以我用0.52999 * 100 就是52(忽略掉小数部分),那么我们有两种解决方法,第一种就是将折扣还原成 我们之前认为的 0.53
,然后在用 BigDecimal 来计算,这样计算结果就能符合我们人类的常识,第二种办法是将计算完的结果四舍五入,但是这个就会造成你计算规则的改变(理论上四舍五入就是让用户多付钱)
解决方案 #
一、将折扣还原
最简单的方法就是使用,Float自带的toString 方法,这个方法会根据你的二进制值转换成对应的十进制(符合IEEE754 进位,去除尾部0的规则),基本上你之前运营配置的是什么10进制,最后你拿到的就是什么
new BigDecimal(Float.valueOf(0.53f).toString()).multiply(new BigDecimal(100)).intValue()
将上面代码改成这样,你最后得到的结果就是你想要的53了,其中 Float.valueOf(0.53f).toString()
等价于 "0.53"
所以在 BigDecimal 中计算的时候最好传入字符串,不要传入浮点值,否则你会得到你意向不到的结果
二、将折扣四舍五入保留两位小数
假如我们知道折扣一般就配2位小数,那我们可以在传入float 后,将折扣改为 2位小数,并且四舍五入
new BigDecimal(0.53f).setScale(2, RoundingMode.HALF_UP).multiply(new BigDecimal(100))
缺点就是,我们强制四舍五入并且保留两位小数了,后续不支持使用三位小数
总结 #
其实最完善的解决方案就是,数据库存储的时候就使用 Decimal 存储,并且在全程使用 BigDecimal 处理折扣计算,而不是存储成浮点数,但是历史已成,只能在新系统逐渐迁移,并且逐渐废弃掉或者改造老系统,对于原有业务的修复,只能通过上面,将浮点数转换成字符串(去除十进制转二进制误差),然后使用字符串导入到 BigDecimal 进行十进制计算出结果,虽然 BigDecimal 的速度比浮点数要慢的多,但是金融系统准确性要比速度更加重要
当然你也发现了,其实内部使用Float或者Double存储也是没啥大问题的,基本上能保证在其误差范围内保持数据准确性,而且浮点数使用也非常方便快速,假如你把你所有的计算都换成 BigDecimal ,你会发现你的系统速度下降10倍甚至100倍,而且对于大部分业务其实也没有对进度要求那么严格,其实Float或者Double 的误差基本上很小很小,就好比大海里面的一滴水,其实我们折扣完全使用Float或者Double计算也是ok的,只要最后将得到的值四舍五入一下,但是如果想不改动历史计算逻辑,还是得采用我上面的方案
总结一下就是,老系统用了Float 或者 Double 也不慌,不需要 削足适履,将上下游的所有入参出参都改成 BigDecimal,只需要让计算的时候,将误差磨平,再使用 BigDecimal 来得到真实结果,这样就可以在不改动核心的基础上解决系统某个特殊折扣产生的bug金额了