设计一个 SaaS 计费平台(类似 Stripe Billing / Chargebee):10 万企业订阅,混合定价 = 月度/年度固定费(seat-based)+ 按量计费(API 调用、存储 GB)。客户随时升降级套餐、增减席位,跨 30+ 国家、多货币、需自动算税。这是 Day 17 支付的上游:支付解决"怎么把一笔钱安全收掉",计费解决"这个月到底该收多少钱、向谁、为什么"。
面试要先澄清的关键约束:
组件职责:① 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,绝不重复出账。
status 列 + 转移日志可审计、可回放;用多个布尔字段拼状态会产生非法组合(既 trialing 又 past_due),早晚出 bug。# 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() # 推进到下一周期
trialing / active / past_due / canceled / unpaid),每个周期生成一张 invoice,billing_cycle_anchor 控制账单日,刻意把不同客户错峰到不同日期。核心 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 抵扣,不退现金
preview 让客户先看差价(对应我们 <300ms 同步预览的需求)。核心 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]
sum/count/last 聚合到账期;官方建议高频场景在应用侧先累加、定期 flush 一个聚合事件,避免打爆 API 与配额。核心 trade-off:本币计价体验最好,但引入 FX 风险、税则复杂度与对账维度爆炸。
【原理】两件事必须做对。货币:每个 price 用 minor units + ISO 货币码存储(日元无小数、第纳尔三位小数——别假设两位);跨币种不在出账时实时换汇,而是为每币种预设价格,避免汇率波动让账单不可复现。税:税额是额外加(exclusive,美国常见)还是已含(inclusive,欧盟含 VAT)由地区/货币决定;商品要打 tax code 决定税率与是否免税。税在出账瞬间按当时税率冻结进 invoice,事后税率变更不回改历史账单。
unpaid。这是 SaaS 挽回收入的关键一环。0.1 + 0.2 ≠ 0.3,累积舍入误差最终在对账时炸出几分钱的 break。永远用整数 minor units,且记住不是所有币种都两位小数。(sub_id, period) 唯一键就会重复出账、重复扣款。幂等是计费的底线。高频追问:① 周期出账用 cron 还是 per-subscription timer,怎么保证不漏不重?② 中途升级的 proration 一步步算给我看(按秒)。③ 10 亿用量事件/天的 metering 怎么设计,迟到事件怎么办?④ 年付怎么做收入确认,现金和收入为什么要分开记?⑤ inclusive vs exclusive 税,多币种为什么不实时换汇?
customer_id % N 分片到多 worker 并行,且用量 rollup 在周期内就增量算好,1 号当天只做"收尾 + 定稿"而非从零扫 10 亿事件。代价:增量状态要持久化与一致,崩溃恢复复杂。二阶收益:出账尖峰会传导到支付侧——10 万张账单同时 charge 会打爆 PSP 配额并撞上发卡行限流,所以打散账单日同时也在给下游支付削峰。
每次变更都按秒生成一对 ±proration line items,三次变更 = 6 条调整行 + 各自的剩余时间补收,账单像流水账。可解释性靠几点:
preview 让客户每次变更当场看差额(对应 <300ms 同步预览需求),减少事后客诉。陷阱:频繁变更可能产生"反复试套餐套利"——某些系统对一周期内多次降级设限或只在周期末结算,正是用干净度换公平性的 trade-off。
本质是 Day 20 流处理的 watermark / 迟到数据问题在计费域的投影:你要在"等多久才定稿"(完整性)与"多快出账"(时效)之间设一个 watermark,并为窗外数据准备可审计的补偿通道。
会计准则(ASC 606 / IFRS 15)要求收入按服务交付进度确认:年付一次收的现金里,未交付部分是递延收入(负债),要随服务逐月($100/月)转成已确认收入。原因是收了钱不等于赚到——若客户第二个月退订,多确认的收入要冲回。
对系统的要求:现金流与收入流必须分开记。Ledger 用双重记账:收款时 借现金 / 贷递延收入;每月 借递延收入 / 贷已确认收入。这正是 TigerBeetle 式 debit/credit 不变量的用武之地——任何时刻"已收现金 = 已确认 + 待确认"恒等,单边写入成为非法状态而非隐蔽 bug。中途取消则冲销剩余递延收入 + 退现金。
不能简单共用,它们幂等的是不同层次的操作:
(subscription_id, period_start),保证"这个周期只生成一张 invoice"。它防的是重复出账。边界:一张 invoice 可能被 charge 多次尝试(dunning:第 1/3/5 天重试)。这些重试应共享同一支付幂等键(针对该 invoice),让 PSP 识别为同一笔、不重复扣;但每次重试是计费状态机的合法转移,不应被计费层幂等当成"重复"丢弃。所以两层幂等键解耦:计费保证"账不重出",支付保证"钱不重扣",dunning 在两者之间编排重试——这正是分层幂等的经典设计。