1.2 浮点数算钱翻车与BigDecimal


文档摘要

1.2 浮点数算钱翻车与 BigDecimal 本节摘要:对账系统每天差出几分钱,根因是 的二进制表示根本无法精确存储 0.1。本节从一次真实对账事故讲清 IEEE 754 的精度机理、浮点比较的正确姿势、 的构造陷阱与舍入模式,最后给出金额处理的一整套工程约定。 一分钱引发的对账事故 结算平台接到商户投诉:日终对账,平台侧总额与渠道侧总额差了 3 分钱,连续多日方向随机。财务要求必须查清,因为"差一分钱就意味着账务模型可能有更大的洞"。 出问题的代码在分摊逻辑里:把一张 100 元优惠券按比例摊到三个商品上: 三个"份额"加回来不等于 100。放大看 : 这是每一门语言课都讲过、每一代工程师都重新踩一遍的坑。 为什么 0.1 存不下 十进制的 0.1 换成二进制是 0.

1.2 浮点数算钱翻车与 BigDecimal

本节摘要:对账系统每天差出几分钱,根因是 double 的二进制表示根本无法精确存储 0.1。本节从一次真实对账事故讲清 IEEE 754 的精度机理、浮点比较的正确姿势、BigDecimal 的构造陷阱与舍入模式,最后给出金额处理的一整套工程约定。

一分钱引发的对账事故

结算平台接到商户投诉:日终对账,平台侧总额与渠道侧总额差了 3 分钱,连续多日方向随机。财务要求必须查清,因为"差一分钱就意味着账务模型可能有更大的洞"。

出问题的代码在分摊逻辑里:把一张 100 元优惠券按比例摊到三个商品上:

double rate1 = 0.3, rate2 = 0.3, rate3 = 0.4; double price = 100.00; double share1 = price * rate1; // 期望 30.00 double share2 = price * rate2; // 期望 30.00 double share3 = price * rate3; // 期望 40.00 System.out.println(share1 + share2 + share3); // 100.00000000000001

三个"份额"加回来不等于 100。放大看 0.1 + 0.2

System.out.println(0.1 + 0.2); // 0.30000000000000004 System.out.println(0.1 + 0.2 == 0.3); // false

这是每一门语言课都讲过、每一代工程师都重新踩一遍的坑。

为什么 0.1 存不下

十进制的 0.1 换成二进制是 0.000110011001100...无限循环。double 用 64 位存储:1 位符号、11 位指数、52 位尾数,无限循环的部分必须在第 53 位截断并舍入。于是内存里的"0.1"其实是 0.1000000000000000055511151231257827...,只是打印时被格式化巧掩盖。

误差有两条传播路径:一是表示误差,数一存进来就不准;二是运算误差,乘法与加法会让误差累积放大,尤其在循环累加几百万笔金额时,误差从 10 的负 17 次方涨到分位可见。

误差如何在分摊场景放大

误差如何在分摊场景放大

修复:BigDecimal 的正确打开方式

第一版修复很快被打回,因为 BigDecimal 也有构造陷阱:

BigDecimal a = new BigDecimal(0.1); // 错:把 double 的误差原样带进来 System.out.println(a); // 0.1000000000000000055511151231257827021181583404541015625 BigDecimal b = BigDecimal.valueOf(0.1); // 对:先走 Double.toString 再解析 BigDecimal c = new BigDecimal("0.1"); // 对:字符串构造 精确

除法必须显式给精度和舍入模式,否则除不尽时直接抛 ArithmeticException

BigDecimal total = new BigDecimal("100.00"); BigDecimal three = BigDecimal.valueOf(3); // 下面的写法在除不尽时抛异常: // total.divide(three); BigDecimal avg = total.divide(three, 2, RoundingMode.HALF_UP);

舍入模式是业务决策不是技术决策:税务计算常用 HALF_UP(四舍五入),金融惯例里 DOWN(截断,利于商户还是利于平台要看合同)、银行家舍入 HALF_EVEN 各有领地。选错舍入模式不报错,只对不平账,这比抛异常的坑阴得多。

分摊的"尾差"问题也需要显式处理:三项各按比例舍入后加总可能不等于总额,惯例是把最后一笔算成"总额减前若干笔"(尾差吸收法):

BigDecimal[] shares = new BigDecimal[n]; BigDecimal allocated = BigDecimal.ZERO; for (int i = 0; i < n - 1; i++) { shares[i] = total.multiply(rates[i]).setScale(2, RoundingMode.HALF_UP); allocated = allocated.add(shares[i]); } shares[n - 1] = total.subtract(allocated); // 尾差吸收

工程约定

场景 推荐做法 理由
数据库存金额 DECIMAL 或整数分(BIGINT 避免 float 列从存储层引入误差
内存高频计算 long 存分 无对象分配 无舍入歧义 性能最好
复杂费率计算 BigDecimal + 显式 RoundingMode 精度可控 可审计
浮点比较 Math.abs(a - b) < 1e-9 等号比较浮点永远是错的
展示格式化 String.formatDecimalFormat 展示层解决展示问题

⚠️ 常见坑:new BigDecimal(double)equals 比较。equals 会同时比较数值与标度,new BigDecimal("1.0").equals(new BigDecimal("1.00")) 是 false,比大小请用 compareTo

💡 关键直觉:double 的精度问题是表示层的,换任何语言都一样。真正要建立的习惯是——涉及钱,从数据库列类型到前端展示,整条链路都不许 float 出现。

本节要点回顾

  • 机理:0.1 二进制无限循环,52 位尾数截断产生表示误差,运算放大误差
  • 症状:对账差分、循环累加漂移、0.1+0.2 != 0.3
  • 修复BigDecimal.valueOf 或字符串构造,除法带精度与舍入模式,compareTo 比大小
  • 分摊:尾差用"总额减已分配"吸收,舍入模式按业务合同选定
  • 链路:DB 用 DECIMAL/整数分,内存计算 long 分,展示层格式化

补一点工程落地的细节。治理精度问题最有效的动作,是把它变成机器可检查的规则而不是口头约定:静态扫描把 new BigDecimal(double) 与金额字段上的 double 类型一并列为阻断项;数据库变更评审里 float 或 double 的金额列一律驳回;序列化契约文档给金额字段显式标注"整数分"或"字符串"。三道闸门建好之后,新代码基本不会再把误差引进来。另一个容易被忽视的角落是配置:费率、阈值这类数值写在配置中心时,格式是字符串还是数字,决定了它进内存的第一跳是否精确——配置平台直接下发 double,前面所有的努力就在入口处白费了。精度的防线必须从入口一路铺到出口,中间断任何一环,账就会从那一环悄悄漏出去。

下一节从数值精度转向控制流——switch 的边界同样是事故高产户。

顺便交代一下事故的尾声:整改完成后,对账模块加了一条硬性冒烟用例——用会除不尽的费率跑一轮分摊再回加总额,断言分毫不差。这条用例此后拦截过两次试图"图省事改回 double"的提交,证明了把事故固化成用例的价值。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U