浮点数精度陷阱与 IEEE 754:银行家舍入 / Python decimal 实战

一、为什么浮点精度是金融系统 P0 风险

2021 年某跨境支付公司因为浮点累计误差,结算日批量给商户打款时少了 0.01 美元/笔,2 万商户总共少打 200美元——这种金额不大,但财务对账时每天都会出现几十笔不平,被审计盯了三个月。真正的灾难是另一个案例:某交易所清结算引擎用 double 计算 USDT 价格,0.1% 误差在千万级订单上放大成 30 万美元的真实亏损。

这不是 bug,是 IEEE 754 双精度浮点数的本质。任何用 float 做金额运算的系统,最终都会撞上 0.01 误差。今天我讲清楚为什么、怎么办。

二、IEEE 754 双精度底层

Python 的 float 是 C 语言的 double,即 IEEE 754 binary64,由三部分组成:

  • 1 位符号
  • 11 位指数(指数偏移1023)
  • 52 位尾数

总共 64 位。关键问题:十进制小数0.1 转二进制是无限循环小数(类似 1/3 = 0.3333…),所以只能近似存储:

1
2
3
4
5
>>>0.1 .hex()
'0x1.999999999999ap-4'
>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
# 真实存储:3602879701896397 / 2**55 = 0.1000000000000000055511151231257827021181583404541015625

所以 0.1 + 0.2 的真相是「两个近似值相加得到另一个近似值」,结果不是精确的 0.3:

1
2
3
4
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False

三、Python float 的极限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import sys
print(sys.float_info)
# sys.float_info(max=1.7976931348623157e+308, max_exp=1024, ...)
# 精度:约 15-17 位有效数字
# epsilon:2.22e-16```

四个常见精度工具对比:

- **float**:原生类型,速度最快,~15 位精度,**不能用于金额**
- **decimal.Decimal**:十进制浮点,可设精度,金融场景首选
- **fractions.Fraction**:精确有理数,速度慢,适合验证算法
- **numpy.float64**:底层还是 IEEE 754,只是批量运算方便

## 四、银行家舍入(IEEE 754 Round half to even)

Python 默认 `round()` 使用银行家舍入,又称"四舍六入五成双"。它和传统"四舍五入"的差别在遇到 5 时:5 前面是奇数就进位(56),前面是偶数就舍去(54)。

```python
# 传统四舍五入 vs银行家舍入
cases = [
(0.5, 0), # 期望 1, round()=0
(1.5, 0), # 期望 2, round()=2
(2.5, 0), # 期望 3, round()=2
(3.5, 0), # 期望 4, round()=4
(4.5, 0), # 期望 5, round()=4
]
for v, n in cases:
print(f"round({v}, {n}) = {round(v, n)}")
# 输出:0, 2, 2, 4, 4
# 注意:2.5 → 2 而非 3,因为 2 是偶数,舍去使结果保持偶数

为什么金融系统偏爱银行家舍入?因为大量数据汇总时,”5 全部进位”会导致系统性偏大,长期下来金额虚高。银行家舍入统计上无偏,5 的半数进半数舍,长期累加期望为零。

五、实战 1:货币运算

金融场景的标准做法是:所有金额用 Decimal 存储,运算前设置上下文精度,输出前 quantize 到 2 位小数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from decimal import Decimal, getcontext, ROUND_HALF_EVEN

# 全局精度:28 位足以覆盖任何货币运算
getcontext().prec = 28

# 金额永远用字符串初始化(Decimal('0.1') 而非 Decimal(0.1))
price = Decimal('19.99')
qty = Decimal('3')
total = price * qty # Decimal('59.97')

# 输出时 quantize 到 2 位小数,使用 ROUND_HALF_EVEN
total_fmt = total.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
print(total_fmt) # 59.97

# 加法
a = Decimal('0.1')
b = Decimal('0.2')
print(a + b) # 0.3 ← 正确!

为什么 Decimal('0.1')正确而 Decimal(0.1) 不正确?因为后者先经过 float 转换,已经被 IEEE 754 污染过。字符串是 Decimal 的唯一安全入口

六、实战 2:阶梯电价分摊

国内阶梯电价把月用电量切成三档,每档单价不同。我见过一个 bug:累计电费时每档单独 round 到分,最后一档舍入误差没补回,导致用户总电费少0.01 元/户,百万用户累计少 1 万元。正确做法:分档计算用 Decimal 但不 round,最后一次汇总时统一 round:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from decimal import Decimal, ROUND_HALF_EVEN

def calc_tiered_electricity(kwh: Decimal) -> Decimal:
"""阶梯电价:0~200 档 0.53 元,200~400 档 0.58 元,400+ 档 0.83 元"""
tiers = [
(200, Decimal('0.53')),
(200, Decimal('0.58')),
(None, Decimal('0.83')),
]
total = Decimal('0')
remaining = kwh
for quota, price in tiers:
if quota is None:
used = remaining
else:
used = min(remaining, quota)
total += used * price
remaining -= used
if remaining <= 0:
break
return total.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)

print(calc_tiered_electricity(Decimal('350'))) # 200*0.53 + 150*0.58 = 193.00
print(calc_tiered_electricity(Decimal('500'))) # 200*0.53 + 200*0.58 + 100*0.83 = 299.00

关键技巧:分档内部用 Decimal 精确运算,只在最终返回前 quantize 一次

七、实战 3:复利计算

金融工程里复利最容易出错,因为小数幂的浮点误差会随期数指数放大。10 万本金、月利率 0.5%、复利 12 个月:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from decimal import Decimal, getcontext
getcontext().prec = 28

principal = Decimal('100000')
rate = Decimal('0.005')
months = 12

# 用 Decimal 精确计算:principal * (1 + rate) ** months
amount = principal * (1 + rate) ** months
print(amount.quantize(Decimal('0.01')))
# 106164.81# 对照 float 版本
amount_f = 100000 * (1 + 0.005) ** 12
print(round(amount_f, 2))
# 106164.81,但中途可能已经偏移

更复杂的”每月定投 + 月复利”:

1
2
3
4
5
6
7
8
9
10
11
def monthly_invest(principal: Decimal, monthly: Decimal,
rate: Decimal, months: int) -> Decimal:
"""每月初定投 monthly 元,月利率 rate,共 months 月"""
amount = principal
for _ in range(months):
amount = (amount + monthly) * (1 + rate)
return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)

print(monthly_invest(Decimal('0'), Decimal('10000'),
Decimal('0.005'), 12))
# 12 万本金 + 复利 = 123,449.18

八、踩坑记录

踩坑 1:JSON 序列化丢失精度

Python json.dumps(Decimal('0.1')) 会抛 TypeError,需要自定义 encoder:

1
2
3
4
5
6
7
8
9
10
11
12
import json
from decimal import Decimal

class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
return str(o)
return super().default(o)

json.dumps({'amount': Decimal('19.99')}, cls=DecimalEncoder)
# '{"amount": "19.99"}'
# 注意:JSON 数字类型本身没有精度保证,前端拿到字符串再解析为 Decimal 是最稳妥的

踩坑 2:ORM 字段类型

SQLAlchemy / Django ORM 默认 Float 映射 Python float。金额字段必须显式声明:

1
2
3
4
# SQLAlchemy 2.0
from sqlalchemy import Numeric
amount = Column(Numeric(precision=18, scale=8)) # 数字 18 位,小数 8 位# Django
amount = models.DecimalField(max_digits=18, decimal_places=8)

踩坑 3:链上 USDT 18 位精度

以太坊 USDT 智能合约用 uint256 表示最小单位(1 USDT = 10^6),不做 18 位小数,但跨链桥接和 DEX 聚合时经常出现 wei/ether 单位混乱。永远用字符串或 Decimal 接收链上金额,乘以 10^decimals 转整数后再做运算。

九、小结:decimal vs float 选型决策树

1
2
3
4
5
是否涉及金钱 / 累计求和?
├─ 是 → Decimal│ └─ 输出前 quantize + ROUND_HALF_EVEN
└─ 否 → 是否需要精确有理数?
├─ 是 → Fraction
└─ 否 → float OK(科学计算、ML、3D 渲染)

关键准则

  1. 金额永远用 Decimal(str(value))绝不用 float初始化
  2. 运算前 getcontext().prec = 28,输出前 quantize(Decimal('0.01'), ROUND_HALF_EVEN)
  3. 中间计算不要 round,只在边界 round
  4. ORM 字段用 Numeric(18, 8) 而非 Float
  5. JSON 序列化自定义 encoder,金额永远是字符串
  6. 跨语言、跨链金额单位转换:先转最小单位整数(wei、satoshi、分),再运算

金融系统不怕复杂逻辑,怕的就是 0.01。把 Decimal 用对,把银行家舍入搞懂,你的支付系统至少能少90% 的对账不平。


浮点数精度陷阱与 IEEE 754:银行家舍入 / Python decimal 实战
https://blog.calcguide.tech/2026-08-10-浮点数精度陷阱与IEEE754/
作者
王争气
发布于
2026年8月10日
许可协议