0.1+0.2≠0.3:浮点数精度原理与工程治理

发布时间:2026/10/27 13:58:31
0.1+0.2≠0.3:浮点数精度原理与工程治理

0.1 + 0.2 = 0.30000000000000004,这行输出我最早是在一个控制台里看到的,当时第一反应是自己敲错了,第二反应是怀疑编译器有问题。后来才明白,这是浮点数精度问题最经典的一次露面,它既不是语言 bug,也不是硬件故障,而是二进制表示与十进制习惯之间一次注定要发生的摩擦。这类问题覆盖的范围远比多数人想得宽:从电商结算的一分钱差额,到科学计算里累加一百万个数的结果偏移,再到游戏里物体坐标跑着跑着飘到了墙外。只要你的代码里出现过float、double、real这些类型,精度问题就已经在你项目里落了户,只是还没到暴露的时候。下面这些内容适合三类人看:写过浮点代码但没系统捋过原理的开发者、被账务差异折磨过的后端同学、以及做数据处理和算法时发现结果对不上的朋友。我会把二进制表示、误差来源、场景影响、方案选型到实操改造完整走一遍,代码可以直接抄,坑也提前给你标出来。

1. 0.1 加 0.2 为什么算出 0.30000000000000004

1.1 十进制小数在二进制世界里的"除不尽"

理解这件事最快的路径,是拿我们自己熟悉的十进制做类比。十进制的 1/3 写成小数是 0.3333...,无论你保留多少位,后面永远有余数,所以十进制存不下 1/3 的精确值。二进制也有它自己的"无限循环小数",只不过被它卡住的数字换了一批:在二进制里,只要是分母含有 2 以外质因数的分数,都表示不干净,1/5、1/10、1/3 全在其中。

0.1 就是 1/10,分母 10 = 2 × 5,那个 5 就是罪魁祸首。用"乘 2 取整"的方法把它展开成二进制:

0.1 × 2 = 0.2 取 0 0.2 × 2 = 0.4 取 0 0.4 × 2 = 0.8 取 0 0.8 × 2 = 1.6 取 1 0.6 × 2 = 1.2 取 1 0.2 × 2 = 0.4 取 0 ← 又回到 0.2,开始循环

结果就是 0.00011001100110011...,循环节 0011 无限重复。0.2 同理,是 0.001100110011...,同样无限循环。而 0.5、0.25、0.125 这类 2 的负整数次幂,在小数点后只有有限位,能精确存下。这就解释了一个经常让人困惑的现象:为什么 0.5 + 0.25 完全正常,0.1 + 0.2 就出问题。不是小数点的锅,是分母的锅。

内存装不下无限长的数,只能截断,截断就产生误差。0.1 在双精度下实际存的是 0.1000000000000000055511151231257827021181583404541015625,比真实的 0.1 略大一点点;0.2 存的是 0.200000000000000011102230246251565404236316680908203125,同样略大。两个偏大的数相加,结果自然比 0.3 大,多出来的那点零头就是你在控制台看到的 0.00000000000000004。

1.2 IEEE 754 的存储账本:一个数被切成三段

现在主流的浮点实现都遵循 IEEE 754 标准,它的思路很像科学计数法:把一个数写成 ±1.xxxx × 2^e 的形式,然后分别存符号、指数、尾数三段。真正决定精度的是尾数能存多少位,因为尾数位越多,能保留的有效数字越多。

类型总位数符号位指数位尾数位十进制有效数字常见语言
半精度 float16161510约 3 位深度学习、GPU 张量
单精度 float32321823约 7 位Java float、C float
双精度 float646411152约 15~16 位Java double、Python float、JS number
扩展精度 long double80/128115/1564/112约 18~34 位C long double,平台差异大

关键在于"有效数字"这一列。双精度能稳定表达大约 15 到 16 位十进制有效数字,超过这个长度,后面的位就是噪声。这就是为什么金额超过 15 位、ID 超过 15 位,用浮点存就会开始丢东西。还有一个经常被忽略的数:2 的 53 次方减一,等于 9007199254740991,这是双精度能精确表示整数的上限。JavaScript 里叫Number.MAX_SAFE_INTEGER,超过它之后,连续两个整数之间会出现"空洞",x + 1 === x这种荒谬的比较会返回 true。我见过后端把雪花算法生成的 19 位 ID 直接以 number 类型返回给前端,前端解析完最后几位变成 0,两边数据对不上,排查了大半天。

1.3 误差的三种来源:表示、运算、舍入

把误差笼统归为"浮点不准"太粗糙了,实际至少有三种不同的来源,处理手段也不一样。

第一种是表示误差,也就是上面讲的 0.1 存不精确。它在你写下这个字面量的那一刻就产生了,跟后续怎么算无关。第二种是运算误差,两个已经带误差的数参与加减乘除,误差会被放大、传递。比如减法里两个非常接近的大数相减,结果的有效位数会急剧缩水,这叫"灾难性相消",后文会详细说。第三种是舍入误差,每次运算结果都要塞回固定长度的尾数里,塞的过程就得舍入,一次运算一次舍入,累积起来就跑偏了。

注意:判断一个值是否"精确",看的是它的分母是否只含因子 2。0.5、0.25、0.375 都没问题,0.1、0.3、0.7 全部有误差。这不是概率问题,是确定性的,你在任何遵循 IEEE 754 的平台上跑出来的误差都一模一样。

顺带说一句,很多人以为"单精度转双精度就能消除误差",这是错的。float32 的 0.1 转成 double,得到的是 0.10000000149011612,误差被完整保留,只是不再继续恶化。类型转换不会把丢失的信息找回来。

2. 精度问题的高发场景与影响范围评估

2.1 金额与账务:最不能容忍的失真现场

金融和电商是浮点误差最敏感的地带,因为这里的每一分钱都要对得上。设想一个典型的优惠计算:商品 99.9 元,叠加一张 0.1 元的券,再按 0.85 折结算,用 double 一路算下来,理论结果是 84.83 元,但实际可能算出 84.82999999999999。如果最后一步直接用Math.round取整到分,多数时候能救回来;可一旦这个中间值还要参与二次计算(比如再乘一个税率、再按比例分摊到多件商品),误差就会被放大,最终对账时出现一毛甚至几块的差额。

更隐蔽的是积少成多的场景。某支付系统的对账模块,每天要汇总几百万笔交易的金额,每笔金额都是 double 类型,单笔误差在 1e-15 量级,看起来微不足道。但百万级累加之后,误差可能累积到 1e-9,再乘以总金额的量级,就可能突破"分"这一级,导致日终报表和明细列表的合计数对不上。这种问题只在数据量大的时候冒头,测试环境几十条数据永远复现不出来,上线三个月后才突然报出来,排查成本极高。

影响面还不止于钱。凡是需要"可复现、可审计"的计算都必须警惕:积分、库存扣减、发票税额、跨境结算的汇率换算、分期手续费的分摊。这些场景的共同点是结果需要对外展示、需要和历史数据比对、需要满足财务侧的校验规则。浮点误差在这类场景里不是性能问题,是正确性问题,也是合规问题。

2.2 条件判断与循环边界:if 里的隐形地雷

第二个高发区是比较运算。很多人直觉上会写if (a === b)来判断两个浮点数相等,这在多数情况下是错的,因为两个数学上相等的值,经过不同的计算路径后,末位可能差一个 ULP(最小可表示单位)。典型例子是0.1 + 0.2 === 0.3返回 false,但更麻烦的是它在业务代码里的变体:订单实付金额与应付金额比较、库存余量与扣减量比较、传感器读数和阈值比较。

循环边界也容易被浮点坑。for (double x = 0.0; x != 1.0; x += 0.1)这种写法会陷入死循环,因为 x 累加十次得到的是 0.9999999999999999,不等于 1.0,继续加下去就永远跳不出。正确做法是用整数循环变量,或者用x < 1.0这种带容差的比较。类似地,二分查找、牛顿迭代、梯度下降的收敛判断,如果终止条件写成while (a != b),很可能一直跑下去。

还有一个分支是排序和去重。浮点数组排序时,理论上相等的两个值可能被判定为不等,导致去重逻辑失效;用浮点数作为哈希键(比如HashMap<Double, X>)时,0.0和-0.0的哈希值不同但比较相等,会引起一些非常怪异的行为。我的建议很简单:只要涉及"是否相等""是否包含""是否重复",就别让浮点数当这个判断的主体,换成整数或者定点数。

2.3 大规模累加与统计:误差的复利效应

单次运算的误差在 1e-16 量级,很安全。但当运算次数变成十万、百万,误差就不再是随机的了——它可能朝同一个方向累积,形成系统性偏差。经典的演示是sum([0.1] * 10),Python 里结果是 0.9999999999999999 而不是 1.0。再放大到一百万个 0.1 相加,误差会涨到 1e-10 量级。

另一个更严重的问题是"大数吃小数"。双精度只有 15 到 16 位有效数字,当累加和已经涨到 1e16,再加一个 1.0,结果还是 1e16,因为 1 落在了有效数字范围之外,直接被吞掉了。这在统计场景里非常致命:先统计一个巨大的总量,再往里加小额的增量,小额增量全部人间蒸发。我见过一个实时统计服务,用一个全局 double 累加总访问量,跑了半年之后,每次加 1 都不再有任何变化,监控曲线直接变成一条水平线。

科学计算里还有更微妙的版本。矩阵求和的顺序不同,结果会有微小差异;并行归约时线程切分方式不同,结果也不同;同一个模型在 GPU 和 CPU 上跑出来的损失值小数点后第八位开始分叉,很大程度上就是浮点累加顺序不同导致的。这不是 bug,是浮点的固有性质,但如果你在做结果比对、做回归测试,就必须把容差考虑进去,否则 CI 会天天红。

2.4 跨语言、跨存储传输:误差的二次放大

数据的生命周期很少局限在一个进程里。一个 double 从数据库读出来,经过 JSON 序列化传输,被另一种语言解析,再做计算——这条链路上每一步都可能引入新问题。

第一站是序列化。JSON 规范里数字就是一个字符串,解析器怎么处理浮点没有强制规定。JavaScript 的JSON.parse用双精度,Python 的json.loads默认也用双精度,但某些语言的解析库可能会转成定点或者保留原始字符串,行为不统一。更麻烦的是整数字段:如果后端把 19 位的订单号以数字形式写进 JSON,前端 JS 解析时超过安全整数上限,末位会被改写,两个系统看到的订单号就不一样了。这个坑的通用解法是:超过 15 位的整数一律用字符串传输。

第二站是数据库。MySQL 的FLOAT和DOUBLE存金额是明确的反面教材,它们的存储精度有限且不可控。金额字段的正确姿势是DECIMAL(18, 4)或者用BIGINT存最小货币单位。还有一个典型问题:DECIMAL列写入0.1再读出来是精确的 0.1,但如果写入之前已经被 double 污染成了 0.1000000000000000055,数据库会按 scale 截断,看起来正常,实际上原始值已经不对了。

第三站是数据交换格式和外部接口。很多第三方接口的金额字段是浮点数字,对接方各自解析,最终两边的合计数差几分钱,扯皮起来谁也说不清。我的经验是,凡是涉及金额和 ID 的接口,宁可多写几行转换成字符串或最小单位整数,也不要省这个事。

3. 技术栈能力盘点与方案选型

3.1 各语言内置数值类型的能力边界

不同语言对浮点的默认处理差别很大,选方案之前得先知道自己手上有什么牌。

语言默认小数字面量类型高精度类型特点与注意点
Pythonfloat(双精度)decimal.Decimal、fractions.FractionDecimal 默认 28 位精度,上下文可调;Fraction 用分数精确表示
JavadoubleBigDecimal必须用 String 构造,除法必须指定 scale 和舍入模式
JavaScriptnumber(双精度)无内置,需第三方库全部数字都是双精度,安全整数上限 2^53-1
C / C++double无内置,需第三方库有 long double 但平台差异大,x87 下 80 位、ARM 下可能是 128 位
Gofloat64math/big.Float、math/big.RatRat 是精确有理数,适合做中间计算
C#doubledecimaldecimal 是 128 位十进制类型,金额场景可直接用
Rustf64无内置,需 crate类型严格,不容易误用,但精度问题照样存在

这里有个容易踩的点:Java 的new BigDecimal(0.1)和new BigDecimal("0.1")结果完全不同,前者得到 0.1000000000000000055511151231257827021181583404541015625,后者才是干净的 0.1。用 double 参数构造 BigDecimal,等于把浮点误差原封不动搬进了高精度类型里,看起来用了 BigDecimal 很安全,实际上问题一点没解决。这个错误在代码评审里出现的频率高得惊人。

Python 的Decimal也有类似陷阱:Decimal(0.1)同样会带进误差,必须写Decimal("0.1")或者Decimal(1) / Decimal(10)。另外 Decimal 的运算上下文(精度、舍入模式)是线程局部的,多线程环境下如果有人在别处改了getcontext().prec,你这边也会受影响,需要显式指定。

3.2 金额场景:整数化与定点数两条路线

金额处理只有两种靠谱路线,没有第三种。

路线一是整数化,把金额统一换算成最小货币单位(人民币是分,日元是没有小数,某些场景需要到厘),全程用long/int64运算,只在展示层除以 100 转成元。这条路线的好处是彻底消灭浮点,加减乘都是精确的整数运算,性能也好,数据库存BIGINT即可。代价是乘除要小心:打折率、税率是小数,乘法之后必须明确舍入规则,而且除法(比如按比例分摊)会产生余数,余数得有个归属方案。

路线二是定点数,用DECIMAL(p, s)或者语言内置的高精度十进制类型,保留固定的小数位数。这条路线更贴近人的直觉,适合需要保留多位小数的场景,比如汇率(通常 6 位)、利率(可能 8 位)、科学计量。代价是运算开销比整数大一个量级,而且每次除法都必须显式指定 scale 和舍入模式,漏写就报错或者静默截断。

两条路线怎么选,我的一般判断是:纯人民币记账、高并发、需要和外部系统对账的,走整数化;涉及汇率、利率、多币种换算、精度要求超过 4 位小数的,走定点数。两者也可以混用,比如主体金额用整数,换算过程用定点数,最后落库回整数,这个组合在实际项目里很常见。

3.3 科学计算场景:误差补偿算法

科学计算、机器学习、图形渲染这类场景没法走定点数路线,因为它们本来就需要浮点的动态范围。这时候的做法不是消灭误差,而是控制误差。

最常用的是Kahan 求和,思路是把每次运算"丢掉"的低位部分单独存起来,下次运算时补回去。代码不长,效果显著:

def kahan_sum(values): total = 0.0 compensation = 0.0 for v in values: y = v - compensation t = total + y compensation = (t - total) - y total = t return total # 对比 import math xs = [0.1] * 1_000_000 print(sum(xs)) # 99999.99999852802 左右 print(kahan_sum(xs)) # 100000.00000000001 左右

Kahan 的局限是它假设数值同号且量级接近,遇到正负交替的大数会失效,这时候可以升级成 Neumaier 求和(也叫 Kahan-Babuška),它对补偿量的处理更稳健。

另一个方向是提高中间精度。机器学习里用 float32 训练会掉点,用 float64 又太慢,所以有了混合精度:前向反向用 float16 或 bfloat16 加速,累加和参数更新用 float32 甚至 float64。PyTorch 的autocast就是干这个的,它会在除法、求和这类敏感操作上自动提升精度,避免梯度下溢。做数值计算的朋友应该都踩过 fp16 累加溢出变成 inf 或者全部归零的坑,本质上还是精度问题。

第三个方向是改变运算顺序。浮点加法不满足结合律,(a + b) + c和a + (b + c)的结果可能不同。排序后从小到大累加,误差比随机顺序小得多;分块求和(先把数据切成若干块各自求和,再合并)也比一路累加稳定。这些技巧成本很低,但需要你意识到"顺序会影响结果"这件事。

4. 实操:一次账务系统精度治理的完整过程

4.1 问题定位:从 3 分钱的对账差异开始

说一个我参与过的真实改造案例,细节做了脱敏。某电商后台的对账报表,连续几天出现合计金额和明细汇总差 0.03 元的情况。差异很小,但财务不认,因为这意味着系统算出来的总账和实际收付对不上。

第一步是定位数据从哪来。报表的合计是在 SQL 里用SUM(amount)算的,amount 字段类型是DOUBLE。明细列表是把每行金额在前端用 JavaScript 累加得到的。两边算法不同,一边是数据库的浮点累加,一边是 JS 的浮点累加,顺序和精度都不同,结果自然有微小差异。

第二步是验证假设。把同一个查询结果导出,分别用三种方式求和:数据库 SUM、Python 的sum()、Python 的Decimal精确求和。前两个结果相同,都和 Decimal 的结果差 0.03。到这一步基本确认是浮点累加误差,量级也吻合——涉及大约 40 万条记录,单条误差 1e-16 量级乘上去差不多就是这个数。

第三步是确认污染的源头。追到上游发现,金额最初是字符串,在某个计算优惠的环节被parseFloat转成了 number,优惠分摊之后又写回数据库的DOUBLE字段。也就是说,从那一刻起金额就带着误差在系统里流动了。找到这个点之后,改造范围就清楚了。

4.2 改造实施:字段、代码、接口三处联动

改造分三步走,顺序不能乱。

第一步是数据层改造。新增amount_cents BIGINT字段,把存量DOUBLE金额乘以 100 后四舍五入写入。这里有个关键细节:转换必须用数据库的ROUND(amount * 100)还是应用层计算?我的做法是导出原始字符串值(在还没被转成 double 之前的那份),用 Decimal 精确计算后写入,避免二次污染。如果原始值已经只剩 double 了,那只能用ROUND(CAST(amount * 100 AS DECIMAL(20,0))),接受那点已经发生的误差。

-- 存量迁移:先在临时列上验证 ALTER TABLE orders ADD COLUMN amount_cents BIGINT NULL; UPDATE orders SET amount_cents = ROUND(CAST(amount * 100 AS DECIMAL(20, 0))) WHERE amount_cents IS NULL; -- 校验:新旧字段是否对得上(允许 1 分以内差异) SELECT COUNT(*) FROM orders WHERE ABS(amount * 100 - amount_cents) > 1;

第二步是代码层改造。所有涉及金额的实体字段从double改成long,计算逻辑全部换成分的整数运算。优惠分摊这里是最麻烦的:一笔订单可能有多个商品、多张券,券要在商品间按比例分摊,分摊必然产生余数。我们的规则是按金额比例分摊后,把余数补给金额最大的那个商品,保证分摊结果之和严格等于券面额。

// 按比例分摊,余数归最大项,保证总额严格守恒 public static long[] allocate(long total, long[] weights) { long sum = Arrays.stream(weights).sum(); if (sum == 0) throw new IllegalArgumentException("权重全为 0"); long[] result = new long[weights.length]; long allocated = 0; int maxIdx = 0; for (int i = 0; i < weights.length; i++) { result[i] = total * weights[i] / sum; // 整数除法,向下取整 allocated += result[i]; if (weights[i] > weights[maxIdx]) maxIdx = i; } result[maxIdx] += total - allocated; // 差额补给最大项 return result; }

第三步是接口层改造。对外提供的金额字段统一改成字符串,格式化为两位小数;内部 RPC 传输用 long 类型的分。前端拿到字符串后不做任何算术,只做展示。这个约定写进了接口规范,后面新增的接口都照此执行。

4.3 参数与策略:scale、舍入模式、epsilon 的确定过程

改造过程中有几个参数需要拍板,这里把我的判断依据也写出来,方便你套用。

金额的 scale 定几位?人民币场景定 2 位就够,但我们的业务涉及退税和部分商品的单价到厘,所以内部用 4 位小数对应的BIGINT(即万分之一元),展示层再降到 2 位。多留两位的理由是:中间计算可能要做除法,位数不够会在除法阶段就丢精度。多留两位的成本是存储从 8 字节变 8 字节、范围缩小,实际上没有损失。

舍入模式选哪个?常见的有四种:四舍五入(HALF_UP)、银行家舍入(HALF_EVEN)、向上取整(CEILING)、向下取整(FLOOR)。财务侧的要求是五五开不能总是往上偏,所以选 HALF_EVEN,这也是 IEEE 754 的默认舍入模式,和多数语言的行为一致。但要注意,Java 的BigDecimal除法不指定模式会抛异常,必须显式写:

BigDecimal result = amount.divide(rate, 4, RoundingMode.HALF_EVEN);

比较的 epsilon 定多少?只有在确实需要比较浮点的场景(比如传感器读数、算法收敛判断)才需要这个参数。我的经验是用相对误差而非绝对误差,公式是|a - b| <= max(relTol * max(|a|, |b|), absTol),其中 relTol 取 1e-9,absTol 取 1e-12。relTol 太大会把不相等的值判成相等,太小则失去容差的意义。有一个反直觉的点:不能简单写Math.abs(a - b) < 1e-6,因为当 a、b 都很大时,1e-6 的绝对容差毫无意义;当二者都接近 0 时,相对容差又会失效,所以两个都要有。

import math def nearly_equal(a, b, rel_tol=1e-9, abs_tol=1e-12): if math.isnan(a) or math.isnan(b): return False if math.isinf(a) or math.isinf(b): return a == b return abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol) print(nearly_equal(0.1 + 0.2, 0.3)) # True print(nearly_equal(1e16, 1e16 + 1)) # True,此时确实无法区分

5. 常见问题速查与踩坑记录

5.1 高频问题速查表

下面这张表是我这几年遇到、被问到、帮人排查过的浮点问题汇总,按出现频率排序,可以当速查手册用。

现象根本原因处理方式
0.1 + 0.2 !== 0.30.1 和 0.2 在二进制下无限循环,存储时舍入用容差比较,或全程用整数/定点数
(1.005).toFixed(2)得到"1.00"1.005 实际存为 1.00499999999999989不要依赖 toFixed 做金额舍入,用定点数或手写舍入
大整数 ID 在前端末位变 0超过 2^53 - 1 后整数不再连续超过 15 位的整数一律以字符串传输
for (double x = 0; x != 1; x += 0.1)死循环累加结果不会精确等于 1.0用整数循环变量,或改成x < 1.0 - eps
大量数据累加后合计对不上误差累积 + 大数吃小数Kahan/Neumaier 求和,或改用整数/Decimal 汇总
两个相近大数相减,结果精度暴跌灾难性相消,有效位大幅缩水变换公式避免相减,或提升中间精度
new BigDecimal(0.1)出现一长串数字用 double 构造,把浮点误差带了进来一律用 String 构造:new BigDecimal("0.1")
BigDecimal除法抛ArithmeticException除不尽且未指定 scale 和舍入模式显式传 scale 与RoundingMode
0.0 == -0.0为 true,但哈希值不同IEEE 754 规定二者相等,符号位不同别用浮点当 Map 的 key,或者先归一化
同一份数据在 GPU 和 CPU 上结果不同并行归约顺序不同导致累加误差差异结果比对标定容差,关键路径用同一种精度的累加
数据库 SUM 和程序累加结果不一致两边累加顺序、类型不同金额字段用 DECIMAL,汇总统一在一端做
百分比乘以金额后差一分舍入方向不一致明确舍入模式并全文统一,余数按规则分配

5.2 我踩过的几个坑

第一个坑是toFixed。我早期写前端金额展示时顺手用了(price).toFixed(2),测试数据都是整数和小数点后一位,一直没问题。直到有一批 1.005、2.675 这样的价格进来,页面上显示 1.00 和 2.67,用户投诉说少算钱。原因就是这些十进制小数在二进制里略小于真实值,toFixed按真实值舍入,结果向下走了。修法不是加个 0.5 的补丁(那样会引入新问题),而是从源头把金额换成分的整数,展示时用整数除法和取余手动拼字符串,从此再没出过问题。

第二个坑是 JSON 里的长整型。后端返回的订单号是 19 位,接口文档写的是 number,前端用JSON.parse解析后,最后两位变成了 00。这个问题最阴险的地方在于它不是每次都错——只有末位超过安全范围时才错,测试环境数据量小、订单号前缀短的时候完全正常,一上线就零星出问题。后来统一改成字符串传输,同时在前端加了一个断言,检测到超过 15 位的数字就报警。

第三个坑是Decimal的上下文。有个服务用 Python 的 Decimal 做汇率换算,本地测试全部通过,部署到容器里结果差了几位。排查发现是有个第三方库在导入时调用了getcontext().prec = 6,把我们默认的 28 位精度改成了 6 位,而 Python 的 Decimal 上下文是线程局部的,不同线程还可能不一致。解决办法是在自己的计算入口显式创建Context对象,或者干脆用localcontext()包起来,不依赖全局默认值。

第四个坑是float转double的误解。有个同事坚信"计算前先转成 double 就更精确",把所有 float 字段在计算前做了强制转换,结果误差一分没少——因为 float32 已经丢掉的信息,转成 64 位也找不回来,只是把误差原样搬过去了。这个认知偏差挺普遍的,值得单独强调:精度是由最初的存储决定的,后续转换只能防止误差扩大,不能修复已发生的损失。

最后分享一个我一直在用的自检习惯。写完任何涉及金额或累积计算的代码,先跑三组数据:一组是 0.1、0.2、0.3 这种典型的二进制不友好值;一组是接近 2^53 的大整数;一组是正负交替、量级悬殊的数值。这三组能过,代码在精度上基本就站得住。这个检查花不了两分钟,但能挡掉后面几天甚至几周的排查工作,投入产出比高得离谱。