PAYATHON 2026

如何设计 custom billing module 的 algorithm 和 db architecture?

支付小周

结论

系统应采用不可变账本(append-only ledger),不要在每次事件发生时只保存 current_balance。

每笔收费、付款、退款和调账都应记录为独立流水,并遵循统一的正负号约定:

  • credit:增加用户余额,记为正数;
  • debit:减少用户余额,记为负数。

对于区间 [start_at, end_at):

preceding_balance = start_at 之前所有已入账流水之和
period_change      = 区间内所有已入账流水之和
ending_balance     = preceding_balance + period_change

为了加快查询,可以额外保存余额缓存或快照,但最终事实来源仍然是账本流水。这样做便于审计和纠错,也为后续扩展留出了空间。

数据模型

金额不要使用 FLOAT 或 DOUBLE。可以用最小货币单位的整数表示,例如美分,也可以使用固定精度的 DECIMAL。如果使用整数,1000 表示 $10.00。

下面是简化后的 PostgreSQL 表结构:

CREATE TABLE ledger_entry (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    account_id      BIGINT NOT NULL,
    effective_at    TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),

    amount_minor    BIGINT NOT NULL CHECK (amount_minor <> 0),
    currency        CHAR(3) NOT NULL,

    entry_type      VARCHAR(32) NOT NULL,
    status          VARCHAR(16) NOT NULL DEFAULT 'posted',
    description     TEXT,

    external_ref    VARCHAR(128),
    reversal_of_id  BIGINT REFERENCES ledger_entry(id),

    metadata        JSONB NOT NULL DEFAULT '{}'::jsonb,

    UNIQUE (account_id, external_ref)
);

CREATE INDEX ledger_entry_statement_idx
    ON ledger_entry (account_id, currency, effective_at, id)
    WHERE status = 'posted';

amount_minor 的符号约定必须固定。例如:

服务费 $100.00       amount_minor = -10000
用户付款 $500.00     amount_minor =  50000
退款给用户 $20.00    amount_minor = -2000
费用减免 $15.00      amount_minor =  1500

entry_type 应使用含义明确的值,例如:

service_charge
payment
refund
adjustment
opening_balance
reversal

账户或余额的维度中必须包含 currency。不同币种不能直接相加。如果系统同时支持 USD 和 EUR,应分别计算余额,除非业务已经明确规定汇率及换汇记账方式。

余额计算

假设要查询某个账户的 USD statement,时间范围是 [start_at, end_at)。

采用半开区间,可以避免相邻 statement 在边界处重复计入同一笔流水:

-- preceding balance
SELECT COALESCE(SUM(amount_minor), 0) AS preceding_balance
FROM ledger_entry
WHERE account_id = $1
  AND currency = $2
  AND status = 'posted'
  AND effective_at < $3;

查询区间内的流水:

SELECT
    id,
    effective_at,
    entry_type,
    description,
    amount_minor
FROM ledger_entry
WHERE account_id = $1
  AND currency = $2
  AND status = 'posted'
  AND effective_at >= $3
  AND effective_at < $4
ORDER BY effective_at, id;

计算区间变动和期末余额:

SELECT
    COALESCE(SUM(amount_minor) FILTER (
        WHERE effective_at < $3
    ), 0) AS preceding_balance,

    COALESCE(SUM(amount_minor) FILTER (
        WHERE effective_at >= $3 AND effective_at < $4
    ), 0) AS period_change,

    COALESCE(SUM(amount_minor) FILTER (
        WHERE effective_at < $4
    ), 0) AS ending_balance
FROM ledger_entry
WHERE account_id = $1
  AND currency = $2
  AND status = 'posted'
  AND effective_at < $4;

如果需要显示每笔流水处理后的运行余额,可以使用窗口函数:

WITH opening AS (
    SELECT COALESCE(SUM(amount_minor), 0) AS amount
    FROM ledger_entry
    WHERE account_id = $1
      AND currency = $2
      AND status = 'posted'
      AND effective_at < $3
),
period_entries AS (
    SELECT
        id,
        effective_at,
        entry_type,
        description,
        amount_minor
    FROM ledger_entry
    WHERE account_id = $1
      AND currency = $2
      AND status = 'posted'
      AND effective_at >= $3
      AND effective_at < $4
)
SELECT
    p.*,
    o.amount
      + SUM(p.amount_minor) OVER (
            ORDER BY p.effective_at, p.id
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS running_balance
FROM period_entries p
CROSS JOIN opening o
ORDER BY p.effective_at, p.id;

查询同时按 effective_at 和 id 排序。这样即使多笔流水使用相同的时间戳,也能得到稳定的结果。

写入流程

每个余额事件可以按下面的流程处理:

  1. 接收收费、付款或退款事件。
  2. 验证账户、币种、金额和事件类型。
  3. 使用 external_ref 或 idempotency key 检查事件是否已经处理。
  4. 在数据库事务中插入一条或多条账本流水。
  5. 事务提交后,再发送通知或执行其他非关键处理。
  6. 查询余额时,根据已入账流水计算,或者从快照开始增量计算。

示例伪代码:

async function postEntry(input: {
  accountId: bigint;
  currency: string;
  amountMinor: bigint;
  entryType: string;
  effectiveAt: Date;
  externalRef: string;
}) {
  return db.transaction(async (tx) => {
    const existing = await tx.query(
      `SELECT id
         FROM ledger_entry
        WHERE account_id = $1
          AND external_ref = $2`,
      [input.accountId, input.externalRef]
    );

    if (existing.rowCount > 0) {
      return existing.rows[0];
    }

    return tx.query(
      `INSERT INTO ledger_entry (
          account_id,
          currency,
          amount_minor,
          entry_type,
          effective_at,
          external_ref,
          status
       ) VALUES ($1, $2, $3, $4, $5, $6, 'posted')
       RETURNING *`,
      [
        input.accountId,
        input.currency,
        input.amountMinor,
        input.entryType,
        input.effectiveAt,
        input.externalRef
      ]
    );
  });
}

唯一约束必不可少。应用层的“先查再插入”无法单独阻止并发请求造成重复记账。生产环境通常还要捕获唯一约束冲突,并将其视为幂等成功。

是否应在每次事件中保存 current balance

可以保存,但不能把它作为唯一依据。

如果每条流水都保存 balance_after,需要处理这些问题:

  • 两笔并发事件可能读到同一个旧余额;
  • 补录历史流水后,后续所有 balance_after 都会失效;
  • 修改或删除旧流水会破坏余额链;
  • 失败重试可能造成重复记账;
  • 很难确认某个余额具体由哪些事件组成。

如果确实需要在写入时维护当前余额,可以建立单独的汇总表:

CREATE TABLE account_balance (
    account_id       BIGINT NOT NULL,
    currency         CHAR(3) NOT NULL,
    balance_minor    BIGINT NOT NULL DEFAULT 0,
    updated_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (account_id, currency)
);

在插入账本流水的同一事务中进行原子更新:

INSERT INTO account_balance (
    account_id,
    currency,
    balance_minor
)
VALUES ($1, $2, $3)
ON CONFLICT (account_id, currency)
DO UPDATE SET
    balance_minor = account_balance.balance_minor
                  + EXCLUDED.balance_minor,
    updated_at = now();

这张表可以快速显示“当前余额”,但其中的数据必须能通过账本重新计算和校验:

SELECT account_id, currency, SUM(amount_minor)
FROM ledger_entry
WHERE status = 'posted'
GROUP BY account_id, currency;

大数据量下的快照方案

单个账户的流水很多时,每次都从账户创建时间开始求和,查询会越来越慢。此时可以定期保存余额快照:

CREATE TABLE balance_snapshot (
    account_id       BIGINT NOT NULL,
    currency         CHAR(3) NOT NULL,
    snapshot_at      TIMESTAMPTZ NOT NULL,
    balance_minor    BIGINT NOT NULL,
    last_entry_id    BIGINT NOT NULL,
    PRIMARY KEY (account_id, currency, snapshot_at)
);

计算 start_at 时的 preceding balance,可以分三步完成:

  1. 查找 snapshot_at <= start_at 的最近快照;
  2. 读取快照中的 balance_minor;
  3. 累加快照之后、start_at 之前的账本流水。
preceding_balance
    = snapshot.balance_minor
    + SUM(entries after snapshot and before start_at)

快照是可以重建的派生数据,不能取代原始账本。系统还需要明确快照究竟包含了哪些流水。最好记录 last_entry_id,以免时间边界的含义不清楚。

纠错与撤销

已经入账的流水原则上不应直接修改或删除。如果发生错误,应新增一笔反向流水:

原服务费:-10000
撤销流水:+10000

然后根据需要写入正确的流水。这样可以保留完整的审计轨迹。reversal_of_id 可以指向被撤销的原始记录,并通过业务约束防止同一笔流水被重复撤销。

pending、posted、failed 等状态也要有明确的含义。通常只有 posted 参与可用余额和 statement 的计算。如果还要展示待处理金额,应分别计算:

posted_balance
pending_debits
pending_credits
available_balance

不要把这些口径混在一个含义不清的 balance 中。

需要提前确定的规则

实现前需要明确以下业务规则:

  • statement 使用哪个时区,边界时间如何解释;
  • effective_at 表示业务生效时间还是系统写入时间;
  • 是否允许补录历史流水;
  • 退款、拒付、冲正和费用减免分别如何记账;
  • pending 交易是否影响余额;
  • 多币种是否分别维护;
  • statement 生成期间出现新流水时,是否需要一致性快照;
  • 正数表示用户拥有资金,还是表示用户欠款。

如果系统对财务准确性要求较高,或者涉及资金清结算,可以进一步采用复式记账。每个业务交易同时写入至少两个账户,同一交易的所有分录合计必须为零。即使目前采用单账户流水,也建议保留 transaction_id 或 journal_id,方便以后扩展为复式账本。

基本原则是:事件写入不可变账本,余额由流水推导。当前余额和周期快照只用于加快查询,所有缓存都必须能够从账本重新构建。

备注:内容仅供参考。