如何 system-design 多币种 payment 并解决舍入误差
结论
不要根据已经舍入的下游金额反算 product 的入账金额。
在这个流程中,product 的结算单位是 A,cheque 的结算单位是 B,cash 的结算单位是 C。每次创建 payment 或 payment allocation 时,都应同时保存:
- 源对象本位币金额;
- 目标对象本位币金额;
- 当时使用的汇率;
- 汇率方向;
- 舍入规则;
- 必要时的舍入差额。
创建 cheque 时,既然已经确认它对应 product 的 4000.00 A,那么 cheque 全部结清后,product 增加的仍应是 allocation 中保存的 4000.00 A,不能用舍入后的 1066.67 B 乘以 3.75,得到 4000.01 A。
4000.01 不是新的经济事实,而是反向换算 1066.67 B 时产生的舍入残差。
问题产生的原因
货币金额通常只能保留有限的小数位。假设 A 和 B 都保留两位小数:
4000.00 A / 3.75 = 1066.666666... B
保存后是:
1066.67 B
这一步已经发生舍入。再反向换算:
1066.67 B * 3.75 = 4000.0125 A
保留两位小数后就是:
4000.01 A
经过两次量化后,正向换算和反向换算通常无法严格互逆:
round(round(x / rate, 2) * rate, 2) != x
如果流程继续经过 A → B → C → B → A,每一层都重新换算并舍入,误差还可能逐层累积。提高计算精度可以减少中间误差,但无法让已经舍入到货币最小单位的金额恢复可逆性。
推荐的数据模型
需要将“支付工具的金额”和“它结算了多少目标债务”分别保存。
可以把 product、payment 和 allocation 分为三类记录:
CREATE TABLE product (
id BIGINT PRIMARY KEY,
currency CHAR(3) NOT NULL,
amount DECIMAL(20, 2) NOT NULL,
paid_amount DECIMAL(20, 2) NOT NULL DEFAULT 0,
escrow_amount DECIMAL(20, 2) NOT NULL DEFAULT 0,
CHECK (amount >= 0),
CHECK (paid_amount >= 0 AND paid_amount <= amount),
CHECK (escrow_amount >= 0 AND escrow_amount <= amount)
);
CREATE TABLE payment (
id BIGINT PRIMARY KEY,
payment_type VARCHAR(20) NOT NULL,
currency CHAR(3) NOT NULL,
amount DECIMAL(20, 2) NOT NULL,
paid_amount DECIMAL(20, 2) NOT NULL DEFAULT 0,
CHECK (amount >= 0),
CHECK (paid_amount >= 0 AND paid_amount <= amount)
);
再通过 allocation 明确记录一笔 payment 对目标对象产生的价值:
CREATE TABLE payment_allocation (
id BIGINT PRIMARY KEY,
source_payment_id BIGINT NOT NULL,
target_type VARCHAR(20) NOT NULL,
target_id BIGINT NOT NULL,
source_currency CHAR(3) NOT NULL,
source_amount DECIMAL(20, 2) NOT NULL,
target_currency CHAR(3) NOT NULL,
target_amount DECIMAL(20, 2) NOT NULL,
exchange_rate DECIMAL(30, 15) NOT NULL,
rate_direction VARCHAR(30) NOT NULL,
rounding_mode VARCHAR(20) NOT NULL,
CHECK (source_amount >= 0),
CHECK (target_amount >= 0),
CHECK (exchange_rate > 0)
);
生产系统还应根据业务需要增加状态、撤销记录、记账时间、汇率来源和幂等键。target_type + target_id 只是一种示意。如果数据库需要严格保证外键完整性,可以将 product allocation 和 payment allocation 分表保存。
这组交易应如何入账
创建 cheque 时,用户声明要 escrow product 的全部余额:
Product allocation: 4000.00 A
Cheque face amount: 1066.67 B
allocation 应保存两个金额:
source_amount = 1066.67 B
target_amount = 4000.00 A
product 更新为:
paid_amount = 0.00 A
escrow_amount = 4000.00 A
cash 支付 cheque 时:
Cash amount settled: 996.89 C
Cheque amount settled: 1066.67 B
如果这笔 cash 明确结清了整个 cheque,那么 cheque 对应的 product allocation 也应全部结清。此时,product 应使用原 allocation 的 target_amount 更新:
product.paid_amount += 4000.00 A
product.escrow_amount -= 4000.00 A
不能这样计算:
product.paid_amount += round(1066.67 * 3.75, 2)
最终结果应为:
Product:
amount = 4000.00 A
paid_amount = 4000.00 A
escrow_amount = 0.00 A
Cheque:
amount = 1066.67 B
paid_amount = 1066.67 B
996.89 C 是否恰好对应 1066.67 B,取决于系统如何定义汇率方向。“B = 1.07 C”这类表达容易引起歧义,接口中应明确记录为以下形式之一:
1 B = 0.934579439252... C
或:
1 C = 1.07 B
数据库和 API 不应只保存没有方向含义的 rate = 1.07。
部分支付的处理
全部结清时,可以直接使用 allocation 剩余的目标金额。部分支付需要按原 allocation 的比例分摊,最后一笔则必须吸收余数。
例如,cheque 总额为:
1066.67 B
它对应:
4000.00 A
某次支付 400.00 B 时,可以这样计算:
target_part =
round(400.00 / 1066.67 * 4000.00, 2)
但不能让每笔分摊都独立换汇后再无限累加。应使用累计值,或者规定最后一笔直接取剩余金额:
if source_payment == cheque.remaining_amount:
product_part = allocation.remaining_target_amount
else:
product_part = round(
source_payment
* allocation.target_amount
/ allocation.source_amount,
product_currency_scale
)
更新时还要同步维护剩余量:
cheque.remaining_amount =
cheque.amount - cheque.paid_amount
allocation.remaining_target_amount =
allocation.target_amount - allocation.settled_target_amount
这样,无论之前发生过多少次舍入,最后一笔都能精确归零,不会产生 4000.01 A,也不会残留 0.01 A。
更稳妥的做法是先计算“截至当前累计应结算的金额”,再减去此前已经结算的金额:
new_source_total = old_source_total + source_payment
if new_source_total == allocation.source_amount:
new_target_total = allocation.target_amount
else:
new_target_total = round(
new_source_total
* allocation.target_amount
/ allocation.source_amount,
target_scale
)
target_increment = new_target_total - old_target_total
与逐笔独立舍入相比,累计舍入通常更稳定。
金额和汇率的精度
货币金额应使用定点十进制或最小货币单位保存。最终财务计算不能使用 float、double 或 JavaScript Number。
常见选择包括:
DECIMAL / NUMERIC
BigDecimal
Decimal
整数 minor units
如果所有货币都固定保留两位小数,可以保存最小单位:
4000.00 A -> 400000
1066.67 B -> 106667
但并非所有货币都保留两位小数,最好为每种货币单独配置 scale。有些货币是 0 位,另一些业务资产可能需要 3 位、8 位或更多,因此不能在所有地方写死 2。
汇率通常需要比金额保留更多小数位,例如:
exchange_rate DECIMAL(30, 15)
具体精度应根据系统支持的货币范围、最大交易金额和会计要求确定。即使汇率保留了很多位,最终入账金额仍须按照目标货币的精度舍入。
系统还必须统一舍入模式。常见规则包括 HALF_UP 和 HALF_EVEN,具体选择应遵循适用的会计、税务和支付渠道规则,不能依赖编程语言或数据库的隐式默认行为。
以 Java 为例:
BigDecimal targetAmount = new BigDecimal("4000.00");
BigDecimal rate = new BigDecimal("3.75");
BigDecimal chequeAmount = targetAmount.divide(
rate,
2,
RoundingMode.HALF_UP
);
// 1066.67 B
后续结清 product 时,直接读取 allocation:
BigDecimal productIncrement =
allocation.getRemainingTargetAmount(); // 4000.00 A
不要再次执行反向换算:
// 不推荐:对已经舍入的金额进行反向换算
BigDecimal productIncrement = chequeAmount
.multiply(rate)
.setScale(2, RoundingMode.HALF_UP);
更新约束和事务处理
不要为了绕过 CHECK constraint,随意增加误差容忍值,例如:
paid_amount <= amount + 0.01
这样会使真实的超额支付也能通过校验。正确的处理方式是在写入数据库前,根据目标对象剩余的本位币金额限制本次 allocation:
product_increment =
min(calculated_increment, product.remaining_amount)
不过,min() 只能用于执行已经定义好的末笔舍入规则,不能静默吞掉任意业务差异。如果差异超过允许的一个最小货币单位或配置阈值,应拒绝交易并转交人工处理。
对 product、cheque、cash 和 allocation 的更新必须在同一个数据库事务中完成,并锁定相关余额,避免并发支付读取到相同的 remaining amount:
BEGIN;
SELECT *
FROM product
WHERE id = :product_id
FOR UPDATE;
SELECT *
FROM payment
WHERE id = :cheque_id
FOR UPDATE;
-- 校验剩余金额
-- 插入 cash payment 和 allocation
-- 更新 cheque paid_amount
-- 更新 product paid_amount、escrow_amount
COMMIT;
如果业务规定 paid 和 escrow 不能同时覆盖 product 的同一部分,仅设置两个独立约束还不够:
paid_amount <= amount
escrow_amount <= amount
可能还需要:
CHECK (paid_amount + escrow_amount <= amount)
是否增加这个联合约束,取决于状态更新顺序和具体业务含义。cheque 兑现时,应在同一事务中等额减少 escrow、增加 paid。如果 escrow 和 paid 分别表示不同维度,则不应盲目增加该约束。
client 应如何接收输入
client 应让用户输入其真正决定的金额,不应要求用户手动配平多个换算结果。
创建 product cheque 时,可以让用户输入:
用于 product 的金额:4000.00 A
汇率:1 B = 3.75 A
系统可以展示换算结果,但不应让用户承担反算责任:
Cheque amount: 1066.67 B
Applied to product: 4000.00 A
Rounding difference: 0.0125 A
使用 cash 支付 cheque 时,可以让用户输入以下任意一项:
- 要结算的 cheque 金额,例如
1066.67 B; - 实际收付的 cash 金额,例如
996.89 C; - 是否“全部结清”。
如果用户选择“全部结清”,系统应将各层 remaining amount 直接清零,并由最后一笔 allocation 吸收合法的舍入余数。界面需要同时显示:
Cash paid
Cheque settled
Product value released from escrow
Exchange rate and rate direction
Rounding adjustment, if any
如果 cash 金额来自银行或收据,应以实际发生的 C 金额为准。它与理论换算金额之间的差额不能通过改写 product 金额来消除,而应按照会计政策计入 exchange gain/loss、rounding adjustment 或手续费科目。
需要避免的做法
- 不要从
C换算回B,再从B换算回A来更新 product。 - 不要使用二进制浮点数保存或累计货币金额。
- 不要假设所有货币都保留两位小数。
- 不要使用方向不明确的 exchange rate。
- 不要每次都按当前汇率重新计算历史 allocation。
- 不要通过放宽
CHECKconstraint 掩盖数据模型中的舍入问题。 - 不要静默丢弃超过既定容差的差额。
- 不要只保存换算结果,而不保存原金额、目标金额和汇率快照。
每一层的本位币金额都应视为独立、不可反推的记账事实。cash 记录实际支付了多少 C,cheque 记录结算了多少 B,product allocation 记录释放并支付了多少 A。汇率用于建立这些事实之间的关系,不应在后续流程中反复重建已经确定的金额。
备注:内容仅供参考。
