題幹與適用場景
設計一個服務,為 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。重複上報只增加接收計數,不增加可計費數量;週期結算後凍結快照,遲到事件進入下一週期或人工調整。用量來源和聚合版本寫進發票明細,允許重新計算並比較差異。