计算机 · 2026 年 11 月 11 日

为什么 0.1 + 0.2 不等于 0.3

0.1 + 0.2 得到 0.30000000000000004,不是语言有 bug,而是二进制浮点的必然结果。讲清表示误差、舍入规则和金额的正确存法。

对账脚本差了 1 分钱,查了两小时,问题出在一行 0.1 + 0.2。控制台打印 0.30000000000000004。这不是 JavaScript 的毛病,Python、Java、Go 里结果一样,因为大家用的都是 IEEE 754 双精度。

更麻烦的是它只在特定数值上暴露。测试用例里的 0.1 和 0.2 看着正常,换成 19.99 加 0.01 就少一分。误差是否显现,取决于二进制表示和后续运算,所以「本地测过没问题」完全不能作为依据。

0.1 在二进制里是无限循环#

双精度(binary64)用 1 位符号、11 位指数、52 位尾数表示,加上隐含的前导 1,有效精度 53 位。它能精确表示 1/2、1/4 这类分母是 2 的幂的数,表示不了 1/10——就像十进制写不出 1/3。

判断一个小数能否精确表示有个简单办法:不断乘 2 取小数部分,能归零就能精确表示。0.5 → 1.0,一步到零;0.1 → 0.2 → 0.4 → 0.8 → 1.6 取 0.6 → 1.2 取 0.2,进入 0.2 → 0.4 → 0.8 → 0.6 的循环,永远到不了 0。0.1 的二进制是 0.0001100110011...,存进去的是最接近的可表示值:

python
>>> 0.1
0.1
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> 0.1 + 0.2
0.30000000000000004

解释器打印的是「能唯一还原这个浮点数的最短表示」,误差就被藏起来了。Decimal(0.1) 接收的是浮点数,于是展开出那个精确的二进制值;想从十进制字面量构造,得写 Decimal('0.1')。

0.1 + 0.2 的结果本身没有「算错」:两个近似值相加,再舍入到最近的可表示值,得到 0.30000000000000004。这是表示误差,不是运算误差。IEEE 754 规定了加法的正确舍入,所以同样的输入在任何平台上结果一致。

比较和累加都会踩坑#

直接用 == 比较浮点几乎总是错的。常见写法是设一个容差:

python
def almost_equal(a, b, rel=1e-9):
    return abs(a - b) <= rel * max(abs(a), abs(b), 1.0)

Number.EPSILON(约 2.22e-16)是 1 和下一个可表示数之间的间隔,不是通用容差。数值大时绝对误差也会变大,所以上面用相对误差。标准库其实已经提供了 math.isclose,语义就是相对误差比较,没必要自己写;numpy.allclose 默认容差是 1e-5,对金额来说太松,要显式传 rtol。

累加更隐蔽:一百万次加 0.0001,结果可能偏出好几分钱。大数吃小数更典型——1e16 + 1 == 1e16,因为 1 小于该量级的间隔。整数的安全范围是 Number.MAX_SAFE_INTEGER,即 9007199254740991,超过就只能近似。需要高精度累加时,Kahan 求和能把误差从 O(n) 降到 O(1),但金额场景直接上定点数更省事。

金额用整数分或十进制#

两条路都可行,选一条并贯彻到底:

  • 整数分:内部全部用 long / BigInt 存「分」,展示时除以 100。适合交易、账单、库存计价。
  • 十进制定点:数据库 DECIMAL(18,2),语言里用 Decimal / BigDecimal,从字符串构造。
python
from decimal import Decimal, ROUND_HALF_UP

total = Decimal('100.00')
ratios = [Decimal('0.3333'), Decimal('0.3333')]
head = [(total * r).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) for r in ratios]
parts = head + [total - sum(head)]     # 最后一份兜底,保证求和等于总额
print(parts, sum(parts))               # 和一定等于 100.00

分摊、拆单、优惠券这类场景,最后一项用「总额减前几项」兜底,是唯一能保证求和相等的做法。每项独立四舍五入,累计误差迟早暴露。

用整数分还有两个细节。单位换算要先乘后除:算百分比分成时写成 amount * rate / 100,别先 amount / 100 再乘,整数除法会提前截断。负数舍入方向也要固定,Math.round(-0.5) 得到的是 -0,不是 -1,退款金额会差一分。

Decimal 的默认上下文是 28 位精度,除法要显式 quantize,否则可能抛 InvalidOperation 或者留下超出预期的位数。

定点数其实很简单#

整数分本质就是定点数:把 1 元当成 100,小数位固定两位。加减直接算;乘法之后要除一次 scale,除法之前要先乘 scale,顺序反了精度就先丢了。每次运算后检查溢出——64 位有符号整数上限约 9.2e18,按分算能表示九亿亿元,金额够用,但中间乘法很容易越界。

Go 的 decimal 库、Java 的 BigDecimal、Python 的 Decimal 都是这个思路的通用实现,区别只在 scale 是否随运算自动调整。自己封装定点类型时把 scale 固定成常量,比让每次运算自己推导简单,也更容易和数据库列对齐。格式化输出时再除,别在中间结果里引入小数。

什么时候必须用精确算术#

判断标准不是「涉不涉及小数」,而是「结果要不要和账目逐分对上」。进度条、图表坐标、压测的百分比,double 完全够用;计费、发票、税额、退款、分摊、汇率换算,必须精确。

最糟糕的是混用:一部分金额用 double,一部分用 BigDecimal,在转换处又引入一次误差,最后连错在哪都定位不到。选一条路,在接口边界上就完成转换。

还有一类常被忽略:跨币种结算的汇率。汇率本身是小数,相乘后再截断,方向错一位就是几十块。汇率保留几位、按什么规则舍入,要在业务规则里写死,不能留给代码里的默认行为。

常见框架的坑#

Java 里字段声明成 double、数据库列是 DECIMAL,MyBatis 会做隐式转换,读出来就可能少一分。实体类统一用 BigDecimal,JDBC 的 getBigDecimal 才不会经过浮点。

JSON 序列化更隐蔽:BigDecimal 默认输出成数字,浏览器 JSON.parse 又把它变成 double。要么序列化成字符串,要么前端引入 decimal 库,别指望「后端精确就够了」。

舍入规则要说清楚#

Python 的 round() 是银行家舍入:round(2.5) 得 2,round(3.5) 得 4。财务场景通常要四舍五入,用 ROUND_HALF_UP 显式指定。

浮点还会让 round 更不可靠:round(2.675, 2) 得 2.67,因为 2.675 实际存的是 2.67499999...。JavaScript 的 toFixed 同理,(1.005).toFixed(2) 是 "1.00"。凡是和钱有关的舍入,都不要依赖默认行为。

有些团队还会在数据库层再做一次舍入,这可能和业务层的规则冲突。舍入规则只能有一个定义处,通常放在领域层,数据库只负责存。

存储和传输也要对齐#

场景 别用 建议
数据库金额列 FLOAT / DOUBLE DECIMAL(18,2) / NUMERIC
接口传输 JSON number 传大整数 字符串传金额,或整数分
前端计算 直接浮点相加 整数分,或引入 decimal 库
跨语言约定 各写各的 明确「单位是分」并写进文档

JSON 没有整数类型,9007199254740993 在 JavaScript 里解析出来会变成 ...992。金额一般到不了这个量级,但 19 位雪花 ID 会,必须用字符串传。

结论#

  • 0.1 + 0.2 的误差来自二进制表示,不是语言 bug;换语言不解决问题。
  • 浮点比较用相对误差,别用 ==,也别把 EPSILON 当万能容差。
  • 金额统一用整数分或十进制定点,从字符串构造,全链路贯彻。
  • 分摊场景最后一项用总额兜底,别让每项独立舍入。
  • 舍入规则显式指定,数据库用 DECIMAL,接口用字符串。

完

#浮点数#精度#金额#基础
回到顶部 ↑