如何设计支持多种计费模式的用户账户余额系统
明确结论
不要把所有计费逻辑压缩到一个可正可负的 balance 字段里,也不要在每次请求时扫描全部交易来重新计算余额。
更合适的做法是把系统分成四个职责清楚、彼此关联的部分:
- 使用记录(Usage):记录每次 API call 的实际情况。
- 计价(Rating):根据合同、套餐和价格版本计算费用。
- 账本(Ledger):通过不可变的借贷分录记录资金变化。
- 开票与收款(Billing):按账期汇总应收费用,生成 invoice,并记录付款。
当前余额可以作为账本的缓存或快照,但审计应以账本分录为准。固定价格、预付费和后付费也不能只用一个 tag 区分,而要由版本化的计费规则决定。
对于设计合理的关系型数据库,一百万条 API 调用记录并不是异常规模。需要避免的是把原始调用、计价结果、资金流水和开票状态都塞进一张表,再在每次请求时做全表汇总。
建议的数据模型
API 使用记录
使用记录只描述“发生了什么”,不负责维护账户余额。
CREATE TABLE usage_event (
id BIGINT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL UNIQUE,
account_id BIGINT NOT NULL,
api_operation VARCHAR(100) NOT NULL,
quantity DECIMAL(20, 6) NOT NULL,
occurred_at TIMESTAMP NOT NULL,
received_at TIMESTAMP NOT NULL,
metadata JSON,
status VARCHAR(20) NOT NULL
);
CREATE INDEX idx_usage_account_time
ON usage_event (account_id, occurred_at);
event_id 用于保证幂等。遇到 API 网关重试或消息重复投递时,同一次调用不能被重复计费。
quantity 也不一定总是 1,它还可以表示请求次数、token 数、存储量或传输字节数。
计费规则与价格版本
价格必须有版本。否则价格修改后,历史调用可能会按新价格重新计算。
CREATE TABLE price_rule (
id BIGINT PRIMARY KEY,
plan_id BIGINT NOT NULL,
api_operation VARCHAR(100) NOT NULL,
billing_mode VARCHAR(20) NOT NULL,
unit_price_minor BIGINT,
currency CHAR(3) NOT NULL,
included_quantity DECIMAL(20, 6),
effective_from TIMESTAMP NOT NULL,
effective_to TIMESTAMP,
version INTEGER NOT NULL
);
billing_mode 可以使用受约束的枚举值,例如:
FIXEDPREPAIDPOSTPAIDINCLUDED
不要给 API 设置一个永久不变的计费标签。实际采用哪种计费方式,通常取决于“账户订阅 + API operation + 生效时间 + 使用额度”。例如,某接口每月前 10 万次包含在套餐内,超出部分再按后付费计价。
计价结果
完成计价后,需要保存本次调用采用的价格版本和计算结果。
CREATE TABLE rated_usage (
id BIGINT PRIMARY KEY,
usage_event_id BIGINT NOT NULL UNIQUE,
account_id BIGINT NOT NULL,
price_rule_id BIGINT NOT NULL,
billing_mode VARCHAR(20) NOT NULL,
quantity DECIMAL(20, 6) NOT NULL,
unit_price_minor BIGINT NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
billing_period VARCHAR(20) NOT NULL,
invoice_id BIGINT,
rated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_rated_unbilled
ON rated_usage (account_id, billing_period, invoice_id);
金额最好使用最小货币单位的整数,例如人民币的“分”。不要用二进制浮点数保存货币金额。
如果价格支持非常小的单价,可以在计价阶段使用固定精度十进制数,并统一规定舍入规则。最终入账时,金额仍要转换为最小货币单位。
使用复式账本,不要直接修改余额
账本至少要包含账户、交易和分录。一次业务交易对应多条借贷分录,所有分录的合计必须平衡。
CREATE TABLE ledger_transaction (
id BIGINT PRIMARY KEY,
transaction_key VARCHAR(100) NOT NULL UNIQUE,
transaction_type VARCHAR(30) NOT NULL,
reference_type VARCHAR(30) NOT NULL,
reference_id VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE ledger_entry (
id BIGINT PRIMARY KEY,
transaction_id BIGINT NOT NULL,
ledger_account_id BIGINT NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_entry_account_time
ON ledger_entry (ledger_account_id, created_at);
可以约定 amount_minor > 0 表示增加,amount_minor < 0 表示减少,也可以明确设置 debit、credit 字段。不管采用哪种形式,同一笔账本交易中的分录总和都必须为零。
例如,用户预充值 100 元:
借:支付清算账户 10000
贷:用户预付余额负债 -10000
用户产生 2 元预付费调用:
借:用户预付余额负债 200
贷:API 服务收入 -200
后付费产生 2 元费用:
借:用户应收账款 200
贷:API 服务收入 -200
这样一来,预付余额和后付应收分别归入不同性质的账户,不需要用一个正负余额字段来勉强兼容。用户欠款应记录在应收账款中,而不是简单地让“预付余额”变成负数。
实际的借贷方向要与公司的会计科目定义一致。上面的正负号只是一种系统内部约定。
三种计费模式如何落地
固定价格
固定价格应由订阅或合同系统按周期生成费用,不应为套餐内的每次 API call 单独扣款。
以月费套餐为例,处理流程可以是:
- 订阅进入新的计费周期。
- 生成固定费用或 invoice item。
- 记录该套餐在当前周期包含的 API、额度和限制。
- API call 仍然写入
usage_event,供审计、限流和容量分析使用。 - 套餐内调用的计价结果为零,或标记为
INCLUDED。 - 超出额度的调用再进入预付费或后付费流程。
即使套餐内调用不产生费用,也建议保留使用记录。具体可以根据审计要求采用汇总记录或分层归档。
预付费
预付费调用需要在服务执行前确认可用额度。
常见流程如下:
- 根据账户和 API operation 查找当前有效的价格规则。
- 预估本次调用的最高费用。
- 原子地冻结或扣减可用额度。
- 执行 API 请求。
- 根据实际用量结算,多退少补。
- 写入使用记录、计价结果和账本分录。
如果请求成本在调用结束前无法确定,可以引入 available 和 reserved 两种余额,避免多个并发请求同时通过余额检查。
后付费
后付费调用不需要预先扣除预付余额,处理流程是:
- 记录
usage_event。 - 根据调用发生时有效的价格版本生成
rated_usage。 - 增加用户应收账款,或者先将费用保存为未开票状态。
- billing run 汇总账期内尚未开票的计价结果。
- 创建 invoice,并将这些记录关联到该 invoice。
- 收款成功后冲销应收账款。
开票系统不能只查询“当前余额是多少”。它需要查询指定账期和币种下,尚未开票且符合开票条件的费用明细。
混合计费的处理方式
同一账户可以同时包含多个计费组件,例如:
基础月费:¥500
API A:每月包含 100000 次
API A 超额:¥0.01/次,后付
API B:从预付额度中扣除
API C:固定套餐内不限次
计价引擎可以按以下顺序处理一次调用:
确定账户合同
→ 查找 API operation 对应的计费规则
→ 判断规则的生效时间
→ 消耗套餐包含额度
→ 应用阶梯价、折扣或最低消费
→ 确定预付或后付资金来源
→ 固化价格版本和计价结果
规则优先级必须清晰并且可以测试。不要依赖散落在代码各处的 if/else,也不能让旧规则的修改影响已经发生的调用。
余额查询不能依赖实时全量求和
账本是事实来源,但高频余额检查不能每次都执行:
SELECT SUM(amount_minor)
FROM ledger_entry
WHERE ledger_account_id = ?;
可以维护一张余额投影表:
CREATE TABLE account_balance (
ledger_account_id BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
balance_minor BIGINT NOT NULL,
version BIGINT NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (ledger_account_id, currency)
);
写入账本时,应在同一个数据库事务中完成以下操作:
- 插入
ledger_transaction。 - 插入所有
ledger_entry。 - 检查借贷是否平衡。
- 更新
account_balance。 - 提交事务。
示意代码如下:
$connection->transactional(function () use (
$transactionKey,
$ledgerEntries
): void {
$transaction = $this->ledgerTransactionRepository
->createIfAbsent($transactionKey);
if ($transaction->wasAlreadyProcessed()) {
return;
}
$sum = 0;
foreach ($ledgerEntries as $entry) {
$sum += $entry->amountMinor();
$this->ledgerEntryRepository->insert(
$transaction->id(),
$entry
);
$this->balanceRepository->incrementAtomically(
$entry->ledgerAccountId(),
$entry->currency(),
$entry->amountMinor()
);
}
if ($sum !== 0) {
throw new LogicException('Ledger transaction is not balanced.');
}
});
实际实现中还要按币种分别检查平衡,不能直接把不同币种的金额相加。
如果 account_balance 损坏,可以根据账本分录重新构建。为了缩短重建时间,还可以定期生成余额快照:
期末余额 = 最近快照余额 + 快照之后的分录合计
如何处理百万甚至更多调用
“一百万次调用产生一百万行”本身没有问题。使用记录属于典型的追加写入数据,设计时应重点考虑查询方式和数据生命周期。
可以采取这些措施:
- 使用
BIGINT或适合分布式生成的唯一 ID。 - 按
occurred_at对usage_event进行月度或每日分区。 - 为
account_id + occurred_at建立复合索引。 - 为未开票记录建立专用索引,避免扫描已经结算的数据。
- 分表保存原始使用记录、计价结果和账本分录。
- 将较早的原始请求详情归档到低成本存储,同时保留可验证的计价数据和账本数据。
- 为报表建立日汇总表或月汇总表,不要直接查询在线明细表。
- 根据法律、合同和审计要求制定保留期限,不能仅仅因为表大就删除账务数据。
如果调用量达到每秒数万次甚至更高,可以让 API 网关把使用事件写入消息队列,再由计价服务异步消费。不过,预付费额度校验一般仍需同步完成,也可以先预留额度。
异步处理必须配合唯一事件 ID、幂等消费者和可重放机制,不能假设每条消息只会投递一次。
并发与一致性
预付费余额很容易在并发场景下发生透支。下面的流程并不安全:
读取余额
判断余额足够
扣减余额
两个请求可能同时读到相同的余额,并同时通过检查。应改用条件更新或行锁:
UPDATE account_balance
SET balance_minor = balance_minor - :amount,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE ledger_account_id = :accountId
AND currency = :currency
AND balance_minor >= :amount;
只有受影响行数为 1,才能确认扣款成功。账本分录和余额更新仍要放在同一个事务中。
如果数据库架构无法保证两者原子提交,可以使用 outbox pattern 发布事件,并设置自动对账和补偿流程。不要指望普通消息发送和数据库提交刚好同时成功。
冲正、退款与价格更正
账本记录应当保持不可变:
- 不修改已经入账的分录。
- 不删除错误交易。
- 新增一笔金额相反的冲正交易来撤销原交易。
- 再写入正确的交易。
- 保留原交易、冲正交易和业务原因之间的引用关系。
usage_event 也应尽量保留原始事实。如果计价有误,应新增计价调整或贷项,不能直接覆盖原金额。
审计时,系统必须能够回答:
- 这笔费用来自哪次 API call?
- 使用了哪个价格版本?
- 为什么计入这个账期?
- 是否已经开票?
- 是否发生过退款或冲正?
- 当前余额由哪些交易构成?
Symfony 和 Doctrine 实现时的注意事项
Doctrine 适合管理账户、订阅、价格规则和 invoice 等业务实体,但高吞吐的使用记录未必适合逐条经过完整的 ORM 生命周期写入。
可以根据流量选择不同的实现方式:
- 普通规模:使用 Doctrine ORM,并配合批量
flush()。 - 较高吞吐:使用 Doctrine DBAL 批量插入。
- 极高吞吐:使用消息队列、分区表和专门的消费进程。
不能只依赖 Doctrine 实体的乐观锁保护预付余额。扣款条件最终应落实为数据库可以原子执行的约束、条件更新或锁。
还应设置数据库级约束,例如:
- 幂等键具有唯一约束。
- 计价结果中的
usage_event_id必须唯一。 - 货币代码不能为空。
- 数量不能为负,除非业务明确支持反向用量。
- 已结算费用只能关联一次 invoice。
- 同一账本交易按币种保持借贷平衡。
部分跨行平衡规则很难只靠普通 CHECK 约束表达,需要由事务服务完成校验,并通过定期对账任务再次验证。
最容易踩的坑
把余额当成唯一事实
余额只能说明现在还有多少钱,无法解释这个数字是如何产生的。系统必须保留不可变账本。
让后付费共用预付余额
两者的会计含义不同。预付费是客户预存的资金,后付费是公司对客户的应收款,应分别建账。
在开票时重新套用当前价格
开票时应使用已经固化的计价结果,或者使用调用发生时有效的价格版本,不能直接采用最新价格。
使用浮点数保存金额
0.1 + 0.2 这类精度问题会导致余额和 invoice 无法对平。货币金额应使用整数最小单位或固定精度十进制数。
允许重复计费
API 重试、消费者重启和 billing run 重跑都很常见。使用记录、计价、入账和开票的每个阶段都需要各自的幂等键。
只保存汇总数据
汇总数据便于查询,但无法满足审计要求。系统应保留从 invoice item 到计价结果、使用事件、价格规则和账本交易的完整追踪链。
这套系统可以归纳为:Usage 记录事实,Rating 决定价格,Ledger 记录资金,Billing 负责结算,Balance 是可以重建的查询投影。 它既能支持固定价格、预付费、后付费和混合套餐,也能随着数据量增长继续扩展,同时保留完整的核算与审计能力。
备注:内容仅供参考。
