1.2 浮点数算钱翻车与 BigDecimal 本节摘要:对账系统每天差出几分钱,根因是 的二进制表示根本无法精确存储 0.1。本节从一次真实对账事故讲清 IEEE 754 的精度机理、浮点比较的正确姿势、 的构造陷阱与舍入模式,最后给出金额处理的一整套工程约定。 一分钱引发的对账事故 结算平台接到商户投诉:日终对账,平台侧总额与渠道侧总额差了 3 分钱,连续多日方向随机。财务要求必须查清,因为"差一分钱就意味着账务模型可能有更大的洞"。 出问题的代码在分摊逻辑里:把一张 100 元优惠券按比例摊到三个商品上: 三个"份额"加回来不等于 100。放大看 : 这是每一门语言课都讲过、每一代工程师都重新踩一遍的坑。 为什么 0.1 存不下 十进制的 0.1 换成二进制是 0.
本节摘要:对账系统每天差出几分钱,根因是
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.000110011001100...无限循环。double 用 64 位存储:1 位符号、11 位指数、52 位尾数,无限循环的部分必须在第 53 位截断并舍入。于是内存里的"0.1"其实是 0.1000000000000000055511151231257827...,只是打印时被格式化巧掩盖。
误差有两条传播路径:一是表示误差,数一存进来就不准;二是运算误差,乘法与加法会让误差累积放大,尤其在循环累加几百万笔金额时,误差从 10 的负 17 次方涨到分位可见。

第一版修复很快被打回,因为 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.format 或 DecimalFormat |
展示层解决展示问题 |
⚠️ 常见坑:
new BigDecimal(double)与equals比较。equals会同时比较数值与标度,new BigDecimal("1.0").equals(new BigDecimal("1.00"))是 false,比大小请用compareTo。
💡 关键直觉:double 的精度问题是表示层的,换任何语言都一样。真正要建立的习惯是——涉及钱,从数据库列类型到前端展示,整条链路都不许 float 出现。
0.1+0.2 != 0.3BigDecimal.valueOf 或字符串构造,除法带精度与舍入模式,compareTo 比大小long 分,展示层格式化补一点工程落地的细节。治理精度问题最有效的动作,是把它变成机器可检查的规则而不是口头约定:静态扫描把 new BigDecimal(double) 与金额字段上的 double 类型一并列为阻断项;数据库变更评审里 float 或 double 的金额列一律驳回;序列化契约文档给金额字段显式标注"整数分"或"字符串"。三道闸门建好之后,新代码基本不会再把误差引进来。另一个容易被忽视的角落是配置:费率、阈值这类数值写在配置中心时,格式是字符串还是数字,决定了它进内存的第一跳是否精确——配置平台直接下发 double,前面所有的努力就在入口处白费了。精度的防线必须从入口一路铺到出口,中间断任何一环,账就会从那一环悄悄漏出去。
下一节从数值精度转向控制流——switch 的边界同样是事故高产户。
顺便交代一下事故的尾声:整改完成后,对账模块加了一条硬性冒烟用例——用会除不尽的费率跑一轮分摊再回加总额,断言分毫不差。这条用例此后拦截过两次试图"图省事改回 double"的提交,证明了把事故固化成用例的价值。