0.1 + 0.2 不等于 0.3:我把浮点数的账算了一遍

前阵子对账,财务说系统里一笔订单是 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

一条发光的二进制 0011 循环缎带,绕成一个永远无法闭合的环

把两个"不准"的数加起来:

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| &lt; Number.EPSILON             → false   ← 明明只差 1 个 ULP,却说"不相等"
|a - b| &lt;= 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 &lt;&lt; 31                      → -2147483648
1 &lt;&lt; 32                      → 1             ← 移位量会 mod 32
1 &lt;&lt; 33                      → 2
-1 >>> 0                     → 4294967295
NaN | 0                      → 0
Infinity | 0                 → 0
(-0) | 0                     → 0             ← -0 被抹平

一排 32 个发光槽位组成的管道,一个大数字从末端溢出绕回左侧变成负数

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 或先排序再求和

# 十一、一份不会自欺欺人的清单

  1. 0.1 从来不是 0.1。 它存的是 0.1000000000000000055511151231257827021181583404541015625。
  2. 误差已经在最优解上了。 0.1+0.2 与 0.3 只差 1 个 ULP,硬件没错,是 0.3 住得离真值远。
  3. 加法不满足结合律。 先加小数后加大数,九个 1 就不会被 1e16 吞掉。
  4. 累加误差随规模增长。 100 万个 0.1 偏差 1.33e-6;Kahan 补偿求和几乎不花时间就能归零。
  5. 2^53 之后整数不连续。 ID 走 JSON 必须转字符串。
  6. Number.EPSILON 是 1 附近的 ULP,不是全局阈值。 大数要用相对比较。
  7. toFixed(2) 在末位是 5 时有一半概率舍错。 金额用整数分,别在最后一刻才舍入。
  8. 位运算只认 32 位。 | 0 会把 1e10 截成 1410065408、把 MAX_SAFE_INTEGER 截成 -1。
  9. JSON 会静默改写 -0、NaN、Infinity。 过一趟网络就找不回来。
  10. "我试过没问题"不算测试。 0.1 * 10 === 1 成立只是巧合。

那一分钱的账,最后我们改成了整数分计算,并且把"先打折后计税、每步截断"这条规则写进了接口文档。浮点数没有骗人——它只是从一开始就老老实实告诉你:我只有 53 位,剩下的我不负责。

真正需要负责的,是在写下 0.1 + 0.2 之前,想清楚这个结果会被拿去做什么。