0.1 + 0.2 不等于 0.3:我把浮点数的账算了一遍
- 作者:Bougie
- 创建于:2026-10-15
前阵子对账,财务说系统里一笔订单是 54.03 元,她手工算出来是 54.02 元。差一分钱,谁也说不清是谁的错。
我把那段代码翻出来:单价 19.99,数量 3,打 85 折,加 6% 的税。用浮点数算下来是 54.03297,toFixed(2) 之后是 54.03。用整数分重算一遍:1999 分 × 3 = 5997,打完折 5097 分,加完税 5402 分,也就是 54.02 元。
两个答案都是"对的",只是它们回答的不是同一个问题。要搞清楚差在哪,得先看清 0.1 这三个字符进到内存之后变成了什么。

所有数据来自本机 Linux x86_64、Node v24.16.0,实验只用 CPU 和内存,不依赖网络。
# 一、0.1 存进内存之后,已经不是 0.1 了
先问一个看起来很蠢的问题:0.1 用二进制怎么写?
;(0.1).toString(2)
// '0.0001100110011001100110011001100110011001100110011001101'
后面那一串不是随机的。我用长除法把 1/10 展开成二进制小数,前 60 位是这样的:
1/10 = 0.000110011001100110011001100110011001100110011001100110011001…
↑ 循环节是 0011,长度 4,永远不终止
1/10 在二进制里是一个无限循环小数。 就像 1/3 在十进制里写不完一样,这是进制的问题,不是计算机的问题。而 double 只有 52 位尾数,写不完就只能截断再舍入。
截断之后存进去的到底是多少?我把它的 64 个位拆开还原成十进制:
符号位 + 指数(原始值)1019 → 实际指数 -4
尾数 7205759403792794 (即 1.100110011…× 2^52 的整数形式)
0.1 的真实值 = 7205759403792794 / 2^52 × 2^-4
= 0.1000000000000000055511151231257827021181583404541015625
也就是说,你写下 0.1,机器里存的是一个比 0.1 大 约 5.55e-18 的数。同样地:
0.1 → 0.100000000000000005551115123125782702118158340454101562500000
0.2 → 0.200000000000000011102230246251565404236316680908203125000000
0.3 → 0.299999999999999988897769753748434595763683319091796875000000
注意 0.3 是小于 0.3 的。这一大一小的偏差,是后面所有麻烦的源头。
顺手提一句:这不是 JavaScript 的锅。同机上的 Python 3 给出完全一样的结果,连字节都一样:
>>> repr(0.1 + 0.2)
'0.30000000000000004'
>>> struct.pack('>d', 0.1).hex()
'3fb999999999999a' # 指数 0x3fb = 1019,尾数 999999999999a
IEEE 754 是硬件标准,凡是用了 double 的语言都逃不掉。
# 二、0.1 + 0.2 与 0.3 之间,正好隔着 1 个 ULP

把两个"不准"的数加起来:
0.1 + 0.2 // 0.30000000000000004
;(0.1 + 0.2).toPrecision(20) // '0.30000000000000004441'
;(0.3).toPrecision(20) // '0.29999999999999998890'
这两个数差多少?我测了一下"0.3 这个量级上相邻两个 double 之间的距离"(也就是 ULP,Unit in the Last Place):
0.1 + 0.2 - 0.3 = 5.551115e-17
ULP(0.3) = 5.551115123125783e-17
差 / ULP = 1
0.1+0.2 的结果,恰好就是紧挨在 0.3 后面的那个 double。 我直接算 nextAfter(0.3) 验证过,返回的就是 0.30000000000000004441。误差只有一个最小单位——已经是最优解了,硬件没算错,是 0.3 这个数本身就"住"在离真值稍远一点的位置上。
ULP 不是固定的,它随数量级指数增长:
| 数值 | ULP | 等于 |
|---|---|---|
| 0.1 | 1.3877787807814457e-17 | 2^-56 |
| 0.3 | 5.551115123125783e-17 | 2^-54 |
| 1 | 2.220446049250313e-16 | 2^-52 |
| 1e6 | 1.1641532182693481e-10 | 2^-33 |
| 1e16 | 2 | 2^1 |
相对误差始终在 1e-16 量级,但绝对精度从 1e-17 一路滑到 2。这张表是后面所有问题的钥匙。
顺便把常见的反例都跑了一遍:
0.1 * 3 === 0.3 → false (0.30000000000000004)
0.1 * 10 === 1 → true (凑巧对上了,别当规律)
0.3 - 0.1 → 0.19999999999999998
1 - 0.9 → 0.09999999999999998
0.07 * 100 → 7.000000000000001
1.15 * 3 → 3.4499999999999997
4.35 * 100 → 434.99999999999994
0.1 + 0.7 === 0.8 → false (0.7999999999999999)
6.02 + 0.01 → 6.029999999999999
17.99 + 0.01 → 18 (另一个凑巧)
Math.sin(Math.PI) === 0 → false (1.2246467991473532e-16)
Math.sqrt(2) ** 2 === 2 → false (2.0000000000000004)
0.1 ** 2 === 0.01 → false (0.010000000000000002)
1 / 3 * 3 === 1 → true (又一次凑巧)
0.1 * 10 === 1 成立、0.1 * 3 === 0.3 不成立,这不是什么规律,纯粹是舍入方向碰巧抵消了。"我试过没问题"是最危险的测试。
# 三、加法结合律,在浮点世界里不成立

小学数学教的 (a + b) + c === a + (b + c),在 double 上是假的:
(0.1 + 0.2) + 0.3 = 0.6000000000000001
0.1 + (0.2 + 0.3) = 0.6
两者 === 比较 → false
放大到一个更直观的例子。1e16 这个量级的 ULP 是 2,也就是说小于 2 的增量加不进去:
数组 [1e16, 1, 1, 1, 1, 1, 1, 1, 1, 1]
原序求和(先加大数) = 10000000000000000
升序求和(先加小数) = 10000000000000008
直接算 1e16 + 9 = 10000000000000008 ← 刻度是 2,9 只能落在 8 上
九个 1 全被吞掉了。 顺序换一下,结果就回到这个刻度上离真值最近的位置。再狠一点:
[1e16, 0.5, 0.5, 0.5] 原序 = 10000000000000000
[1e16, 0.5, 0.5, 0.5] 升序 = 10000000000000002
累加次数一多,误差就攒起来了:
0.1 累加 10 次 = 0.9999999999999999 差 -1.11e-16
0.1 累加 100 次 = 9.99999999999998 差 -1.95e-14
0.1 累加 1000 次 = 99.9999999999986 差 -1.41e-12
0.1 累加 10000 次 = 1000.0000000001588 差 +1.59e-10
0.1 累加 100000 次 = 10000.000000018848 差 +1.88e-8
0.1 累加 1000000 次 = 100000.00000133288 差 +1.33e-6
0.01 累加 1e6 次 = 10000.000000171856 差 +1.72e-7
1e-10 累加 1e7 次 = 0.0010000000001300626 相对误差 1.30e-10
误差不是单调变大的(有正有负),但量级是稳稳往上走的。
# 补偿求和:几乎免费的修正
Kahan 求和把每一步丢掉的低位记在一个补偿变量里,下一轮再补回去:
function kahanSum(arr) {
let sum = 0
let c = 0
for (const v of arr) {
const y = v - c
const t = sum + y
c = t - sum - y // 这一轮丢掉的部分
sum = t
}
return sum
}
100 万个 0.1:
朴素 = 100000.00000133288 (差 1.33e-6) 5.58 ms
Kahan = 100000 (差 0) 5.11 ms
树形两两 = 100000 (差 0) 5.41 ms
补偿求和在实测里甚至没有更慢(5.11 ms vs 5.58 ms),因为瓶颈根本不在那几次加减上。100 万个 [0,1) 随机数上,朴素与 Kahan 相差 1.228e-8。
如果不想改算法,最简单的改良是先排序再求和:把小数先加起来,它们有机会攒到一个能影响大数的量级。
# 四、2^53 之后,整数不再连续

52 位尾数 + 1 位隐含的整数位 = 53 位有效精度。超过这个范围,整数就开始"跳格":
2 ** 53 // 9007199254740992
2 ** 53 + 1 === 2 ** 53 // true ← 加了个寂寞
2 ** 53 + 2 // 9007199254740994 ← 只有偶数还能表示
Number.isSafeInteger(2 ** 53) // false
再往上走,间距变成 4、8、16……到 1e16 时 ULP 已经是 2:
1e16 + 1 === 1e16 → true
1e16 + 3 → 10000000000000004 ← 3 正好卡在 2 和 4 中间,按"舍入到偶数"取了 4
1e16 + 4 → 10000000000000004
这一条在前后端交界处最要命。后端的 Long 型 ID 走 JSON 到前端,只要超过 2^53 就会被悄悄改写:
字符串 "1837468647967162368" → JSON.parse → 1837468647967162400
字符串 "12345678901234567890" → JSON.parse → 12345678901234567000
JSON.stringify(9007199254740993) → "9007199254740992"
两次相邻的 ID 会 collapse 成同一个数,数据库里两条不同的记录,前端眼里变成一条。规则很简单:ID 一律用字符串传输。
好消息是时间戳还早。Date.now() 现在是 13 位(1792004694114),而 2^53 毫秒换算过来是 285420 年——毫秒级时间戳还能再用大约 28 万年。
# 五、Number.EPSILON 不是"误差阈值"
很多人这么写:
Math.abs(a - b) < Number.EPSILON // 以为这能判断"相等"
Number.EPSILON 是 1 附近的那个 ULP(2^-52),不是全局误差上限。用在小数上凑巧能过:
Math.abs(0.1 + 0.2 - 0.3) // 5.551115123125783e-17
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true (只有 EPSILON 的 0.25 倍)
但换到大数上就完全反了:
a = 1e16, b = 1e16 + 2
a === b → false
|a - b| → 2
|a - b| < Number.EPSILON → false ← 明明只差 1 个 ULP,却说"不相等"
|a - b| <= EPSILON × max(|a|, |b|) → true ← 相对比较才是对的
正确的写法是绝对 + 相对两条都留:
function nearlyEqual(a, b, rel = Number.EPSILON) {
const diff = Math.abs(a - b)
if (diff <= rel) return true // 处理 0 附近
return diff <= rel * Math.max(Math.abs(a), Math.abs(b))
}
更硬核的做法是直接数它们隔着几个 ULP——把 double 的位模式当整数做差:
ULP 距离(0.1 + 0.2, 0.3) = 1
ULP 距离(1, 1.0000000000000002) = 1
ULP 距离(1e16, 1e16 + 2) = 1
"相差不超过 1 个 ULP"是浮点世界里"相等"最严格、也最诚实的定义。
# 六、toFixed 会把 1.005 舍成 1.00
这一条我踩过。要给金额保留两位小数,第一反应是 toFixed(2):
;(1.005).toFixed(2) // '1.00' ← 期望 1.01
;(0.615).toFixed(2) // '0.61' ← 期望 0.62
;(35.855).toFixed(2) // '35.85'
;(8.575).toFixed(2) // '8.57'
;(1.0005).toFixed(3) // '1.000'
;(1.45).toFixed(1) // '1.4'
原因在第一节:这些"以 5 结尾"的十进制数,存进去之后其实小那么一点点:
1.005 → 1.0049999999999998934
0.615 → 0.61499999999999999112
35.855 → 35.854999999999996874
toFixed 忠实地对这个真实值做了四舍五入,答案"正确"——只是不是你想要的那个。
我拿 20 万个三位小数统计了一遍,以十进制书写值该有的四舍五入作为参照:
样本 200000 个,末位是 5 的有 20807 个(10.40%)
其中 toFixed(2) 舍错方向的有 9755 个 = 末位 5 样本的 46.88%
末位非 5 却出错的 0 个
末位是 5 的时候,toFixed 有一半的概率舍错方向。 末位不是 5 的时候它从不犯错。
换成 Math.round(v * 100) / 100 会好很多,但仍不完美(错 1237 个,0.619%)。有意思的是 Intl.NumberFormat 反而是对的:
1.005 toFixed(2)=1.00 Intl=1.01
0.615 toFixed(2)=0.61 Intl=0.62
35.855 toFixed(2)=35.85 Intl=35.86
另外几个容易翻车的边界:
Math.round(-0.5) // -0 (Object.is 判定,String() 出来是 "0")
Math.round(2.5) // 3
Math.round(-1.5) // -1 (朝 +∞ 方向舍,不是朝远离 0)
;(1e21).toFixed(2) // '1e+21' ← 科学计数法,不是你要的金额字符串
;(1e-7).toFixed(2) // '0.00'
Number.isNaN('abc') // false (它不做类型转换)
isNaN('abc') // true
# 七、一分钱是怎么丢的:用整数分重算

回到开头那笔订单:
浮点路线:19.99 × 3 × 0.85 = 50.9745,× 1.06 = 54.03297,toFixed(2) → "54.03"
分制路线:1999 × 3 = 5997 分,× 85 / 100 = 5097 分,× 106 / 100 = 5402 分 → 54.02 元
差一分钱。原因不是"误差累积",而是两条路线规定舍入时机的业务规则不同:浮点路线一路不截断,只在最后保留两位(54.03297 → 54.03);分制路线在打折和计税这两步除法之后各截断了一次(5997 → 5097 → 5402)。财务手工算的是后者。
这里没有谁对谁错,但必须写清楚并让两端一致。 我的建议是把规则固化成整数运算:
// 金额一律以"分"为单位,用 BigInt 或普通整数
const subtotal = 1999n * 3n // 5997 分
const discounted = (subtotal * 85n) / 100n // 5097 分
const taxed = (discounted * 106n) / 100n // 5402 分 = 54.02 元
分摊也能用同一套思路,避免"三个人分 100 元,加起来是 99.99":
100 元 = 10000 分
每人 10000 / 3 = 3333 分,余 1 分
把余下的 1 分补给第一个人 → 3333 / 3333 / 3334,合计正好 10000
从"元"转"分"这一步也有坑,别直接乘:
19.99 * 100 → 1998.9999999999998 (Math.round 救回来了,得 1999)
1.005 * 100 → 100.49999999999999 (Math.round → 100,想要的是 101)
35.855 * 100 → 3585.4999999999995 (Math.round → 3585,想要的是 3586)
Math.round 只能救回一部分。可靠的做法是先移小数点再舍入、再移回来——把舍入交给十进制字符串而不是二进制乘法:
Number(Math.round(1.005 + 'e2') + 'e-2') // 1.01 ← 对了
Number(Math.round(0.615 + 'e2') + 'e-2') // 0.62 ← 对了
或者干脆让上游就传分。
至于把一堆浮点金额加起来再 toFixed:
[19.99, 0.07, 1.15, 3.3, 6.02] 朴素求和 = 30.529999999999998
toFixed(2) → "30.53" (这次蒙对了)
蒙对是因为误差只有 -3.55e-15,远小于 0.005。这是运气,不是设计。 一旦数量上到几十万笔,误差就会长到能跨过半个分位——前面测过,100 万笔 1 分钱累加的偏差是 1.72e-7 元,还不到半分;但 100 万个 0.1 的偏差是 1.33e-6,如果你在累加的是 0.1 元的单位,就已经越过红线了。
# 八、位运算把你打回 32 位
| 0、~~、<< 这些操作符只认 32 位整数,它们会先把 double 转成 int32:
(2**31) | 0 → -2147483648 ← 正数变负数
(2**32) | 0 → 0
1e10 | 0 → 1410065408 ← 只保留低 32 位
1e21 | 0 → -559939584
Number.MAX_SAFE_INTEGER | 0 → -1
(2**53) | 0 → 0
1 << 31 → -2147483648
1 << 32 → 1 ← 移位量会 mod 32
1 << 33 → 2
-1 >>> 0 → 4294967295
NaN | 0 → 0
Infinity | 0 → 0
(-0) | 0 → 0 ← -0 被抹平

1e10 | 0 = 1410065408 是怎么来的:1e10 的低 32 位就是 1410065408,转回有符号 int32 还是它自己。而 MAX_SAFE_INTEGER | 0 = -1,因为它的低 32 位全是 1。
所以拿 | 0 给大数做"取整"是错的,老老实实用 Math.trunc:
Math.trunc(2 ** 31) // 2147483648 ← 正确
;(2 ** 31) | 0 // -2147483648 ← 错误
# 九、那些被静默改写的值
JSON 是最常见的"丢失现场",因为它没有 NaN、没有 Infinity、也不区分 -0:
JSON.stringify(-0) → "0" ← -0 没了
JSON.stringify(NaN) → "null"
JSON.stringify(Infinity) → "null"
JSON.stringify(0.1 + 0.2) → "0.30000000000000004" ← 误差原样带走
JSON.stringify(1e21) → "1e+21"
JSON.stringify(1n) → TypeError: Do not know how to serialize a BigInt
如果 -0 对你有意义(比如表示方向),过一趟 JSON 就再也找不回来了。
还有几个值得记住的边界:
Object.is(0, -0) → false 而 0 === -0 → true
1 / -0 → -Infinity
Math.max() → -Infinity (不是 0)
Math.min() → Infinity
Math.max(1, NaN) → NaN (一个 NaN 污染整个聚合)
Math.hypot(1e200, 1e200) → 1.414213562373095e+200
Math.sqrt(1e200**2 + 1e200**2) → Infinity ← 中间结果溢出
Math.hypot(1e-200, 1e-200) → 1.414213562373095e-200
Math.sqrt(1e-200 ** 2) → 0 ← 中间结果下溢
Math.fround(0.1) → 0.10000000149011612 (float32 只有 24 位尾数)
new Float32Array([0.1])[0] === 0.1 → false
Math.log1p(1e-10) → 9.999999999500001e-11 (精确)
Math.log(1 + 1e-10) → 1.000000082690371e-10 (丢了一半有效位)
Number('') → 0
Number(' ') → 0
parseInt('1e3') → 1 ← 只解析到 e 为止
parseFloat('1e3') → 1000
Number('12px') → NaN
parseInt('12px') → 12
Math.hypot 不是为了省事,它会内部缩放避免溢出/下溢——自己写 sqrt(a*a + b*b) 在极端量级上会直接崩掉。
# 十、什么时候该换 BigInt,代价是什么
BigInt 能精确表示任意大整数,但它不是免费的。实测(每项跑 3 次取最快,1e7 次):
Number 加法 0.727 ns/次
BigInt 加法(含 BigInt(i) 构造) 11.780 ns/次 ← 约 16 倍
BigInt 加法(已构造好的值) 3.814 ns/次
Float64 数组加法 2.892 ns/次
如果循环里要从 Number 转 BigInt,那是 16 倍的差距;如果 BigInt 已经准备好了,纯加法只慢约 1.3 倍。结论是别在热路径上反复转换,把边界放在入口和出口。
另外一个大坑是 BigInt 与 Number 不能混算:
1n + 1 // TypeError: Cannot mix BigInt and other types
1n < 2 // true ← 比较可以,运算不行
100n / 3n // 33n ← 除法是截断,没有小数
JSON.stringify(1n) // TypeError
类型系统会在运行时帮你挡住混算(这点比浮点好),但除法截断意味着你仍然要想清楚最后一步怎么舍入。
选型上我的经验:
| 场景 | 建议 |
|---|---|
| 金额 | 整数分(Number 或 BigInt 都行),只在展示层除 100 |
| 超过 2^53 的 ID | 字符串全程传输 |
| 需要十进制语义的舍入 | decimal 类库(decimal.js / big.js),别手写 |
| 科学计算、图形、统计 | 继续用 double,接受 1e-16 的相对误差 |
| 判断相等 | 相对误差或 ULP 距离,别用 === |
| 大量累加 | Kahan 或先排序再求和 |
# 十一、一份不会自欺欺人的清单
0.1从来不是 0.1。 它存的是 0.1000000000000000055511151231257827021181583404541015625。- 误差已经在最优解上了。 0.1+0.2 与 0.3 只差 1 个 ULP,硬件没错,是 0.3 住得离真值远。
- 加法不满足结合律。 先加小数后加大数,九个 1 就不会被 1e16 吞掉。
- 累加误差随规模增长。 100 万个 0.1 偏差 1.33e-6;Kahan 补偿求和几乎不花时间就能归零。
- 2^53 之后整数不连续。 ID 走 JSON 必须转字符串。
Number.EPSILON是 1 附近的 ULP,不是全局阈值。 大数要用相对比较。toFixed(2)在末位是 5 时有一半概率舍错。 金额用整数分,别在最后一刻才舍入。- 位运算只认 32 位。
| 0会把 1e10 截成 1410065408、把 MAX_SAFE_INTEGER 截成 -1。 - JSON 会静默改写 -0、NaN、Infinity。 过一趟网络就找不回来。
- "我试过没问题"不算测试。
0.1 * 10 === 1成立只是巧合。
那一分钱的账,最后我们改成了整数分计算,并且把"先打折后计税、每步截断"这条规则写进了接口文档。浮点数没有骗人——它只是从一开始就老老实实告诉你:我只有 53 位,剩下的我不负责。
真正需要负责的,是在写下 0.1 + 0.2 之前,想清楚这个结果会被拿去做什么。