前阵子对账,一笔订单 3 件、单价 19.99,脚本算出来的应收是 59.96999999999999531041794398。写这段代码的人其实已经"用了 Decimal",问题出在Decimal(19.99)少了一对引号。
金额计算换成 Decimal 只是第一步,后面还有舍入、精度、序列化、入库一串坑。下面八条都在 Python 3.14.6 上跑过,输出贴在代码下面。
1. float 的问题不只是 0.1 + 0.2
print(0.1 + 0.2, 0.1 + 0.2 == 0.3) print(sum([0.1] * 10), 19.99 * 3)0.30000000000000004 False 1.0 59.97第二行可能和你的预期不一样:sum([0.1] * 10)在新版本里是1.0,因为 Python 3.12 起sum()对浮点数做了补偿求和,误差被压下去了;19.99 * 3打印出来也"正好"是 59.97。
这恰恰是 float 难缠的地方:大部分时候看起来是对的,于是测试都能过,直到某个组合刚好暴露出来。金额不要用 float,这条没有例外。
2. Decimal(19.99) 和 Decimal("19.99") 是两个数
print(Decimal(0.1)) print(Decimal("0.1")) print(Decimal(19.99) * 3, Decimal("19.99") * 3)0.1000000000000000055511151231257827021181583404541015625 0.1 59.96999999999999531041794398 59.97Decimal(0.1)会把那个 float 的二进制误差原封不动地变成十进制,精确到 55 位小数。开头那个对账问题就是这么来的。
规则:Decimal 只从字符串或整数构造。数据从 JSON、数据库里出来已经是 float 的话,先str()一下再转,不过这只是补救,更好的做法是源头就别让它变成 float(见第 6、8 条)。
3. round() 和 quantize() 默认都是"银行家舍入"
print(round(2.5), round(3.5), round(0.125, 2), round(2.675, 2)) print(Decimal("2.675").quantize(Decimal("0.01"))) print(Decimal("0.125").quantize(Decimal("0.01")), Decimal("0.125").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))2 4 0.12 2.67 2.68 0.12 0.13几个现象要分开看:
round(2.5)是 2,round(3.5)是 4:Python 的round用的是"四舍六入五成双"(ROUND_HALF_EVEN),恰好是 5 的时候向偶数靠。round(2.675, 2)是 2.67:这次不是舍入规则的问题,是 2.675 这个 float 实际存的是 2.67499999...- Decimal 的
quantize默认也是 HALF_EVEN,所以0.125变成0.12。
财务和电商场景大多要的是我们小学学的四舍五入,必须显式传rounding=ROUND_HALF_UP。我见过最隐蔽的一个 bug,就是某处quantize忘了传这个参数,只有在千分位恰好是 5、而且百分位是偶数时才会差一分钱。
4. 默认精度 28 位,是有效数字不是小数位
print(getcontext().prec) print(Decimal(1) / Decimal(3)) big = Decimal("12345678901234567890123456789.01") print(big + Decimal("0.01"))28 0.3333333333333333333333333333 1.234567890123456789012345679E+2828 是总的有效数字位数。整数部分已经 29 位,加一分钱之后结果被舍入到 28 位有效数字,小数部分整个没了,还变成了科学计数法。
日常金额到不了这个量级,但要注意两种情况:一是汇率、利率这种多位小数连乘,中间结果的位数会涨得很快;二是有人在某个地方getcontext().prec = 6想"控制小数位",结果是全局生效、而且控制的是有效数字。控制小数位请用quantize,需要改精度就用localcontext()限定范围。
5. 分摊:先舍入再求和,和先求和再舍入,差一分钱
q = Decimal("0.01") items = [Decimal("33.333"), Decimal("33.333"), Decimal("33.334")] print(sum(x.quantize(q, ROUND_HALF_UP) for x in items), sum(items).quantize(q, ROUND_HALF_UP)) total, n = Decimal("100.00"), 3 each = (total / n).quantize(q, ROUND_HALF_UP) print("平均分", each, each * n, "差", total - each * n) parts = [each] * (n - 1) + [total - each * (n - 1)] print("尾差给最后一个", parts, sum(parts))99.99 100.00 平均分 33.33 99.99 差 0.01 尾差给最后一个 [Decimal('33.33'), Decimal('33.33'), Decimal('33.34')] 100.00100 块分给 3 个人,每人 33.33,加起来 99.99,凭空少了一分。优惠券分摊到商品、运费分摊到子订单都会碰到。
常见做法是"前 n-1 个按比例算并舍入,最后一个用总额减",保证加总严格等于原值。哪个子项承担尾差要提前和业务说好,比如给金额最大的那项,否则退款时又会对不上。
6. json 不认识 Decimal
json.dumps({"amount": Decimal("19.99")}) # TypeError: Object of type Decimal is not JSON serializable print(json.dumps({"amount": str(Decimal("19.99"))})) print(json.loads('{"amount": 19.99}', parse_float=Decimal)) print(json.loads('{"amount": 0.1}')["amount"] + 0.2){"amount": "19.99"} {'amount': Decimal('19.99')} 0.30000000000000004输出方向:要么转成字符串,要么转成"分"为单位的整数。我更推荐后者,前端 JavaScript 拿到"19.99"字符串后很可能又parseFloat回去了,整数分就没有这个问题。
输入方向:json.loads默认把小数解析成 float,误差在解析那一刻就产生了。传parse_float=Decimal可以让它直接构造 Decimal(内部用的是原始字符串,不经过 float)。
7. 1.0 和 1.00 相等,但打印出来不一样
print(Decimal("1.0") == Decimal("1.00"), str(Decimal("1.0")), str(Decimal("1.00"))) print(len({Decimal("1.0"), Decimal("1.00")})) print(Decimal("1.10") == 1.1, Decimal("1.5") == 1.5)True 1.0 1.00 1 False TrueDecimal 会保留尾随的 0,比较时相等、放进集合也算同一个,但str()不同。如果你拿字符串去做签名、做缓存 key、或者和第三方对账,1.0和1.00就是两个值。对外输出前统一quantize(Decimal("0.01"))。
第三行更值得注意:Decimal("1.10") == 1.1是 False,Decimal("1.5") == 1.5是 True。Decimal 和 float 比较时按精确值比,1.5 在二进制里能精确表示,1.1 不能。代码里混着比,结果取决于具体数值,等于埋雷。
8. 入库:sqlite3 直接拒绝,NUMERIC 列存回来是 float
con = sqlite3.connect(":memory:") con.execute("create table t(amount numeric)") con.execute("insert into t values (?)", (Decimal("19.99"),)) # ProgrammingError: Error binding parameter 1: type 'decimal.Decimal' is not supported con.execute("insert into t values (?)", (str(Decimal("19.99")),)) row = con.execute("select amount, typeof(amount) from t").fetchone() print(row, type(row[0]))(19.99, 'real') <class 'float'>sqlite3 默认不认 Decimal;转成字符串存进 NUMERIC 列后,SQLite 按亲和性把它存成了 REAL,读出来又是 float,前面的功夫白做。
在 SQLite 里存金额,最省心的是整数分。MySQL、PostgreSQL 有真正的 DECIMAL 类型,驱动一般会返回 Decimal,但也要确认一下 ORM 的字段定义,有些框架默认会转成 float。
小结
| 坑 | 做法 |
|---|---|
| float 算金额 | 不用 |
| Decimal(0.1) | 只从字符串或整数构造 |
| round / quantize | 显式 ROUND_HALF_UP |
| prec=28 | 是有效数字,小数位用 quantize |
| 分摊差一分 | 尾差给指定的一项 |
| JSON | 输出转整数分或字符串,输入用 parse_float |
| 1.0 vs 1.00 | 对外输出前统一 quantize |
| SQLite | 存整数分 |
说个局限:这篇只讲了 Python 这一侧。真实系统里金额要经过前端、接口、数据库好几道,任何一道转成了 float 都会前功尽弃,所以最根本的还是全链路约定一个表示法(整数分最不容易出错)。
我平时在 forxi.cn 做一些在线小工具,单位换算、金额计算这类场景绕不开精度问题,这些坑基本都是反复碰到之后整理的。