MySQL 金额存储:为什么用 DECIMAL 而不用 float/double
这是互联网系统中一个非常经典且容易踩坑的问题。
很多人知道**“金额不能用 float/double,要用 decimal”,但是不知道为什么**,也不知道 MySQL 的 DECIMAL 到底是怎么做到没有精度误差的,更不知道如果没有 MySQL 的 DECIMAL(例如 Java、Go、Redis、Kafka、内存计算等)应该如何处理。
下面我从底层原理开始详细介绍。
为什么 float/double 不能表示金额
先来看一个最经典的例子:
0.1 + 0.2
理论上应该得到:
0.3
但是很多语言输出的是:
0.30000000000000004
例如:
Java:
System.out.println(0.1 + 0.2);
Go:
fmt.Println(0.1 + 0.2)
Python:
print(0.1 + 0.2)
都会得到类似:
0.30000000000000004
很多人认为:
CPU 算错了。
其实不是,而是:
0.1 根本无法被 IEEE754 二进制浮点数精确表示。
为什么不能表示?
计算机里的 float / double 使用的是:
IEEE754 二进制浮点数
例如 double 类型的 0.1,实际上会被转换成:
0.000110011001100110011...
它是无限循环的,就像十进制里面:
1/3 = 0.333333333333...
一样。
计算机只能截断,于是实际上保存的是:
0.10000000000000000555...
所以:
0.1 + 0.2
实际上变成了:
0.10000000000000000555
+
0.20000000000000001110
最后得到:
0.3000000000000000444...
打印出来就是:
0.30000000000000004
为什么金额不能这样?
假设有一个支付系统,用户余额为:
1000.00
连续扣 0.1 元 10000 次。
理论上:
1000 - 1000 = 0
但是如果使用 double,最后可能得到:
-0.00000000023
或者:
0.000000001
这就是金融系统不能接受的。
所以:
金额绝不能使用 float/double。
MySQL 是如何解决的?
MySQL 提供了:
DECIMAL(M,D)
例如:
price DECIMAL(10,2)
意思是:最多 10 位,其中 2 位小数,例如:
99999999.99
很多人误认为:
DECIMAL 就是一个高精度 float。
其实完全不是。DECIMAL 根本不是浮点数,它是:
十进制定点数(Fixed-Point Decimal)
也就是说:
它保存的是十进制数字本身,而不是二进制浮点近似值。
DECIMAL 的思想
例如 12.34:
-
float保存的是1100.010101011... -
而 DECIMAL 保存的是
1、2、3、4,或者说1234 -
再配合
scale = 2,表示12.34
所以 12.34 永远就是 12.34,不会变成:
12.3400000001
MySQL 底层到底怎么存?
例如:
DECIMAL(10,2) 12345678.90
MySQL 不会存 IEEE754,而是存:
1234567890
然后知道 scale = 2,于是解释为:
12345678.90
本质上就是:
整数 + 小数位数
这就是 Fixed Point。
MySQL 内部编码
MySQL 为了节省空间,不是一个数字一个字节,而是:
9 位十进制数字打包为 4 个字节。
例如 123456789 存成 4 Bytes,而不是 9 Bytes。
为什么是 9?因为:
10^9 = 1000000000
可以放进 32 bit 里面,因为:
2^32 = 4294967296
足够。
所以 MySQL 每次处理 9 位作为一个整数。例如:
123456789012345678
拆成:
123456789 012345678
每组 4 Bytes。这样比字符串快很多。
DECIMAL 如何做加法?
例如:
12.34 + 5.67
MySQL 实际上会变成:
1234 + 567
先统一 scale:
1234 + 567 = 1801
最后得到:
18.01
整个过程都是整数运算,所以不会有误差。
为什么不会丢精度?
因为 1234 就是 1234,整数没有 0.99999999999 这种问题。
所以 0.01 实际上就是 1(单位是 0.01),因此不会产生:
0.0100000000002
如果没有 MySQL 怎么办?
实际上互联网系统大多数时候根本不用 DECIMAL,而是另外一种更普遍的方法:
金额全部转换成最小货币单位整数。
例如人民币,12.34 元 保存为:
1234 分
数据库用 BIGINT:
balance = 1234
所有计算(+、-、*)全部是整数,例如:
1234 + 567 = 1801
最后显示为 18.01。
这种方案比 DECIMAL 更快。
为什么互联网几乎都喜欢整数?
因为:
-
整数计算 CPU 原生支持
-
不会丢精度
-
Redis 支持
-
Kafka 支持
-
JSON 不需要额外转换
-
protobuf 支持
-
跨语言一致
例如支付宝、微信支付等系统,通常都会以"分"作为内部存储单位,而不是直接存"元"。
Java 是怎么做?
Java 提供 BigDecimal,它的思想和 MySQL DECIMAL 非常类似。
例如:
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b));
输出:
0.3
而不是:
0.30000000000000004
需要注意的是,应优先使用字符串构造:
new BigDecimal("0.1")
而不是:
new BigDecimal(0.1) // 不推荐
因为后者会把 double 已经存在的近似值带入 BigDecimal。
Go 怎么做?
Go 没有内置 BigDecimal 类型,常见做法有两种:
-
金额按最小单位(如分)保存为
int64(最常见)。 -
使用第三方高精度十进制库,例如
shopspring/decimal等,实现十进制定点运算。
什么时候用 DECIMAL,什么时候用整数?
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 数据库存储金额 | DECIMAL(M,D) 或整数最小货币单位 |
保证精度,不受浮点误差影响 |
| 支付、账户余额、转账 | 整数(如分、厘) | 运算简单、性能高、跨系统一致 |
| 税率、汇率、折扣率 | 高精度十进制(如 DECIMAL、BigDecimal) |
可能需要较多小数位,避免累计误差 |
| 科学计算、图形计算 | float / double |
更关注性能和近似值,而非十进制精确性 |
总结
| 类型 | 底层表示 | 是否精确表示十进制 | 是否适合金额 |
|---|---|---|---|
FLOAT |
IEEE 754 单精度二进制浮点 | ✗ | ✗ |
DOUBLE |
IEEE 754 双精度二进制浮点 | ✗ | ✗ |
MySQL DECIMAL |
十进制定点数(按十进制分组编码存储) | ✓ | ✓ |
Java BigDecimal |
任意精度整数 + 小数位数(scale) | ✓ | ✓ |
BIGINT(按分/厘存储) |
整数 | ✓ | ✓(互联网支付系统最常见) |
可以发现,MySQL DECIMAL、Java BigDecimal 以及"整数存储最小货币单位"三种方案,本质都遵循同一个思想:避免把金额交给二进制浮点数表示,而是用十进制或整数来保存和计算,从根源上消除浮点精度误差。