PAYATHON 2026

如何设计支持多种计费模式的用户账户余额系统

支付老李

明确结论

不要把所有计费逻辑压缩到一个可正可负的 balance 字段里,也不要在每次请求时扫描全部交易来重新计算余额。

更合适的做法是把系统分成四个职责清楚、彼此关联的部分:

  1. 使用记录(Usage):记录每次 API call 的实际情况。
  2. 计价(Rating):根据合同、套餐和价格版本计算费用。
  3. 账本(Ledger):通过不可变的借贷分录记录资金变化。
  4. 开票与收款(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 可以使用受约束的枚举值,例如:

  • FIXED
  • PREPAID
  • POSTPAID
  • INCLUDED

不要给 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 单独扣款。

以月费套餐为例,处理流程可以是:

  1. 订阅进入新的计费周期。
  2. 生成固定费用或 invoice item。
  3. 记录该套餐在当前周期包含的 API、额度和限制。
  4. API call 仍然写入 usage_event,供审计、限流和容量分析使用。
  5. 套餐内调用的计价结果为零,或标记为 INCLUDED。
  6. 超出额度的调用再进入预付费或后付费流程。

即使套餐内调用不产生费用,也建议保留使用记录。具体可以根据审计要求采用汇总记录或分层归档。

预付费

预付费调用需要在服务执行前确认可用额度。

常见流程如下:

  1. 根据账户和 API operation 查找当前有效的价格规则。
  2. 预估本次调用的最高费用。
  3. 原子地冻结或扣减可用额度。
  4. 执行 API 请求。
  5. 根据实际用量结算,多退少补。
  6. 写入使用记录、计价结果和账本分录。

如果请求成本在调用结束前无法确定,可以引入 available 和 reserved 两种余额,避免多个并发请求同时通过余额检查。

后付费

后付费调用不需要预先扣除预付余额,处理流程是:

  1. 记录 usage_event。
  2. 根据调用发生时有效的价格版本生成 rated_usage。
  3. 增加用户应收账款,或者先将费用保存为未开票状态。
  4. billing run 汇总账期内尚未开票的计价结果。
  5. 创建 invoice,并将这些记录关联到该 invoice。
  6. 收款成功后冲销应收账款。

开票系统不能只查询“当前余额是多少”。它需要查询指定账期和币种下,尚未开票且符合开票条件的费用明细。

混合计费的处理方式

同一账户可以同时包含多个计费组件,例如:

基础月费:¥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)
);

写入账本时,应在同一个数据库事务中完成以下操作:

  1. 插入 ledger_transaction。
  2. 插入所有 ledger_entry。
  3. 检查借贷是否平衡。
  4. 更新 account_balance。
  5. 提交事务。

示意代码如下:

$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 是可以重建的查询投影。 它既能支持固定价格、预付费、后付费和混合套餐,也能随着数据量增长继续扩展,同时保留完整的核算与审计能力。

备注:内容仅供参考。