目錄
DoorDash 的捐贈活動系統設計是一道在系統設計面試中出現頻率較高的題目,曾在 LeetCode Discuss 和多個面試分享平台上被記錄。題目看似簡單(用戶結帳時可以選擇捐款),但細節處理和規模討論可以展開很多。這篇複盤一個完整的 Mock 過程,重點在設計決策背後的推理,而不是記答案。
TL;DR
設計 DoorDash 捐贈活動的核心問題是:在數百萬用戶同時結帳、每筆訂單都可能觸發一次捐贈的情況下,如何可靠地記錄每筆捐贈、防止重複計算、並提供準確(或接近準確)的即時總計。答案是事件驅動架構 + Redis counter + 非同步 reconciliation,而不是每次捐贈都去寫資料庫再查計數。
設計哲學
DoorDash 的真實系統是基於 Apache Kafka 的事件驅動架構,核心系統叫做 Iguazu,每天處理數千億事件。這個背景解釋了為什麼 DoorDash 的系統設計思維天然地傾向事件驅動:微服務之間解耦,透過 Kafka topic 通信,下游服務訂閱各自需要的事件流。
核心概念
需求拆解
功能需求:
- 用戶在結帳時可以選擇捐款(通常是 $1、$2 或自訂金額)
- 顯示活動累積捐贈總計(例如「本次活動已募集 $1,234,567」)
- 捐款成功需要通知用戶
- 後台可以查詢特定時間段的捐贈統計
非功能需求:
- 捐款記錄不能丟失(金融交易的可靠性要求)
- 捐款不能重複計算(用戶只點一次捐款,但系統重試不能讓它被計算兩次)
- 累積總計允許秒級延遲(不需要強一致性的即時更新)
- 高峰期(晚餐時間)每秒可能同時有數萬筆訂單
規模估算
在面試中,規模估算的目的不是算出精確數字,而是確認設計選擇的量級合理性:
- DoorDash 的日訂單量約在數百萬筆(2024 年數據)
- 假設 30% 用戶選擇捐款:約 100 萬筆捐款/天
- 高峰期(晚餐 3 小時)集中約 40% 的訂單:約 40 萬筆/3 小時 ≈ 37 筆/秒平均,峰值可能 3-5 倍 ≈ 100-180 筆/秒
- 這個量級單一 Postgres 資料庫可以處理,但累積計數器的讀寫爭用是問題
系統架構
graph TB
subgraph "結帳流程"
Client["用戶端\n結帳 + 捐款選項"]
OrderSvc["訂單服務\n處理支付"]
PaySvc["支付服務\n扣款"]
end
subgraph "捐贈流程"
DonSvc["捐贈服務\n寫入捐贈記錄"]
DonDB["捐贈資料庫\nPostgres"]
Kafka["Kafka\ndonate.created topic"]
end
subgraph "統計流程"
Aggregator["計數聚合服務\n消費 Kafka 事件"]
Redis["Redis\n捐贈計數器"]
Dashboard["儀表板 API\n讀取計數器"]
end
subgraph "通知流程"
NotifSvc["通知服務\n訂閱 Kafka"]
Push["推播通知"]
end
Client --> OrderSvc
OrderSvc --> PaySvc
PaySvc -->|"支付成功回調"| DonSvc
DonSvc --> DonDB
DonSvc -->|"發布事件"| Kafka
Kafka --> Aggregator
Aggregator --> Redis
Redis --> Dashboard
Kafka --> NotifSvc
NotifSvc --> Push
關鍵設計決策
決策一:捐贈記錄的強一致性
捐款是金融交易,必須要求:
- 支付成功才記錄捐贈(不能先記錄再付款)
- 捐贈記錄不能因系統重試而重複
解法:冪等性設計
每筆捐贈用 order_id + donation_attempt_id 作為唯一鍵(或支付系統提供的 payment_reference_id):
CREATE TABLE donations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id TEXT NOT NULL,
payment_ref TEXT NOT NULL UNIQUE, -- 防重複插入
amount_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
payment_ref 加上 UNIQUE 約束,重複的支付回調 insert 會失敗(pg 的 ON CONFLICT DO NOTHING 讓這個情況靜默處理),確保即使支付回調被觸發多次,資料庫裡只有一筆記錄。
決策二:計數器聚合用 Redis,不用 SQL COUNT(*)
如果每次有人查看捐贈總計都去跑 SELECT SUM(amount_cents) FROM donations WHERE campaign_id = 'xxx',在百萬筆記錄下這個查詢很重。解法是維護一個 Redis counter:
INCRBYFLOAT campaign:2026q2:total_cents 200
每次新的捐贈事件被消費(從 Kafka),就用 INCRBYFLOAT 原子性地累加。Redis 的原子操作保證計數的一致性,效能是 O(1)。
反詰點:Redis 重啟後 counter 怎麼辦?
回答:Redis AOF 或 RDB 持久化可以減少丟失窗口。但即使丟失,可以用資料庫的 SUM 重新計算並回填。設計上接受「顯示的總計可能有秒級到分鐘級的延遲,但不會永久不一致」。
決策三:Kafka 消費的 at-least-once 語意
Kafka 的 consumer 保證 at-least-once delivery(可能重複消費同一事件)。這意味著計數器可能被重複增加。
解法:在消費端記錄已處理的事件 ID(存在 Redis set 或 DB),用冪等性 check:
event_id = event.headers['donation_id']
if redis.sismember('processed_donations', event_id):
return # 已處理,跳過
redis.incrbyfloat(f'campaign:{campaign_id}:total', event.amount)
redis.sadd('processed_donations', event_id)
redis.expire('processed_donations', 86400 * 7) # 7天後清理
跟常見替代方案比較
| 方案 | 優點 | 缺點 |
|---|---|---|
| 每次查詢跑 SQL COUNT/SUM | 強一致性,實作簡單 | 高並發下 DB 壓力大,慢 |
| Redis counter(事件驅動) | 快,可擴展 | 需要處理重複消費,最終一致 |
| 資料庫 + 物化視圖 | 強一致性,SQL 查詢 | 更新頻繁時物化視圖刷新代價高 |
| 2PC(分散式事務) | 強一致性 | 複雜度高,效能差,容易成瓶頸 |
適合面試的展開方向
面試中這道題的深度在於「你選擇什麼、為什麼、有什麼代價」:
-
Scale up:如果活動有 3,000 萬捐款,Redis counter 還夠嗎?(是的,
INCR是 O(1),Redis 每秒可以處理數百萬次操作) -
Fraud detection:同一個用戶短時間內捐款 1,000 次,怎麼處理?(rate limiting + anomaly detection 在捐贈服務層加)
-
Campaign end 的精確計數:活動結束時需要精確的最終數字而不是估算值(關閉 Kafka 消費後,跑一次 SQL SUM 作為最終確認,用 DB 值覆蓋 Redis counter)
整體來說
DoorDash 捐贈活動這道題的價值不在於答案,而在於它覆蓋了分散式系統設計裡最常見的幾個問題:冪等性、計數器聚合、最終一致性 vs 強一致性、事件驅動解耦。一個好的 Mock 過程是先釐清需求和規模、再提出幾個方案並討論取捨,而不是直接背出一個「標準答案」。
參考資料
相關標籤
相關文章
DDIA 第一章導讀:可靠性、可擴展性、可維護性——三個你一定用錯的詞
《Designing Data-Intensive Applications》第一章的核心論點是:「大資料」不是問題,資料密集型系統真正的挑戰是在可靠性(Reliability)、可擴展性(Scalability)和可維護性(Maintainability)三個維度上做出清醒的取捨。
系統設計面試是背八股嗎?
系統設計面試的核心不是記答案,而是展示你能從 first principles 推導出設計決策的過程。背熟 Kafka、Redis、一致性雜湊沒有用;能解釋「為什麼在這個情境選這個方案、它的代價是什麼」才重要。
系統設計 Mock:書籍電商平台的架構決策
設計一個書籍銷售平台時,關鍵決策是搜尋架構(Elasticsearch vs 全文搜尋)、庫存一致性(強一致 vs 最終一致)、以及訂單狀態機的設計。