题干与适用场景
设计一个服务,为 SaaS、媒体或会员产品管理周期性订阅。用户可以创建月付或年付计划,试用期结束后自动转为付费,周期中升级或降级会生成按比例调整,续费失败进入重试流程。系统还要生成可审计的发票、通知业务侧开通或收回权益,并处理支付方的异步事件。
公开面试资料把“设计订阅计费系统”作为系统设计题,要求处理订阅创建、试用结束等事件并讨论瓶颈;另一份公开记录还追问客户指标和系统监控。Stripe 的官方资料也把订阅、发票、PaymentIntent、试用、按比例计费、收入恢复和 Webhook 放在同一生命周期中。本文不宣称任何公司固定使用此题。
本文采用这些面试假设:100 万个活跃订阅,平均每天 1 次账单相关事件;续费高峰可达平时 10 倍;账单金额以最小货币单位保存;支付方可能超时、重复发送事件或晚于本系统完成状态更新。系统必须做到不重复扣款、账单可追溯、权益状态最终收敛。
面试官考察点
强回答先区分“计划、订阅、发票、支付尝试和权益”五个对象,而不是把余额直接写回用户表。面试官会观察候选人能否把订阅状态转换、账单金额快照和外部支付结果拆开。
第二个信号是幂等边界。创建发票、创建支付尝试、处理 Webhook 和发放权益都可能重试;只说“加一个重试队列”无法防止双扣或重复开通。
第三个信号是时间与会计语义。账单周期、试用结束、时区、闰月、升级即时生效和取消于周期末生效都要有明确规则。金额应来自不可变的发票行项目,而不是实时读取会变化的计划价格。
最后要能解释高峰、失败支付、事件乱序、人工退款、对账差异和数据修复。系统设计题的重点是恢复后仍能知道“应该收多少钱、实际收了多少、客户现在有什么权益”。
回答前需要澄清的问题
- 计费锚点是什么? 月付按自然月还是按创建日滚动?不同答案会改变周期结束计算和 2 月、31 日的处理。
- 升级何时收费? 立即开票、下个周期生效或允许客户选择,会改变 proration 和待付款状态。
- 降级如何处理? 立即退差额、只在下周期生效,还是生成余额抵扣,决定发票行项目与退款流程。
- 支付失败时何时收回权益? 可在宽限期内保留只读权限,也可立即暂停;必须区分
past_due与真正取消。 - 税、折扣和用量计费是否在范围内? 若包含,需要把价格计算版本化并在发票冻结前锁定输入。
- 谁是权益事实来源? 业务侧直接监听支付方事件,还是计费系统发布内部
entitlement.changed事件?这会改变一致性和重放方式。 - 是否需要多币种、退款和手工调整? 若需要,不能用浮点数相减,也不能覆盖历史发票。
30 秒回答框架
“我会把计划、订阅、发票、支付尝试和权益拆成独立对象。订阅状态机只决定当前周期和下一步动作,账单引擎在周期边界创建不可变发票快照,再用 invoice_id 作为扣款幂等键调用支付方。支付结果通过签名 Webhook 和主动查询双重确认,事件处理按事件 ID 去重并允许乱序。升级降级只写明确的正负发票行项目,失败支付进入带上限和抖动的重试;权益根据已确认的支付状态与宽限策略变化。最后用账务对账、状态不变量和故障注入证明不会重复扣款或永久错误开通。”
分步骤深入解答
第一步:定义对象和不变量。
Plan(id, version, currency, interval, unit_amount, trial_days)
Subscription(id, customer_id, plan_version, status, period_start, period_end)
Invoice(id, subscription_id, period_start, period_end, currency, total, status)
InvoiceLine(id, invoice_id, source, description, quantity, unit_amount, amount)
PaymentAttempt(id, invoice_id, attempt_no, provider_key, status, provider_id)
Entitlement(id, customer_id, feature, valid_until, source_invoice_id)计划可以更新,但已生成的发票必须保存价格版本和币种快照。核心不变量是:一张周期发票最多对应一个有效扣款操作;同一个支付方结果只能推进一次账单状态;已确认支付不能被晚到的失败事件覆盖;权益变更可以重放并收敛到同一版本。
第二步:用状态机驱动动作。
trialing --trial_end--> active
active --renewal--> invoice_open
invoice_open --payment_succeeded--> paid
invoice_open --payment_failed--> past_due
past_due --retry_succeeded--> paid
past_due --retries_exhausted--> canceled
active --cancel_at_period_end--> canceling
canceling --period_end--> canceled状态转换由带版本的命令或事件完成,不能让任意 Web 请求直接写状态。active、pastdue 和 canceled 的含义要写进权益策略:例如 pastdue 保留 3 天只读权限,canceled 才停止全部付费功能。每次转换记录原因、来源事件、操作者和时间。
第三步:生成账单并处理升级降级。
周期任务按 periodend 分片扫描到期订阅,先以唯一约束 (subscriptionid, period_start) 创建发票,再计算行项目。升级在周期中第 15 天发生时,可以把剩余旧计划记为负数,把剩余新计划记为正数;公式应基于账单周期内的可计费秒数或明确的日数规则,不能把金额四舍五入到浮点数后再相减。
发票创建后冻结金额、税、折扣、用量快照和计划版本。周期任务可重复运行:唯一约束让第二次运行拿到原发票,随后继续检查它是否已经有支付尝试。取消于周期末只修改 cancelatperiod_end,不删除历史发票;立即取消则产生退款或余额调整记录。
第四步:把支付幂等和网络超时分开。
provider_key = "invoice:" + invoice_id + ":attempt:" + attempt_no
POST payment-provider/charges
Idempotency-Key: provider_key系统在本地先写 PaymentAttempt,再调用支付方。超时后不能凭空判断失败,使用相同幂等键重试或查询支付方。Stripe 官方文档说明,重复的幂等请求会返回第一次请求的结果,并会比较参数避免同一键被错误复用;因此键必须稳定代表一次业务操作,而不是每次重试随机生成。
支付成功后由 Webhook 或主动查询写入 providerid、金额、币种和完成时间。数据库条件更新确保只有 open 或 pastdue 能被推进为 paid;晚到的 payment_failed 只能进入审计表,不能回滚已确认支付。金额或币种不匹配时进入人工对账,不自动开通或收回权益。
第五步:用事件表处理 Webhook 乱序和重复。
WebhookInbox(event_id PRIMARY KEY, received_at, payload_hash, processed_at)
Outbox(id, aggregate_id, event_type, payload, published_at)接收端先验证签名,把原始事件和哈希写入 WebhookInbox,相同 event_id 直接返回成功。处理器根据事件中的对象版本、支付方状态和本地账单状态决定是否推进;不要用“最后收到的事件”作为排序依据。Stripe 文档明确建议用订阅 Webhook 处理异步活动和权益变更,因此内部处理也要可重放。
本地事务可以同时写状态、权益变更意图和 Outbox;发布器再把 Outbox 发给权益服务。消费者处理内部事件时以 (aggregate_id, version) 去重。这样支付方重复事件、内部队列重试和权益服务重启都不会产生第二次有效授权。
第六步:计算容量并隔离高峰。
假设 100 万活跃订阅每天平均一次账单事件,稳态约 11.6 个事件/秒;10 倍续费峰值约 116 个事件/秒。真正的压力通常来自同一时刻的发票行项目、支付调用、Webhook 和通知,而不是状态表写入本身。周期任务应按时间桶分片、限制每批数量,并给任务加入抖动,避免整点惊群。
| 工作负载 | 主要瓶颈 | 控制手段 |
|---|---|---|
| 周期扫描 | 热点索引与锁竞争 | 按 period_end 分桶、短事务、唯一约束 |
| 支付调用 | 外部延迟与限流 | 有界并发、幂等键、指数退避 |
| Webhook | 重复和乱序 | Inbox 去重、版本条件更新、重放队列 |
| 权益同步 | 下游积压 | Outbox、消费 lag、按租户配额 |
数据库按订阅 ID 和周期结束时间建立索引;支付和通知采用异步队列。大租户、集中续费日或高用量账户应有独立配额,不能让一个租户占满支付并发。容量数字是起始假设,必须用真实支付方限额和故障压测校准。
第七步:对账、修复和可观测性。
每天按发票、支付方交易和银行结算导出对账。对每张发票检查:本地总额等于行项目之和,支付尝试金额等于发票应收,权益有效期不超过已确认的付费周期。发现差异时创建不可变的调整单或退款单,不直接覆盖旧金额。
关键指标包括到期扫描 lag、发票生成延迟、支付未知结果、重试次数、pastdue 年龄、Webhook 重复率和处理 lag、权益传播延迟、对账差异金额以及按租户的支付失败率。审计日志要关联 subscriptionid、invoiceid、paymentattempt_id、事件 ID 和 trace ID,支持从客户投诉追到原始支付响应。
第八步:用故障矩阵证明边界。
至少注入这些故障:周期任务重复运行;发票创建成功但进程崩溃;支付方已扣款但响应超时;同一 Webhook 重复或乱序;升级产生 proration 后支付失败;试用结束时没有支付方式;取消与续费并发;Outbox 发布后消费者崩溃;人工退款与自动重试同时发生。
验收断言是:同一 (subscriptionid, periodstart) 只有一张有效发票;同一 provider_key 不会产生第二次扣款;已确认支付不会被晚到失败覆盖;权益事件重放后版本相同;对账差异都有可追踪调整;所有自动动作都能从审计记录复原。
高质量示范回答
“我会先把计划、订阅、发票、支付尝试和权益拆开,计划价格只在发票生成时快照。订阅状态机处理试用、续费、宽限和取消,周期任务按 period_end 分桶创建唯一发票。升级降级写正负发票行项目,使用整数最小货币单位并明确按比例规则。
支付时,本地先写 attempt,再用稳定的 invoiceid + attemptno 作为支付方幂等键。遇到超时就用相同键重试或查询,不能随机换键。Webhook 先验签并写 Inbox,按事件 ID 去重;状态更新使用对象版本和条件更新,避免乱序的失败事件覆盖已确认支付。Outbox 把账单状态和权益变更意图可靠地交给下游。
我会把支付并发、周期扫描、Webhook、通知和租户配额分开限流,给续费峰值加抖动。每天用本地发票、支付方交易和结算结果对账,发现差异创建调整单而不覆盖历史。测试重点是重复周期任务、扣款成功但响应丢失、Webhook 乱序、proration 失败、取消与续费并发,以及重放后权益版本是否收敛。”
常见错误
- 错误表现:订阅表直接保存“已付费” → 失败原因:无法表达发票、支付尝试、宽限和退款 → 修正方法:拆出发票、支付尝试与权益对象。
- 错误表现:每次超时重试都生成新支付 ID → 失败原因:同一业务扣款可能被支付方执行多次 → 修正方法:一次操作固定一个幂等键,并查询未知结果。
- 错误表现:按收到事件的时间覆盖状态 → 失败原因:Webhook 可重复、乱序和延迟 → 修正方法:保存 Inbox,按对象版本和条件转换状态。
- 错误表现:实时读取当前计划价格重算历史发票 → 失败原因:改价会改变已开账单金额 → 修正方法:发票冻结价格、税、折扣和用量版本。
- 错误表现:失败就立即取消订阅 → 失败原因:短暂支付故障被放大成权益中断 → 修正方法:使用
past_due宽限和有上限的 dunning 状态机。 - 错误表现:取消操作删除周期数据 → 失败原因:审计、退款和对账失去依据 → 修正方法:只写取消事件、调整单和不可变历史。
- 错误表现:把数据库事务当成支付事务 → 失败原因:外部支付方不在本地事务中 → 修正方法:幂等调用、Webhook、查询和对账共同闭环。
- 错误表现:整点扫描全部订阅 → 失败原因:锁、支付并发和下游通知同时形成尖峰 → 修正方法:分桶、抖动、队列和租户配额。
追问及应对
追问一:升级后支付失败,权益应该如何变化?
先保留旧计划权益,创建 pendingupdate 和新发票;只有支付成功事件确认后才切换新计划。若业务允许先升级后追款,可以把新权益标记为宽限状态,并设置明确的收回时间。两种策略都要让发票和权益版本可追溯,不能只改 planid。
追问二:支付方 Webhook 连续三天没有到达,如何发现?
周期任务根据本地发票状态和支付方查询接口建立 reconciliation job。对 open、past_due 和已过期但没有终态事件的发票设置年龄告警;查询结果仍未知时暂停自动取消并进入人工队列。Webhook 是低延迟通知通道,不能作为唯一事实来源。
追问三:同一订阅同时收到取消和续费请求,谁先发生?
为订阅命令分配单调版本,在数据库条件更新或串行分区中决定顺序。cancelatperiodend 可以与当前周期续费共存;立即取消则需要检查是否已有 invoiceopen 或 payment_attempt,再创建退款或作废动作。最终状态和账务结果必须由审计事件解释。
追问四:如何支持按用量计费且不能重复计量?
用不可变 usage event ID 去重,按账单周期生成 usage snapshot。重复上报只增加接收计数,不增加可计费数量;周期结算后冻结快照,迟到事件进入下一周期或人工调整。用量来源和聚合版本写进发票行项目,允许重新计算并比较差异。