我用 Python 的 float 累加金额做对账,几万笔加下来总额和数据库就是差了几分钱,我对着浮点数精度排查了大半天的复盘
那是我写的一个对账脚本:把几万笔交易的金额用 Python 累加起来,和数据库里记录的总额做核对。逻辑简单到不能再简单,可结果就是死活对不上——我算出来的总额,和数据库的总额,总是差那么几分钱。我反复检查:数据没漏、没重、每一笔都对得上,加法也没写错。可几万笔加完,总额就是差了 0.03 这种诡异的小数。我一度怀疑是数据库的问题、是数据精度的问题。排查了大半天,最后用 Python 敲了一行 0.1 + 0.2,看到输出是 0.30000000000000004 时,我才恍然大悟:问题出在浮点数(float)本身,它根本就存不准小数。这篇就把这场"float 算钱算不准"的事故,从头复盘一遍。
故障现场:0.1 + 0.2 居然不等于 0.3
先看现场。一行最简单的加法,就暴露了 float 的真面目:
# 我的对账代码: 用 float 累加金额
total = 0.0
for txn in transactions: # 几万笔交易
total += txn.amount # txn.amount 是 float, 如 19.99, 0.1, ...
# 最后 total 和数据库总额 差几分钱
# 一行代码揭示真相:
>>> 0.1 + 0.2
0.30000000000000004 # ← 不是 0.3 !
>>> 0.1 + 0.2 == 0.3
False # ← 居然是 False !
>>> 0.3 - 0.2
0.09999999999999998 # ← 也不是 0.1 !
# 为什么? float 是"二进制浮点数", 它存不准很多十进制小数:
>>> from decimal import Decimal
>>> Decimal(0.1) # 看看 0.1 在 float 里"真实"存的是什么:
Decimal('0.1000000000000000055511151231257827021181583404541015625')
# ↑ 0.1 这个十进制小数, 在二进制浮点里, 根本无法精确表示!
# 存进去的是一个"无限接近 0.1、但不等于 0.1"的近似值。
# 现象拼图:
# - 计算机用二进制存数。1/2, 1/4 这种能精确表示, 但 0.1(=1/10)
# 在二进制里是【无限循环小数】, 就像 1/3 在十进制里是 0.333...一样。
# - float(双精度)只有有限位数, 只能存 0.1 的一个【近似值】(略有误差)。
# - 单次运算误差极小(小数点后十几位), 你平时看不出来。
# - 但: 几万笔金额累加, 这些微小的误差会【不断累积、放大】,
# 最终汇成了"差几分钱"的可见误差 → 对账对不上!
# - ★ 根因: float 天生存不准十进制小数, 它不适合用来做"需要精确"的
# 金额/货币计算。我用错了数据类型。
看到 0.1 + 0.2 == 0.3 返回 False 时,我才明白对账对不上的根子在哪。问题出在 float(二进制浮点数)本身——它存不准很多十进制小数。原因是:计算机用二进制存数,1/2、1/4 能精确表示,但 0.1(=1/10)在二进制里是无限循环小数(就像 1/3 在十进制里是 0.333…),而 float 只有有限位数,只能存 0.1 的一个近似值(略有误差)。单次运算的误差极小(小数点后十几位),平时看不出来;但几万笔金额累加,这些微小误差会不断累积、放大,最终汇成"差几分钱"的可见误差,导致对账对不上。根因很清楚:float 天生存不准十进制小数,它不适合用来做"需要精确"的金额/货币计算——我从一开始就用错了数据类型。
第一件事:搞懂 float 为什么存不准小数
要解决它,得先理解浮点数的本质,以及它"精度问题"是怎么来的。
浮点数(float)精度问题的本质
# 一、float 是怎么存数的? —— 二进制科学计数法
# - float(IEEE 754 双精度)用二进制存: 符号位 + 指数 + 尾数。
# - 它能精确表示的, 是"能写成 二进制有限小数"的数:
# 1/2=0.1₂, 1/4=0.01₂, 0.75=0.11₂ ... 这些都精确。
# - 但 0.1(十进制)= 1/10, 在二进制里是 0.0001100110011...(无限循环)!
# 就像 1/3 在十进制是 0.333... 写不尽一样。
# 二、为什么会有误差?
# - float 尾数位数有限(双精度约15-17位十进制有效数字)。
# - 存 0.1 这种"二进制无限循环"的数, 只能截断成有限位 → 存的是近似值。
# - 这个近似值 ≈ 0.1 但 ≠ 0.1, 误差在小数点后约17位。
# 三、误差怎么"放大"成可见问题?
# - 单次: 0.1 + 0.2 = 0.30000000000000004, 误差在第17位。
# - 累加: 几万次运算, 误差不断累积; 或大数+小数时小数的精度被"吃掉"。
# - 比较: 0.1+0.2 == 0.3 为 False(两边的近似误差不同)。
# - 这些在"金额、计数、需要精确相等判断"的场景, 就是致命的。
# 四、关键认知: 这不是 Python 的 bug, 是所有用 IEEE754 的语言通病!
# - Java、C、JS、Go... 的 float/double 都有这个问题(JS 全是 float 更明显)。
# - 它是"二进制表示十进制小数"的固有局限, 不是哪个语言写错了。
# 核心: float用二进制存数, 0.1等十进制小数在二进制是无限循环、只能存近似值;
# 误差在累加/比较时放大; 这是所有IEEE754语言的通病, 金额等精确场景不能用float。
想透浮点数的本质,这个"诡异"的现象就一点都不诡异了。一、float 怎么存数?——用二进制科学计数法,能精确表示的是"能写成二进制有限小数"的数(1/2、1/4、0.75);但 0.1(=1/10)在二进制里是无限循环小数,就像 1/3 在十进制写不尽一样。二、为什么有误差?——float 尾数位数有限,存 0.1 这种二进制无限循环的数只能截断成有限位,存的是近似值(≈0.1 但 ≠0.1)。三、误差怎么放大?——单次误差在第 17 位看不出来,但几万次累加误差不断累积、或大数+小数时小数精度被吃掉、或精确相等比较时为 False;在金额/计数/精确判断场景就是致命的。四、关键认知:这不是 Python 的 bug,是所有用 IEEE 754 的语言通病——Java、C、JS、Go 的 float/double 都有这个问题,它是"二进制表示十进制小数"的固有局限。
第二件事:正解——金额用 Decimal 或整数(分),别用 float
搞懂了原理,正解就清晰了:精确的金额计算用 Decimal(十进制定点)或"用整数存分";比较浮点数用容差;别对 float 做精确相等判断。
from decimal import Decimal, ROUND_HALF_UP
# ====== 正解一(推荐): 用 Decimal 做金额计算 ======
# Decimal 是"十进制"数, 精确表示十进制小数, 没有 float 的二进制误差。
a = Decimal("0.1") # ★ 关键: 用【字符串】构造, 别用 Decimal(0.1)!
b = Decimal("0.2")
print(a + b) # Decimal('0.3') ← 精确!
print(a + b == Decimal("0.3")) # True ← 精确相等!
# 对账累加:
total = Decimal("0")
for txn in transactions:
total += Decimal(str(txn.amount)) # 从源头就用 Decimal(或存字符串)
# → total 精确, 和数据库对得上!
# 注意: Decimal(0.1) 是错的! 它会把"已经不准的 float 0.1"转进来:
print(Decimal(0.1)) # Decimal('0.1000000000000000055511...') ← 还是脏的!
print(Decimal("0.1")) # Decimal('0.1') ← 干净! 必须用字符串构造。
# ====== 正解二: 用"整数(最小单位)"存金额 —— 即"用分代替元"======
# 把金额统一用"分"(整数)存和算, 彻底避开小数:
price_cents = 1999 # 19.99元 存成 1999分
total_cents = 0
for txn in transactions:
total_cents += txn.amount_cents # 整数加法, 绝对精确
# 显示时再转成元: total_cents / 100
# → 很多金融系统就是这么做的: 底层全用整数(分/厘), 杜绝浮点。
# ====== 正解三: 必须用 float 时, 比较用"容差", 别用 == ======
import math
x = 0.1 + 0.2
print(x == 0.3) # False (别这么比!)
print(math.isclose(x, 0.3)) # True ✓ 用 isclose 带容差比较
print(abs(x - 0.3) < 1e-9) # True ✓ 或手动设容差 epsilon
# ====== 正解四: Decimal 的舍入要显式指定(金额舍入很关键)======
amount = Decimal("19.995")
# 保留2位小数, 四舍五入(银行家舍入 ROUND_HALF_EVEN 也常用)
print(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) # 20.00
# → 别用 round()(它对 float 也有精度坑); 用 Decimal.quantize 明确舍入规则。
# ====== 正解五: 各语言的对应方案 ======
# Java: BigDecimal(同样要用 new BigDecimal("0.1") 字符串构造!)
# JS: 用整数(分)、或 decimal.js / big.js 库
# Go: shopspring/decimal 库, 或整数
# 核心: 金额精确计算用 Decimal(必须用字符串构造!)或整数存分; float比较用isclose容差;
# Decimal舍入用quantize显式指定; 各语言都有对应方案(Java BigDecimal等)。
修复的核心,是"金额这种需要精确的计算,换用能精确表示十进制的类型,别用 float"。正解一(推荐):用 Decimal——Decimal 是十进制数,精确表示十进制小数、没有二进制误差(Decimal("0.1") + Decimal("0.2") == Decimal("0.3") 为 True);关键是必须用字符串构造 Decimal("0.1"),绝不能用 Decimal(0.1)(那会把已经不准的 float 0.1 转进来,还是脏的)。正解二:用整数(最小单位)存金额——把金额统一用"分"(整数)存和算,彻底避开小数(很多金融系统就这么做)。正解三:必须用 float 时,比较用容差——用 math.isclose 或 abs(x-y) < 1e-9,别用 ==。正解四:Decimal 舍入用 quantize 显式指定(别用 round(),它对 float 也有坑)。正解五:各语言都有对应方案(Java BigDecimal 同样要字符串构造、JS 用整数或 decimal.js、Go 用 decimal 库)。归根结底:金额精确计算用 Decimal(必须字符串构造)或整数存分;float 比较用 isclose 容差;Decimal 舍入用 quantize;各语言都有对应方案。
第三件事:什么时候能用 float、什么时候绝不能
排查后我把"float 能不能用"的边界彻底搞清了,免得从一个极端走向另一个极端。
float 的适用边界
# ✗ 绝不能用 float 的场景(需要"精确"):
# 1. 金额、货币、财务计算(本文)—— 差一分钱都是事故。
# 2. 需要精确相等判断的(== 比较)。
# 3. 大量累加/累乘(误差会累积放大)。
# 4. 需要精确十进制表示的(如精确到分的价格)。
# → 这些用 Decimal 或 整数。
# ✓ 可以用 float 的场景(容忍微小误差):
# 1. 科学计算、工程计算、统计(本就有测量误差, 浮点误差可忽略)。
# 2. 图形、游戏、物理模拟(坐标差 1e-15 无所谓)。
# 3. 机器学习(权重、概率, 本就是近似)。
# 4. 比例、百分比的中间计算(最后结果取整/容差处理)。
# → 这些 float 又快又够用(Decimal 慢得多)。
# 关键判断: "这个计算, 容忍微小的误差吗?"
# - 容忍(科学/图形/ML)→ float, 快。
# - 不容忍(钱/精确比较)→ Decimal/整数, 准。
# 一个常见误区(别矫枉过正):
# - 不是"float 一无是处、什么都用 Decimal"。
# - Decimal 精确但【慢很多】(约几十倍), 内存也大。
# - 科学计算、ML 用 Decimal 是灾难(慢且没必要)。
# - 正确: 按"是否需要精确"选, 该 float 用 float, 该 Decimal 用 Decimal。
# 核心: 金额/精确比较/大量累加 绝不用float(用Decimal/整数); 科学/图形/ML 可用float
# (快且容忍误差); 判断标准是"容不容忍微小误差", 别矫枉过正全用Decimal(它慢)。
排查后我厘清了"float 能不能用"的边界,避免从一个极端走向另一个极端。绝不能用 float 的场景(需要精确):金额/货币/财务(差一分都是事故)、需要精确相等判断、大量累加累乘(误差累积)、需要精确十进制表示——这些用 Decimal 或整数。可以用 float 的场景(容忍微小误差):科学/工程/统计计算(本就有测量误差)、图形/游戏/物理模拟、机器学习(权重概率本就近似)、比例百分比的中间计算——这些 float 又快又够用。关键判断就一句:"这个计算,容忍微小的误差吗?"——容忍就用 float(快),不容忍就用 Decimal/整数(准)。而一个常见误区是别矫枉过正:不是"float 一无是处、什么都用 Decimal"——Decimal 精确但慢很多(约几十倍)、内存也大,科学计算/ML 用 Decimal 是灾难;正确做法是按"是否需要精确"选。下面这张图,是这次 float 算钱对不上的成因与解法:
第四件事:float vs Decimal vs 整数(分)对比速查
这次踩坑后,我把三种数值表示方式整理成一张表,按场景对照着选。
| 方式 | 精确度 | 性能 | 适用 |
|---|---|---|---|
| float | 有二进制误差 | 快 | 科学/图形/ML,容忍误差 |
| Decimal | 精确(十进制) | 慢(约几十倍) | 金额/财务,需精确 |
| 整数(分/厘) | 精确 | 最快 | 金额,性能敏感 |
这张表,把三种方式的取舍摆清了。核心是:容忍误差、要性能用 float;要精确的金额用 Decimal(可读性好)或整数存分(最快)。它给我的启发是:"数字"在计算机里,从来不是一个理所当然、唯一的概念,而是有多种表示方式,每种都在"精确度、性能、范围、可读性"之间做了不同的权衡。我之前犯错,正是因为把 float 当成了"数字"的默认、唯一选择——看到一个带小数的金额,想都没想就用了 float,完全没意识到"用什么类型来表示这个数"本身就是一个需要根据场景认真决策的问题。这让我领悟到:选择正确的"数据类型/表示方式",是写出正确程序的第一步,也是最容易被忽略的一步;很多 bug,不是逻辑写错了,而是"用错了承载数据的类型"——用 float 装钱、用 int 装可能溢出的大数、用普通字符串装需要精确比较的东西……。所以,每当我要表示一个重要的数据,我都会多想一步:"这个数据的本质要求是什么(精确?范围?性能?),什么类型最匹配这个要求?"——选对了类型,等于在地基上就避开了一大类问题。
第五件事:浮点数的其他常见坑
金额只是浮点数最典型的坑,它还有几个常见陷阱,我一并梳理了。
| 坑 | 现象 | 对策 |
|---|---|---|
| 精确相等比较 | 0.1+0.2 == 0.3 为 False | 用 math.isclose / 容差 |
| 累加误差放大 | 大量累加后偏差明显(本文) | Decimal / 整数 / math.fsum |
| 大数吃小数 | 1e16 + 1 还是 1e16 | 注意数量级差距,排序后加 |
| round 的银行家舍入 | round(2.5)=2 不是 3 | 明确舍入规则,金额用 Decimal.quantize |
| float 转字符串显示 | 显示出一长串小数 | 格式化指定位数 f"{x:.2f}" |
| NaN/Inf | 0.0/0.0=nan, nan != nan | 用 math.isnan 判断 |
这张表,把浮点数的"坑系列"列全了。除了金额累加,还有:精确相等比较为 False、大数吃小数(1e16 + 1 还是 1e16)、round 的银行家舍入(round(2.5)=2)、显示出一长串小数、NaN 的诡异行为(nan != nan)。它给我的最大启发是:浮点数,是一个我们"每天都在用、却很少真正理解"的东西;它表面上就是"带小数的数字",朴素得不能再朴素,背后却藏着一整套由"二进制表示有限"带来的、反直觉的行为。这其实是很多"基础概念"的共性:越是"基础、看起来简单、人人都在用"的东西(浮点数、字符编码、时间日期、整数溢出),越容易被我们"想当然",也越容易在不经意间埋下坑;因为我们对它们太熟悉了,熟悉到从不怀疑自己是否真的理解它们。这让我对"基础"多了一份敬畏:真正吃透那些"简单到不值得深究"的基础概念(它的原理、它的边界、它反直觉的地方),往往比学一个花哨的新框架,更能避开那些最隐蔽、也最普遍的 bug。地基上的裂缝,远比顶楼的裂缝更危险。
第六件事:遇到一个"数"时,我现在的类型决策习惯
现在每当我要表示或计算一个数,我都会先想清楚"它要不要精确":
这张图的精髓,是"用一个数前,先问'它要不要精确'"。第一问 "这个数需要精确吗":不需要(科学/图形/ML/统计)就用 float(快且够用);需要精确(钱/计数/精确比较)再细分。需要精确时:金额且性能敏感用整数存分/厘、金额一般用 Decimal(字符串构造)、其他精确数用 Decimal 或整数。而用 float 时,要比较就用 math.isclose 容差、别用 ==。最后显示时再转换 + 格式化指定位数。这套习惯,让我处理数字时,从"带小数就用 float"变成了"先想它要不要精确"——核心始终是:float 存不准十进制小数,需要精确的场景(尤其金额)必须用 Decimal 或整数。
我立下的几条规矩
这场"float 算钱对不上"的事故,换来了我写代码时,刻进骨子里的几条铁律:
- 金额绝不用 float。float 存不准十进制小数,累加误差会放大成对账对不上。
- 金额用 Decimal 或整数(分)。Decimal 精确可读,整数存分最快,金融底层常用整数。
- Decimal 必须用字符串构造。Decimal("0.1") 才干净,Decimal(0.1) 会把脏 float 带进来。
- float 比较用容差,别用 ==。math.isclose 或 abs(a-b)
- 舍入用 Decimal.quantize 明确规则。别用 round(它对 float 有坑、还有银行家舍入)。
- 按"要不要精确"选类型,别矫枉过正。科学/ML 仍用 float,Decimal 慢几十倍。
- 这是所有 IEEE754 语言通病。Java BigDecimal、JS 整数、Go decimal,都一样。
附:一段亲手验证 float 之坑与 Decimal 之解的实验
口说无凭。下面这段代码,让你亲眼看见 float 累加的误差、以及 Decimal/整数的精确,跑一遍胜过看十遍:
from decimal import Decimal
# ====== 实验1: 经典的 0.1 + 0.2 ======
print("float: 0.1 + 0.2 =", 0.1 + 0.2) # 0.30000000000000004
print("float: == 0.3 ? ", 0.1 + 0.2 == 0.3) # False
print("Decimal:", Decimal("0.1") + Decimal("0.2")) # 0.3
print("Decimal == ?", Decimal("0.1") + Decimal("0.2") == Decimal("0.3")) # True
print()
# ====== 实验2: 累加误差(模拟对账)======
# 把 0.1 累加 100 万次, 理论上应该是 100000.0
n = 1_000_000
# float 版: 误差累积
total_float = 0.0
for _ in range(n):
total_float += 0.1
print(f"float 累加100万次0.1 = {total_float}") # 不是 100000.0! 有误差
print(f" 与理论值100000差 = {total_float - 100000}") # 一个非零的小数!
# Decimal 版: 精确
total_dec = Decimal("0")
step = Decimal("0.1")
for _ in range(n):
total_dec += step
print(f"Decimal 累加100万次 = {total_dec}") # 100000.0 精确!
# 整数(分)版: 精确且最快
total_cents = 0
for _ in range(n):
total_cents += 10 # 0.1元 = 10分
print(f"整数(分)累加 = {total_cents / 100}") # 100000.0 精确!
print()
# ====== 实验3: Decimal 构造方式的天壤之别 ======
print("Decimal(0.1) =", Decimal(0.1)) # 0.10000000000000000555... 脏!
print("Decimal('0.1') =", Decimal("0.1")) # 0.1 干净!
print()
# ====== 实验4: 大数吃小数 ======
print("1e16 + 1 =", 1e16 + 1) # 1e16 (那个+1被"吃"掉了!)
print("精度不足以表示这么大的数+1的差别")
print()
# ====== 实验5: 必须用float时, 正确的比较方式 ======
import math
x = 0.1 + 0.2
print("x == 0.3: ", x == 0.3) # False
print("math.isclose: ", math.isclose(x, 0.3)) # True ✓
# 核心: float累加100万次0.1得不到精确100000(误差累积), Decimal和整数都精确;
# Decimal(0.1)脏、Decimal('0.1')干净; 大数吃小数; float比较用isclose。亲手跑一遍最深刻。
这段实验代码,把"float 之坑"和"Decimal/整数之解"变成了一行行可以亲眼验证的输出。它的精妙,在于用"累加 100 万次 0.1"这个极端场景,把平时藏在小数点后第 17 位、肉眼看不见的微小误差,累积、放大成一个明显的、非零的偏差——你会清清楚楚地看到:float 累加 100 万次 0.1,得到的不是精确的 100000.0,而是一个带着误差尾巴的数;而 Decimal 和整数(分)版,都精确地得到了 100000.0。再加上 实验 3 对比 Decimal(0.1) 的脏和 Decimal("0.1") 的干净、实验 4 的"大数吃小数"、实验 5 的正确比较方式,这一组实验,几乎把浮点数的核心坑都"跑给你看"了。这,正是我想用这段代码,留给每个程序员的最后一课:对于浮点数精度这种"反直觉、且后果严重"的问题,最好的学习方式,就是亲手写一段实验,把那个"平时看不见的误差",通过极端场景(大量累加、大数小数)逼出来、放大到你眼前。当你亲眼看着 float 累加 100 万次 0.1 得到一个"不对的数",这个坑就会以一种无法被遗忘的方式,刻进你的认知里。把"书上说的精度问题",变成"我亲眼跑出来的错误数字"——这种从"知道"到"见过"的跨越,是真正掌握一个反直觉知识点,最扎实可靠的途径。而对于金钱这种"差一分都不行"的东西,这份"亲眼见过它会出错"的敬畏,值得每个工程师拥有。
写在最后
回头看,这场由"float 精度"引发的、对账差几分钱的事故,真正教给我的,远不止"金额用 Decimal"这一个技巧。它让我对"计算机里的数字"这个最最基础的概念,产生了一种全新的敬畏。我一直以为,"数字"是计算机里最简单、最可靠、最不会出错的东西——1 就是 1,0.3 就是 0.3,加减乘除天经地义。可这次事故狠狠地打破了这个幻觉:原来计算机里的"0.3",根本就不是数学上那个精确的 0.3,而是一个"无限接近、但并不等于"它的近似值;原来"0.1 + 0.2",在计算机里真的可以"不等于 0.3"。这种"连最基础的数字都靠不住"的认知冲击,让我领悟到一个深刻的道理:计算机里的一切,都是对现实世界的"有限的、近似的建模",而非现实本身;数字是对数学的近似(浮点)、字符是对文字的近似(编码)、时间是对时间的近似(时间戳/时区)……每一个我们习以为常的"基础类型",背后都是一套带着假设、带着局限、带着取舍的工程实现。而当我们"想当然地"把这些工程实现,等同于它们所建模的"理想概念"时(把 float 当成数学实数、把字符串当成文本),就会在它们"近似而非精确"的缝隙里栽跟头。所以,真正扎实的工程能力,有很大一部分,来自于清醒地理解这些"基础抽象"的边界和代价:它在多大程度上忠实于它所建模的概念?又在哪些地方、为了什么(性能、空间)而做了妥协、留下了局限?不把抽象当现实、看清每个基础类型的"言外之意"——这,是我用一次"差几分钱"的事故,换来的、关于编程、也关于"基础抽象的边界"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写金额计算时,条件反射地避开 float、用上 Decimal 或整数,那我对着那几分钱的差额熬的这大半天,就值了。
—— 别看了 · 2026