为什么金额不能用 `float` 存:一次精度丢失引发的对账事故

发布时间:2026/7/24 19:47:48
为什么金额不能用 `float` 存:一次精度丢失引发的对账事故 前言“存个金额而已用float不就行了”这大概是新手最容易踩的坑之一。程序跑起来没报错测试也过了直到某天财务对账发现总额差了几分钱——查了半天罪魁祸首就是那个float。《阿里巴巴 Java 开发手册》对此有一条毫不含糊的**【强制】**规定【强制】小数类型为decimal禁止使用float和double。说明float和double在存储的时候存在精度损失的问题很可能在值的比较时得到不正确的结果。如果存储的数据范围超过decimal的范围建议将数据拆成整数和小数分开存储。这篇文章讲清楚为什么float/double会丢精度底层到底发生了什么以及金额到底该怎么存。环境说明本文基于 MySQL 8.0存储引擎 InnoDB。一、先复现float存金额会出什么问题建一张用float存金额的表插入几个再普通不过的数字CREATETABLEt_money_float(idINTPRIMARYKEYAUTO_INCREMENT,amountFLOAT);INSERTINTOt_money_float(amount)VALUES(0.1),(0.2),(1.1),(999999.99);存进去的时候一切正常。但当你做一次求和SELECTSUM(amount)FROMt_money_float;-------------------- | SUM(amount) | -------------------- | 1000001.4100341797 | --------------------0.1 0.2 1.1 999999.99应该是1000001.39结果却冒出来一串1000001.4100341797。多出来的那一长串小数就是精度丢失。再看一个更直观的直接比较SELECT*FROMt_money_floatWHEREamount0.1;Empty set (0.00 rows)空的明明插入了0.1用amount 0.1却查不到。因为存进去的根本不是精确的0.1。这正是手册说的在值的比较时得到不正确的结果。二、底层为什么float存不下一个简单的 0.1问题的根源不在 MySQL而在二进制浮点数这个东西本身。float和double遵循的是 IEEE 754 标准它用二进制的科学计数法来表示小数。2.1 十进制小数怎么变成二进制整数转二进制没问题但小数不一定。十进制小数转二进制的方法是乘 2 取整。我们试着把0.1转成二进制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.4 × 2 0.8 → 取 0 ...0.1转成二进制是0.0001100110011001100...0011无限循环是个无限循环小数。2.2 有限的位数装不下无限的小数这就是症结所在0.1在二进制里是无限循环的但float只有 32 位、double只有 64 位位数是有限的。存不下无限循环就只能在某一位截断。截断就意味着存进去的值不完全等于0.1而是一个非常接近、但略有偏差的值比如0.100000001490116...。单个数字这点偏差你察觉不到显示时被四舍五入了但一旦累加SUM成千上万个微小偏差叠在一起就放大成了肉眼可见的误差一旦精确比较 0.1存进去的近似值和字面量0.1对不上就查不到一句话float/double是为科学计算、工程计算这种能容忍误差的场景设计的不是为一分钱都不能错的金额设计的。三、正解用decimal精确存储decimal和float的根本区别decimal是按十进制精确存储的你存0.1它就精确地存0.1不做二进制转换没有截断也就没有精度丢失。把上面的表换成decimalCREATETABLEt_money_decimal(idINTPRIMARYKEYAUTO_INCREMENT,amountDECIMAL(10,2)-- 共 10 位其中 2 位小数);INSERTINTOt_money_decimal(amount)VALUES(0.1),(0.2),(1.1),(999999.99);SELECTSUM(amount)FROMt_money_decimal;------------- | SUM(amount) | ------------- | 1000001.39 | -------------结果精确等于1000001.39。精确比较也正常SELECT*FROMt_money_decimalWHEREamount0.1;------------ | id | amount | ------------ | 1 | 0.10 | ------------查得到了。DECIMAL(M, D)的两个参数要理解清楚M总共多少位有效数字精度D其中几位是小数标度DECIMAL(10, 2)就是总共 10 位小数占 2 位即最大能存到99999999.99整数部分 8 位。存钱一般DECIMAL(10,2)或DECIMAL(15,2)足够具体看金额上限。四、除了 decimal还有一种做法用整数存分手册里那句如果存储的数据范围超过decimal的范围建议将数据拆成整数和小数分开存储其实还引出了另一种业界常见做法用最小货币单位分存金额字段用BIGINT。比如12.34元存成1234分。CREATETABLEt_money_cent(idINTPRIMARYKEYAUTO_INCREMENT,amount_centBIGINT-- 以分为单位);INSERTINTOt_money_cent(amount_cent)VALUES(10),(20),(110),(99999999);-- 分别代表 0.10, 0.20, 1.10, 999999.99 元整数运算天然精确不存在任何精度问题SUM、比较全都可靠。展示时再除以 100 转成元。decimal和整数存分怎么选decimal可读性好SQL 里直接就是金额运算直观是大多数业务的首选。整数存分性能略优整数运算最快常见于对性能敏感或涉及大量货币计算的系统如支付、交易。缺点是每次展示都要换算容易忘记 ÷100。两种都对关键是绝对不用float/double。五、常见误区与面试高频问答Qdouble比float精度高用double存金额行不行不行。double只是位数更多64 位 vs 32 位能存更多有效数字但它同样是二进制浮点0.1照样是无限循环、照样要截断。精度更高只是让误差更小、更晚暴露本质问题没变累加和比较照样出错。QJava 里对应的是什么一样的道理。Java 里禁止用float/double做金额运算要用BigDecimal对应数据库的decimal。而且BigDecimal要用字符串构造new BigDecimal(0.1)不能用new BigDecimal(0.1)——后者传进去的已经是丢了精度的 double 了。Qdecimal有什么代价吗有一点。decimal按十进制存储和运算比原生的二进制浮点运算慢一些占用空间也略大。但对金额来说正确性远比这点性能重要这个代价必须付。Q为什么float单个值显示又是对的因为显示时做了四舍五入把那串近似值修饰回了你期望的样子。误差一直都在只是被藏起来了直到SUM累加或精确比较时才暴露。总结金额不能用float/double不是什么玄学规范而是二进制浮点的硬伤float/double遵循 IEEE 754用有限位的二进制表示小数而0.1这类十进制小数在二进制里是无限循环的只能截断——于是产生精度丢失。单个值看不出来显示四舍五入了但SUM累加会放大误差、精确比较会失败对账、支付这类场景一分钱都不能错。正解是decimal十进制精确存储或BIGINT存分整数运算精确。一句话记忆float是给科学计算用的能容忍误差金额一分都不能错只能用decimal或整数存分。这也是阿里手册把它列为【强制】的原因——因为这个坑踩一次就是线上资损事故。