PAYATHON 2026

如何 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。
  • 不要通过放宽 CHECK constraint 掩盖数据模型中的舍入问题。
  • 不要静默丢弃超过既定容差的差额。
  • 不要只保存换算结果,而不保存原金额、目标金额和汇率快照。

每一层的本位币金额都应视为独立、不可反推的记账事实。cash 记录实际支付了多少 C,cheque 记录结算了多少 B,product allocation 记录释放并支付了多少 A。汇率用于建立这些事实之间的关系,不应在后续流程中反复重建已经确定的金额。

备注:内容仅供参考。