AiVedio:计费、成本与对账
围绕额度预占、结算、释放、退款、成本归集、供应商账单、RocketMQ 重复投递、幂等账本、余额快照、差异对账、预算控制和成本感知路由,设计 AI 视频平台的计费、成本与对账系统。
第 20 章:计费、成本与对账
本章目标:设计一套适用于 AI 视频生成平台的计费与成本系统,使平台在长耗时、强异步、第三方结果不确定、消息至少一次投递的条件下,仍能做到不重复扣款、可解释结算、可追溯成本、可发现差异、可执行补偿。
20.1 本章要解决的业务问题
AI 视频任务与普通同步接口不同。一次请求可能经历排队、供应商提交、数分钟生成、回调或轮询、结果回源、转码、审核和最终交付。任意阶段都可能失败,而供应商是否计费、平台是否应向用户收费,并不总是由同一个状态决定。
计费系统至少要同时处理三套相互关联、但不能混为一谈的事实:
-
用户权益与收入事实
- 用户还有多少可用额度。
- 某个任务预占了多少额度。
- 最终实际消费、释放或退款了多少。
- 使用的是充值额度、订阅赠送额度还是促销额度。
-
供应商与基础设施成本事实
- 某次供应商调用预计成本与实际成本。
- 失败、取消、重复提交是否仍然产生费用。
- GPU 渲染、审核、对象存储、转码和 CDN 产生了多少成本。
-
对账与审计事实
- 平台内部任务、供应商任务和供应商账单能否一一对应。
- 钱包余额快照是否等于账本聚合结果。
- 重复生成、漏记成本、重复扣款和金额差异是否被发现。
本章重点解决以下典型问题:
- 用户余额足够时,如何原子地完成额度预占与任务创建。
- 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_of、reason_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 必须使用独立数据库,则不能声称存在原子事务。可采用:
- 先通过幂等 Billing API 创建 reservation。
- 再幂等创建 generation task。
- 若任务创建最终失败,由孤儿预占扫描器释放 reservation。
- 所有步骤均有业务键和可重放状态。
这种方案增加了 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
即使系统没有建设完整财务总账,也建议使用这种双分录思路,避免只记录一个难以解释的正负金额。
数据库权限上应禁止普通应用角色对账本执行 UPDATE 和 DELETE。修正只能追加新的 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 不能被简单理解为“到期立即释放”。只有确认任务从未进入可能计费的供应商阶段时,扫描器才能自动释放。处于 SUBMITTED、RUNNING 或 SUBMIT_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
报价必须基于服务端归一化参数。客户端传入的 price、discount 或 final_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 原子预占与任务创建
推荐流程:
- 解析并校验
Idempotency-Key。 - 服务端归一化参数并获取价格版本。
- 开启 PostgreSQL 事务。
- 查询已有幂等结果;若已存在,返回原任务。
SELECT ... FOR UPDATE锁定对应 wallet 和 budget 行。- 检查可用额度、信用额度和预算。
- 插入唯一的
ledger_transaction。 - 追加 RESERVE 分录。
- 更新
wallet_account.available_amount和reserved_amount。 - 创建 reservation、generation task、charge snapshot 和 Outbox。
- 提交事务。
伪代码:
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。
- 供应商终态已经被幂等写入。
- 输出满足产品交付条件,或收费策略明确规定“供应商成功即计费”。
- 结算使用创建任务时锁定的价格版本。
结算事务通常包含:
- 锁定
credit_reservation。 - 检查
settle:{task_id}:{charge_version}是否已经存在。 - 计算
final_charge_amount。 - 追加 SETTLE 分录。
- 若预占大于实收,追加 RELEASE 分录。
- 更新 reservation 和钱包快照。
- 更新
generation_charge.final_charge_amount。 - 写入结算完成 Outbox。
例如预占 120,实际收费 90:
SETTLE 90
RELEASE 30
两个动作应在同一事务中完成,避免任务已经结算但剩余额度长期未释放。
20.6.5 实际金额高于预占额
应在产品设计阶段明确策略,不能等任务完成后临时决定。常见方案:
- 固定售价:用户最多支付已报价金额,超出部分由平台承担。
- 执行前追加预占:在延长时长、高清放大或最终渲染前再做一次 top-up reserve。
- 企业后付费:允许在信用额度内结算,并进入应收账款。
- 硬性上限:达到预占上限后停止可选后处理或拒绝扩展操作。
预付费账户不应在未授权的情况下静默产生负余额。
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:
- 任务从未创建成功,且幂等请求已终止。
- 任务处于明确的本地终止状态。
- 没有任何
SUBMITTED、RUNNING、SUBMIT_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 预算与止损
建议至少设置四层预算:
- 租户或项目周期预算。
- 单任务最大预算。
- 供应商账号和模型日预算。
- 平台全局小时、日和月 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 对账层次
建议分四层:
- 钱包对账:账本聚合与余额快照一致。
- 任务对账:task、provider attempt、结算记录和成本事件一致。
- 供应商操作对账:供应商任务列表与本地 attempt 一致。
- 账单对账:供应商正式账单或 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_id 和 charge_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_account、ledger_transaction、ledger_entry。credit_reservation和generation_charge。provider_attempt与provider_cost_event。budget_account和reconciliation_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 视频平台计费系统的难点不在“金额相减”,而在于正确描述和收敛跨系统的不确定事实。
面试中应反复强调以下几句话:
- 先预占,再允许产生外部高成本。
- 账本只追加,余额是事务性快照。
- MQ 可以重复投递,业务流水不能重复生效。
- 供应商已受理但本地超时属于未知,不是普通失败。
- 用户价格与供应商成本必须分账。
- 取消、失败和退款都要绑定价格与收费政策版本。
- 每个成功视频成本必须包含失败、重复、媒体处理、存储和 CDN。
- 对账是最终一致性链路的一部分,不是月底才看的报表。
- Redis 管速度和准入,PostgreSQL 管账务事实。
- 所有修正通过追加补偿事实完成,而不是覆盖历史。