
订单明细相加差一分钱、退款金额与原支付对不上、同一张账单在两个服务里得到不同结果,通常不是算术能力出了问题,而是系统没有约定金额的表示方式、舍入时机和余数归属。金额计算要追求的不是“看起来差不多”,而是每一步都可复现、可解释。
浮点数为什么不适合结算
大多数语言的 float 或 double 使用二进制浮点数,很多有限十进制小数无法被它精确表示。经典现象只是问题最小的外观:
1 | console.log(0.1 + 0.2) // 0.30000000000000004 |
误差不一定会直接显示出来,却会在乘税率、汇总和多次舍入后越过最小货币单位。给结果加一个很小的 epsilon 只能掩盖个别输入,不能形成业务规则。
常见表示方式各有边界:
| 表示方式 | 优点 | 主要风险 | 适用场景 |
|---|---|---|---|
| 二进制浮点 | 运算快、生态普遍 | 十进制金额不可精确表示 | 统计估算、非结算展示 |
| 最小单位整数 | 精确、易比较 | 比例计算仍会产生余数 | 支付金额、账户余额 |
| 十进制定点数 | 保留十进制语义 | 必须显式约定精度与舍入 | 税费、折扣、汇率计算 |
金额不只是一个数字
领域模型至少应包含数值和币种;最小单位整数还要让单位在字段名或类型中可见,例如 amount_minor,不要让调用方猜它是元还是分。币种也不应被写死为“两位小数”,应由业务支持的币种元数据决定精度。
跨服务传输可以选择十进制字符串,或“最小单位整数 + 币种”的结构。数据库使用 DECIMAL/NUMERIC 或整数列,避免经过浮点列再转回来:
1 | { |
整数范围、允许的小数位、正负号含义和溢出策略都应进入 API 契约。不要让序列化器把十进制对象悄悄转成 JSON 浮点数。
把舍入写成业务规则
“保留两位小数”仍不完整。系统还要明确舍入模式、发生阶段,以及负数如何处理。Python 的 Decimal 应从字符串构造,并在结算边界显式量化:
1 | from decimal import Decimal, ROUND_HALF_UP |
不要写 Decimal(0.1),因为浮点误差已经在构造前产生。更重要的是统一舍入时机:三个明细各为 0.335,逐行四舍五入后合计为 1.02,先汇总再舍入则是 1.01。两种结果都可能符合某种规则,但不能由不同服务自行选择。

分摊时守住总额不变量
满减、优惠券和平台补贴常需要按比例分摊到明细。若每行独立四舍五入,分摊之和可能不等于订单优惠总额。可靠做法是先计算高精度份额,向下取整到最小单位,再把剩余单位按“小数余数从大到小”的稳定顺序逐个补齐;余数相同时用明细 ID 打破平局。
分摊算法必须保持下面的不变量:
1 | sum(allocated_items) == discount_total |
退款应读取支付时保存的原始分摊结果,而不是用现价、现税率或新版规则重新计算。否则规则升级后,同一订单会失去可逆性。
用测试和审计保证可复现
普通示例之外,还应覆盖零金额、负数、最大值、半单位边界、大数量、余数并列和跨币种拒绝等情况。属性测试尤其适合验证“分摊总和守恒”“折扣后不为负”“重复执行结果一致”等不变量。
账务日志不要只记最终金额,还应保存输入金额、币种、费率、舍入模式、舍入前结果、分摊明细和规则版本。对账出现一分钱差异时,这些数据能回答差异在哪一步产生,而不是靠工程师反推历史代码。
金额系统的核心约束可以压缩为四句话:浮点数不进入结算链路;单位与币种进入类型和契约;舍入模式与时机集中定义;每次分摊守恒且结果可追溯。做到这些,一分钱就不再是随机误差,而是能够解释和验证的业务结果。