)
前阵子对账一笔订单 3 件、单价 19.99脚本算出来的应收是 59.96999999999999531041794398。写这段代码的人其实已经用了 Decimal问题出在Decimal(19.99)少了一对引号。金额计算换成 Decimal 只是第一步后面还有舍入、精度、序列化、入库一串坑。下面八条都在 Python 3.14.6 上跑过输出贴在代码下面。1. float 的问题不只是 0.1 0.2print(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), roundingROUND_HALF_UP))2 4 0.12 2.67 2.68 0.12 0.13几个现象要分开看round(2.5)是 2round(3.5)是 4Python 的round用的是四舍六入五成双ROUND_HALF_EVEN恰好是 5 的时候向偶数靠。round(2.675, 2)是 2.67这次不是舍入规则的问题是 2.675 这个 float 实际存的是 2.67499999...Decimal 的quantize默认也是 HALF_EVEN所以0.125变成0.12。财务和电商场景大多要的是我们小学学的四舍五入必须显式传roundingROUND_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.234567890123456789012345679E2828 是总的有效数字位数。整数部分已经 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 不认识 Decimaljson.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_floatDecimal)) 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_floatDecimal可以让它直接构造 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是 FalseDecimal(1.5) 1.5是 True。Decimal 和 float 比较时按精确值比1.5 在二进制里能精确表示1.1 不能。代码里混着比结果取决于具体数值等于埋雷。8. 入库sqlite3 直接拒绝NUMERIC 列存回来是 floatcon 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 floatsqlite3 默认不认 Decimal转成字符串存进 NUMERIC 列后SQLite 按亲和性把它存成了 REAL读出来又是 float前面的功夫白做。在 SQLite 里存金额最省心的是整数分。MySQL、PostgreSQL 有真正的 DECIMAL 类型驱动一般会返回 Decimal但也要确认一下 ORM 的字段定义有些框架默认会转成 float。小结坑做法float 算金额不用Decimal(0.1)只从字符串或整数构造round / quantize显式 ROUND_HALF_UPprec28是有效数字小数位用 quantize分摊差一分尾差给指定的一项JSON输出转整数分或字符串输入用 parse_float1.0 vs 1.00对外输出前统一 quantizeSQLite存整数分说个局限这篇只讲了 Python 这一侧。真实系统里金额要经过前端、接口、数据库好几道任何一道转成了 float 都会前功尽弃所以最根本的还是全链路约定一个表示法整数分最不容易出错。我平时在 forxi.cn 做一些在线小工具单位换算、金额计算这类场景绕不开精度问题这些坑基本都是反复碰到之后整理的。