一个想不通的关于精度问题的BUG


9c951e88a5d756c0789bbbe16d96c09f146525.jpg
回复列表(17|隐藏机器人聊天)
  • @Ta / 2019-05-16 / /
    为什么这个 BUG的点是0.06呢,其他的小数点没问题的
  • @Ta / 2019-05-16 / /

    你先打印$actual_money,看看精度有没有丢失。如果已经是丢失了,就用高精度的数学运算函数来做运算,而不是简单的A + B
    小米5s Plus

  • @Ta / 2019-05-17 / /
    @读书顶个鸟用,打印就是0.06
  • @Ta / 2019-05-20 / /
    为什么不用整形存储金额呢
  • @Ta / 2019-05-21 / /

    0.01无法用浮点数精确表示,每次累加结果都比你预期的多一点或者少一点,直到6次之后超出了你PHP字符串转换的精度阙值表现了出来。
    看这个例子:

    <?php
    for ($d=0.0; $d < 0.1; $d +=0.01) {
            var_dump(sprintf('%0.32f',$d));
    }
    

    string(34) "0.00000000000000000000000000000000"
    string(34) "0.01000000000000000020816681711722"
    string(34) "0.02000000000000000041633363423443"
    string(34) "0.02999999999999999888977697537484"
    string(34) "0.04000000000000000083266726846887"
    string(34) "0.05000000000000000277555756156289"
    string(34) "0.06000000000000000471844785465692"
    string(34) "0.07000000000000000666133814775094"
    string(34) "0.08000000000000000166533453693773"
    string(34) "0.08999999999999999666933092612453"
    string(34) "0.09999999999999999167332731531133"

    @胡椒舰长,不建议使用浮点数表示需要精确表示的数据类型。可以考虑使用整数表示法(*100)或者定点数函数库。
    如果一定要使用浮点数,应该使用sprintf('%0.2f', $d)转换为字符串。PHP的(string)操作默认精度应该是sprintf('%0.14f', $d),不同版本的PHP可能不同。

    你的代码大部分情况下没出问题,只是因为误差大小没有达到默认精度阙值。一但误差大小达到阙值,浮点数的默认字符串转换结果就会变成例子中的那样(0.0600000000000000047等),导致与数据库中的定点数不匹配。如果不自己控制精度(比如sprintf('%0.2f',$d)),出问题是迟早的事。

  • @Ta / 2019-05-21 / /

    浮点数累加后精度误差会扩大。以下例子展示了需要多久后才能产生默认设置中可见的差异:

    <?php
    for ($d=0.0; true; $d +=0.01) {
            $a = (string)$d;
            $b = sprintf('%0.2f', $d);
            if ($a != $b) {
                    var_dump($a, $b);
                    break;
            }
    }
    

    在我的PHP中,结果是:

    string(15) "4.4299999999999"
    string(4) "4.43"
    

    结果可能会因为PHP版本的不同而不同。

  • @Ta / 2019-05-21 / /

    此外,楼主的代码还有一个偶然因素,$param['money']是多少并不确定。
    所以,浮点数不仅可以无法精确表示0.01,也可以恰好无法精确表示$param['money'],并且误差较大。此时,两个数加起来的误差完全可以直接超过PHP字符串转换精度阙值。

  • @Ta / 2019-05-21 / /

    对于定点模拟(用整数来表示定点数),要记得最后格式化的时候不能转换为浮点数(也就是*100并强制转换为整数后不能/100变回去),而是应该使用字符串处理函数添加小数点。否则在变成浮点数后,再转换成整数时依然可以产生误差。
    或者,最好的方法是从接收参数开始就转换成整数(用字符串处理函数去掉小数点),全程不要有小数,最后运算完了再通过字符串处理函数添加小数点,这样就不会产生误差。

  • @Ta / 2019-05-21 / /
    @老虎会游泳,确实,没有考虑这么细致。
    如果用户输入的是 0.1而非0.10,那应该是(int)0.1*100,然后
    转成String?
  • @Ta / 2019-05-22 / /

    @胡椒舰长,考虑用字符串处理函数
    $d = explode('.', $d);
    $d = (int)($d[0].str_pad(substr($d[1], 0, 2), 2, '0'));

  • @Ta / 2019-05-27 / /

    最近项目中后端同学使用的是node.js也出现了精度问题导致验证支付失败,在掘金上刷文章又看到精度问题的文章,看QQ群的thinkphp周刊里又提到运算精度问题,逛葫芦林也看到类似文章 ...快入魔了

    这是什么原因?@老虎会游泳,那么如果是电商场景,有一些优惠券之类的加减运算操作小数的,代码层以及数据库的最佳实践是什么?
    小米5黑色低配版

  • @Ta / 2019-05-27 / /

    备注:下文中 ^ 表示乘方运算,不是异或运算。

    @水木易安,对于金融相关计算,以下是建议:

    1. 如果有选择,不要使用任何浮点类型,double、float、只要是小数位数不定长的都不建议使用,因为通常都会产生难以发现的精度问题。如果没有选择(比如lua只支持double型),就不要在计算中产生小数(如果产生了,要立即舍入到整数范围)。
    2. 在数据库中使用定点数存储金额(比如mysql的decimal(14, 2))。也可以使用整数存储金额,把单位设置为分。
    3. 在计算中,尽量使用定点数。如果程序语言不支持定点数,可以考虑扩大100倍然后使用整数运算,运算完成后转换回定点数。注意,从数据库中拿到定点数后不应该直接*100,这可能会导致数据从字符串转换为浮点数然后*100,导致精度问题。建议使用sql语句*100,或者拿到结果后用字符串操作进行转换(直接去掉小数点)。转换回定点数的时候也一样,添加小数点。
    4. 如果你使用整数分存储金额,那只要在计算和存储过程中一直使用整数就没问题啦,轻松愉快。所以可能这才是最佳实践。至于显示,让前端添加小数点就可以(甚至可以考虑css添加小数点)。
    5. IEEE754双精度浮点型的基本表示法如下:
    // double的二进制表示: <1bit符号位><11bit指数><52bit尾数>
    $double的值 = bindec("1$尾数") * pow(2, $指数-1023) * ($符号位==1 ? -1 : 1);
    

    尾数有一个隐含位1,也就是说它总是1开头,所以开头的1不包含在二进制表示里面。在浮点计算完成后,结果的尾数部分必须表示为1开头,也就是把尾数的前导0去掉,直到开头为1,同时缩小指数部分,然后删掉开头的1,这一过程叫做浮点数的“规格化”。

    并不是所有浮点数都是“规格化”的,某些浮点数值是非规格化的,比如0(因为具有隐含位,底数的最小值为1,1的任意次方都不等于0,所以0没有办法规格化,必须用特殊方法表示)。

    具体来说,浮点数的表示方法分为以下几种情况:

    1. 可以使用 y = 0b1<x> * 2^n 形式精确表示的数,没有精度损失。
    2. 完全不能使用 y = 0b1<x> * 2^n 形式表示,所以用了特殊方法表示的数,没有精度损失。
    3. 可以使用 y = 0b1<x> * 2^n 形式表示,但尾数的位数不够多,或者尾数出现“无限循环”的情况,此时出现精度损失。

    所以,以下行为是精度安全的:

    • 在浮点型中存储整数。只要整数的范围是在-2^($尾数位数+1)-12^($尾数位数+1)-1内就可以(+1是因为隐含位)。在这种情况下,整数的最高位1被去掉,剩下的部分填充尾数(高位对齐),然后指数的值是整数的二进制位数。(0则是非规格化的,也是精确的。)
    • 两个浮点型表示的整数相加减。因为结果依然是整数,只要结果不超出上述范围就可以。
    • 两个浮点型表示的整数相乘。结果依然是整数,只要不超出上述范围就可以。

    以下行为不是精度安全的:

    • 浮点数值包含小数部分。很多常见的两位小数都不能用浮点型精确表示,结果的尾数部分是无限循环。
    • 两个浮点型整数相除。因为结果可能包含小数部分,导致不精确。
  • @Ta / 2019-05-27 / /

    上面说到最好只使用整数运算,那么怎么用整数计算打折呢?
    比如75折:
    y = x * 75 / 100
    只要乘法不溢出,结果就是良好的。在大部分编程语言中结果都还是整数。如果不是整数,舍入到整数即可。

  • @Ta / 2019-05-27 / /

    @水木易安,哦对了,其实如果只是为了防止“相等”比较时出现不匹配的问题,只需要做以下两件事就可以了:

    1. 数据库中使用decimal(n, 2)
    2. 比较前对数据使用round($x, 2)并且转换为字符串再发送给数据库。或者sprintf('%0.2f', $x)
  • @Ta / 2019-05-27 / /
    @老虎会游泳,似乎老虎没有注意到 并不存在“阙值”这个词。 
  • @Ta / 2019-05-28 / /

    @老虎会游泳,学习了
    小米5黑色低配版

  • @Ta / 2019-05-29 / /

    @iola1999,阙值和阈值长的那么像,你居然看得出来

添加新回复
回复需要登录