我用 float 累加订单金额,对账时发现总额差了几分钱、还有笔订单因为金额判等永远不成立而卡住:一次 Python 浮点数精度坑的深度复盘
那两个 bug 是财务对账和一笔卡单同时炸出来的:我们有个订单系统,金额计算图省事全用了 Python 的 float。第一个症状是——财务月底对账,发现系统统计的总金额,和把每笔订单加起来的"正确值",差了几分钱,死活对不平。第二个更诡异——有笔订单,业务逻辑里有一句 if paid_amount == order_amount: 判断"是否付清",可这笔订单明明金额一分不差,这个判断却永远是 False、订单一直卡在'未付清'。我对着这两个"玄学"查了好久,在解释器里敲了几下,才看明白,后背发凉:问题出在我用 float(浮点数)来表示和计算金额上。计算机里的 float 是二进制浮点数,它无法精确表示大多数十进制小数(比如 0.1、0.2)——就像十进制无法精确表示 1/3(=0.333…)一样,二进制也无法精确表示 0.1,只能存一个无限接近但不完全等于的值;于是 0.1 + 0.2 在 Python 里不等于 0.3,而是 0.30000000000000004;这种微小的误差,在大量金额累加时会积累、放大成几分钱的偏差(对账对不平),在用 == 判断两个浮点数是否相等时会因为这无限小的尾差而永远不相等(判等失败、卡单)。问题的根,是用 float 表示金额:float 是二进制浮点、无法精确表示十进制小数,累加会积累误差、== 判等会因尾差失败——钱这种要求精确的东西,绝不能用 float。这篇就把这次"浮点数精度"的坑,从头到尾复盘一遍。
故障现场:float 算钱,越算越偏、还判等失败
问题在于用 float 表示金额,二进制浮点无法精确表示十进制小数:
# ✗ 出问题的代码: 用 float 表示和计算金额
>>> 0.1 + 0.2
0.30000000000000004 # ✗ 不等于 0.3! 有个微小的尾差
>>> 0.1 + 0.2 == 0.3
False # ✗ 判等失败! 因为左边是0.300...04, 不等于0.3
# 场景一: 大量金额累加, 误差积累放大
total = 0.0
for _ in range(1000000):
total += 0.01 # 累加一百万次 0.01
print(total) # ✗ 不是精确的 10000.0, 而是 10000.000000xxxx 之类 → 对账差几分
# 场景二: 用 == 判断金额是否相等(判付清)
paid_amount = 0.1 + 0.2 # 用户分两次付了0.1和0.2
order_amount = 0.3 # 订单总额0.3
if paid_amount == order_amount: # ✗ 永远False! 0.300...04 != 0.3 → 订单永远"未付清", 卡单
mark_as_paid()
# 为什么? float 是"二进制浮点数", 无法精确表示大多数十进制小数:
# - 计算机用二进制存储float; 十进制的 0.1, 转成二进制是【无限循环小数】(0.0001100110011...);
# - 就像十进制写不尽 1/3=0.333...; 二进制也写不尽 0.1;
# - float位数有限, 只能存一个【无限接近0.1、但不完全等于】的值;
# - → 0.1、0.2 本身就带着微小误差, 它们的运算结果(如0.1+0.2)就会偏离精确的0.3;
# - 这误差在累加时积累(场景一)、在==判等时暴露(场景二)。
# 错误的"修补": 用 round() 凑
# round(0.1 + 0.2, 2) == 0.3 # 这次碰巧True, 但round只是"四舍五入显示", 没根治, 累加误差/边界仍会咬你。
# 关键: float是二进制浮点, 无法精确表示十进制小数(0.1等), 运算带微小误差;
# 累加会积累误差、==判等会因尾差失败 —— 钱、以及一切要求精确的十进制数, 都不能用float。
第一次在解释器里看到 0.1 + 0.2 蹦出个 0.30000000000000004 时,我又荒谬又恍然:"我一直以为计算机算数是绝对精确的,0.1 加 0.2 怎么可能不等于 0.3?完全没想到 float 压根存不下精确的 0.1。"这个坑最隐蔽的地方在于:误差极其微小(小数点后十几位),单次计算、打印出来往往看不出问题(甚至 print 还会帮你显示成 0.3);它只在"大量累加(误差积累)"或"== 精确判等(尾差暴露)"时才发作;而且它违反了所有人的算术直觉(0.1+0.2 居然不等于 0.3),让人第一反应是"不可能、是不是哪里写错了"。下面就来拆解,浮点数精度到底怎么回事、钱该怎么算。
第一件事:搞懂 float 为什么算不准
我顺着这次事故,把浮点数的精度问题彻底理清了。
float 为什么"算不准"? 为什么钱不能用 float?
【核心: float是二进制浮点, 无法精确表示大多数十进制小数, 运算带微小误差; 累加会积累、==判等会失败; 钱要用整数(分)或Decimal】
1. 根本原因: 二进制存不下大多数十进制小数
- 计算机用二进制(IEEE 754)存储float;
- 十进制小数 0.1, 转成二进制是【无限循环小数】(0.000110011001100...);
- 类比: 十进制写不尽 1/3 = 0.3333...; 二进制同样写不尽 0.1;
- float位数有限(如64位double约15-17位有效数字), 只能存一个【最接近的近似值】;
- → 0.1 在内存里其实是 0.1000000000000000055..., 本身就不精确。
2. 误差从哪来、到哪去:
- 每个不能精确表示的小数(0.1/0.2/0.3...)一存进float就带了微小误差;
- 运算(加减乘)会让这些误差【传播、组合】 → 0.1+0.2 = 0.30000000000000004;
- 单次误差极小(约1e-16), 但: 大量累加会【积累放大】、==判等会因这尾差【判不等】。
3. 为什么 == 判浮点相等危险:
- 两个"理论上应该相等"的浮点, 因各自的微小误差, 实际存的值可能差一点点;
- == 要求"完全相等"(每一位都一样) → 这一点点尾差就让它返回False;
- → 浮点数【永远不要用 == 判相等】, 要用"误差范围内接近"(math.isclose)。
4. 为什么钱尤其不能用float:
- 钱要求【精确】(差一分都不行)、且常做大量累加、判等(对账、付清判断);
- float的误差积累和判等失败, 正好在钱上酿成对账不平、卡单等事故;
- → 钱要用"精确表示十进制"的方式: 整数(以分为单位)或 Decimal。
5. 正确的表示:
- 整数分: 把金额都用"分"(整数)存和算, 10.50元 = 1050分, 整数运算绝对精确;
- Decimal: Python的decimal.Decimal, 精确的十进制运算(注意用字符串构造 Decimal("0.1"));
- 比较浮点: 非金额的浮点比较用 math.isclose(a, b)。
一句话: float是二进制浮点, 无法精确表示十进制小数(0.1等)、运算带微小误差, 累加会积累、==判等会因尾差失败;
钱用整数(分)或Decimal精确表示, 浮点比较用math.isclose; 要求精确的十进制数绝不用float。
这套认知,是整个坑的根。根本原因:二进制存不下大多数十进制小数——0.1 转成二进制是无限循环小数(类比十进制写不尽 1/3),float 位数有限只能存最接近的近似值,0.1 在内存里其实是 0.1000…0055。误差从哪来到哪去:每个不能精确表示的小数一存进 float 就带误差,运算让误差传播组合(0.1+0.2=0.30000000000000004),单次极小但累加会积累放大、==判等会因尾差判不等。为什么 == 判浮点相等危险:两个理论上相等的浮点因各自误差实际差一点点,== 要求完全相等就返回 False;浮点永远别用 == 判相等,用 math.isclose。为什么钱尤其不能用 float:钱要求精确、常做大量累加和判等,float 误差正好酿成对账不平、卡单。正确表示:整数分(10.50 元=1050 分,整数运算绝对精确)或 Decimal(用字符串构造 Decimal("0.1")),非金额浮点比较用 math.isclose。一句话:float 是二进制浮点,无法精确表示十进制小数、运算带微小误差,累加会积累、==判等会因尾差失败;钱用整数(分)或 Decimal 精确表示,浮点比较用 math.isclose;要求精确的十进制数绝不用 float。
第二件事:正解——金额用整数分或 Decimal,浮点比较用 isclose
搞懂了原理,正解就清晰了:金额用"整数分"或 Decimal 精确表示和运算;Decimal 一定用字符串构造;非金额的浮点比较用 math.isclose,绝不用 ==。
# ====== 正解一: 金额用"整数分"表示和计算(简单、绝对精确) ======
# 所有金额都用"分"(整数)存储和运算; 显示时再除以100
price_cents = 1050 # 10.50元 = 1050分
qty = 3
total_cents = price_cents * qty # 整数运算, 绝对精确 = 3150分
print(f"{total_cents / 100:.2f}") # 显示时转元: 31.50
# 累加也精确:
total = 0
for _ in range(1000000):
total += 1 # 1分
print(total) # ✓ 精确的 1000000 分 = 10000.00元, 不再有误差
# 判等也精确: paid_cents == order_cents 整数比较, 绝对可靠。
# ====== 正解二: 用 Decimal 做精确的十进制运算 ======
from decimal import Decimal
a = Decimal("0.1") # ★ 必须用【字符串】构造! Decimal(0.1)会把float的误差带进来
b = Decimal("0.2")
print(a + b) # ✓ Decimal('0.3') —— 精确!
print(a + b == Decimal("0.3")) # ✓ True
# 金额计算:
price = Decimal("10.50")
total = price * 3 # Decimal('31.50'), 精确
# 注意: Decimal(0.1) (传float) 是错的 → Decimal('0.1000000000000000055...'); 一定传字符串或整数。
# ====== 正解三: 非金额的浮点比较, 用 math.isclose(别用 ==) ======
import math
a = 0.1 + 0.2
print(a == 0.3) # ✗ False
print(math.isclose(a, 0.3)) # ✓ True —— 在误差范围内即视为相等
# isclose 可调相对/绝对容差: math.isclose(a, b, rel_tol=1e-9, abs_tol=1e-12)
# ====== 选型建议 ======
# - 金额/货币: 首选【整数分】(简单高效精确)或 Decimal(需要小数位运算、四舍五入规则时); 数据库也用 DECIMAL类型;
# - 一般的科学计算/比例/不要求精确十进制的: float够用, 但比较用 isclose, 别用 ==;
# - 永远别: 用float表示钱、用 == 比较两个浮点、Decimal(传float构造)。
# 核心: 钱用整数分或Decimal(字符串构造)精确表示和运算(对账平、判等准); 浮点比较一律用math.isclose而非==;
# 要求精确十进制的场景(钱)绝不用float; float只用于可容忍微小误差的场景且比较用isclose。
修复的核心,是"钱用整数分或 Decimal,浮点比较用 isclose"。正解一:金额用整数分——所有金额用"分"(整数)存和算(10.50 元=1050 分),整数运算绝对精确,累加、判等都可靠,显示时再除 100。正解二:用 Decimal 做精确十进制运算——Decimal("0.1") + Decimal("0.2") == Decimal("0.3") 为 True;必须用字符串构造,Decimal(0.1) 会把 float 误差带进来。正解三:非金额浮点比较用 math.isclose(误差范围内即视为相等),别用 ==。选型:金额首选整数分或 Decimal(数据库用 DECIMAL 类型)、一般科学计算 float 够用但比较用 isclose、永远别用 float 表示钱/别用 == 比浮点/别 Decimal 传 float 构造。归根结底:钱用整数分或 Decimal(字符串构造)精确表示和运算;浮点比较一律用 math.isclose 而非 ==;要求精确十进制的场景(钱)绝不用 float。
第三件事:数值计算中其他常见的坑
排查后我把数值计算、类型表示相关的其他坑也系统梳理了一遍。
数值计算与表示的其他常见坑
# 1. float表示钱/累加误差/==判等(本文): 精度丢失。→ 整数分/Decimal/isclose。
# 2. Decimal用float构造: Decimal(0.1)把float误差带进来。→ 用字符串 Decimal("0.1")。
# 3. 整数溢出(其他语言): int超范围溢出(Python int无限大, 但C/Java/Go会溢出)。→ 用足够大的类型/检查。
# 4. 整数除法: Python3 / 是浮点除、// 是整除; 别混淆。→ 明确要哪种除法。
# 5. 浮点的特殊值: NaN(NaN != NaN)、inf; 比较/运算要小心。→ math.isnan判断。
# 6. 大数相加小数被"吃掉": float有效位有限, 1e16 + 1 可能还是1e16。→ 注意量级差异。
# 7. 货币换算/汇率/税率: 中间结果的舍入策略不一致, 导致差分。→ 统一舍入规则(Decimal的quantize)。
# 8. 数据库float列存钱: 同样不精确。→ 用 DECIMAL/NUMERIC 类型。
# 共同根源: 计算机表示数值有"精度和范围的限制"(浮点的精度、整数的范围), 它不是数学上的"实数";
# 把计算机的数值类型当成"数学上无限精确的数"来用, 在精度敏感(钱)或边界(溢出/特殊值)处就会出错。
# 核心: 理解计算机数值类型的限制(float不精确、整数有范围); 按场景选对类型——钱用整数分/Decimal、
# 浮点比较用isclose、注意溢出和特殊值; 别把有限精度的浮点当成数学上精确的实数。
排查让我把数值计算的其他坑也梳理清了。一、float 表示钱(本文)。二、Decimal 用 float 构造。三、整数溢出(其他语言)。四、整数除法 / 与 //。五、浮点特殊值 NaN/inf。六、大数相加小数被吃掉。七、舍入策略不一致。八、数据库 float 列存钱。它们的共同根源是:计算机表示数值有"精度和范围的限制"(浮点的精度、整数的范围),它不是数学上的"实数";把计算机的数值类型当成"数学上无限精确的数"来用,在精度敏感(钱)或边界(溢出/特殊值)处就会出错。核心是:理解计算机数值类型的限制(float 不精确、整数有范围);按场景选对类型——钱用整数分/Decimal、浮点比较用 isclose、注意溢出和特殊值;别把有限精度的浮点当成数学上精确的实数。下面这张图,是这次浮点精度坑的成因与解法:
第四件事:金额表示方案对比表
这次踩坑后,我把几种金额表示方案对比成一张表。
| 方案 | 精确性 | 性能 | 易用性 | 适用 |
|---|---|---|---|---|
| float | ✗ 不精确(误差) | 快 | 顺手 | 钱绝不用 |
| 整数分(int) | ✓ 绝对精确 | 快 | 显示要除100 | ★ 货币首选 |
| Decimal | ✓ 精确 | 较慢 | 要字符串构造 | 需小数位/舍入规则 |
| 数据库 DECIMAL | ✓ 精确 | — | — | 存储金额 |
这张表把金额方案钉清了。核心是:选择的本质,是"用什么方式去表示一个本该精确的量"——float 为了"能表示很大范围的小数、且运算快"牺牲了"十进制的精确性";而钱要的恰恰是"十进制的绝对精确",所以要选整数分或 Decimal 这种"为精确而生"的表示;用错了表示方式(用 float 表示钱),就是用一个"本质上做不到精确"的工具去做一件"必须精确"的事。它给我的最大启发是:"选择用什么数据类型/结构去表示一个东西",不是个无关紧要的细节,而是一个关乎正确性的根本决策——每种表示方式都有它"擅长保证什么、牺牲了什么"(float 擅长范围和速度、牺牲十进制精确;整数擅长精确、范围有限);选型时要让"表示方式的特长"匹配"问题的核心要求"。这给了我一种建模时的清醒:给任何一个值选类型/表示时,先问"这个值, 最核心、最不能妥协的要求是什么?(精确?范围?速度?)",再选一个"恰好能保证这个核心要求"的表示——钱的核心要求是精确, 就选精确的整数分/Decimal, 而非顺手的 float;"让数据的表示方式, 服从于它最核心的正确性要求",是从源头避免一整类精度/范围/正确性 bug 的关键。认清表示方式的选择关乎正确性、让表示服从于核心要求——是这个坑带给我的认知。
第五件事:这次事故暴露的"计算机不是数学"
这次让我反思更深一层:0.1+0.2≠0.3 之所以让我震惊,是因为我潜意识里把"计算机算数"等同于"数学算数"了。我把"数学中的数"和"计算机中的数"对比成表。
| 维度 | 数学中的数(实数) | 计算机中的数(float等) |
|---|---|---|
| 精度 | 无限精确 | 有限精度(有误差) |
| 范围 | 无限 | 有限(会溢出) |
| 0.1+0.2 | 精确等于0.3 | 0.30000000000000004 |
| 相等 | 精确相等 | 需容差判断(isclose) |
| 本质 | 抽象、理想 | 有限位的近似表示 |
这张表道出了一个根本的认知偏差。核心是:我的错误根源,是把"计算机里的数"想当然地等同于"数学里的数"了——我以为计算机和数学一样,数是无限精确、无限大的;可计算机里的数,是"用有限的二进制位去近似表示"的,它有精度的上限、有范围的边界,是数学之数的一个"有损的、近似的影子"。它给我的深刻启发是:计算机里的很多东西,都是对"理想概念"的"有限的、有损的实现"——float 是实数的有限近似、内存是有限的、整数有范围、时间精度有限、网络不可靠;而我们(尤其受数学/理论训练后)常常会不自觉地用"理想模型"去想当然地假设计算机的行为(以为数无限精确、内存无限、网络可靠);这些"理想假设与有限现实的差距",正是大量 bug 的藏身之处。这给了我一种工程上的清醒:做工程时,要时刻区分"理想的抽象模型"和"计算机上有限的、有损的实现",并对后者的"限制"保持警觉——数不是无限精确的(精度)、空间不是无限的(内存/磁盘)、操作不是瞬时的(延迟)、组件不是绝对可靠的(故障);"用'计算机的现实(有限、有损、会错)'而非'数学的理想'去推理系统行为",是写出在真实计算机上正确运行的代码的根本前提。认清计算机的数不是数学的数、用计算机的有限现实而非数学理想去推理——是这个浮点坑带给我的认知升华。
第六件事:处理数值尤其是金额时,我现在的自检习惯
现在每当我要处理一个数值,我都会先按这张图问自己:
这张图的精髓,是"钱用整数分/Decimal,浮点比较用 isclose,绝不用 float 算钱、绝不用 == 比浮点"。钱整数分/Decimal、一般浮点可 float 但比较用 isclose、数据库用 DECIMAL。这套习惯,让我从"金额随手 float"变成了"先判断是不是要精确、是钱就整数分/Decimal"——核心始终是:钱用整数分或 Decimal(字符串构造)精确表示,浮点比较用 math.isclose,要求精确的十进制绝不用 float。
我立下的几条规矩
这场"对账差几分钱、订单判等卡单"的事故,换来了我处理数值时,刻进骨子里的几条铁律:
- float 是二进制浮点,无法精确表示大多数十进制小数(如 0.1)。0.1+0.2≠0.3。
- float 误差累加会积累放大,== 判等会因尾差永远不相等。
- 钱用整数分(以分为单位的整数)或 Decimal,绝不用 float。
- Decimal 必须用字符串构造,Decimal(0.1) 会把 float 误差带进来。
- 浮点比较一律用 math.isclose,永远别用 ==。
- 数据库存金额用 DECIMAL/NUMERIC 列,别用 float/double 列。
- 计算机的数不是数学的数,有精度和范围的限制。别用数学理想去想当然。
写在最后
回头看,这场由"用 float 算钱"引发的、对账不平加卡单的事故,真正教给我的,远不止"钱用整数分或 Decimal"这一个技巧。它让我对"我们脑子里那些'理所当然、天经地义'的假设(比如 0.1+0.2=0.3), 在'另一个语境(计算机)'里, 可能根本不成立; 而最危险的, 正是那些我们'从未怀疑过'的假设",有了一次刻骨的体会。我栽跟头,是因为我带着一个从小到大、根深蒂固、从未怀疑过的假设——"0.1 加 0.2 等于 0.3"——去写代码。这个假设在数学的世界里是绝对正确的真理;可我忘了,我的代码运行在计算机的世界里,而在那个用有限的二进制位来近似表示数的世界里,这个"真理"不再成立;我把一个'在数学语境下成立'的假设, 不假思索地搬到了'计算机语境'下使用,而这个假设在新语境下恰恰是错的——错得如此基础,以至于我从未想过要去验证它。这让我领悟到一个关于"隐含假设"的深刻认知:我们的每一段代码、每一个判断, 背后都站着一大堆我们'默认成立、从未明说、也从未怀疑'的隐含假设(数是精确的、内存是够的、网络是通的、输入是合法的、时钟是单调的……);而 bug, 尤其是那些最诡异、最难查的 bug, 常常就源于"某个我们深信不疑的隐含假设, 在当前的具体语境下, 悄悄地不成立了";"越是'理所当然'、越少被怀疑的假设, 一旦失效, 就越难被发现"——因为你根本不会去查它。这给了我一种排查与思考的根本方法:当遇到"违反直觉、匪夷所思"的 bug 时(比如 0.1+0.2≠0.3),不要急着否认("这不可能"),而要反过来审视"我是不是有某个'想当然'的假设, 在这里其实不成立?"——把那些"我从没怀疑过的前提"拎出来,逐一验证它们在当前语境下是否真的成立;"敢于怀疑并检验自己最根深蒂固的隐含假设",是攻克玄学 bug、也是深刻理解一个系统的关键能力。认清代码背后站着大量未经怀疑的隐含假设、违反直觉的 bug 往往源于某个想当然的假设失效——这,是我用一次浮点算钱的事故,换来的、关于数值计算、也关于如何审视自己思维盲区的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次算钱时,本能地避开 float、改用整数分或 Decimal,那我对着那几分钱的对账差额排查的这段时间,就值了。
—— 别看了 · 2026