Tesla Robotaxi 自駕叫車 (Design Tesla Robotaxi App)
設計 Tesla Robotaxi 叫車 App:乘客輸入起點/目的地拿 車資預估 → 確認後 叫車 → 系統把乘客與 附近可用的自駕車(AV)配對 → 派車前往接送。本質是 即時地理配對 + 強一致派遣 系統(Uber/Lyft 的近親,但司機換成自駕車隊)。
招牌取捨:配對牽涉 海量位置寫入(1,000 萬 AV × 每 5 秒一次 ≈ 200 萬 writes/sec)與 強一致派遣(同一台 AV 不可同時被指派給多個行程、同一行程不可被多台 AV 同時指派)。設計核心是 位置走 Redis GEO(in-memory + 地理指令)、叫車事件走 queue 緩衝尖峰、配對狀態集中放 Redis(matching state + per-ride lock)、最終靠 Ride DB 的 partial unique index 兜底——用「集中共享狀態 + 唯一約束」換 stateless worker 之下的正確性。
配速(35 min 預算):~5 min 釐清需求 → ~4 min 容量估算 → ~3 min API + ~4 min 資料模型 → ~6 min 高階架構(4 條 flow)→ ~9 min deep dive(位置/GEO、queue 防丟、1 ride 1 AV、1 AV 1 ride)→ ~3 min 擴展瓶頸 → ~1 min 收尾。開場先一句話複述題目、宣告配速、把「車資預估怎麼算」「H3/S2 自製地理索引」等細節標記為待會 deep dive 或 black box——宣告配速本身就是高分動作。
1. 釐清需求與範圍 (Clarify & Scope ~5 min)
開場後先問清楚邊界,別急著畫架構。叫車題的關鍵是先鎖定「配對」是主戰場、把「車資怎麼算」推成 black box。建議問題:
| 釐清問題 | 為何要問 / 影響到哪個決策 | 本題假設(依來源) |
|---|---|---|
| 配對要多快回應成功/失敗? | 決定是否需 in-memory 地理索引、是否上 queue | 1 分鐘內完成配對成功或失敗 |
| 一致性要求多強? | 決定要不要 lock / 唯一約束 / 交易 | 強一致:1 AV : 1 Ride(雙向不可重複指派) |
| 規模多大?同時多少 AV / 尖峰多少請求? | 直接驅動位置寫入估算與 queue 決策 | 同時 1,000 萬 AV、尖峰單社區 10 萬請求 |
| AV 位置多久更新一次?要保留歷史嗎? | 決定位置儲存選型(in-memory vs 持久 DB) | 每 5 秒一次;位置是 ephemeral,不留歷史 |
| 車資預估要不要算?多複雜? | 避免掉進 ML/pipeline 兔子洞 | 視為 black box(實務需 offline pipeline) |
| AV 怎麼跟後端通訊? | 決定派遣命令的協定(push 而非 poll) | 持久連線(gRPC 雙向 stream / WebSocket) |
| AV 可以拒絕行程嗎? | 決定 dispatch 是否需 retry 下一個 candidate | 可以(如 LOW_BATTERY → 換下一台) |
「車資怎麼精準計算 我先標記成 black box——實務上它需要 offline data pipeline + online service 餵即時供需資料,但那不是本題的決勝點。我們先把它當成 Ride Service 內回傳一個 Fare 的函式,這樣能先把 配對與派遣 這條主線串起來。」把非核心細節推遲,保住敘事連貫。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
-
功能性需求 (FR)
- 乘客輸入 起點 + 目的地,取得 車資預估(fare estimate)。
- 乘客依預估車資 發出叫車請求(request a ride)。
- 系統把乘客與 附近且可用的 AV 配對。
- 配對成功後 派遣 AV 前往接送,並把 matched AV 即時位置 / 車牌 等通知乘客。
-
非功能性需求 (NFR)
- 低延遲配對:配對成功或失敗 1 分鐘內 完成。
- 強一致性:避免 同一 AV 被指派給多個行程、或 同一行程被多台 AV 同時指派。
- 高吞吐量:尖峰 / 特殊活動(如單社區同時 10 萬請求)不可丟請求。
- 隱含:海量位置寫入(後段估出 ≈200 萬 writes/sec)、位置 ephemeral(不需歷史)。
設計北極星(NFR 直接導出後續所有選型的判準):
| NFR | 導出的設計北極星 | 後續對映選型 |
|---|---|---|
| 低延遲配對(<1 min) | proximity search 不能 full scan、不能打慢速 DB | Redis GEO(in-memory 地理索引) |
| 海量位置寫入(200 萬/s) | 不能用一般 DB 承接(上限 ~10k/s)、且不需歷史 | in-memory store(Redis)只存最新位置 |
| 強一致(1 AV : 1 Ride) | stateless worker 之間要共享配對狀態 + 最終要有唯一兜底 | Redis matching state + per-ride lock + DB unique index |
| 高吞吐量、尖峰不丟 | Ride 接單與配對要解耦,要有緩衝吸收尖峰 | Ride Service → Queue → Matching Service |
| AV 拒絕可換車 | dispatch 要能對下一個 candidate retry(同步回饋好寫) | MS↔AV / MS→RS 用 RPC(retry loop 易實作) |
「這是一個 即時地理配對(位置寫密集)+ 強一致派遣(狀態共享)+ 尖峰不丟(queue 緩衝) 的系統」——記住這三組詞,後面每個選型都能從它推回去。
3. 容量估算 (Back-of-Envelope ~4 min)
估算的目的是 驅動位置儲存與 queue 決策(估算 → 選型的 hand-off)。回連 02-Distributed-Systems/05-Numbers-to-Know。
位置寫入流量(決定性數字)
同時在線 AV ≈ 10,000,000(1,000 萬)
每台 AV 位置更新間隔 = 5 s
位置寫入 QPS = 10,000,000 ÷ 5 = 2,000,000 writes/sec(200 萬)
對照一般 DB 寫入上限
一般關聯式 DB 單節點寫入吞吐 ≈ 10,000 writes/sec(量級)
200 萬 / 1 萬 = 200 倍 → 一般 DB 直接不可行
即便水平擴展也成本高到不合理 → 必須換 in-memory store
叫車尖峰流量
單社區尖峰 ≈ 100,000(10 萬)ride requests 同時湧入
→ 若 Ride Service 同步等配對完成才回覆,極易被打爆 / 丟請求
→ 需要 queue 把「接單」與「配對」解耦
下表位置 QPS 為由「在線 AV 數 ÷ 更新間隔」算出的 推導值;DB ~10k/s 上限為量級 推導值(來源以此論證為何要換 in-memory);10 萬尖峰為來源直接給出的輸入。
| 維度 | 數字 | 對後續決策的意義 |
|---|---|---|
| 位置寫入 QPS | ≈ 2,000,000 /sec(推導值) | 遠超一般 DB(~10k/s)→ 位置改用 Redis in-memory |
| DB 寫入上限 | ≈ 10,000 /sec(量級推導值) | 證明位置不能放一般 DB;硬擴展成本不合理 |
| 尖峰叫車並發 | ≈ 100,000 /社區(來源輸入) | 接單與配對 解耦、中間插 queue 吸收尖峰 |
| 位置資料性質 | ephemeral,只留最新 | 不需歷史 → in-memory 可接受(丟失靠 RDB/AOF persistence) |
200 萬 writes/sec 這個數字就是整題的轉折點:它一口氣淘汰「位置放一般 DB」,逼出 in-memory store(Redis)只存最新位置。能把「為什麼一般 DB 不行(量級對比)」講清楚,比直接說「我用 Redis」更高分。對照 06-Database-Tech/01-Database 的寫入特性與 04-Database-Indexing/05-Geospatial-Index 的地理查詢限制。
4. 用到的積木 / Building Blocks Used
架構即目錄:先給導覽圖,再展開。下表每個決策都對映一個既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源(single source of truth)。
| 設計決策 | 積木 | 在本題的作用(≤1 行) |
|---|---|---|
| 容量估算驅動選型 | 02-Distributed-Systems/05-Numbers-to-Know | 算出 200 萬 writes/sec,逼出 in-memory 位置儲存 |
| 地理鄰近查詢 | 04-Database-Indexing/05-Geospatial-Index | B-tree 不適合多維座標,需專門地理索引做 proximity search |
| 位置 in-memory 高頻寫入 + GEO | 07-Caching-Storage/04-Redis | Redis 存最新位置、GEOADD/GEOSEARCH 做鄰近查詢、RDB/AOF 防丟 |
| 配對狀態跨 worker 共享 | 07-Caching-Storage/03-Distributed-Cache | matching state(candidates/cursor/status)集中放 Redis 共享 |
| 尖峰緩衝、接單與配對解耦 | 09-Messaging-Coord/02-Queue | Ride 事件入 queue,吸收 10 萬尖峰,Matching Service 慢慢消費 |
| 尖峰不丟請求 / 不因 MS crash 丟 | 12-Ops-and-Reliability/03-Reliable-Delivery | queue 持久化 + at-least-once 消費,保證 ride event 不遺失 |
| 多 worker 更新同一 ride state | 09-Messaging-Coord/04-Distributed-Lock | per-ride lock,確保同時只有一個 worker 更新該 matching state |
| 防止重複指派的根因(競爭) | 12-Ops-and-Reliability/01-Dealing-With-Contention | dispatch 競態(timeout 後雙 ACCEPT)是典型 contention 問題 |
| 1 AV : 1 Ride 最終兜底 | 05-Database-Advanced/01-Transactions | Ride DB ACID + partial unique index 保證唯一 active ride/AV |
| 唯一性靠索引兜底 | 04-Database-Indexing/01-Indexing-Overview | rides(av_id) partial unique index:只一個 update 成功 |
| AV ↔ 後端持久連線 | 01-Networking/05-Realtime-Protocols | AV 與 Gateway 持久驗證連線(gRPC stream / WebSocket)回傳位置 |
| 同步派遣命令 / retry 回饋 | 03-API-Design/04-RPC-and-gRPC | MS↔AV、MS→RS 用 gRPC,retry loop(換下一台)易實作 |
| 派車成功即時通知乘客 | 10-Design-Patterns/05-Real-time-Updates | 把 matched AV 位置/車牌即時推給乘客 App |
| AV 大量連線分散 | 01-Networking/06-Load-Balancing | AV Gateway 前置 LB,分散 1,000 萬條持久連線 |
5. API 設計 (API Design ~3 min)
由 FR 驅動。乘客面是 REST(理據見 03-API-Design/02-REST);AV 面是 gRPC(持久連線 + 雙向命令,見 03-API-Design/04-RPC-and-gRPC)。
| 角色 | Method / 介面 | Path / message | 用途 | 重點 |
|---|---|---|---|---|
| 乘客 | POST |
/fare |
取得車資預估 | body { pickupLocation, destination } → Fare |
| 乘客 | POST |
/rides |
依報價發出叫車 | body { fareId } → Ride(觸發配對) |
| AV | gRPC(server→AV) | DispatchCommand |
派遣命令 | { ride_id, pickup+destination ... } |
| AV | gRPC(AV→server) | DispatchDecision |
AV 接受 / 拒絕 | { ride_id, decision: ACCEPT|REJECT, reason } |
| AV | gRPC bidi stream(持續) | location/status updates | 定期回傳位置與狀態 | 走持久連線,AV Gateway 轉發給 Location Service |
乘客先 POST /fare 拿到帶 fareId 的報價,確認後 才 POST /rides { fareId }——兩步式讓「報價」與「下單」分離,後端可在 /rides 才真正觸發昂貴的配對流程;fareId 也綁定了報價快照,避免下單時價格漂移。
6. 資料模型與儲存選型 (Data Model & Storage Choice ~4 min)
本題有 三種狀態,各有最適儲存:持久行程(Ride DB)、最新位置(Redis GEO)、配對中間狀態(Redis matching state)。分開選型是核心。
Ride DB(關聯式,ACID)
--------------------------------------------------
ride_id PK
rider_id 乘客
av_id 指派到的 AV(配對前為 NULL)
fare_id 對應報價快照
status REQUESTED | DRIVER_ASSIGNED | IN_PROGRESS | DONE | FAILED
pickup, destination, created_at ...
--------------------------------------------------
關鍵:partial unique index 保證 1 AV : 1 active ride
CREATE UNIQUE INDEX uniq_active_ride_per_driver
ON rides(av_id)
WHERE status IN ('DRIVER_ASSIGNED','IN_PROGRESS');
Redis 位置(in-memory,承高頻寫)
--------------------------------------------------
雙向對應(更新時要能從舊位置移除 driver):
encoded_location → [driver_ids] (GEO 索引,供 GEOSEARCH)
driver_id → encoded_location(反查,更新時清舊位置)
--------------------------------------------------
Redis matching state(in-memory,跨 worker 共享)
--------------------------------------------------
Key: match:ride:<ride_id>
Value: { candidates: [v1,v2,v3,...],
cursor: 0/1/2...,
status: "SEARCHING" | "DONE" | "FAILED" }
儲存選型:
| 狀態 / 資料 | 選擇 | 理由 |
|---|---|---|
| 行程(持久) | 關聯式 DB(ACID) | 需 強一致兜底(partial unique index)、需 transaction 保證狀態轉移正確 |
| AV 最新位置 | Redis(in-memory + GEO) | 200 萬 writes/sec 一般 DB 扛不住;位置 ephemeral 不需歷史;GEO 指令做鄰近查詢 |
| 配對中間狀態 | Redis(共享 K-V) | stateless worker 需共享 candidates/cursor/status;天生短命、要快 |
| 一般 DB 存位置 | ❌ 不選 | 寫入上限 ~10k/s,比需求低 200 倍;B-tree 不適合多維地理座標 |
→ 積木:行程強一致見 05-Database-Advanced/01-Transactions 與 04-Database-Indexing/01-Indexing-Overview;位置與配對狀態見 07-Caching-Storage/04-Redis;地理查詢限制見 04-Database-Indexing/05-Geospatial-Index。
「Redis 存位置」靠的是 位置 ephemeral(只留最新) 這個前提。一旦需求變成「保存歷史軌跡做計費 / 重播」,就得再加一條 持久寫入路徑(位置事件入 09-Messaging-Coord/03-Kafka 落地)——那是另一個系統,本題刻意不做。
7. 高階架構 (High-Level Design ~6 min)
把積木串成端到端四條 flow,再看 scaled 圖。
(A) 報價 flow(fare estimate)
Rider App
│ POST /fare { pickup, destination }
▼
Ride Service
│ 1. 計算 estimated fare(black box → 實務需 offline pipeline)
│ 2. 在 DB 建立 fare 紀錄(保存報價快照)
▼
回傳 Fare(含 fareId)
(B) 叫車 flow(request ride)
Rider App
│ POST /rides { fareId }
▼
Ride Service
│ 1. 在 ride table 建立紀錄(status=REQUESTED)
│ 2. 發佈 ride event 到 Queue,立即回 200(不等配對完成)
▼
回傳 Ride(接單即完成,配對非同步)
(C) 配對 flow(matching)
AV ──持久連線(gRPC stream)──► AV Gateway ──► Location Service ──► Redis(位置, GEOADD)
(每 5 秒回傳 location/status;更新時用 driver_id→loc 反查清舊位置)
Queue ──ride event──► Matching Service (多個 stateless worker)
│ 1. GEOSEARCH 取附近可用 AV → candidates 清單
│ 2. 在 Redis 建 matching state { candidates, cursor:0, status:SEARCHING }
▼
進入派遣 flow (D)
(D) 派遣 flow(dispatch + 通知)
Matching Service worker
│ 取 per-ride lock → 讀 matching state → 對 candidates[cursor] 發 DispatchCommand(gRPC)
▼
AV ──DispatchDecision──►
├─ REJECT/TIMEOUT → 取 lock、cursor++、dispatch 下一個 candidate
└─ ACCEPT → 取 lock、確認 status 仍為 SEARCHING → 設 status=DONE
│
▼
通知 Ride Service(RPC)→ DB 寫 ride(av_id, status=DRIVER_ASSIGNED)
│ └─ partial unique index 兜底:同一 av_id 只有一個 update 成功
▼
Ride Service 即時通知乘客:matched AV 位置 / 車牌(見 10-Design-Patterns/05)
(E) Scaled 架構(端到端)
┌─────────────── Rider 側 ───────────────┐
Rider App ──► Load Balancer ──► Ride Service (stateless, N台)
│ POST /rides
▼
┌──────────────────┐
│ Queue │ ← 吸收 10 萬尖峰、防 MS crash 丟單
└────────┬─────────┘
▼
Matching Service (stateless workers, M台)
│ GEOSEARCH │ 共享配對狀態 + per-ride lock
▼ ▼
┌──────────────┐ ┌────────────────────┐
│ Redis(位置GEO)│ │ Redis(matching state)│
└──────▲───────┘ └──────────┬─────────┘
│ GEOADD │ RPC 更新
│ ▼
AV ──持久連線──► AV Gateway ──► Location Ride DB (ACID)
(1,000 萬條, 前置 LB) Service partial unique index
(1 AV : 1 active ride)
關鍵 hand-off:Ride Service 接單即入 queue 立刻回(配對非同步)→ Matching worker 消費、GEOSEARCH 找車、Redis 共享狀態 + lock 派遣 → Ride DB unique index 做最終一致性兜底 → 即時通知乘客。位置寫入這條(AV→Gateway→Redis GEO)與配對主線解耦。
8. 深入探討 (Deep Dives ~9 min)
通用機制(Redis 原理、queue 投遞語意、distributed lock 實作、transaction 隔離)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。任一機制超過 ~3 行就砍掉改連結。本題無 home note 的新材料(Redis GEO 指令、matching state cursor、partial unique index)才在此 inline 教學並登記到 new_concepts_introduced。
8.1 海量位置寫入 + 高效鄰近查詢
問題:1,000 萬 AV、每 5 秒更新 → ≈ 200 萬 writes/sec,且要能對乘客位置做 proximity search。
為何是瓶頸:(1) 一般 DB 寫入上限 ~10k/s,差 200 倍,硬擴展成本不合理;(2) 對 lat/long 查詢若無優化要 full table scan + 逐一算距離,數百萬車時極慢;B-tree 是一維索引,不適合多維地理座標,proximity search 仍差。
- GEOADD:把
(driver_id, lat, long)加入一個 geo 索引(底層用 geohash → sorted set)。 - GEOSEARCH:給一個中心點 + 半徑 / 範圍,回傳範圍內成員——這就是 proximity search。
- 本題需 雙向對應:
encoded_location → [driver_ids](供查找)與driver_id → encoded_location(反查)。後者是為了 位置更新時先把 driver 從舊位置移除,否則同一台車會殘留在多個格子。
| 方案 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| A. 一般 DB + lat/long 索引 | B-tree on lat/long | 簡單、持久 | 寫入扛不住(10k/s);B-tree 不適合多維 → 查詢慢 |
| B. Redis GEO(本題選) | GEOADD/GEOSEARCH,只存最新位置 | in-memory 扛高頻寫;GEO 指令高效 | 記憶體有上限、故障可能丟資料(靠 RDB/AOF) |
| C. 自製 H3/S2 地理索引 | cell hierarchy + in-memory(Uber/Waymo) | production 級、最高效 | 實作成本高,面試非必要 |
選擇與理由:選 B(Redis GEO)。位置是 ephemeral(只需最新),正好契合 in-memory;GEO 指令讓 proximity search 高效且好實作。丟失風險用 RDB(snapshot)/ AOF(append log) persistence 緩解。
誠實補充(來源 Note):200 萬 writes/sec 下 單一 Redis 撐不住,即使 Redis Cluster 也需 數十個 shard;真實世界(Uber/Waymo)多半 自製 H3/S2 + in-memory 地理索引服務。但面試裡 Redis GEO 已是完全可接受的答案,因為重點是 配對架構,不是地理索引底層優化——點到為止即可,別掉進 C 的兔子洞。
→ 積木:07-Caching-Storage/04-Redis、04-Database-Indexing/05-Geospatial-Index
driver_id → encoded_location 這個反向對應?
因為位置 每 5 秒就變。若只有 location → [driver_ids],更新時無法知道該 driver 上次在哪個格子,舊格子裡會殘留這台車,導致 GEOSEARCH 把它當成「還在舊位置的可用車」誤配。有了反向對應,更新時先 從舊 encoded_location 移除 driver、再加到新位置,索引才乾淨。
8.2 尖峰期間如何不丟任何叫車請求
問題:尖峰 / 大型活動單社區可能 10 萬請求同時湧入;又要避免 Matching Service instance crash / restart 導致行程遺失。
為何是瓶頸:若 Ride Service 同步等配對跑完才回覆,配對是慢流程(要 GEOSEARCH、逐一 dispatch、等 AV ACCEPT),尖峰下會塞爆、丟請求;MS 直接從 RS 同步接也會被打爆。
| 方案 | 做法 | 取捨 |
|---|---|---|
| A. RS 同步呼叫 MS 等配對完成 | POST /rides 內聯做完配對才回 |
簡單;但尖峰塞爆、RS 可用性綁死在 MS 上、MS crash 直接丟單 |
| B. RS → Queue → MS(本題選) | RS 收到確認叫車後 只發事件到 queue 立即回 | 多一個元件;但 queue 吸收尖峰、解耦可用性、持久化防丟 |
選擇與理由:在 RS 與 MS 之間插 queue 當緩衝層。RS 收到確認叫車後 只發事件、立即完成 request——配對結果不影響 RS 可用性。MS 從 queue 消費 ride events,慢慢做配對。queue 的 持久化 + at-least-once 也解決「MS crash/restart 丟行程」:事件還在 queue 裡,重啟後繼續消費。
通訊協定細節:MS → RS 更新 ride state 用 RPC(gRPC)而非再走 queue——因為配對是個 retry loop(dispatch 失敗就換下一個 candidate),同步回饋在 retry loop 裡更好寫;而 RS → MS 用 queue 是為了吸收尖峰。兩段刻意用不同機制。
→ 積木:09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/03-Reliable-Delivery、03-API-Design/04-RPC-and-gRPC
8.3 如何避免同一筆行程被多台 AV 指派(1 Ride : 1 AV)
問題:MS 依序對 candidates dispatch;若對 V1 的 dispatch timeout,MS 已改 dispatch V2,但 V1 與 V2 的 ACCEPT 同時到達,誰算數?
為何是瓶頸:為了水平擴展,MS 設計成 stateless 多 worker——發 dispatch 的 worker 與收 accept 的 worker 可能不是同一個,無法靠單機記憶體判斷狀態。這是典型 12-Ops-and-Reliability/01-Dealing-With-Contention 競態。
把配對狀態 集中放 Redis(match:ride:<ride_id> → { candidates, cursor, status }),所有 worker 共享。cursor 指向「目前嘗試到第幾個 candidate」,status 是 SEARCHING → DONE/FAILED 的狀態機。
做法(per-ride lock + 狀態檢查):
- 某 worker 算出 candidates,在 Redis 建 matching state,
cursor=0、status=SEARCHING。 candidate[0]REJECT/TIMEOUT → 另一個 worker 可重讀 state、cursor++、dispatch 下一個。- per-ride lock:對每個 ride 用一把鎖,同時只有一個 worker 能更新該 state(避免並發更新 corrupt 狀態)。
- worker 收到某 AV 的 ACCEPT 時,先取 lock,確認
status仍為SEARCHING,才把status設為DONE。 - 由 3+4 保證 只有一個 worker 能成功把 status 設成 DONE → 同一 ride 只指派給一台 AV。
選擇與理由:狀態不能放 worker 記憶體(stateless + 多 worker),故 集中放 Redis;並發更新用 per-ride lock 串行化;"先檢查 SEARCHING 再設 DONE" 是 compare-and-set 語意,讓晚到的 ACCEPT 失敗。這正是本題 core_tradeoff 的一半。
→ 積木:09-Messaging-Coord/04-Distributed-Lock、07-Caching-Storage/03-Distributed-Cache、12-Ops-and-Reliability/01-Dealing-With-Contention
鎖的 粒度是「單一 ride」,不同 ride 完全並行,所以不是全域瓶頸——它只序列化「同一筆 ride 的狀態更新」,而那本來就該序列化。對比若用一把全域大鎖才是災難。鎖粒度選對是 contention 設計的精髓,見 12-Ops-and-Reliability/01-Dealing-With-Contention。
8.4 如何避免同一台 AV 被指派給多筆行程(1 AV : 1 Ride)
問題:不同 ride 的候選清單可能重疊,例如 R1→[V1,V2,V3]、R2→[V1,V3,V5]。若兩個 ride 的 worker 同時 dispatch 給 V1,且 因網路問題或 AV bug,V1 同時接受了 R1 與 R2,怎麼辦?
為何是瓶頸:這個競態 跨越兩筆不同的 ride state,8.3 的 per-ride lock 管不到(它只鎖單一 ride)。需要一個 以 AV 為維度 的最終兜底。
CREATE UNIQUE INDEX uniq_active_ride_per_driver
ON rides(av_id)
WHERE status IN ('DRIVER_ASSIGNED','IN_PROGRESS');
它強制:所有 active(已指派 / 進行中)的 ride 中,av_id 必須唯一。WHERE 子句讓已完成 / 失敗的歷史 ride 不佔用唯一性(同一台車一天可跑很多趟)。
做法:依賴 Ride DB 的 ACID + 上述 partial unique index。當兩個 RS worker 嘗試把不同 ride 指派給同一 av_id,只有一個 INSERT/UPDATE 會成功,另一個違反 unique constraint 失敗 → 失敗的 worker 發出「指派失敗」事件,觸發 re-match(重新配對)。
選擇與理由:跨 ride 的競態無法靠 Redis lock 收斂,最終一致性交給 DB 的原子唯一約束——這是強一致最可靠的兜底,且不需分散式交易。這是本題 core_tradeoff 的另一半:Redis 管「配對過程」的快與共享,DB 管「最終結果」的對。
→ 積木:05-Database-Advanced/01-Transactions、04-Database-Indexing/01-Indexing-Overview
不重複,是兩層、管不同維度。Redis per-ride lock 管 「同一 ride 不被多台 AV 搶」(ride 維度、過程中),讓配對流程在 stateless worker 下狀態不亂、能快速收斂。DB partial unique index 管 「同一 AV 不被多 ride 搶」(AV 維度、結果上),兜住跨 ride 的競態與 AV/網路異常。前者快但只在 Redis、後者慢但是強一致的真相來源——過程靠 Redis 收斂、結果靠 DB 鎖死,正是本題招牌取捨。
9. 擴展與瓶頸 (Scaling & Bottlenecks ~3 min)
主動找出系統會先在哪裂開並補強。
| 瓶頸 / 風險 | 補強手段 | 積木 |
|---|---|---|
| 位置寫入 200 萬/s 壓垮單 Redis | Redis Cluster 分片(數十 shard);極限時走自製 H3/S2 地理索引服務 | 07-Caching-Storage/04-Redis、04-Database-Indexing/05-Geospatial-Index |
| 尖峰 10 萬叫車塞爆 Ride Service | queue 緩衝 + RS 接單即回;MS workers 水平擴展消費 | 09-Messaging-Coord/02-Queue、02-Distributed-Systems/03-Scalability |
| MS instance crash / restart 丟行程 | queue 持久化 + at-least-once;matching state 在 Redis,重啟可續 | 12-Ops-and-Reliability/03-Reliable-Delivery |
| 1,000 萬條 AV 持久連線壓垮 Gateway | AV Gateway 水平擴展 + 前置 LB 分散連線 | 01-Networking/06-Load-Balancing、01-Networking/05-Realtime-Protocols |
| 熱點社區(單區 10 萬請求)GEOSEARCH 變慢 | 該區 位置 shard 細分、candidate 數量上限、配對退避 | 04-Database-Indexing/05-Geospatial-Index |
| Redis 故障導致最新位置/配對狀態遺失 | RDB/AOF persistence;matching state 可重建(重新 GEOSEARCH);DB 為真相來源 | 07-Caching-Storage/04-Redis、05-Database-Advanced/01-Transactions |
「系統會 先在位置寫入路徑裂開(200 萬/s 遠超單 Redis)→ 所以位置層要 Redis Cluster 分片,甚至自製地理索引;其次是 尖峰叫車,靠 queue 把接單與配對解耦吸收。配對正確性不是『擴展』問題而是『一致性』問題,靠 Redis lock + DB unique index 兩層解。」先點最可能裂開處再補強。
10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~1 min)
| 決策點 | 選擇 | 放棄了什麼 | 為什麼值得 |
|---|---|---|---|
| 位置儲存 | Redis(in-memory + GEO) | 持久性 / 歷史軌跡 | 200 萬 writes/s 一般 DB 不可行;位置 ephemeral 不需歷史 |
| 地理索引深度 | Redis GEO(不自製 H3/S2) | production 級極致效能 | 面試重點是配對架構,GEO 已足夠,避免兔子洞 |
| 接單 vs 配對 | Queue 解耦(接單即回) | 同步回配對結果的簡單 | 吸收 10 萬尖峰、解耦可用性、防 MS crash 丟單 |
| 配對狀態 | 集中放 Redis + per-ride lock | stateless 單機記憶體的簡單/快 | 多 worker 共享、1 ride 不被多 AV 搶(招牌取捨上半) |
| 1 AV : 1 Ride | Ride DB partial unique index 兜底 | 純 Redis 自己收斂跨 ride 競態 | 跨 ride 競態 + AV/網路異常的強一致真相(招牌取捨下半) |
| MS→RS / MS↔AV 通訊 | RPC(gRPC) | 全用 queue 的一致 | retry loop(換下一台 candidate)同步回饋好寫 |
「整題單一錨點是 『過程靠 Redis、結果靠 DB』的兩層一致性:配對是個多 stateless worker 的 retry loop,所以把 matching state 集中放 Redis 並用 per-ride lock 讓同一 ride 不被多 AV 搶;但跨 ride 的競態(同一 AV 被多 ride 搶)Redis 收不住,就交給 Ride DB 的 partial unique index 做原子兜底。位置那條因為 200 萬 writes/s + ephemeral,走 Redis GEO,並坦白單 Redis 不夠、實務要分片或自製地理索引——但面試裡 GEO 已足夠。尖峰用 queue 解耦 防丟單。」引用既有決策用連結,不重新推導。
後續追問演練 (Interviewer Follow-ups — "What if…")
兩個原因:(1) 寫入量 = 1,000 萬 AV ÷ 5 s ≈ 200 萬 writes/sec,遠超一般 DB ~10k/s(差 200 倍),硬擴展成本不合理;(2) 查詢:lat/long 用 B-tree 仍要近乎 full scan 逐一算距離,B-tree 不適合多維座標。位置又是 ephemeral(只需最新),所以改用 Redis in-memory + GEO 指令。見 07-Caching-Storage/04-Redis、04-Database-Indexing/05-Geospatial-Index。
否則同一台車會殘留在多個 geo 格子,GEOSEARCH 會把它當成「還在舊位置的可用車」誤配。靠 driver_id → encoded_location 反向對應找到舊格子並移除。見 8.1。
(1) 吸收尖峰:單社區 10 萬叫車同時湧入時,RS 發事件即回、不被配對的慢流程塞爆;(2) 防丟單:queue 持久化 + at-least-once,即使 Matching Service crash/restart,事件仍在 queue 裡可續做。見 09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/03-Reliable-Delivery。
把 matching state 放 Redis(candidates/cursor/status),收 ACCEPT 的 worker 先取 per-ride lock、確認 status 仍為 SEARCHING,才設成 DONE(compare-and-set 語意)。第二個 ACCEPT 取得 lock 時 status 已是 DONE → 失敗。確保 1 ride : 1 AV。見 09-Messaging-Coord/04-Distributed-Lock、8.3。
8.3 的 per-ride lock 管不到跨 ride 競態。靠 Ride DB 的 partial unique index rides(av_id) WHERE status IN (DRIVER_ASSIGNED,IN_PROGRESS):兩個 update 只有一個成功,失敗者發「指派失敗」事件觸發 re-match。確保 1 AV : 1 ride。見 05-Database-Advanced/01-Transactions、8.4。
位置寫入層先裂(已是 200 萬/s 的 100x)。依序:(1) Redis Cluster 加 shard,極限時上 自製 H3/S2 地理索引服務;(2) Matching Service workers 水平擴展 消費 queue(queue 本身吸收尖峰);(3) AV Gateway + LB 擴容 撐持久連線;(4) 熱點社區做 geo shard 細分 + candidate 數上限。見 02-Distributed-Systems/03-Scalability、04-Database-Indexing/05-Geospatial-Index。
(1) 位置:靠 RDB(snapshot)/ AOF(log) persistence 恢復近期狀態;即便丟失,AV 每 5 秒就重傳,幾秒內索引重建。(2) matching state:可由 worker 重新 GEOSEARCH 重建 candidates,cursor 從頭。(3) 真相來源是 Ride DB:已 DRIVER_ASSIGNED 的行程不受 Redis 丟失影響。所以 Redis 丟失是「可恢復的暫態」,DB 才是強一致地基。見 07-Caching-Storage/04-Redis、05-Database-Advanced/01-Transactions。
兩段目標不同。RS→MS 用 queue:要 吸收尖峰、解耦可用性、防丟單——非同步緩衝正合適。MS→RS 用 RPC:配對是 retry loop(dispatch 失敗就換下一個 candidate、更新失敗要立刻知道),同步回饋在 retry loop 裡好寫,走 queue 反而讓「立即重試」變複雜。為不同需求選不同機制,是成熟設計信號。見 03-API-Design/04-RPC-and-gRPC、09-Messaging-Coord/02-Queue。
WHERE status IN (...) 為什麼不可省略?
若對整個 av_id 做 unique,則一台 AV 一輩子只能跑一趟(歷史 DONE 紀錄會佔住唯一性)。WHERE 把唯一性 限定在 active 狀態(DRIVER_ASSIGNED / IN_PROGRESS),已完成 / 失敗的歷史 ride 不佔用 → 同一台車隔天可再接單,同時又保證「任一時刻一台 AV 只有一筆 active ride」。見 04-Database-Indexing/01-Indexing-Overview。
點到為止、不深入。可以一句「production(Uber/Waymo)通常自製 H3/S2 cell hierarchy + in-memory 索引」展示見識,但 立刻收回到 Redis GEO,因為本題評分重點是 配對架構與一致性,不是地理索引底層。深入 H3/S2 會吃掉處理 1 ride 1 AV、queue 防丟這些真正得分點的時間——這是典型的優先級陷阱。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:用 Postgres/MySQL 存即時位置,對 lat/long 建 B-tree。
Why:200 萬 writes/sec 遠超一般 DB ~10k/s(差 200 倍);B-tree 不適合多維座標,proximity search 仍要近乎 full scan。
正確做法:位置 ephemeral,用 Redis in-memory + GEOADD/GEOSEARCH,故障靠 RDB/AOF。見 07-Caching-Storage/04-Redis、04-Database-Indexing/05-Geospatial-Index。
What:POST /rides 內聯跑完 GEOSEARCH + dispatch + 等 ACCEPT 才回 200。
Why:配對是慢流程,尖峰 10 萬請求會塞爆、丟單;MS crash 直接丟行程;RS 可用性被 MS 綁死。
正確做法:RS 收到確認叫車 發事件到 queue 即回,MS 非同步消費;queue 持久化防丟。見 09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/03-Reliable-Delivery。
What:worker 用本機變數記 candidates/cursor/status。
Why:MS 是 stateless 多 worker,發 dispatch 與收 accept 可能不同 instance,本機狀態彼此看不到 → timeout 後雙 ACCEPT 無法判斷。
正確做法:matching state 集中放 Redis,加 per-ride lock + CAS(確認 SEARCHING 再設 DONE)。見 09-Messaging-Coord/04-Distributed-Lock、07-Caching-Storage/03-Distributed-Cache。
What:只靠 Redis per-ride lock 收斂所有重複指派。
Why:per-ride lock 只鎖 單一 ride;跨 ride 競態(R1、R2 都搶 V1)橫跨兩把鎖,管不到。
正確做法:再加 Ride DB partial unique index rides(av_id) WHERE status active 做 AV 維度的原子兜底,失敗者 re-match。見 05-Database-Advanced/01-Transactions、04-Database-Indexing/01-Indexing-Overview。
What:面試一半時間在講 geohash / S2 cell hierarchy 底層。
Why:本題評分重點是 配對架構與一致性(1 ride 1 AV / 1 AV 1 ride / 防丟單),不是地理索引優化;深入它會吃掉真正得分點。
正確做法:Redis GEO 即可,一句帶過 production 會自製,立刻收回主線。見 04-Database-Indexing/05-Geospatial-Index。
WHERE status 子句
What:直接對 av_id 建全表 unique index。
Why:歷史 DONE 紀錄會佔住唯一性,一台 AV 一輩子只能跑一趟。
正確做法:唯一性 限定 active 狀態(DRIVER_ASSIGNED / IN_PROGRESS),歷史不佔用。見 04-Database-Indexing/01-Indexing-Overview。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 02-Distributed-Systems/03-Scalability
- 03-API-Design/02-REST
- 03-API-Design/04-RPC-and-gRPC
- 04-Database-Indexing/01-Indexing-Overview
- 04-Database-Indexing/05-Geospatial-Index
- 05-Database-Advanced/01-Transactions
- 06-Database-Tech/01-Database
- 07-Caching-Storage/03-Distributed-Cache
- 07-Caching-Storage/04-Redis
- 09-Messaging-Coord/02-Queue
- 09-Messaging-Coord/04-Distributed-Lock
- 09-Messaging-Coord/03-Kafka
- 10-Design-Patterns/05-Real-time-Updates
- 01-Networking/05-Realtime-Protocols
- 01-Networking/06-Load-Balancing
- 12-Ops-and-Reliability/01-Dealing-With-Contention
- 12-Ops-and-Reliability/03-Reliable-Delivery