这个 bug,是被财务同事追着问出来的。她说我们的对账系统,总价偶尔会和实际"差那么一两分钱"——金额不大,但财务的账,差一分钱都是不能容忍的。我起初以为是某条数据错了,可翻来覆去查数据,每一笔单价、数量都对得上,加起来却就是会差一点点。我把计算过程一步步打印出来,终于看到了那个让无数程序员都"会心一笑"又"头皮发麻"的经典场景:在控制台里敲下 0.1 + 0.2,得到的不是 0.3,而是一个诡异的 0.30000000000000004。
就是这多出来的、几乎无穷小的一点点误差,在成千上万笔金额的累加中,一点点地累积、放大,最终就成了财务账上那"差的一两分钱"。这背后,是几乎所有编程语言(不止 JavaScript)都绕不开的一个底层真相——计算机用来表示小数的"浮点数"(遵循 IEEE 754 标准),本质上是不精确的;像 0.1、0.2 这样的十进制小数,在二进制的浮点世界里,根本无法被精确地表示,只能用一个"无限接近、但不完全相等"的值来近似。而当我们拿这些"近似值"去做计算、尤其是去做对精度要求极高的金额计算时,那些被掩藏的微小误差,就会暴露出来、累积成灾。这篇文章,就从这次"对账总差一分钱"的事故讲起,把浮点数精度这个看似简单、却坑过无数人(尤其在涉及钱的时候)的坑,讲清楚。
故障现场:消失的、和多出来的一分钱
先把这个精度问题最直观的样子摆出来:
console.log(0.1 + 0.2); // 0.30000000000000004 (不是 0.3!)
console.log(0.1 + 0.2 === 0.3); // false (所以千万别这么比较浮点数)
console.log(0.3 - 0.1); // 0.19999999999999998
console.log(1.005 * 100); // 100.49999999999999 (不是 101)
// 放到金额场景, 灾难就来了:
let total = 0;
for (let i = 0; i < 10; i++) {
total += 0.1; // 累加 10 次 0.1
}
console.log(total); // 0.9999999999999999 (不是 1!)
// 成千上万笔这样的累加, 误差不断累积, 对账自然差那么一点点
看到 0.1 + 0.2 等于 0.30000000000000004 那一刻,很多人的第一反应是"计算机的加法器坏了吗?"。当然不是。问题出在"表示"环节,而不是"计算"环节:计算机内部用二进制存储数字,而 0.1 这个十进制小数,转换成二进制后,是一个无限循环的小数(就像 1/3 在十进制里是 0.333…无限循环一样)——计算机的浮点数位数有限,存不下无限位,只能截断、近似存储。所以你存进去的 0.1,其实是一个"非常接近 0.1、但不完全等于 0.1"的近似值;0.2 也是。两个近似值相加,得到的结果,自然就带上了那一点点近似的误差,显示出来就是 0.30000000000000004。
这个误差单看一次,小到可以忽略(在小数点后第 17 位)。可一旦你反复累加(像对账系统那样把成千上万笔金额加起来),这些微小的误差就会不断累积;或者你用它做相等比较(if (total === expected)),那 false 就会让你的逻辑直接出错。对账差一分钱,本质上就是无数笔金额在浮点近似下累加,误差累积到了"分"这个量级,被财务的火眼金睛逮住了。钱,是对精度零容忍的领域,而浮点数恰恰是不精确的——这两者撞在一起,就是事故。
第一件事:理解浮点数为什么"天生不精确"
要避开这个坑,必须先接受一个反直觉、但极其重要的事实:浮点数(JS 里的 number、其它语言里的 float/double)的不精确,不是 bug,而是它的设计本质。它遵循 IEEE 754 标准,用有限的二进制位(JS 是 64 位双精度)去表示一个数,而很多十进制小数,在二进制下是无限循环的,无法被有限位精确表示,只能近似。
为什么 0.1 在计算机里不精确?
十进制 0.1, 转成二进制是: 0.0001100110011001100... (1100 无限循环)
就像十进制里 1/3 = 0.33333... 永远除不尽一样
计算机的 double 只有 64 位, 存不下无限循环, 只能在某一位截断、近似
→ 存进去的"0.1", 其实是一个最接近 0.1 的、有微小误差的近似值
能被二进制精确表示的小数, 只有"分母是 2 的幂"的那些(如 0.5, 0.25, 0.125)
其余绝大多数十进制小数(0.1, 0.2, 0.3...), 都是近似的
所以,关键认知是:只要你在用浮点数,就要默认它带着微小的、不可避免的近似误差;这个误差平时藏在小数点后十几位,你感觉不到,但它确实存在,并会在"累加"和"相等比较"时浮出水面。这不是 JavaScript 的问题,几乎所有用 IEEE 754 浮点数的语言(Java 的 double、C 的 float、Python 的 float……)都一样,只是 JS 因为"所有数字都是 double"、且前端常做金额展示,让这个问题显得格外突出。理解了"浮点数天生近似"这个本质,你就明白了:不能指望浮点数给你精确的结果,尤其是在不能有半点误差的金额、计量等场景。
第二件事:金额计算的金科玉律——用"整数(分)"算
知道了浮点不精确,金额计算最经典、最可靠的解药就来了:不要用浮点数(元)来算钱,而是把金额统一换算成最小货币单位(分),用整数来计算。因为整数运算是完全精确的(没有小数,就没有近似误差),算完之后,只在最后展示给用户的那一刻,再换算回"元"。
// 反面: 用浮点(元)直接算, 累积误差
let total = 0;
items.forEach(it => { total += it.price * it.count; }); // price 如 19.9 元
// 正面: 全程用"分"(整数)算, 精确无误差
let totalCents = 0;
items.forEach(it => {
// 单价存成"分"(1990 分), 或换算时取整, 用整数乘加
totalCents += it.priceCents * it.count; // 整数运算, 绝对精确
});
// 只在最后展示时, 换算回"元"
const display = (totalCents / 100).toFixed(2); // "39.80"
"用最小单位整数计算钱"是金额处理的金科玉律,它的逻辑很简单:既然浮点小数会带来误差,那我就干脆不用小数——把 19.9 元存成、算成 1990 分,所有的加减乘都在整数域里进行,整数运算是百分百精确的,自然就没有任何累积误差;直到最后要显示给用户看时,才把"分"除以 100 变回"元"。关键在于:数据库里存的、后端传的、前端算的,金额都应该统一用"分"(整数)这个单位;只在"展示"这唯一的环节,才转成"元"。把这个原则贯彻到底,对账差一分钱的问题就从根上消失了。这也是为什么很多成熟的支付、金融系统,数据库里的金额字段存的都是"分"(甚至更小的单位)的整数,而不是带小数的"元"。
第三件事:别用 === 比较浮点,要用"误差容忍"
精度问题的第二个高发区,是比较。前面看到 0.1 + 0.2 === 0.3 是 false,所以,永远不要用 ===(或 ==)去直接比较两个浮点数是否相等——因为它们各自带着微小误差,几乎永远不会"完全相等"。正确的做法是,判断它们的"差的绝对值"是否小于一个极小的容忍值(epsilon)。
// 反面: 直接比较浮点数, 几乎注定出错
if (0.1 + 0.2 === 0.3) { ... } // false! 这个分支永远进不去
// 正面: 判断两数之差是否小于一个极小的容忍值(epsilon)
function almostEqual(a, b, epsilon = 1e-9) {
return Math.abs(a - b) < epsilon; // 差距足够小, 就认为相等
}
if (almostEqual(0.1 + 0.2, 0.3)) { ... } // true, 正确
// JS 还内置了 Number.EPSILON 作为参考的最小精度
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // true
这个思路叫"误差容忍比较":既然浮点数不可能"完全精确相等",那我们就退一步,只要两个数"足够接近"(差距小于一个我们定义的、可以忽略的极小值 epsilon),就认为它们相等。这个 epsilon 该取多大,取决于你的业务对精度的要求。核心准则是:对浮点数,用"约等于"的思维去比较,而不是"严格相等";凡是看到 floatA === floatB 这种写法,都要警惕——它大概率是个 bug。这条准则,和前面"金额用整数"是互补的:整数运算从源头避免了误差(适合金额这种能用整数表示的场景),而误差容忍比较则是在"不得不用浮点"时(比如科学计算、几何、物理量)安全比较的办法。我把这两类场景的对策画成图:
这张图的分叉点很关键:能用整数表示的(钱、数量),就换算成整数算,从源头杜绝浮点误差;实在要用浮点的(连续的科学量),就用"误差容忍比较"安全地比较、用 Decimal 类库做高精度运算。分清这两类,对症下药,浮点精度的坑就基本被你堵住了。
第四件事:连 toFixed 和"大整数"也有坑
顺着精度这条线,我又揪出几个相关的、同样阴险的坑。第一个是 toFixed——很多人拿它做四舍五入,但它的舍入行为本身就受浮点不精确影响,有时会给你意外的结果;而且它返回的是字符串。第二个是"大整数"——JS 的 number 能精确表示的整数是有上限的(Number.MAX_SAFE_INTEGER,约 9 千万亿),超过它,大整数也会失真。
// 坑1: toFixed 的四舍五入受浮点影响, 有时不符合预期
console.log((1.005).toFixed(2)); // "1.00" (期望 "1.01"! 因 1.005 实为 1.00499...)
console.log((0.1).toFixed(20)); // 暴露出 0.1 真实存储的那一长串近似值
// 坑2: 大整数超过安全范围会失真(常见于后端传来的雪花ID/订单号)
console.log(9007199254740993); // 9007199254740992 (末位被吃了!)
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991, 超过它就不安全
// 解法: 大整数ID用"字符串"传输和存储, 或用 BigInt
const bigId = 9007199254740993n; // BigInt 字面量(后缀 n), 精确
// 高精度小数运算, 用专门的库(decimal.js / big.js)
// const sum = new Decimal(0.1).plus(0.2); // 精确得到 0.3
这两个坑都很实战:toFixed 的坑提醒我们,连"四舍五入"这种看似无害的操作,在浮点世界里都可能不准(1.005 实际存储是 1.00499...,所以 toFixed(2) 给了 "1.00" 而非 "1.01"),涉及金额舍入时要格外小心、最好在整数域处理;"大整数失真"的坑则是前后端协作里的常见雷区——后端用 64 位长整型生成的订单号、雪花 ID,数值往往超过了 JS 的安全整数范围,如果直接当 number 传给前端,末尾几位就会被"吃掉"、变成另一个数字!所以,后端那些超长的整数 ID,传给前端时一定要用"字符串"类型传输和存储,绝不能用 JS 的 number 去接(这又是一个我见过无数次的生产 bug)。而需要做高精度小数运算时,可以用 decimal.js、big.js 这类专门的库。把这几种应对方案汇总成一张表:
| 方案 | 适用场景 | 说明 |
|---|---|---|
| 整数(分)运算 | 金额计算 | 最可靠, 全程整数, 只在展示转回 |
| epsilon 误差容忍 | 浮点数比较 | 判断 abs(a-b)<ε, 别用 === |
| Decimal/big.js 库 | 高精度小数运算 | 财务、科学计算需精确小数时 |
| 字符串传输 | 超长整数 ID | 超 MAX_SAFE_INTEGER 的 ID 用字符串 |
| BigInt | 超大整数运算 | JS 原生大整数类型, 后缀 n |
第五件事:把常见的浮点"陷阱场景"列个清单
最后,我把浮点精度问题最容易暴雷的几个场景,整理成一张"危险清单"——看到代码里有这些,就该警惕了:
| 危险场景 | 为什么坑 | 正确姿势 |
|---|---|---|
| 金额用浮点(元)加减乘 | 误差累积, 对账差钱 | 换成整数(分)运算 |
| floatA === floatB | 几乎永远 false | 用 epsilon 误差容忍比较 |
| 大量浮点累加求和 | 误差不断累积放大 | 整数累加, 或 Decimal |
| toFixed 做金额四舍五入 | 舍入受浮点影响不准 | 整数域处理舍入 |
| 用 number 接后端长整型ID | 超安全范围末位失真 | ID 用字符串传输 |
| 百分比/利率反复乘 | 每次乘都引入误差 | 转整数或用高精度库 |
这张清单几乎覆盖了浮点精度在实战中暴雷的所有高发地。它们的共性,都是"在一个对精度敏感的场景(钱、ID、比较、累加)里,直接用了不精确的浮点数"。而所有应对的核心思路,其实就两条:要么从源头避免用浮点(能用整数就用整数、能用字符串传就用字符串),要么在不得不用浮点时,带着"它不精确"的觉悟去小心处理(用 epsilon 比较、用高精度库)。把这张清单记在心里,写到相关代码时多一个心眼,这一类"差一分钱""ID 对不上"的诡异 bug,就能被你提前掐灭。
一张"涉及数字精度怎么做"的决策图
把这次踩坑沉淀成一张图。每当你写的代码涉及"数字、尤其是钱或精确比较"时,照着它过一遍:
这张图把"涉及数字精度"的几种典型场景和对策串了起来:金额用整数分、浮点比较用 epsilon、长整数 ID 用字符串、高精度运算用 Decimal 库。核心始终是那个意识——默认浮点数是不精确的,然后根据场景,要么避开它,要么小心地与它共处。把这张图过一遍成为习惯,浮点精度的坑就难再咬到你。
我立下的几条数字精度规矩
这次"对账差一分钱"的事故后,团队的规范里加了这么几条:
- 金额一律用整数(分):数据库存、后端传、前端算,金额统一用最小单位整数,只在展示层转回"元",绝不用浮点元做运算。
- 浮点比较用 epsilon:绝不用 === 比较两个浮点数,用"差的绝对值小于极小容忍值"来判断约等。
- 长整数 ID 用字符串:超过 JS 安全整数范围的 ID(雪花ID、长订单号),前后端一律用字符串传输和存储。
- 慎用 toFixed 做金额舍入:它的舍入受浮点影响、且返回字符串;金额舍入尽量在整数域处理。
- 高精度需求上 Decimal 库:确需精确小数运算(财务、计费)时,用 decimal.js/big.js 这类专业库。
- 警惕浮点累加:大量浮点数累加会累积误差,涉及精度时改用整数累加或高精度库。
- 涉及钱的逻辑必测边界:对金额计算写单元测试,专门覆盖会暴露浮点误差的累加、舍入、比较场景。
这几条里,第一条"金额用整数分"是金科玉律中的金科玉律——它一条就能根治绝大多数和钱相关的精度事故。而第三条"长整数 ID 用字符串"是我特别想再强调的、极其高发却又极其隐蔽的坑:它不报错、不崩溃,只是默默地把你后端那个精心生成的、全局唯一的长 ID,在前端 number 接收的瞬间,末位"四舍五入"成了另一个数字——于是前端拿着一个"错误的 ID"去请求,各种"找不到""对不上"的诡异问题接踵而至,而你对着代码怎么都看不出哪里错了。把它记牢:JS 的 number 接不住后端的长整型 ID,必须用字符串。
写在最后:别把"抽象"当成"现实"
这次被一分钱坑到的经历,让我对"计算机里的数字"生出了一种新的认识。在那之前,我潜意识里把代码里的 0.1,就当成了数学课本里那个精确的、完美的 0.1——它们看起来一模一样,我从没想过它们之间会有什么差别。可这次事故狠狠地告诉我:代码里的 0.1,不是数学世界里那个理想的 0.1,而是计算机用有限的二进制位去近似它的一个"赝品";它们形似而神不似,在绝大多数场景下你看不出区别,但在金额这种对精度零容忍的地方,这个"赝品"的破绽就会暴露无遗。我犯的错,本质上是把"计算机对数字的抽象表示",错当成了"数学上精确的现实"。
想通这一层,我领悟到一个更普遍的道理:我们写代码时用的很多概念——数字、字符串、时间、对象……——都是计算机对现实世界的一种"抽象表示",而抽象,必然伴随着"简化"和"失真"。计算机用有限的浮点位去表示无限精度的实数(于是有了精度误差);用有限的整数位去表示无限大的整数(于是有了溢出);用一个时间戳去表示连续流逝的时间(于是有了时区、闰秒的种种问题)……这些抽象,平时工作得很好,好到让我们忘了它们只是"近似的表示"而非"现实本身";可一旦你在某个对精度、对边界敏感的场景下,把抽象天真地当成了现实,它就会用一个 bug 提醒你:我只是个赝品,别太信我。
所以,如果你也在写涉及数字、时间、或任何"现实世界量"的代码,我想把这次踩坑最想说的话送给你:请始终记得,你代码里操作的,不是现实本身,而是计算机对现实的一种有损的抽象表示。用浮点数时,记得它不精确;用整数时,记得它有范围;用时间戳时,记得它背后有时区。在那些对精度、对边界要求严苛的场景(钱、ID、计量、时间),尤其要清醒地意识到"抽象和现实的差距",并主动地去弥合它(用整数算钱、用字符串传 ID……),而不是天真地以为"它就等于现实"。那消失的一分钱,最终教给我的,正是这份对"抽象之有损"的清醒——它让我在每一次和数字打交道时,都多了一分"别全信它"的审慎。而正是这份审慎,把那些藏在 0.30000000000000004 里的、会让财务彻夜难眠的小魔鬼,挡在了门外。愿你我的账,从此都分毫不差。
一个延伸:精度问题"不报错",才最可怕
这次事故还有一个值得单独说的特点:它从头到尾,没有任何报错。程序跑得好好的,代码逻辑"看起来"也对,只是算出来的数字,悄悄地差了那么一点点。这正是精度类 bug 最阴险的地方——它不像空指针、数组越界那样会"啪"地崩给你看,而是静默地产生一个"看起来合理、实则错误"的结果,然后让这个错误的结果继续往下流,直到在某个遥远的下游(比如财务对账)被一双火眼金睛逮住,而那时,你已经很难追溯它最初是从哪一步开始错的了。
这让我对"会报错的 bug"反而生出了几分感激——至少它诚实,它把问题大大方方地摆在你面前。而像浮点精度、像数据悄悄被截断、像缓存返回了旧值这种"静默错误",才是真正需要我们提高警惕的:它们不吵不闹,却在你看不见的地方,持续地、悄悄地输出着错误的结果。对付这类静默错误,光靠"等它报错"是没用的(它根本不报错),你必须主动出击:在关键的、敏感的环节(尤其是涉及钱和核心数据的地方),主动地去校验结果、去对账、去写覆盖边界场景的测试——用主动的验证,去把那些不会自己跳出来的静默错误,提前揪出来。这,也是那一分钱教给我的:最危险的敌人,往往不是那个张牙舞爪的,而是那个悄无声息的。
—— 别看了 · 2026