金额计算别用二进制浮点:定点数、舍入规则与对账边界

账本、硬币与计算流水线组成的金额计算示意图

订单明细相加差一分钱、退款金额与原支付对不上、同一张账单在两个服务里得到不同结果,通常不是算术能力出了问题,而是系统没有约定金额的表示方式、舍入时机和余数归属。金额计算要追求的不是“看起来差不多”,而是每一步都可复现、可解释。

浮点数为什么不适合结算

大多数语言的 floatdouble 使用二进制浮点数,很多有限十进制小数无法被它精确表示。经典现象只是问题最小的外观:

1
2
console.log(0.1 + 0.2)              // 0.30000000000000004
console.log((1.255 * 100).toFixed()) // 125,而非直觉中的 126

误差不一定会直接显示出来,却会在乘税率、汇总和多次舍入后越过最小货币单位。给结果加一个很小的 epsilon 只能掩盖个别输入,不能形成业务规则。

常见表示方式各有边界:

表示方式 优点 主要风险 适用场景
二进制浮点 运算快、生态普遍 十进制金额不可精确表示 统计估算、非结算展示
最小单位整数 精确、易比较 比例计算仍会产生余数 支付金额、账户余额
十进制定点数 保留十进制语义 必须显式约定精度与舍入 税费、折扣、汇率计算

金额不只是一个数字

领域模型至少应包含数值和币种;最小单位整数还要让单位在字段名或类型中可见,例如 amount_minor,不要让调用方猜它是元还是分。币种也不应被写死为“两位小数”,应由业务支持的币种元数据决定精度。

跨服务传输可以选择十进制字符串,或“最小单位整数 + 币种”的结构。数据库使用 DECIMAL/NUMERIC 或整数列,避免经过浮点列再转回来:

1
2
3
4
{
"amountMinor": 1099,
"currency": "CNY"
}

整数范围、允许的小数位、正负号含义和溢出策略都应进入 API 契约。不要让序列化器把十进制对象悄悄转成 JSON 浮点数。

把舍入写成业务规则

“保留两位小数”仍不完整。系统还要明确舍入模式、发生阶段,以及负数如何处理。Python 的 Decimal 应从字符串构造,并在结算边界显式量化:

1
2
3
4
5
6
7
8
from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

def settle(unit_price: str, quantity: int, rate: str) -> Decimal:
subtotal = Decimal(unit_price) * quantity
adjusted = subtotal * Decimal(rate)
return adjusted.quantize(CENT, rounding=ROUND_HALF_UP)

不要写 Decimal(0.1),因为浮点误差已经在构造前产生。更重要的是统一舍入时机:三个明细各为 0.335,逐行四舍五入后合计为 1.02,先汇总再舍入则是 1.01。两种结果都可能符合某种规则,但不能由不同服务自行选择。

金额经过计算、舍入、分摊与对账阶段的示意图

分摊时守住总额不变量

满减、优惠券和平台补贴常需要按比例分摊到明细。若每行独立四舍五入,分摊之和可能不等于订单优惠总额。可靠做法是先计算高精度份额,向下取整到最小单位,再把剩余单位按“小数余数从大到小”的稳定顺序逐个补齐;余数相同时用明细 ID 打破平局。

分摊算法必须保持下面的不变量:

1
2
3
sum(allocated_items) == discount_total
0 <= allocated_item <= eligible_item_amount
same_input_and_rule_version => same_output

退款应读取支付时保存的原始分摊结果,而不是用现价、现税率或新版规则重新计算。否则规则升级后,同一订单会失去可逆性。

用测试和审计保证可复现

普通示例之外,还应覆盖零金额、负数、最大值、半单位边界、大数量、余数并列和跨币种拒绝等情况。属性测试尤其适合验证“分摊总和守恒”“折扣后不为负”“重复执行结果一致”等不变量。

账务日志不要只记最终金额,还应保存输入金额、币种、费率、舍入模式、舍入前结果、分摊明细和规则版本。对账出现一分钱差异时,这些数据能回答差异在哪一步产生,而不是靠工程师反推历史代码。

金额系统的核心约束可以压缩为四句话:浮点数不进入结算链路;单位与币种进入类型和契约;舍入模式与时机集中定义;每次分摊守恒且结果可追溯。做到这些,一分钱就不再是随机误差,而是能够解释和验证的业务结果。