如何设计 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 排序。这样即使多笔流水使用相同的时间戳,也能得到稳定的结果。
写入流程
每个余额事件可以按下面的流程处理:
- 接收收费、付款或退款事件。
- 验证账户、币种、金额和事件类型。
- 使用
external_ref或 idempotency key 检查事件是否已经处理。 - 在数据库事务中插入一条或多条账本流水。
- 事务提交后,再发送通知或执行其他非关键处理。
- 查询余额时,根据已入账流水计算,或者从快照开始增量计算。
示例伪代码:
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,可以分三步完成:
- 查找
snapshot_at <= start_at的最近快照; - 读取快照中的
balance_minor; - 累加快照之后、
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,方便以后扩展为复式账本。
基本原则是:事件写入不可变账本,余额由流水推导。当前余额和周期快照只用于加快查询,所有缓存都必须能够从账本重新构建。
备注:内容仅供参考。
