图 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 转分」也必须用四舍五入、且最好从字符串解析的原因。
图 2:成因链与两种正确方案的对照。
可能的追问与易错点。
「BigDecimal 和整数分怎么选?」内部记账与高频计算用整数分:加减乘天然精确、比较排序零成本、落库就是 long;对外展示与复杂换算用 BigDecimal:十进制语义直观,除法必须显式指定舍入模式否则抛异常。共同铁律:构造时别让 double 先进来——BigDecimal 要用字符串构造,整数分要从字符串解析。「误差那么小,四舍五入一下不就行了?」对单笔展示也许行,对账不行:误差会累积,十万笔交易各差一点的合计就是资损;而且「看起来对」的舍入会掩盖错误,让 bug 活到审计那天。易错点有三:一是用 == 比较金额;二是 BigDecimal 用 new BigDecimal(0.1) 这种 double 构造,误差原样带入;三是打折计算先除后乘,精度在中间步骤就丢了。
回到面试现场,建议的答题节奏:先给结论(不能用 double),再讲 IEEE 754 截断成因,然后并排给两种正解与选型依据,最后主动提「字符串构造」这个细节——它是区分「背过答案」和「真修过账」的分水岭。AI 业务的计费、token 用量结算、标注报酬核算全是这条规则的适用现场:内部流转用分,对外呈现格式化,任何一环都别让 double 碰到钱。
你遇过浮点误差造成的真实账单事故吗?欢迎分享。