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 保存的是 1234,或者说 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 类型,常见做法有两种:

  1. 金额按最小单位(如分)保存为 int64(最常见)。

  2. 使用第三方高精度十进制库,例如 shopspring/decimal 等,实现十进制定点运算。


什么时候用 DECIMAL,什么时候用整数?

场景 推荐方案 原因
数据库存储金额 DECIMAL(M,D) 或整数最小货币单位 保证精度,不受浮点误差影响
支付、账户余额、转账 整数(如分、厘) 运算简单、性能高、跨系统一致
税率、汇率、折扣率 高精度十进制(如 DECIMALBigDecimal 可能需要较多小数位,避免累计误差
科学计算、图形计算 float / double 更关注性能和近似值,而非十进制精确性

总结

类型 底层表示 是否精确表示十进制 是否适合金额
FLOAT IEEE 754 单精度二进制浮点
DOUBLE IEEE 754 双精度二进制浮点
MySQL DECIMAL 十进制定点数(按十进制分组编码存储)
Java BigDecimal 任意精度整数 + 小数位数(scale)
BIGINT(按分/厘存储) 整数 ✓(互联网支付系统最常见)

可以发现,MySQL DECIMAL、Java BigDecimal 以及"整数存储最小货币单位"三种方案,本质都遵循同一个思想:避免把金额交给二进制浮点数表示,而是用十进制或整数来保存和计算,从根源上消除浮点精度误差。