返回文章列表

AiVedio:计费、成本与对账

围绕额度预占、结算、释放、退款、成本归集、供应商账单、RocketMQ 重复投递、幂等账本、余额快照、差异对账、预算控制和成本感知路由,设计 AI 视频平台的计费、成本与对账系统。

第 20 章:计费、成本与对账

本章目标:设计一套适用于 AI 视频生成平台的计费与成本系统,使平台在长耗时、强异步、第三方结果不确定、消息至少一次投递的条件下,仍能做到不重复扣款、可解释结算、可追溯成本、可发现差异、可执行补偿


20.1 本章要解决的业务问题

AI 视频任务与普通同步接口不同。一次请求可能经历排队、供应商提交、数分钟生成、回调或轮询、结果回源、转码、审核和最终交付。任意阶段都可能失败,而供应商是否计费、平台是否应向用户收费,并不总是由同一个状态决定。

计费系统至少要同时处理三套相互关联、但不能混为一谈的事实:

  1. 用户权益与收入事实

    • 用户还有多少可用额度。
    • 某个任务预占了多少额度。
    • 最终实际消费、释放或退款了多少。
    • 使用的是充值额度、订阅赠送额度还是促销额度。
  2. 供应商与基础设施成本事实

    • 某次供应商调用预计成本与实际成本。
    • 失败、取消、重复提交是否仍然产生费用。
    • GPU 渲染、审核、对象存储、转码和 CDN 产生了多少成本。
  3. 对账与审计事实

    • 平台内部任务、供应商任务和供应商账单能否一一对应。
    • 钱包余额快照是否等于账本聚合结果。
    • 重复生成、漏记成本、重复扣款和金额差异是否被发现。

本章重点解决以下典型问题:

  • 用户余额足够时,如何原子地完成额度预占与任务创建。
  • RocketMQ 重复投递或消费者重试时,如何避免重复扣款。
  • 第三方已经受理,但本地请求超时时,是否释放额度、是否重提任务。
  • 用户取消与供应商完成同时发生时,如何确定收费结果。
  • 供应商失败但仍收费时,平台如何记录成本并决定是否向用户收费。
  • 供应商实际消耗低于或高于预估时,如何结算差额。
  • 如何计算“每个成功视频的真实成本”,而不是只看供应商标价。
  • 如何实施租户预算、供应商预算、成本感知路由和紧急止损。
  • 如何通过每日对账把内部流水与外部账单闭环。

20.1.1 先区分几个容易混淆的概念

概念含义是否改变用户可用额度
Quote / 报价按价格版本和归一化参数计算出的预计用户价格
Reserve / 预占将可用额度冻结为任务专用额度是,可用减少、预占增加
Settle / 结算将已预占额度确认为实际消费是,预占减少
Release / 释放将未消费的预占额度退回可用额度
Refund / 退款对已经结算的消费追加一笔返还
Compensate / 补偿因平台故障、客服处理等新增一笔补偿额度
Provider Cost / 供应商成本平台对外部供应商承担的真实费用否,不能直接等同于用户价格
Reconciliation / 对账比较不同事实源并处理差异通常不直接改账,确认后以补偿流水改账

Reserve 不是消费,Release 也不是 Refund。 只有已经发生 Settle 后,返还才叫 Refund。这个语义必须在状态机、表结构和接口命名中保持一致。

20.1.2 业务模型也必须区分

平台通常同时存在三类用户模型:

  • 预付费钱包:余额不能为负,任务提交前必须完成预占。
  • 订阅额度:每个计费周期授予额度,可能有有效期、结转规则和促销额度优先级。
  • 企业后付费:不是检查余额,而是检查信用额度、合同预算和账期内累计应收。

三种模型可以共享“预占—结算—释放”的抽象,但不能用同一条简单的 user.balance 逻辑硬编码。


20.2 核心设计原则

原则一:PostgreSQL 是账务事实源,Redis 不是余额事实源

用户余额、预占、结算、退款、供应商成本和对账结果必须落在 PostgreSQL。Redis 可以缓存展示余额、做快速预算准入或限流,但缓存丢失、主从切换或重复执行不能改变最终账务结果。

原则二:账本只追加,不修改历史流水

错误流水不能通过 UPDATE amount 或物理删除“修正”。正确做法是追加反向流水或补偿流水,并通过 reversal_ofreason_code 和审批记录建立关联。

账本回答的是“发生过什么”,余额快照回答的是“现在还剩多少”。两者都在 PostgreSQL 中事务性维护,但职责不同。

原则三:消息可以至少一次投递,业务效果必须幂等一次

RocketMQ 不应被描述为天然 exactly-once。计费消费者必须假设同一事件可能重复到达,并通过唯一业务键、数据库唯一约束和状态条件更新,使同一业务效果只生效一次。

原则四:任务进入高成本外部执行前先完成预占

对于预付费用户,应先完成额度预占,再允许任务进入供应商提交阶段。否则会出现供应商已经产生费用、用户却没有可扣额度的信用风险。

原则五:“结果未知”不是“失败”

供应商已经受理但本地超时,是典型的 uncertain outcome。此时不能立即释放预占,也不能盲目重提。任务应进入 SUBMIT_UNKNOWN,通过供应商幂等键、查询接口、任务列表或后续回调确认结果。

原则六:用户价格与供应商成本分账管理

用户价格由产品策略、套餐、折扣和价格版本决定;供应商成本由路由结果、真实用量和外部账单决定。两者必须通过任务和尝试记录关联,但不能直接用供应商回调金额修改用户钱包。

原则七:价格和策略必须版本化

任务创建时锁定:

  • 价格版本。
  • 计费单位和舍入规则。
  • 取消收费规则。
  • 失败收费规则。
  • 退款规则。
  • 订阅或企业合同版本。

后续调价只能影响新任务,不能改变正在执行任务的已确认报价。

原则八:金额使用定点数,不使用浮点数

内部额度建议使用 BIGINT 表示最小计费单位,例如微额度单位或最小 credit 单位;真实货币可使用 BIGINT minor_unit 或 PostgreSQL NUMERIC。Go 代码中不能使用 float64 直接做账务加减。

每笔金额必须带有:

amount
currency_or_credit_unit
scale
rounding_rule

不同币种不能直接相加。汇率换算应保存所使用的汇率版本和换算时间。

原则九:远程调用不能放进本地数据库事务

不能开启 PostgreSQL 事务后调用供应商,再等待数十秒后提交。这样会长期持锁,并且仍然无法获得跨系统原子性。正确方式是本地事务写入任务、预占和 Outbox,再由异步 Worker 调用供应商。

原则十:补偿不是回滚历史,而是追加新的业务事实

一旦供应商已受理、用户已结算或账单已入账,系统通常无法真正回滚外部世界。Saga 的补偿应表现为取消、释放、退款、补偿额度或成本调整,而不是假装原事件从未发生。


20.3 总体架构与组件职责

Client


Generation API
  ├── 参数归一化
  ├── Idempotency-Key
  └── 请求报价


Pricing Service
  ├── 价格版本
  ├── 套餐、折扣和取消策略
  └── 最大预占金额


Billing Module / Service
  ├── Wallet
  ├── Reservation
  ├── Immutable Ledger
  ├── Tenant Budget
  └── Provider Spend Budget
  │  同一 PostgreSQL 本地事务
  ├── 创建 GenerationTask
  └── 写入 OutboxEvent


      Outbox Relay ──► RocketMQ ──► Scheduler

                                      ├── 成本感知路由
                                      ├── 供应商预算准入
                                      └── Provider Adapter


                                           AI Provider

                                      Callback / Polling


                                      Task State Service

                            ┌───────────────────┴───────────────────┐
                            ▼                                       ▼
                    User Settlement                         Provider Cost Collector
                    ├── settle                              ├── 预计成本
                    ├── release                             ├── 实际成本事件
                    └── refund                              └── 外部任务映射


Provider Invoice/API ──► Raw Billing Archive ──► Reconciliation Service
                         对象存储,原始文件不可变       ├── 精确匹配
                                                     ├── 差异单
                                                     ├── 自动补偿候选
                                                     └── 人工复核

Object Storage / CDN / Render Logs ──► Media Cost Aggregator ──► Cost Analytics

20.3.1 组件职责

组件核心职责不应承担的职责
Pricing Service归一化计费参数、选择价格版本、输出报价和最大预占额直接修改钱包余额
Billing Service钱包、预占、结算、释放、退款、账本和预算事实调用 FFmpeg 或下载媒体
Generation Service任务创建、状态机和产品语义自己计算余额并绕过 Billing
Scheduler在能力、SLO、预算和成本约束下选择供应商将 Redis 计数当作最终成本事实
Provider Adapter外部幂等键、提交、查询、取消和错误归类决定用户最终收费政策
Cost Collector收集每次 provider attempt 的预计和实际成本直接根据估算修改用户价格
Reconciliation Service对比内部记录、外部任务和账单,生成差异单静默覆盖历史账本
PostgreSQL账本、余额快照、任务、预算、对账状态的事实源高频展示缓存
Redis快速额度展示缓存、近实时 burn-rate、准入加速核心余额和唯一账本
RocketMQ传递结算、成本采集、对账和补偿事件保证天然 exactly-once
对象存储保存供应商原始账单、用量文件和对账证据存储可被随意覆盖的余额快照

20.3.2 服务边界取舍

若 Generation 与 Billing 共用同一 PostgreSQL 集群,可以在一个本地事务中完成“预占、建任务、写 Outbox”,这是最直接、最强的一致性方案。服务可以逻辑拆分,但不必为了微服务形式强行拆数据库。

若 Billing 必须使用独立数据库,则不能声称存在原子事务。可采用:

  1. 先通过幂等 Billing API 创建 reservation。
  2. 再幂等创建 generation task。
  3. 若任务创建最终失败,由孤儿预占扫描器释放 reservation。
  4. 所有步骤均有业务键和可重放状态。

这种方案增加了 Saga 和补偿复杂度,只有在组织、合规或容量边界确实需要时才值得采用。


20.4 文字版时序图

20.4.1 正常任务:预占、生成、结算

1. Client -> Generation API:提交任务,携带 Idempotency-Key。
2. Generation API -> Pricing Service:按归一化参数请求报价。
3. Pricing Service -> Generation API:返回 price_version、quoted_amount、max_reserve_amount。
4. Generation API -> PostgreSQL:开启事务。
5. PostgreSQL:检查幂等请求;锁定 wallet_account;验证可用额度和预算。
6. PostgreSQL:插入 credit_reservation。
7. PostgreSQL:追加 RESERVE 账本流水并更新余额快照。
8. PostgreSQL:创建 generation_task、generation_charge 和 outbox_event。
9. Generation API -> PostgreSQL:提交事务。
10. Outbox Relay -> RocketMQ:投递 TASK_CREATED。
11. Scheduler:完成供应商能力、成功率、预算和成本检查。
12. Scheduler -> PostgreSQL:创建 provider_attempt 和供应商预算预占。
13. Provider Adapter -> AI Provider:使用稳定 external_idempotency_key 提交任务。
14. AI Provider -> Provider Adapter:返回 provider_task_id。
15. Provider Adapter -> PostgreSQL:记录已受理状态和预计供应商成本。
16. AI Provider -> Callback Gateway:返回完成事件;或 Poller 查询到完成。
17. Task State Service:幂等确认 SUCCESS,并写入 TASK_SUCCEEDED Outbox。
18. Settlement Consumer -> PostgreSQL:锁定 reservation,追加 SETTLE;若有差额再追加 RELEASE;更新快照。
19. Cost Collector:记录 provider actual cost;后续等待外部账单校验。
20. Output Worker:回源、转码和交付;媒体成本异步归集。

20.4.2 供应商已受理,但本地超时

1. Provider Adapter -> AI Provider:提交 external_idempotency_key=attempt_id。
2. AI Provider:已创建任务并开始计费。
3. 网络超时:本地没有收到 provider_task_id。
4. Provider Adapter:不得把任务标记为普通 FAILED,也不得立即重新提交。
5. 本地状态改为 SUBMIT_UNKNOWN,保留用户预占和供应商预算暴露。
6. Poller:按 external_idempotency_key、请求标签或供应商任务列表查询。
7A. 查到任务:补录 provider_task_id,继续正常状态机。
7B. 明确查不到且超过供应商幂等窗口:在策略允许下创建新的 attempt。
7C. 始终无法确认:进入人工或账单对账队列,不自动释放可能已经发生的成本。

20.4.3 用户取消与完成竞态

1. Client -> API:请求取消。
2. API:以条件更新将任务从可取消状态改为 CANCEL_REQUESTED。
3. 同时,供应商完成回调到达。
4. Task State Service:根据状态版本、provider terminal state 和计费政策判定唯一终态。
5A. 供应商确认未执行且不收费:CANCELLED,释放全部预占。
5B. 供应商已经完成:SUCCESS,按规则结算;取消请求记录为未生效。
5C. 供应商已执行部分且收取消费:结算取消费或实际用量,释放剩余部分。
6. 任何分支都通过唯一业务流水键防止重复结算或重复释放。

20.5 关键数据结构与数据库设计

20.5.1 钱包快照

wallet_account
- wallet_id              uuid primary key
- tenant_id              uuid not null
- account_type           varchar not null  -- PREPAID / SUBSCRIPTION / POSTPAID
- unit_code              varchar not null  -- CREDIT / USD / CNY ...
- available_amount       bigint not null
- reserved_amount        bigint not null
- credit_limit_amount    bigint not null default 0
- version                 bigint not null
- status                  varchar not null
- updated_at              timestamptz not null

unique (tenant_id, unit_code, account_type)
check (account_type <> 'PREPAID' or available_amount >= 0)
check (reserved_amount >= 0)

wallet_account 是高效查询和并发控制所需的可靠快照,不替代历史账本。写入必须与账本流水处于同一 PostgreSQL 事务。

20.5.2 不可变账本事务

推荐将“业务事务头”和“账本分录”分开:

ledger_transaction
- ledger_txn_id           uuid primary key
- wallet_id               uuid not null
- business_key            varchar not null
- event_type              varchar not null
- task_id                 uuid null
- reservation_id          uuid null
- reversal_of             uuid null
- reason_code             varchar null
- metadata                jsonb not null
- created_at              timestamptz not null

unique (wallet_id, business_key)
ledger_entry
- entry_id                uuid primary key
- ledger_txn_id           uuid not null
- bucket                  varchar not null
- amount_delta            bigint not null
- unit_code               varchar not null
- created_at              timestamptz not null

概念上的分录示例:

RESERVE 100:
  AVAILABLE  -100
  RESERVED   +100

SETTLE 80:
  RESERVED    -80
  CONSUMED    +80

RELEASE 20:
  RESERVED    -20
  AVAILABLE   +20

REFUND 30:
  CONSUMED    -30
  AVAILABLE   +30

即使系统没有建设完整财务总账,也建议使用这种双分录思路,避免只记录一个难以解释的正负金额。

数据库权限上应禁止普通应用角色对账本执行 UPDATEDELETE。修正只能追加新的 ledger_transaction

20.5.3 额度预占

credit_reservation
- reservation_id          uuid primary key
- wallet_id               uuid not null
- task_id                 uuid not null
- quoted_amount           bigint not null
- reserved_amount         bigint not null
- settled_amount          bigint not null default 0
- released_amount         bigint not null default 0
- state                   varchar not null
- price_version_id        uuid not null
- expires_at              timestamptz null
- version                 bigint not null
- created_at              timestamptz not null
- updated_at              timestamptz not null

unique (task_id)
check (settled_amount >= 0)
check (released_amount >= 0)
check (settled_amount + released_amount <= reserved_amount)

推荐状态:

ACTIVE
PARTIALLY_SETTLED
SETTLED
RELEASED
PARTIALLY_REFUNDED
REFUNDED

expires_at 不能被简单理解为“到期立即释放”。只有确认任务从未进入可能计费的供应商阶段时,扫描器才能自动释放。处于 SUBMITTEDRUNNINGSUBMIT_UNKNOWN 的任务不能仅因为 reservation 超时就释放额度。

20.5.4 价格版本与任务收费快照

price_book
- price_version_id        uuid primary key
- product_code            varchar not null
- effective_from          timestamptz not null
- effective_to            timestamptz null
- currency_or_unit        varchar not null
- rules                   jsonb not null
- status                  varchar not null
- created_at              timestamptz not null
generation_charge
- task_id                 uuid primary key
- price_version_id        uuid not null
- normalized_params       jsonb not null
- quoted_amount           bigint not null
- max_reserve_amount      bigint not null
- final_charge_amount     bigint null
- charge_policy_code      varchar not null
- created_at              timestamptz not null
- finalized_at            timestamptz null

报价必须基于服务端归一化参数。客户端传入的 pricediscountfinal_amount 只能作为展示信息,不能作为入账依据。

20.5.5 供应商尝试与成本事件

一个用户任务可能因为路由切换、明确失败或人工重跑而对应多个 provider attempt:

provider_attempt
- attempt_id              uuid primary key
- task_id                 uuid not null
- attempt_no              int not null
- provider                varchar not null
- provider_account_id     varchar not null
- model_code              varchar not null
- external_idempotency_key varchar not null
- provider_task_id        varchar null
- submit_state            varchar not null
- estimated_cost_amount   numeric(20, 8) not null
- estimated_cost_currency varchar not null
- actual_cost_amount      numeric(20, 8) null
- actual_cost_currency    varchar null
- billable_state          varchar not null
- created_at              timestamptz not null
- updated_at              timestamptz not null

unique (task_id, attempt_no)
unique (provider, provider_account_id, external_idempotency_key)
unique (provider, provider_account_id, provider_task_id)
provider_cost_event
- cost_event_id           uuid primary key
- attempt_id              uuid null
- provider                varchar not null
- provider_account_id     varchar not null
- external_charge_id      varchar null
- external_line_id        varchar null
- event_type              varchar not null
- amount                  numeric(20, 8) not null
- currency                varchar not null
- source                  varchar not null  -- CALLBACK / POLL / API / INVOICE
- occurred_at             timestamptz not null
- raw_payload_hash        varchar null
- created_at              timestamptz not null

unique (provider, provider_account_id, external_charge_id, event_type)

如果供应商没有稳定的 external_charge_id,应构造保守的复合业务键,并将无法精确去重的记录送入对账队列,而不是静默累加。

20.5.6 预算表

budget_account
- budget_id               uuid primary key
- scope_type              varchar not null
- scope_id                varchar not null
- period_start            timestamptz not null
- period_end              timestamptz not null
- limit_amount            numeric(20, 8) not null
- reserved_amount         numeric(20, 8) not null
- consumed_amount         numeric(20, 8) not null
- currency                varchar not null
- version                 bigint not null

unique (scope_type, scope_id, period_start, period_end, currency)

scope_type 可以是:

TENANT
PROJECT
PROVIDER_ACCOUNT
MODEL
REGION
GLOBAL

预算同样应采用 reserve/settle/release,而不是只在任务完成后累计。否则高峰期大量在途任务会同时突破预算。

20.5.7 对账数据

reconciliation_run
- run_id                  uuid primary key
- provider                varchar not null
- billing_period          daterange not null
- source_object_key       varchar not null
- source_checksum         varchar not null
- status                  varchar not null
- total_external_amount   numeric(20, 8) not null
- total_internal_amount   numeric(20, 8) not null
- started_at              timestamptz not null
- finished_at             timestamptz null
reconciliation_item
- item_id                 uuid primary key
- run_id                  uuid not null
- attempt_id              uuid null
- external_line_id        varchar null
- discrepancy_type        varchar not null
- internal_amount         numeric(20, 8) null
- external_amount         numeric(20, 8) null
- currency                varchar not null
- status                  varchar not null
- resolution_code         varchar null
- resolution_ledger_txn_id uuid null
- evidence                jsonb not null
- created_at              timestamptz not null
- resolved_at             timestamptz null

常见差异类型:

MISSING_INTERNAL
MISSING_EXTERNAL
AMOUNT_MISMATCH
DUPLICATE_EXTERNAL
CURRENCY_MISMATCH
TASK_STATUS_MISMATCH
UNMAPPED_PROVIDER_TASK
LATE_CHARGE
PROVIDER_CREDIT_NOT_APPLIED

20.5.8 促销额度与有效期扩展

若存在“充值额度、赠送额度、活动额度和过期时间”,仅有总余额不够。可增加:

credit_lot
reservation_allocation
settlement_allocation

预占时按“最早过期优先”或产品规则从多个 credit lot 分配;释放时退回原 lot;退款时根据合同决定退回原 lot、生成新 lot,还是恢复原有效期。否则会出现用即将过期的赠送额度消费,却退款成永久充值额度的漏洞。


20.6 正常流程

20.6.1 报价与最大预占额

价格通常由以下维度共同决定:

产品模式:text-to-video / image-to-video / extend / render
模型与模型版本
时长或输出帧数
分辨率和宽高比
是否生成音频
优先级
批量折扣或企业合同
订阅套餐
区域、税费与币种

Pricing Service 输出的不应只有一个金额,还应包括:

quote_id
price_version_id
normalized_params
quoted_amount
max_reserve_amount
unit_code
expires_at
charge_policy_code

对于固定价任务,quoted_amount 可以等于 max_reserve_amount。对于按实际时长、帧数或 GPU 秒计费的任务,应预占最大可能金额或带安全系数的上限。

报价只在有限时间内有效,但任务一旦成功创建,应锁定其价格版本。供应商后续涨价影响平台成本,不应自动改变已承诺给用户的价格。

20.6.2 原子预占与任务创建

推荐流程:

  1. 解析并校验 Idempotency-Key
  2. 服务端归一化参数并获取价格版本。
  3. 开启 PostgreSQL 事务。
  4. 查询已有幂等结果;若已存在,返回原任务。
  5. SELECT ... FOR UPDATE 锁定对应 wallet 和 budget 行。
  6. 检查可用额度、信用额度和预算。
  7. 插入唯一的 ledger_transaction
  8. 追加 RESERVE 分录。
  9. 更新 wallet_account.available_amountreserved_amount
  10. 创建 reservation、generation task、charge snapshot 和 Outbox。
  11. 提交事务。

伪代码:

func CreateGeneration(ctx context.Context, cmd CreateCommand) (*Task, error) {
    return withTx(ctx, db, func(tx Tx) (*Task, error) {
        if old := findByIdempotencyKey(tx, cmd.TenantID, cmd.IdempotencyKey); old != nil {
            return old, nil
        }

        wallet := lockWalletForUpdate(tx, cmd.TenantID, cmd.UnitCode)
        quote := loadImmutableQuote(tx, cmd.QuoteID)

        if wallet.AvailableAmount < quote.MaxReserveAmount {
            return nil, ErrInsufficientCredit
        }

        // business_key 的唯一约束是最后一道并发防线。
        ledgerTxn := appendReserveLedger(tx, wallet, cmd, quote)
        reservation := createReservation(tx, wallet, cmd, quote, ledgerTxn)
        task := createTask(tx, cmd, reservation)
        insertOutbox(tx, "GENERATION_TASK_CREATED", task.ID)
        updateWalletSnapshot(tx, wallet, quote.MaxReserveAmount)

        return task, nil
    })
}

真实代码中若唯一键冲突,应回滚当前事务并读取已有结果,不能在 ON CONFLICT DO NOTHING 后继续扣减余额。

20.6.3 供应商预算预占

用户额度预占解决的是收入风险,供应商预算预占解决的是平台现金成本风险。

Scheduler 在创建 provider attempt 前,应按预计成本检查:

  • 供应商账号日预算。
  • 模型预算。
  • 租户或项目成本预算。
  • 全局小时 burn-rate。
  • 单任务最大成本。

通过后,为该 attempt 预占 provider spend budget。任务完成后按实际成本结算并释放差额。若路由切换产生新 attempt,必须新增预算预占,不能复用已经可能发生费用的旧 attempt 预算。

20.6.4 成功结算

任务成功并不等于收到任意一个成功回调。结算前至少应确认:

  • 事件对应当前合法的 provider attempt。
  • 供应商终态已经被幂等写入。
  • 输出满足产品交付条件,或收费策略明确规定“供应商成功即计费”。
  • 结算使用创建任务时锁定的价格版本。

结算事务通常包含:

  1. 锁定 credit_reservation
  2. 检查 settle:{task_id}:{charge_version} 是否已经存在。
  3. 计算 final_charge_amount
  4. 追加 SETTLE 分录。
  5. 若预占大于实收,追加 RELEASE 分录。
  6. 更新 reservation 和钱包快照。
  7. 更新 generation_charge.final_charge_amount
  8. 写入结算完成 Outbox。

例如预占 120,实际收费 90:

SETTLE 90
RELEASE 30

两个动作应在同一事务中完成,避免任务已经结算但剩余额度长期未释放。

20.6.5 实际金额高于预占额

应在产品设计阶段明确策略,不能等任务完成后临时决定。常见方案:

  1. 固定售价:用户最多支付已报价金额,超出部分由平台承担。
  2. 执行前追加预占:在延长时长、高清放大或最终渲染前再做一次 top-up reserve。
  3. 企业后付费:允许在信用额度内结算,并进入应收账款。
  4. 硬性上限:达到预占上限后停止可选后处理或拒绝扩展操作。

预付费账户不应在未授权的情况下静默产生负余额。

20.6.6 失败、取消和释放

供应商提交前失败

没有外部成本暴露,任务进入终态后释放全部预占和预算预占。

供应商明确拒绝且不收费

记录 provider attempt 失败原因,释放对应供应商预算;若不再重试,释放用户额度。

供应商失败但仍收费

供应商成本照实记录。用户是否收费取决于锁定的产品政策:

  • 平台承诺“成功才收费”:用户额度全部释放,供应商费用计入失败成本。
  • 合同规定“提交即收费”或按用量收费:按实际规则结算用户。
  • 内容审核拒绝:可根据责任归属、条款和供应商收费政策处理,但规则必须版本化,不能由 Worker 临时判断。

用户取消

取消收费应基于供应商阶段:

阶段常见处理
尚未提交供应商立即取消并释放全部预占
已提交但供应商确认未开始且不收费确认取消后释放
正在运行且供应商收取消费结算取消费或实际用量,释放余额
已完成取消请求不再改变完成事实,按正常成功规则结算
提交结果未知保留预占,进入取消确认流程,不立即释放

20.6.7 退款与补偿

退款必须引用原结算流水:

refund:{refund_request_id}
reversal_of:{settle_ledger_txn_id}

支持全额或部分退款,但累计退款不能超过可退款金额。客服补偿与退款应分开:

  • REFUND 表示返还原消费。
  • COMPENSATE 表示平台额外赠送,可能有单独有效期和预算归属。

高金额补偿建议采用审批流和双人复核。


20.7 异常流程与竞态条件

20.7.1 重复请求与重复消费

可能来源:

  • 用户双击提交。
  • API 网关超时后客户端重试。
  • Outbox Relay 重复投递。
  • RocketMQ 消费重试。
  • Callback 与 Polling 同时发现完成。
  • 对账任务重复运行。

防线应分层:

HTTP Idempotency-Key

业务唯一键

PostgreSQL UNIQUE 约束

状态条件更新 / version CAS

账本流水幂等检查

不要只依赖 Redis SETNX。Redis 锁可以减少并发碰撞,但不能替代数据库唯一约束。

20.7.2 供应商已受理,本地超时

这是本章最重要的异常场景之一。

错误做法:

请求超时
→ 标记失败
→ 释放用户额度
→ 重新提交供应商

该流程可能同时造成:

  • 两个真实生成任务。
  • 两笔供应商费用。
  • 用户只支付一笔甚至零笔。
  • 两个结果竞争写入同一任务。

正确做法:

  • 每个 attempt 使用稳定的 external_idempotency_key
  • 状态进入 SUBMIT_UNKNOWN
  • 保留用户 reservation 和 provider budget exposure。
  • 优先查询而不是重提。
  • 只有获得“未受理”的确定证据后才允许新 attempt。
  • 若供应商完全不支持幂等与查询,自动重试次数应更保守,并将潜在重复成本纳入路由评分。

20.7.3 回调与轮询竞争

Callback 和 Poller 都可能产生 TASK_SUCCEEDED。两者必须调用同一状态转移函数,并通过:

update generation_task
set state = 'SUCCEEDED', version = version + 1
where task_id = $1
  and state in ('SUBMITTED', 'RUNNING', 'CANCEL_REQUESTED')
  and version = $2;

仅成功更新的一方产生终态 Outbox。即使两方都投递了结算事件,账本唯一业务键仍能防止重复收费。

20.7.4 取消与完成竞争

不能简单规定“先收到哪个请求就以哪个为准”。需要同时考虑:

  • 供应商是否已经进入不可取消阶段。
  • 供应商最终是否收费。
  • 用户看到的取消确认是否已承诺成功。
  • 当前任务状态版本。

取消接口最好先返回“取消请求已受理”,只有供应商确认取消后才返回或通知“取消成功”。这样避免平台过早承诺释放额度。

20.7.5 结算成功,但响应丢失

Billing Consumer 在 PostgreSQL 已提交结算事务,但确认 MQ 消息前进程崩溃,消息会再次投递。重复消费时查询唯一业务键,返回“已处理”并确认消息,不再追加流水。

20.7.6 退款与结算竞争

退款请求可能在任务刚完成时到达。应要求退款引用已存在的 settle transaction;若结算尚未完成,退款请求进入 PENDING_SETTLEMENT,而不是先增加余额再等待结算,否则可能出现先退款、后重复扣款的短暂或永久不一致。

20.7.7 价格变更与任务执行竞争

任务创建后只读取 price_version_id 对应的不可变规则。不能在结算时重新读取“当前价格”,否则长任务可能因执行期间调价而出现前后不一致。

20.7.8 结果生成成功,但平台回源或转码失败

这里必须区分三种状态:

PROVIDER_SUCCEEDED
MEDIA_INGEST_FAILED
USER_DELIVERY_FAILED

供应商成本已经发生。用户是否结算取决于产品承诺:

  • 若承诺“生成成功即收费”,可以结算,但应提供重试交付。
  • 若承诺“可播放交付才收费”,则暂缓结算;超过恢复窗口后释放或退款,并将供应商成本记入平台故障损失。

无论选择哪种,状态和政策都必须明确,不能把所有失败压成一个 FAILED

20.7.9 预占扫描器误释放

扫描器只能释放满足以下条件的 reservation:

  • 任务从未创建成功,且幂等请求已终止。
  • 任务处于明确的本地终止状态。
  • 没有任何 SUBMITTEDRUNNINGSUBMIT_UNKNOWN 的 attempt。
  • 没有未处理的成功、成本或对账事件。

仅凭 expires_at < now() 释放属于高风险设计。


20.8 幂等、一致性、重试与补偿

20.8.1 一致性边界

能够原子完成的本地操作:

钱包快照更新
+ 账本追加
+ reservation 创建或更新
+ generation task 创建或更新
+ Outbox 写入

无法原子完成的跨系统操作:

本地数据库提交
+ 第三方供应商受理
+ 第三方实际扣费

因此整体应采用本地事务 + Outbox + 幂等远程请求 + 状态机 + 对账补偿,而不是分布式两阶段提交。

20.8.2 推荐业务键

reserve:generation:{task_id}:v1
settle:generation:{task_id}:{charge_version}
release:generation:{task_id}:{reason_code}
refund:generation:{task_id}:{refund_request_id}
compensate:{case_id}
provider-budget-reserve:{attempt_id}
provider-cost:{provider}:{external_charge_id}
reconcile-adjust:{reconciliation_item_id}

业务键应表达“哪一个业务效果只能发生一次”,而不是使用随机 UUID 代替语义。

20.8.3 重试分类

场景是否可自动重试关键条件
PostgreSQL 短暂连接失败事务整体重试,幂等键不变
RocketMQ 消费失败数据库业务键防重复
供应商 429/明确未受理遵循退避和供应商幂等语义
供应商提交超时、结果未知否,不能直接重提先查询、等待回调或人工确认
供应商内容审核拒绝通常否属于不可重试业务失败
回调处理失败回调事件去重,状态条件更新
结算消费者失败唯一结算业务键
对账文件下载失败文件 checksum 和 run 幂等
对账金额不一致否,不能盲目自动改账生成差异单,按规则审批处理

20.8.4 补偿策略

常见补偿动作:

  • 任务未执行:RELEASE。
  • 已结算但平台决定免责:REFUND。
  • 用户体验受损但原消费仍成立:COMPENSATE。
  • 供应商重复计费:先记录成本差异,等待供应商 credit note;不能凭猜测删除成本。
  • 平台漏记供应商费用:追加 provider cost adjustment。
  • 钱包快照与账本不一致:冻结高风险写入,重算快照,保留审计记录。

补偿事件同样必须幂等,并关联原任务、原流水或对账差异单。

20.8.5 余额一致性校验

可定期执行:

snapshot.available_amount
vs.
初始余额 + 所有 AVAILABLE bucket 的 amount_delta

以及:

snapshot.reserved_amount
vs.
所有 ACTIVE reservation 的未结清金额

发现差异时不要直接把账本改成快照。应先确定事实源、冻结受影响账户的高风险操作,并通过重建快照或补偿流水恢复。


20.9 成本模型与成本感知路由

20.9.1 真实成本不只包含模型调用费

单个任务的可归属成本可拆为:

C_task = C_provider_generation
       + C_duplicate_attempt
       + C_moderation
       + C_output_fetch
       + C_transcode_or_gpu_render
       + C_storage
       + C_cdn_egress
       + C_observability_allocated
       - C_provider_credit

“每个成功视频成本”应把失败和重复尝试摊入成功结果:

CostPerSuccessfulVideo =
  统计周期内所有相关真实成本
  / 成功且达到可交付标准的视频数

若平台以视频时长为核心产品单位,还应同时计算:

CostPerSuccessfulSecond
CostPerPlayableMinute
CostPerExportedMinute

只用“成功任务的供应商标价 / 成功数”会系统性低估失败、超时重提和媒体链路成本。

20.9.2 重复生成成本

需要单独监控:

DuplicateGenerationCostRate =
  被判定为重复或无效 attempt 的实际成本
  / 全部供应商实际成本

重复成本应按根因拆分:

  • 提交超时后盲目重试。
  • Callback 与 Polling 状态竞态。
  • 消费者重复执行远程提交。
  • 人工重跑缺少 attempt 隔离。
  • 供应商幂等窗口过短。
  • 路由切换时旧任务没有被确认取消。

该指标直接反映幂等设计质量,比单看 API 错误率更有业务价值。

20.9.3 成本感知路由

路由不能只比较供应商标价。应先做硬约束过滤:

模型能力
输入类型
分辨率与时长
区域与合规
租户合同
供应商并发额度
预算上限
熔断状态

再对候选供应商评分:

ExpectedCostPerSuccess_i =
  (预计提交成本
   + 可计费失败概率 × 失败成本
   + 重复概率 × 重复成本
   + 预计媒体后处理成本)
  / 近期成功率

综合路由评分可以是:

score_i =
  w1 × ExpectedCostPerSuccess_i
+ w2 × LatencyPenalty_i
+ w3 × QualityPenalty_i
+ w4 × ReliabilityRisk_i

权重按产品层级区分:

  • 免费或低价任务更重成本。
  • 付费极速任务更重延迟。
  • 企业品牌任务更重质量、区域和稳定性。

成功率、延迟和成本应基于同一模型、区域、输入类型和时间窗口统计,不能用全局平均数代替。

20.9.4 预算与止损

建议至少设置四层预算:

  1. 租户或项目周期预算。
  2. 单任务最大预算。
  3. 供应商账号和模型日预算。
  4. 平台全局小时、日和月 burn-rate。

阈值可分为:

70%:告警
90%:限制高成本或低优先级任务
100%:拒绝新任务、排队等待或路由到替代供应商
异常斜率:即使总额未达上限,也触发熔断或人工确认

Redis 可以维护近实时 burn-rate 和快速准入计数,但最终预算预占和结算必须写入 PostgreSQL。若 Redis 与 PostgreSQL 不一致,应以 PostgreSQL 为准。

20.9.5 存储与 CDN 生命周期成本

媒体成本通常来自:

供应商原始结果
平台归档原片
代理视频
HLS/CMAF 切片
缩略图和波形
渲染中间文件
最终导出文件
CDN 下行流量
跨区域复制和回源流量

对象应带有可归属标签:

tenant_id
project_id
task_id
asset_type
retention_class
created_at
expires_at

建议生命周期策略:

  • 供应商临时结果立即回源,但临时下载缓存短期保留。
  • 渲染中间文件在任务完成后短 TTL 删除。
  • 代理视频按项目活跃度保留。
  • 原始素材和最终成品按套餐或合同保留。
  • 冷数据转低频或归档存储,但恢复时间必须符合产品承诺。
  • CDN 通过合理缓存、Range 请求和签名 URL 降低重复回源。

媒体成本不应在每次播放请求上同步写主账本。更合理的方式是从对象存储清单、CDN 日志和渲染用量中按小时或按日聚合,再归属到租户、项目和产品。


20.10 每日账单对账

20.10.1 为什么内部记录不能替代外部账单

内部系统记录的是“我们认为发生了什么”,供应商账单记录的是“对方最终向我们收费什么”。两者都会出错:

  • 回调丢失导致内部漏记成功和成本。
  • 提交超时导致本地没有 provider task id。
  • 供应商延迟计费或跨日入账。
  • 供应商重复收费或发放 credit。
  • 币种、税费、舍入和套餐折扣不同。
  • 内部路由账号与供应商账单账号映射错误。

因此对账不是报表功能,而是跨系统最终一致性的必要组成部分。

20.10.2 对账层次

建议分四层:

  1. 钱包对账:账本聚合与余额快照一致。
  2. 任务对账:task、provider attempt、结算记录和成本事件一致。
  3. 供应商操作对账:供应商任务列表与本地 attempt 一致。
  4. 账单对账:供应商正式账单或 usage export 与内部实际成本一致。

20.10.3 对账流程

1. 从供应商 API、账单文件或用量导出获取原始数据。
2. 原始文件写入对象存储,保存 checksum、来源和获取时间,不覆盖旧文件。
3. 将不同供应商格式归一化为统一 invoice_line。
4. 优先按 provider_account_id + provider_task_id / external_charge_id 精确匹配。
5. 比较模型、时间、状态、数量、金额、币种和税费。
6. 生成 matched、unmatched 和 discrepancy 记录。
7. 对确定性差异执行自动修复候选;高风险差异进入人工复核。
8. 所有修复通过追加 cost event、refund、compensate 或 adjustment 完成。
9. 输出日对账报告和月度关账报告。

20.10.4 匹配策略

匹配优先级:

外部 charge id 精确匹配
→ provider task id 精确匹配
→ external idempotency key 精确匹配
→ 账号 + 模型 + 时间窗口 + 金额候选匹配
→ 人工复核

最后一种模糊匹配只能用于生成候选,不能自动认定事实。提示词、文件名或相近时间不能作为可靠唯一键。

20.10.5 自动修复边界

可以自动处理:

  • 同一外部 charge id 被重复导入。
  • 已知延迟账单行补录到唯一 attempt。
  • 供应商明确 credit note 抵扣原 charge。
  • 内部成本事件缺失但外部任务映射唯一。

不宜自动处理:

  • 一条外部账单可能对应多个本地 attempt。
  • 金额差异超出舍入容差。
  • 供应商任务找不到任何本地记录。
  • 用户已退款但供应商成本归属不清。
  • 涉及高金额、跨币种或税费调整。

20.10.6 对账时区与币种

  • 供应商账单周期可能使用 UTC 或供应商本地时区。
  • 内部事件统一存储 UTC,同时保存账单周期定义。
  • 原币金额、税费和供应商折扣必须保留。
  • 报表换算时保存 FX rate、rate source 和 rate timestamp。
  • 不要用当前汇率重算历史账单后覆盖原始金额。

20.11 性能瓶颈与容量估算

20.11.1 写入量估算

设:

T = 每月生成任务数
A = 每个任务平均 provider attempt 数
L = 每个任务平均用户账本事务数
C = 每个 attempt 平均成本事件数

则:

月账本事务数约为 T × L
月供应商成本事件数约为 T × A × C

例如每月 1000 万个任务,平均每任务 3.5 个用户账本事务,则每月约 3500 万个账本事务。若含索引后每个事务及分录平均占用 500~900 字节,应按数十 GB/月的量级规划,并通过真实压测修正。

20.11.2 峰值 TPS

设峰值任务创建速率为 R_task,一次创建事务写入:

1 个幂等记录
1 个 reservation
1 个 ledger transaction
2 个 ledger entry
1 个 task
1 个 charge snapshot
1 个 outbox
1 次 wallet update

粗略写放大约为 8~10 行,因此:

峰值行写入速率 ≈ R_task × 8~10

还需叠加完成结算、退款、预算和成本事件写入。容量规划不能只看 HTTP QPS。

20.11.3 在途预占资金估算

利用 Little’s Law:

在途任务数 ≈ 任务到达速率 × 平均完成时间
在途预占额 ≈ 在途任务数 × 平均预占金额

例如平均每秒 20 个任务、平均 180 秒完成、平均预占 80 credits:

在途任务约 3600
在途预占约 288000 credits

这个指标用于评估用户可见余额冻结规模、企业信用额度和平台供应商预算暴露。

20.11.4 热点钱包行

普通个人用户并发较低,SELECT FOR UPDATE 锁单个钱包行通常足够简单可靠。企业租户可能由大量成员共享一个钱包,形成热点行。

可选优化:

  • 按项目或组织部门拆子钱包。
  • 将大客户预算划分为多个可回收额度桶。
  • 使用 version 做乐观并发,并对冲突重试。
  • 对极高并发租户采用串行账户 Actor 或分区消费。
  • 预先分配有限的 spend allowance 给多个调度分区,再定期归并。

这些优化都必须保证总额度约束。不能简单把余额拆到多个 Redis key 后失去全局不超卖保证。

20.11.5 索引与分区

推荐索引:

ledger_transaction(wallet_id, business_key) unique
ledger_transaction(task_id, created_at)
credit_reservation(task_id) unique
credit_reservation(state, updated_at)
provider_attempt(provider, provider_account_id, provider_task_id) unique
provider_attempt(task_id, attempt_no)
provider_cost_event(provider, occurred_at)
reconciliation_item(run_id, status, discrepancy_type)
outbox_event(status, next_retry_at)

大表可按月分区,但需注意 PostgreSQL 分区表全局唯一约束限制。若 business_key 必须跨月份全局唯一,可将紧凑的 ledger_transaction 或专门的 idempotency registry 保持在可全局唯一的表中,再将大体量 entry 按时间分区。

20.11.6 批处理与实时性的取舍

  • 用户钱包结算:实时或准实时。
  • 供应商 attempt 预计成本:实时。
  • 正式供应商账单:按日增量、月度关账。
  • 对象存储和 CDN 成本:按小时或按日聚合。
  • 财务报表:从只读副本、数仓或 OLAP 生成,避免扫描主库账本。

20.12 高可用与降级方式

20.12.1 PostgreSQL 不可用

对于新预付费高成本任务应失败关闭:无法确认余额和写入账本时,不应继续调用供应商。

已经提交供应商的任务不能因为 Billing 暂时不可用就被盲目取消。回调、轮询和结算事件应进入 RocketMQ 重试或持久化待处理队列,数据库恢复后幂等补记。

20.12.2 Redis 不可用

  • 余额展示回退到 PostgreSQL 或短时返回“稍后刷新”。
  • 快速预算计数不可用时,最终 PostgreSQL 预算检查仍然生效。
  • 不因 Redis 丢失而重建、释放或重复扣款。

20.12.3 RocketMQ 不可用

本地任务、预占和 Outbox 仍可提交;Outbox backlog 增长。需要设置:

  • 最大 backlog。
  • 最长等待时间。
  • 高成本任务 admission control。
  • 超过阈值时暂停新任务,避免用户额度长期冻结。

20.12.4 Pricing Service 不可用

可在以下条件下使用本地缓存的不可变价格版本:

  • 版本仍在有效期。
  • 规则有完整签名或校验。
  • 未超过价格缓存最大陈旧时间。
  • 单任务金额低于安全上限。

否则拒绝新任务,而不是使用默认零价格或客户端价格。

20.12.5 Cost Collector 或对账服务不可用

核心用户任务可以继续,但应:

  • 保留所有 provider attempt 标识。
  • 让成本事件进入可重放队列。
  • 监控未入账成本暴露金额。
  • 达到阈值时降低高成本路由或暂停相关供应商。

对账服务属于最终一致性链路,短时不可用不应阻断用户结算;长期积压则必须触发风险控制。

20.12.6 供应商账单接口不可用

保留上次成功对账游标,使用幂等时间窗口回拉。恢复后应允许重叠窗口抓取,并通过外部行唯一键去重,避免严格按“上次时间 + 1 秒”导致边界漏单。

20.12.7 降级顺序

建议按业务价值和成本风险降级:

关闭免费重试和自动高质量重跑
→ 限制高分辨率、长时长和低优先级任务
→ 路由到成本更可控的模型
→ 暂停企业预算外任务
→ 暂停所有新高成本任务

不能优先关闭对账、审计或幂等检查来“提高吞吐”。


20.13 安全风险

20.13.1 客户端价格篡改

所有金额由服务端根据归一化参数和价格版本计算。客户端不能指定折扣、最终金额、供应商成本或退款金额。

20.13.2 越权读取和操作钱包

  • 每个钱包、账单和对账记录必须带 tenant 归属。
  • 内部 API 使用服务身份和最小权限。
  • 管理员退款、补偿和预算调整需要独立 RBAC。
  • 可结合 PostgreSQL RLS,但不能只依赖前端隐藏按钮。

20.13.3 账本篡改

  • 应用账号禁止更新和删除历史账本。
  • 高权限操作写入不可变审计日志。
  • 定期将账本摘要或校验结果导出到独立存储。
  • 对账原始文件保存 checksum,防止被覆盖后无法追责。

20.13.4 重放攻击

充值回调、供应商成本回调和退款接口都应验证:

签名
时间戳
nonce 或 event id
允许时间窗
业务唯一键

仅验证签名但不做 event id 去重,仍可能被合法报文重放。

20.13.5 内部人员滥用

  • 大额退款和补偿采用双人审批。
  • 补偿必须填写 case id、reason code 和证据。
  • 限制单人、单日和单租户的操作额度。
  • 定期审计异常补偿率和关联账户。

20.13.6 敏感数据与日志

账单原始文件可能包含账号、合同、交易和税务信息,应加密存储、限制访问并设置保留策略。日志中不要输出完整支付凭证、外部密钥、签名原文或不必要的用户信息。

20.13.7 促销额度套利

需要防范:

  • 批量注册领取额度。
  • 退款后恢复为更高价值额度。
  • 多租户转移或共享促销额度。
  • 即将过期额度通过反复预占延长有效期。

预占不能改变额度 lot 的原始到期日;释放后应恢复到原 lot 及原到期规则。


20.14 常见错误设计及其后果

错误设计后果
只在 user 表维护一个 balance无法解释余额变化,退款和对账缺少审计证据
把 Redis 当作唯一余额源故障切换、过期或重复执行会造成永久账务错误
供应商提交后再检查余额外部成本已发生但用户可能无可扣额度
在数据库事务中调用供应商长事务、锁等待,仍无法解决跨系统原子性
MQ 消费一次就假设只会执行一次重复结算、重复释放或重复远程提交
供应商请求超时就立即重提产生重复视频和双倍供应商费用
请求超时就释放预占供应商可能已受理,平台形成无担保成本暴露
回调成功就直接按回调金额扣用户混淆用户定价与供应商成本,且回调可重复或伪造
修改或删除历史流水修账审计链断裂,无法复盘和对账
使用 float64 计算金额累计舍入误差和跨语言不一致
结算时读取当前价格长任务在调价前后产生同参不同价争议
取消请求到达就立即退款供应商可能已经完成或仍会收费
只统计成功 attempt 的供应商费用低估失败、重复生成和故障损失
用提示词和时间做账单主匹配键容易错配,无法可靠自动补账
忽略存储、转码和 CDN 成本毛利判断失真,低价产品可能持续亏损
对账差异直接自动改余额错误匹配会放大为真实账务事故
预占到期扫描器无条件释放正在运行或结果未知的任务失去收费保障

20.15 面试官可能追问的 10 个问题与资深回答

问题 1:为什么不在任务完成后直接扣款,为什么一定要预占?

资深回答:

AI 视频任务在供应商受理后就可能产生不可逆成本,而任务完成可能在数分钟之后。如果只在完成后扣款,用户可以在任务执行期间耗尽或转移余额,平台承担信用风险。预占把“是否允许产生外部成本”前移到任务创建阶段。它不是消费,只是把可用额度转成冻结额度;成功后结算,失败后释放。这样既不提前确认收入,也能覆盖在途成本暴露。对于企业后付费,预占的对象不是余额,而是信用额度和合同预算。

问题 2:RocketMQ 重复投递时,怎样保证不重复扣款?

资深回答:

我不依赖 MQ exactly-once,而是让业务效果幂等。结算事件携带稳定的 task_idcharge_version,Billing 使用 settle:generation:{task_id}:{charge_version} 作为唯一业务键。事务内先检查或插入唯一的 ledger_transaction,再更新 reservation 和钱包快照。若消费者在提交数据库后、确认消息前崩溃,消息会重投,但唯一约束会识别已经结算,消费者只返回成功。状态条件更新是第一层,账本唯一键是最终防线。

问题 3:供应商已经受理,但本地提交超时,怎么处理?

资深回答:

不能按普通失败处理。我的 attempt 会携带稳定的外部幂等键,请求超时后进入 SUBMIT_UNKNOWN,保留用户预占和供应商预算暴露,不自动重提。优先通过幂等键、供应商任务列表、查询 API 或后续回调确认。只有获得明确未受理证据后,才创建新 attempt。若供应商既不支持幂等也不支持查询,我会降低自动重试次数,把潜在重复成本纳入路由评分,并依靠账单对账发现漏记任务。

问题 4:用户取消时应该收多少钱?

资深回答:

取消不是一个固定收费动作,而是由供应商阶段和任务创建时锁定的取消政策决定。提交前取消可以全部释放;供应商确认未开始且不收费时也可以释放;若已经消耗部分资源,则结算取消费或实际用量并释放剩余部分;已经完成则取消请求不再改变成功事实。对于结果未知状态,我只把请求记为 CANCEL_REQUESTED,不提前承诺取消成功或释放额度。取消与完成竞争通过状态版本和唯一结算键收敛。

问题 5:实际用量小于或大于预占额怎么处理?

资深回答:

小于预占额时,在同一事务中 SETTLE 实际金额并 RELEASE 差额。大于预占额要在产品规则中预先定义:固定价产品由平台承担超额;可扩展操作在执行前追加预占;企业后付费在信用额度内结算;或者设置硬上限停止后处理。预付费账户不能未经授权静默变负。所有任务都保存 price version 和 charge policy,不能在完成时临时决定。

问题 6:不可变账本和余额快照会不会重复保存事实?怎样保证一致?

资深回答:

两者服务不同查询:账本用于审计和重放,快照用于低延迟余额判断。每次 reserve、settle、release 或 refund,都在同一 PostgreSQL 事务中追加账本并更新快照,因此正常写路径强一致。后台再用账本聚合、active reservation 聚合对快照做校验。如果不一致,先冻结高风险操作并重建快照或追加补偿,不修改历史账本。Redis 只是快照缓存,不参与最终一致性。

问题 7:大型企业共享钱包造成行锁热点怎么办?

资深回答:

先量化冲突率。个人钱包使用 SELECT FOR UPDATE 往往最简单可靠。确实出现大型租户热点后,可以按项目或部门拆子钱包、分配可回收额度桶、使用乐观版本重试,或者让同一账户的账务命令进入固定分区串行处理。更极端时可以给调度分区预分配有限 allowance,再集中归并。无论哪种优化,都要保持总额度不超卖;我不会把多个 Redis key 的近似和当作账务约束。

问题 8:每日供应商对账具体怎样做?

资深回答:

先把供应商原始 usage 或 invoice 文件保存到对象存储,记录 checksum,之后标准化为统一账单行。匹配优先使用 external charge id、provider task id 和 external idempotency key,比较账号、模型、状态、金额、币种和时间。差异分类为内部缺失、外部缺失、金额不符、重复外部行和状态不符。确定性差异可以自动生成调整候选,歧义或高金额差异进入人工复核。最终修复通过追加 cost event、refund 或 adjustment 完成,不覆盖原流水。

问题 9:成本感知路由是不是永远选最便宜的供应商?

资深回答:

不是。先做能力、区域、合规、并发、预算和熔断等硬约束,再比较预期成功成本。预期成功成本应把可计费失败、重复提交、成功率和后处理成本都考虑进去。便宜但成功率低、超时率高的供应商,最终每个成功视频可能更贵。路由还要同时考虑延迟和质量,并按免费、极速、企业等产品等级使用不同权重。模型统计需要按供应商、模型、区域和输入类型细分。

问题 10:Billing PostgreSQL 故障时系统怎样降级?

资深回答:

对新预付费高成本任务应失败关闭,因为无法可靠确认额度和落账时继续提交供应商会形成无担保成本。已经提交的任务仍可能完成,回调和结算事件应进入持久化重试链路,数据库恢复后幂等补记。Redis 余额不能作为故障期间的替代事实源。若故障持续,还应限制免费重试、高分辨率和长时任务,防止在途成本暴露继续扩大。


20.16 3 分钟口述稿

我们平台的计费不是在用户表上减一个 balance,而是围绕 AI 任务建立“报价、预占、结算、释放、退款和对账”闭环。原因是视频生成是分钟级异步任务,供应商一旦受理就可能产生费用,而且还存在提交超时但对方已经成功、回调重复、取消与完成竞争等情况。

用户提交任务时,服务端先根据归一化参数和不可变价格版本计算报价与最大预占额。然后在同一个 PostgreSQL 事务里锁定钱包和预算,追加 RESERVE 账本,更新余额快照,创建 reservation、generation task 和 Outbox。这样只有预占成功的任务才会进入 RocketMQ 和供应商调度。Redis 只用于快速展示和准入加速,不作为余额事实源。

任务成功后,结算消费者使用稳定业务键追加 SETTLE 流水;如果实际费用低于预占,再在同一事务里 RELEASE 差额。RocketMQ 即使重复投递,也会被数据库唯一约束挡住。已经结算后的返还使用 REFUND,客服补偿使用 COMPENSATE,历史账本不修改、不删除。

最关键的异常是供应商已受理但本地超时。此时任务进入 SUBMIT_UNKNOWN,保留用户预占和供应商预算,优先用外部幂等键或查询接口确认,绝不直接释放后盲目重提,否则可能产生双份视频和双倍成本。用户取消也要等供应商阶段确认:提交前可以释放,运行中可能结算取消费,完成后则按成功规则结算。

用户价格与供应商成本分开管理。每个 provider attempt 都记录预计成本、实际成本和外部任务标识。路由不是简单选标价最低的供应商,而是比较包含失败、重复、成功率和媒体后处理在内的预期成功成本。对象存储、转码、GPU 渲染和 CDN 也需要按任务或租户归集,才能算出真实的单成功视频成本。

最后,我们每天把供应商任务和正式账单导入对象存储并做对账,优先按 charge id、provider task id 和幂等键匹配。所有差异形成可审计的 discrepancy,修复通过追加调整或补偿流水完成。这样即使消息至少一次、第三方状态不确定,系统仍能做到业务效果幂等、成本可追溯和账务最终一致。


20.17 10 分钟深入讲解提纲

第 0~1 分钟:说明问题边界

  • AI 视频任务长耗时、外部成本高、结果弱确定。
  • 区分用户权益、供应商成本和对账事实。
  • 强调 reserve、settle、release、refund 的不同语义。

第 1~2 分钟:讲核心不变量

  • PostgreSQL 是事实源,Redis 只负责速度。
  • 账本不可变,余额是可靠快照。
  • 用户价格与供应商成本分离。
  • 所有金额使用定点数,价格和政策版本化。

第 2~4 分钟:讲任务创建与结算时序

  • 服务端报价和最大预占。
  • 本地事务完成 wallet lock、RESERVE、task 和 Outbox。
  • RocketMQ 至少一次,结算使用唯一业务键。
  • 成功时 SETTLE 实际金额并 RELEASE 差额。

第 4~5 分钟:重点讲 uncertain outcome

  • 供应商受理、本地超时不能当失败。
  • SUBMIT_UNKNOWN、稳定外部幂等键、查询优先。
  • 保留用户 reservation 和 provider budget exposure。
  • 盲目重试造成的重复生成成本。

第 5~6 分钟:讲取消、失败和退款竞态

  • 提交前、运行中、已完成的取消收费不同。
  • Callback 与 Polling 共享状态转移函数。
  • 退款引用原 settle,补偿与退款分开。
  • 平台交付失败与供应商生成失败分状态处理。

第 6~7 分钟:讲数据模型

  • wallet_accountledger_transactionledger_entry
  • credit_reservationgeneration_charge
  • provider_attemptprovider_cost_event
  • budget_accountreconciliation_item
  • 关键唯一约束和状态条件更新。

第 7~8 分钟:讲成本与路由

  • 真实成本包含模型、失败、重复、审核、转码、存储和 CDN。
  • 单成功视频成本的分子必须包含失败尝试。
  • 硬约束过滤后,按预期成功成本、延迟、质量和风险评分。
  • 租户、供应商和全局多层预算及 burn-rate 止损。

第 8~9 分钟:讲每日对账

  • 原始账单不可变保存并记录 checksum。
  • charge id、provider task id、幂等键优先精确匹配。
  • 差异分类、自动修复边界和人工复核。
  • 调整通过追加成本或账本事件,不覆盖历史。

第 9~10 分钟:讲性能、高可用与取舍

  • 用任务量、attempt 数和流水放大估算写入与存储。
  • 热点钱包先用行锁,必要时拆子钱包或额度桶。
  • Billing 数据库故障时新高成本任务失败关闭。
  • MQ、Redis、Pricing 和对账服务分别如何降级。
  • 最后总结:本地强一致保证钱包与账本,跨系统靠幂等、状态机、Outbox、查询确认和对账补偿实现最终一致。

20.18 本章总结

AI 视频平台计费系统的难点不在“金额相减”,而在于正确描述和收敛跨系统的不确定事实。

面试中应反复强调以下几句话:

  1. 先预占,再允许产生外部高成本。
  2. 账本只追加,余额是事务性快照。
  3. MQ 可以重复投递,业务流水不能重复生效。
  4. 供应商已受理但本地超时属于未知,不是普通失败。
  5. 用户价格与供应商成本必须分账。
  6. 取消、失败和退款都要绑定价格与收费政策版本。
  7. 每个成功视频成本必须包含失败、重复、媒体处理、存储和 CDN。
  8. 对账是最终一致性链路的一部分,不是月底才看的报表。
  9. Redis 管速度和准入,PostgreSQL 管账务事实。
  10. 所有修正通过追加补偿事实完成,而不是覆盖历史。