浮点数精度踩坑:0.1+0.2 为何不等于 0.3 与正确解法

浮点数精度问题,是几乎所有程序员都踩过的隐形坑:你在代码里写下 0.1 + 0.2,期望得到 0.3,运行结果却可能是 0.30000000000000004。这类由二进制浮点表示带来的微小误差,在科学计算里无伤大雅,可一旦落到金额、计数、价格比较等业务场景,就会变成「少一分钱」「对账不平」「订单金额漂移」的真实故障,甚至引发资损。本文从二进制根因讲到各语言的正确解法,并给出一张可直接落地的工程清单,帮你把精度问题挡在写代码阶段。

一、为什么 0.1 + 0.2 不等于 0.3

计算机用 IEEE 754 标准以二进制存储浮点数。十进制里的 0.1、0.2 在二进制中是无限循环小数,只能被截断成有限位,于是存储的并不是精确的 0.1,而是一个极接近但不相等的近似值。多次运算后误差会累积放大,只是大多数时候小到看不出来。

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> 0.1 + 0.2 - 0.3
5.551115123125783e-17

以 64 位 double 为例,它用 1 位符号、11 位指数、52 位尾数来表示一个数。0.1 在二进制里是 0.0001100110011... 的无限循环,52 位尾数装不下,只能四舍五入到最近的可表示值,于是误差从第一行赋值就埋下了。这不是某个语言的小 bug,Python、JavaScript、Java、Go 的 double 都遵循同一套标准,结果完全一致。

1.1 机器精度与「误差容限」比较

既然浮点天生有误差,判断两个浮点「相等」就不能用 ==,而要看它们的差是否小于一个足够小的容限(epsilon)。这个容限通常取 1e-91e-6,取决于你的业务对精度的要求。

def nearly_equal(a, b, eps=1e-9):
    return abs(a - b) < eps

print(nearly_equal(0.1 + 0.2, 0.3))   # True

二、三大经典踩坑场景

2.1 用 float 算钱,越算越亏

最致命的误用,是把金额、单价、利率用 float/double 表示。单次乘法看起来没问题,可一旦累加、计复利、做折扣叠加,误差就会肉眼可见地偏掉。更隐蔽的是,这种 bug 在测试环境的小数据量下往往不暴露,等到生产环境跑批对账时才炸出来。

price = 0.1
qty = 3
print(price * qty)        # 0.30000000000000004,不是 0.3
total = sum([0.1] * 10)
print(total)              # 0.9999999999999999,凭空少一分钱

2.2 浮点数直接比较相等

== 比较两个浮点结果,几乎必然翻车。哪怕数学上应该相等,二进制表示也可能差一个最小精度单位。这在分支判断、循环终止条件里尤其危险——你可能永远等不到那个「相等」,导致死循环或逻辑错乱。

double a = 0.1 + 0.2;
double b = 0.3;
System.out.println(a == b);          // false
System.out.println(a > b);           // true,顺序都不对

2.3 四舍五入的「银行家舍入」陷阱

不少语言默认采用「四舍六入五成双」的银行家舍入,而非我们直觉的「四舍五入」。在金额结算、税费计算里,这会导致与财务口径对不上,出现「为什么系统算出 2.34 而财务要 2.35」的扯皮。一定要显式指定舍入模式。

System.out.println(Math.round(2.5));   // 3
System.out.println(Math.round(2.4));   // 2
// 想要「四舍五入」必须显式指定舍入模式
BigDecimal v = new BigDecimal("2.345");
System.out.println(v.setScale(2, RoundingMode.HALF_UP)); // 2.35

三、各语言的正确姿势

3.1 Java:用 BigDecimal,且必须用字符串构造

BigDecimal 是定点数,能精确表示十进制小数,但有一个极易踩的坑:用 double 构造 BigDecimal 时,会先把 double 自身的误差带进去,等于白做。务必用字符串或 BigDecimal.valueOf 构造。另外比较时要用 compareTo 而非 equals,因为 equals 还会比较 scale(标度),2.02.00 会被判为不等。

BigDecimal price = new BigDecimal("19.99");
BigDecimal qty = new BigDecimal("3");
BigDecimal total = price.multiply(qty);          // 59.97,精确

// 错误示范:double 构造把误差一并带入
BigDecimal wrong = new BigDecimal(0.1);          // 0.1000000000000000055...
// 正确比较用 compareTo,不要用 equals(scale 不同也会判不等)
if (total.compareTo(new BigDecimal("59.97")) == 0) {
    // 金额相等
}

3.2 Python:decimal.Decimal + 指定舍入

Python 的 float 同样是二进制浮点,做金额要用 decimal 模块。Decimal 同样忌讳从 float 转换,应始终用字符串构造,并用 quantize 明确小数位与舍入模式,避免依赖全局上下文的默认设置。

from decimal import Decimal, ROUND_HALF_UP

price = Decimal("19.99")
total = price * 3                       # Decimal('59.97')
total = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# 切忌 Decimal(0.1),同样会带入二进制误差

3.3 SQL:金额字段一律 DECIMAL,禁止 FLOAT/DOUBLE

数据库层面也要守住。金额列必须声明为 DECIMAL(p, s) 定点类型,绝不能图省事用 FLOAT/DOUBLE。并且尽量让求和、比较在数据库层用 DECIMAL 完成,避免把浮点结果取到应用层再算,减少一次误差传播。

CREATE TABLE orders (
  id      BIGINT PRIMARY KEY,
  amount  DECIMAL(12,2) NOT NULL   -- 金额用定点数,禁止 FLOAT/DOUBLE
);
-- 比较与求和都在数据库层用 DECIMAL 完成,避免取到应用层再算

3.4 JavaScript:前端用「分」做整数运算

前端展示金额时,界面用「元」,但内部累积、比较一律用「分」整数或 BigInt,只在最后格式化输出时用 toFixed。跨服务传输金额建议用字符串,避免 JSON 里的 number 在大数或高精度时被截断——这其实是浮点问题在序列化环节的延伸。

const totalCents = 1999 * 3;          // 5997 分,整数运算无误差
const display = (totalCents / 100).toFixed(2);  // "59.97"
// 跨服务传输金额建议用字符串,避免 JSON number 精度被截断

四、浮点 vs 定点:一张对照表

维度FLOAT / DOUBLEDECIMAL / BigDecimal
存储方式二进制近似十进制精确
适用场景科学计算、图形、ML金额、计数、价格
相等比较禁止直接用 ==用 compareTo / 误差容限
序列化可能丢精度字符串最稳妥

五、工程落地清单

场景正确做法
金额计算BigDecimal(String) / Decimal / DECIMAL 字段
相等比较Math.abs(a-b) < 1e-9,或定点数 compareTo
前端展示内部用「分」整数,输出再格式化
数据库金额列 DECIMAL(N,2),禁止浮点列
跨服务传输JSON 中金额用字符串,别用 number
单元测试对金额结果断言用容差,而非硬等于

六、当精度问题遇上分布式金额

单机的浮点误差只是开始。在分布式系统里,一笔转账要在多个服务间保持一致,金额若用浮点,再叠加网络重试、消息重复消费,差错会被成倍放大。这时候光靠定点数不够,还要配合事务边界与幂等设计:用 Seata 这类分布式事务框架 保证跨服务一致性,在数据库层用 合适的事务隔离级别 避免脏读导致的重复扣减,并在业务入口用 Spring 事务传播 框定资金操作的边界。定点数是地基,事务与幂等是护栏,三者一起才能把「钱算对」这件事真正兜住。

七、总结

浮点数精度不是玄学,而是二进制表示的必然结果。记住一条铁律:凡是跟钱、计数、比较有关的数据,绝不使用原生浮点。Java 用字符串构造的 BigDecimal,Python 用 decimal,SQL 用 DECIMAL,前端用「分」整数,跨服务用字符串传金额。把这几点写进团队的代码规范,再配一张本文的落地清单做 Code Review 检查项,绝大多数精度故障都能在写代码阶段被拦下。下次再看到 0.1 + 0.2 != 0.3,你就能笑着把它修对了。

上一篇 可观测性驱动开发实战:用 SLO 与错误预算管可靠性
下一篇 Java 定时任务调度:@Scheduled 与 Quartz