Day 18 Hard Billing Subscriptions Proration Metering

订阅与计费 — 把"按时间和用量收钱"做成可对账的引擎Subscription & Billing: Lifecycle State Machine, Proration, Usage Metering, Multi-currency & Tax

问题场景与需求约束

设计一个 SaaS 计费平台(类似 Stripe Billing / Chargebee):10 万企业订阅,混合定价 = 月度/年度固定费(seat-based)+ 按量计费(API 调用、存储 GB)。客户随时升降级套餐、增减席位,跨 30+ 国家、多货币、需自动算税。这是 Day 17 支付的上游:支付解决"怎么把一笔钱安全收掉",计费解决"这个月到底该收多少钱、向谁、为什么"。

面试要先澄清的关键约束:

高层架构

graph TD CAT[Pricing Catalog
产品/价格/计划] --> SUB[Subscription Service
生命周期状态机] EV[用量事件 ~10亿/天] --> ING[Metering Ingest
去重+幂等] ING --> AGG[聚合 Rollup
Redis→Postgres] SUB --> BILL[Billing Engine
周期触发 / Proration] AGG --> BILL TAX[Tax Engine
税率+税则] --> BILL BILL --> INV[(Invoice Store
不可变账单)] INV --> PAY[Payment / 收款
Day 17 幂等支付] PAY -->|失败| DUN[Dunning
重试+催收] INV --> LED[(Ledger
双重记账/收入确认)]

组件职责:① Pricing Catalog 定义产品、价格、计划(版本化,改价不影响存量订阅);② Subscription Service 维护每个订阅的生命周期状态机;③ Metering 把海量用量事件去重、聚合成可计费数量;④ Billing Engine 是核心——周期到点把"固定费 + 用量 + proration 调整 + 税"汇成一张 invoice;⑤ 账单交给 Day 17 的幂等支付收款,失败进 Dunning 催收;⑥ 所有金额落 Ledger 做收入确认与对账。

关键技术点

① 订阅生命周期状态机 + 周期触发 — 让"到点扣费"既不漏也不重

核心 trade-off:用一个显式落库的状态机,换掉散落在各处的 if-else 与重复扣费风险。

【原理】订阅不是一个布尔"激活/未激活",而是状态机:trialing → active → past_due → canceled / unpaid。状态转移由两类事件驱动:时间(周期结束 = 该出账)和动作(升级、取消、支付成功/失败)。到点扣费的触发有两种实现:扫表 cron(每分钟查 next_billing_at <= now)或为每个订阅注册一个 scheduled job。关键是幂等:把"为周期 P 生成 invoice"用 (subscription_id, period_start) 做唯一键,cron 重跑只命中已存在的 invoice,绝不重复出账。

选型对比:
# pseudo-code: 幂等周期出账(cron 每分钟跑)
def bill_due_subscriptions(now):
    for sub in db.query("status IN ('active','past_due') AND next_billing_at <= %s", now):
        period = (sub.current_period_start, sub.current_period_end)
        # 唯一键去重:同周期只出一张账
        inv = db.get_or_create_invoice(sub.id, period[0])
        if inv.status != 'draft':          # 已出过 → 跳过
            continue
        add_recurring_lines(inv, sub)      # 固定费/席位
        add_metered_lines(inv, sub, period)# 用量(见技术点③)
        finalize_and_charge(inv)           # 定稿→交支付
        sub.advance_period()               # 推进到下一周期
现实案例:

② Proration(按比例计费)— 中途变更怎么"补差价"

核心 trade-off:proration 让定价公平且即时,但每一次变更都往账单塞入正负两条调整行,复杂度与可解释性成本陡增。

【原理】客户在周期中途从 $10/月 升到 $20/月,不能整月按新价收。proration 把变更点切成两段:退还旧套餐剩余时间的未使用部分(负数 credit),补收新套餐剩余时间(正数 charge)。半个月升级 → -$5(旧价未用)+ $10(新价剩余)= 净补 $5。这些以 invoice line item 形式累积,下次出账或立即结算。比例因子 = 剩余秒数 / 周期总秒数——按秒算,不是按天,否则边界不公。

选型对比:
# pseudo-code: 升级 proration(按秒比例)
def prorate_change(sub, new_price, now):
    total = sub.period_end - sub.period_start      # 周期总秒数
    remain = sub.period_end - now                  # 剩余秒数
    factor = remain / total
    credit = -round(sub.current_price * factor)    # 退旧价未用(minor units)
    charge =  round(new_price        * factor)     # 补新价剩余
    add_line(sub.invoice, "unused time credit", credit)
    add_line(sub.invoice, "remaining time @ new", charge)
    sub.current_price = new_price                  # 余额 credit 抵扣,不退现金
现实案例:

③ 用量计费(Metering)— 10 亿事件/天怎么聚合成几个数字

核心 trade-off:在精度(每个事件都算)与吞吐/成本(聚合后再记)之间权衡,外加迟到事件破坏已定稿账单的风险。

【原理】metering pipeline 四段:Emit(业务发用量事件)→ Ingest(校验+幂等去重)→ Meter(聚合成可计费量)→ Invoice(汇入账单)。10 亿/天直接一条条写 DB 会爆,工业做法是两级聚合:先在应用内存/Redis 按 (customer, meter, 小时) 累加,每分钟 flush 一次到 Postgres rollup 表,出账时只 SUM 几千行小时桶。聚合函数受限:通常只支持 sum / count / last——"峰值并发""日均存储"这类 max/avg 要业务侧自己算好上报 last 值。事件必须带 幂等键(event_id)去重,否则客户端重试 = 多计费。

选型对比:
# pseudo-code: 两级聚合 + 幂等去重
def ingest(event):
    if not redis.set(f"seen:{event.id}", 1, nx=True, ex=DAY):  # 幂等
        return                                  # 重复事件丢弃
    key = (event.customer, event.meter, hour_bucket(event.ts))
    redis.hincrby("usage", key, event.qty)      # 内存累加

def flush_every_minute():                        # 二级:落 Postgres
    for key, qty in redis.hgetall("usage"):
        db.upsert_rollup(key, qty)               # 小时桶
# 出账:SELECT SUM(qty) ... WHERE customer=? AND ts IN [period]
现实案例:

④ 多货币与税务 — 同一套引擎服务全球

核心 trade-off:本币计价体验最好,但引入 FX 风险、税则复杂度与对账维度爆炸。

【原理】两件事必须做对。货币:每个 price 用 minor units + ISO 货币码存储(日元无小数、第纳尔三位小数——别假设两位);跨币种不在出账时实时换汇,而是为每币种预设价格,避免汇率波动让账单不可复现。:税额是额外加(exclusive,美国常见)还是已含(inclusive,欧盟含 VAT)由地区/货币决定;商品要打 tax code 决定税率与是否免税。税在出账瞬间按当时税率冻结进 invoice,事后税率变更不回改历史账单。

选型对比:
现实案例:

扩展与优化(增长后怎么办)

常见陷阱 + 面试追问

1. 用 float 存钱。 0.1 + 0.2 ≠ 0.3,累积舍入误差最终在对账时炸出几分钱的 break。永远用整数 minor units,且记住不是所有币种都两位小数。
2. 出账不幂等。 cron 重跑、消息重投、客户狂点升级,没有 (sub_id, period) 唯一键就会重复出账、重复扣款。幂等是计费的底线。
3. proration 按天而非按秒。 边界日的归属会让客户多付/少付,争议不断;且要明确升级立即收、降级记 credit 的策略。
4. 改价就地改存量订阅。 价格必须版本化,存量订阅引用旧版本;否则一次调价把所有老客户账单全改了,等着上新闻。
5. 迟到用量事件直接塞进已定稿账单。 已 finalize 的 invoice 应不可变;迟到事件入下期或走显式 credit note 调整,而不是偷偷改历史。

高频追问:① 周期出账用 cron 还是 per-subscription timer,怎么保证不漏不重?② 中途升级的 proration 一步步算给我看(按秒)。③ 10 亿用量事件/天的 metering 怎么设计,迟到事件怎么办?④ 年付怎么做收入确认,现金和收入为什么要分开记?⑤ inclusive vs exclusive 税,多币种为什么不实时换汇?

深入资源

深入思考(点击展开答案)

1. 10 万订阅都在每月 1 号出账,billing run 扛不住尖峰。除了"加机器",至少给两种结构性解法,各自代价?
  • 打散 anchor date:按客户创建日或 hash 把账单日均匀分布到全月 1–28 号,尖峰摊成 1/30。代价:客户账单日不统一,企业客户做财务对账时可能希望统一账期,需要支持"自定义对齐日"作为例外。
  • 分片并行 + 增量预聚合:billing run 按 customer_id % N 分片到多 worker 并行,且用量 rollup 在周期内就增量算好,1 号当天只做"收尾 + 定稿"而非从零扫 10 亿事件。代价:增量状态要持久化与一致,崩溃恢复复杂。

二阶收益:出账尖峰会传导到支付侧——10 万张账单同时 charge 会打爆 PSP 配额并撞上发卡行限流,所以打散账单日同时也在给下游支付削峰。

2. 客户在一个周期内升级→降级→再升级三次,账单会变成什么样?怎么让它仍然可解释?

每次变更都按秒生成一对 ±proration line items,三次变更 = 6 条调整行 + 各自的剩余时间补收,账单像流水账。可解释性靠几点:

  • 每条 line item 带 period_start/end + 关联的 price_id + 比例因子,能还原"为什么是这个数"。
  • 负 proration 进账户 credit 余额而非立即退款,最终账单 = 净额,避免反复退款手续费与套现欺诈。
  • 提供 preview 让客户每次变更当场看差额(对应 <300ms 同步预览需求),减少事后客诉。

陷阱:频繁变更可能产生"反复试套餐套利"——某些系统对一周期内多次降级设限或只在周期末结算,正是用干净度换公平性的 trade-off。

3. 一个用量事件在周期结束后 2 小时才到达(客户端缓冲/网络延迟)。塞进已关账单、计入下期、还是别的?各方案破在哪?
  • reopen 已关账单:归属正确,但已 finalize/已收款的 invoice 再改违反"账单不可变",且若已对账会制造 break,监管也不喜欢。
  • 计入下期:实现简单、账单不可变;但收入归属错期(这个月的用量算到下个月),RevRec 与客户对账会对不上。
  • 宽限窗口 + credit note:设一个出账宽限窗(如周期末后 N 小时才定稿),窗内的迟到事件仍计入本期;窗外的用显式 credit/debit note 作为对历史账单的可审计调整,而非偷改原单。

本质是 Day 20 流处理的 watermark / 迟到数据问题在计费域的投影:你要在"等多久才定稿"(完整性)与"多快出账"(时效)之间设一个 watermark,并为窗外数据准备可审计的补偿通道。

4. 年付一次性收了 $1200,为什么不能当月就确认 $1200 收入?这对系统数据模型有什么要求?

会计准则(ASC 606 / IFRS 15)要求收入按服务交付进度确认:年付一次收的现金里,未交付部分是递延收入(负债),要随服务逐月($100/月)转成已确认收入。原因是收了钱不等于赚到——若客户第二个月退订,多确认的收入要冲回。

对系统的要求:现金流与收入流必须分开记。Ledger 用双重记账:收款时 借现金 / 贷递延收入;每月 借递延收入 / 贷已确认收入。这正是 TigerBeetle 式 debit/credit 不变量的用武之地——任何时刻"已收现金 = 已确认 + 待确认"恒等,单边写入成为非法状态而非隐蔽 bug。中途取消则冲销剩余递延收入 + 退现金。

5. 计费系统和 Day 17 支付系统的幂等键,能不能共用一个?边界在哪?

不能简单共用,它们幂等的是不同层次的操作

  • 计费幂等键 = (subscription_id, period_start),保证"这个周期只生成一张 invoice"。它防的是重复出账
  • 支付幂等键 = 每次 charge 的 idempotency key(如 invoice_id 或一次性 UUID),保证"这张账单只扣一次钱"。它防的是重复扣款

边界:一张 invoice 可能被 charge 多次尝试(dunning:第 1/3/5 天重试)。这些重试应共享同一支付幂等键(针对该 invoice),让 PSP 识别为同一笔、不重复扣;但每次重试是计费状态机的合法转移,不应被计费层幂等当成"重复"丢弃。所以两层幂等键解耦:计费保证"账不重出",支付保证"钱不重扣",dunning 在两者之间编排重试——这正是分层幂等的经典设计。