深蓝黑背景品牌封面:居中大标题「0.1 + 0.2 ≠ 0.3」,副标题「金额相加为什么不能用 double」;下方原创装饰——一枚二进制位条,符号位、指数段、尾数段三段分色,尾数段末端一个省略号外溢框,寓意十进制小数在二进制尾数里是无穷循环只能截断;左上标注「经典题解析 · 数据处理」,右上标注「Day 08 · 10-16」 图 1:本文封面,尾数段溢出的那个省略号就是误差的老家。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。它是后端面试的常客,因为答错它的代价不是面试挂掉,而是线上账单对不上。

想象面试现场:「计算金额用什么类型?double 行不行?0.1 + 0.2 等于 0.3 吗?」

核心结论一句话

不行。double 是 IEEE 754 双精度浮点数,十进制的 0.1、0.2 转成二进制都是无穷循环小数,尾数 52 位装不下只能截断,截断误差平时藏着,相加与比较时显形。Java 官方教程的原话是:float 与 double 绝不应该用于要求精确的值,比如货币。钱的正确形态有两个:BigDecimal,或以「分」为单位的整数。

成因链:误差从哪来。

十进制有限小数在二进制里大多不是有限的——就像 1/3 在十进制里是 0.333… 写不完,0.1 在二进制里是 0.00011001100… 循环不止。浮点数用「符号位 + 指数 + 尾数」三段存储,尾数位数固定,写不完的只能截断到最近的近似值。于是 0.1 和 0.2 各自带了一点误差进内存,相加后误差叠加,结果是 0.30000000000000004——比 0.3 大了约 5.55×10⁻¹⁷。等值比较直接失败,而对账系统里最不缺的就是等值比较。

现场还原与正确方案,代码实测。

下面这份脚本把三个经典现场和整数分正解放在一起,用 Node.js 22.15.0 直接运行(node money.js)即可——JavaScript 的 number 同样是 IEEE 754 双精度,坑完全一致:

'use strict';

// 现场一:等值比较直接失败
console.log('0.1 + 0.2 =', 0.1 + 0.2);
console.log('0.1 + 0.2 === 0.3 ?', 0.1 + 0.2 === 0.3);

// 现场二:连乘把误差带进后续小数位
console.log('1.1 * 3 =', 1.1 * 3);

// 现场三:「乘 100 取整」用错取整方式会少钱
console.log('19.9 * 100 =', 19.9 * 100);
console.log('截断取整 Math.floor:', Math.floor(19.9 * 100), '分(少了一分)');
console.log('四舍五入 Math.round:', Math.round(19.9 * 100), '分');

// 正解:全程用「分」为单位的整数,只在展示层除回 100
function toCents(yuanStr) {
  const [intPart, fracPart = ''] = yuanStr.split('.'); // 从字符串解析,误差进不来
  const frac = (fracPart + '00').slice(0, 2);
  return Number(intPart) * 100 + Number(frac);
}
function percent(cents, numerator, denominator) {
  // 打折先乘后除,舍入规则显式写在代码里(此处四舍五入到分)
  return Math.round((cents * numerator) / denominator);
}
function format(cents) {
  return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, '0')}`;
}

const unit = toCents('19.9');
const total = percent(unit * 3, 90, 100);
console.log('整数分方案:19.9 元 =', unit, '分;×3 再 9 折 =', total, '分 =', format(total), '元');

// 对拍:十万组随机单价 × 数量 × 折扣,结果必须与独立展开的基准一致
let mismatch = 0;
for (let t = 0; t < 100000; t++) {
  const price = `${Math.floor(Math.random() * 100)}.${Math.floor(Math.random() * 100)}`;
  const qty = 1 + Math.floor(Math.random() * 10);
  const disc = 50 + Math.floor(Math.random() * 50);
  const cents = percent(toCents(price) * qty, disc, 100);
  const expect = Math.round((toCents(price) * qty * disc) / 100);
  if (cents !== expect) mismatch++;
}
console.log('十万组随机对拍,不一致组数:', mismatch);

实测输出:

0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 === 0.3 ? false
1.1 * 3 = 3.3000000000000003
19.9 * 100 = 1989.9999999999998
截断取整 Math.floor: 1989 分(少了一分)
四舍五入 Math.round: 1990 分
整数分方案:19.9 元 = 1990 分;×3 再 9 折 = 5373 分 = 53.73 元
十万组随机对拍,不一致组数: 0

现场三最值得细看:19.9 × 100 在浮点世界里是 1989.9999999999998,截断取整直接少一分钱——这就是「乘 100 转分」也必须用四舍五入、且最好从字符串解析的原因。

成因加解法图:上部是成因链——十进制的 0.1 与 0.2 转成二进制都是无穷循环小数,尾数位装不下只能截断,截断误差在相加时显形,于是 0.1+0.2 的结果略大于 0.3,等值比较直接失败。下部两种正确方案并排:左案 BigDecimal——以字符串构造避免先把误差带进来,运算自带精确十进制语义,适合对外展示与复杂换算,代价是对象开销与 API 繁琐;右案整数分——金额统一乘 100 存 long,加减乘天然精确、比较与排序零成本,适合内部记账与高频计算,代价是展示层要记得除回来、币种换算要另管。底部结论:内部流转用分、对外呈现用格式化,任何一环都别让 double 碰到钱 图 2:成因链与两种正确方案的对照。

可能的追问与易错点。

「BigDecimal 和整数分怎么选?」内部记账与高频计算用整数分:加减乘天然精确、比较排序零成本、落库就是 long;对外展示与复杂换算用 BigDecimal:十进制语义直观,除法必须显式指定舍入模式否则抛异常。共同铁律:构造时别让 double 先进来——BigDecimal 要用字符串构造,整数分要从字符串解析。「误差那么小,四舍五入一下不就行了?」对单笔展示也许行,对账不行:误差会累积,十万笔交易各差一点的合计就是资损;而且「看起来对」的舍入会掩盖错误,让 bug 活到审计那天。易错点有三:一是用 == 比较金额;二是 BigDecimal 用 new BigDecimal(0.1) 这种 double 构造,误差原样带入;三是打折计算先除后乘,精度在中间步骤就丢了。

回到面试现场,建议的答题节奏:先给结论(不能用 double),再讲 IEEE 754 截断成因,然后并排给两种正解与选型依据,最后主动提「字符串构造」这个细节——它是区分「背过答案」和「真修过账」的分水岭。AI 业务的计费、token 用量结算、标注报酬核算全是这条规则的适用现场:内部流转用分,对外呈现格式化,任何一环都别让 double 碰到钱。

你遇过浮点误差造成的真实账单事故吗?欢迎分享。