題目與適用情境
設計一個供演唱會、運動賽事或熱門展覽使用的票務訂票系統。某場活動有 50,000 個指定席,200 萬名使用者在開賣後 5 分鐘內到達,入口尖峰為每秒 50,000 次請求。系統最多放行 10,000 名使用者同時瀏覽座位圖,尖峰處理每秒 2,000 次保留座位與 1,000 次付款授權。座位保留 5 分鐘;保留後未完成購買的座位必須重新可售。
本題假設付款服務支援「先授權、後請款」,座位圖 p99 低於 500 毫秒、保留座位 p99 低於 300 毫秒、訂單狀態查詢 p99 低於 200 毫秒。入口規模、延遲、保留時間與付款吞吐量都是面試假設,不是任何票務平台的公開基準。核心正確性要求是:同一活動的同一座位最多關聯一筆有效售出訂單。可用性下降時可以暫停放行或拒絕保留,不能猜測庫存後繼續銷售。
範圍包含活動瀏覽、虛擬等候室、座位圖、成組保留座位、結帳、出票前確認、到期釋出與故障復原。活動建立、動態定價、二手轉售、退款會計與完整反機器人系統不在基礎範圍,但答案應說明它們如何銜接庫存和付款邊界。現有公開的 2026 系統設計資料持續把票務訂票列為同時考查併發與流量尖峰的題型;Ticketmaster 的公開資料也展示先進入虛擬等候室,再依後端承載量放行選位與結帳的實際流程。
面試官評估重點
第一個訊號是能否分開「排隊公平」與「庫存正確」。虛擬等候室保護來源站並決定誰能進入售票流程,卻不能阻止兩名已放行的使用者同時搶同一座位。最後的排他性必須由權威庫存交易保證。
第二個訊號是能否區分資料庫資料列鎖與業務保留。PostgreSQL 的資料列鎖只應存在於短交易內,並在交易結束時釋放;不能在使用者填寫資料和付款的 5 分鐘內持續占用連線與交易。較長的業務語意應儲存為帶有 holdid 和 expiresat 的持久狀態。
第三個訊號是能否處理時間與重試。延遲的到期工作不能釋出已被新使用者重新保留或已售出的座位;用戶端逾時也不能自動建立第二份保留或第二次付款。每個狀態轉換都要核對目前狀態、擁有者、版本和期限,並透過冪等鍵回傳原結果。
第四個訊號是付款邊界是否準確。付款授權成功但本機確認失敗、售出交易提交但回應遺失、請款成功但 webhook 延遲,都會形成未知窗口。強答案會保留訂單和付款操作紀錄,透過查詢、webhook 與對帳收斂,不把「沒有收到回應」視為失敗。
最後看容量與驗證是否對準瓶頸。大量瀏覽可以由 CDN、快取和唯讀副本吸收;單場熱門活動的座位寫入仍應保留在一個可執行交易的權威分片。太早把同一活動的座位拆到多個寫入分片,會讓一次三座位保留變成分散式交易。
回答前需要釐清的問題
- 是指定席還是自由席? 指定席需要逐座位排他狀態;自由席較適合依票種維護可用數量,再以條件扣減避免負庫存。
- 保留座位必須全部成功嗎? 本題要求同一請求的 1 至 6 個座位全部成功或全部失敗。若允許部分成功,使用者體驗、定價和釋出邏輯都會改變。
- 排隊採用哪種公平規則? FIFO 優先較早到達者;隨機出隊可降低毫秒級網路差異的影響。規則必須預先公開並保持穩定,不能在活動中途更改後仍宣稱順序公平。
- 付款能否分開授權與請款? 可以分開時,先授權、再確認座位、最後請款;只能立即扣款時,需要處理「扣款成功但座位確認失敗」的自動退款或撤銷。
- 每個帳戶最多可買幾張? 限購規則必須在保留座位與訂單確認兩個邊界檢查;只在頁面限制很容易被繞過。
- 允許多區域同時寫入同一活動嗎? 基礎方案讓每場活動固定一個主要寫入區域,其他區域提供瀏覽和排隊。多區域同時寫入會引入跨區域共識或庫存預先分配,也會改變延遲與故障語意。
- 座位圖需要多即時? 允許數秒陳舊時可使用快照加增量推送;任何「可用」顯示都只是提示,最後仍以保留交易為準。
- 保留到期時付款仍在驗證怎麼辦? 需要定義一次有上限的
PAYMENT_PENDING寬限期;不能無限延長保留,也不能在結果未知時立刻把座位賣給下一人。
30 秒回答架構
「我會先把 200 萬入口流量擋在依活動隔離的虛擬等候室,只依資料庫、付款與出票鏈路的健康容量發出短效簽章准入權杖。瀏覽和座位圖走 CDN、快取與版本化增量;保留座位寫入活動所屬的單一權威資料庫分片。短交易依座位編號排序鎖定所選資料列,只在所有座位可用或已到期時,寫入同一 holdid 和由資料庫時間產生的 5 分鐘期限。使用者結帳期間不持有資料庫鎖。到期佇列只加速清理,真正釋出時仍條件檢查 holdid、狀態和期限。付款用穩定操作 ID 先授權;授權成功後,交易再次驗證保留並把座位、訂單與 outbox 一起確認為售出,再非同步請款。任何逾時都進入未知狀態,並透過 webhook、主動查詢和對帳收斂。我會用同座位併發競爭、到期與付款競態、回應遺失、重複訊息和分片故障證明不超賣。」
分步深入解答
第一步:把容量換算成入口控制目標
200 萬名使用者在 300 秒內到達,平均約每秒 6,667 人;每秒 50,000 次入口請求表示開賣瞬間與用戶端重新整理會遠高於平均值。後端只規劃每秒處理 2,000 次保留與 1,000 次付款授權,因此不能讓入口重試直接穿透到庫存服務。
每次保留最多包含 6 個座位,因此每秒 2,000 次請求在發生衝突和回滾前,最多觸發約每秒 12,000 次座位資料列判斷。容量測試必須採用熱門區域的偏斜分布,不能把這 12,000 次平均分散到 50,000 個座位後就宣稱沒有競爭。
虛擬等候室依 eventid 隔離,開賣前可先進入預排隊。它記錄排隊批次、進入時間、帳戶與風險訊號,在允許進入時發出短效、有簽章、綁定活動與帳戶的 admissiontoken。售票入口檢查權杖、到期時間和一次性工作階段;無效請求在邊緣就被拒絕。放行速率依活動分片的鎖等待、保留座位 p99、付款錯誤率、目前活躍購票者與完成率動態調整。資料庫變慢時先降低放行,不用更多副本掩蓋寫入壅塞。
Cloudflare 公開的等候室文件說明,FIFO 可以依訪客第一次進入的時間戳排序,隨機模式則從等候者中抽取。兩者都是產品政策,並非資料庫一致性機制。所謂 FIFO 也要寫清楚粒度、斷線重連、多裝置、時鐘來源與機器人處理;不能承諾跨全球網路的絕對逐請求順序。
第二步:分離瀏覽、座位檢視與權威庫存
活動詳情、場館靜態圖和價格說明放在 CDN 或快取。座位圖由版本化快照與狀態增量組成:用戶端先取得 snapshotversion,再接收 seatid、新狀態與更高版本的變更。斷線或版本有缺口時重新取得快照。10,000 名活躍使用者若每 5 秒輪詢一次,約產生每秒 2,000 次讀取;增量推送可減少重複的全量讀取,但不能成為庫存真源。
座位圖允許短暫陳舊。使用者看到 AVAILABLE 後,保留座位仍可能回傳衝突。快取不能確認售出,也不能在資料庫不可用時依舊值接受保留。這樣可分開高可用的瀏覽路徑與強一致的庫存寫入路徑。
每場活動固定到一個主要寫入分片,分片內使用關聯式資料庫交易。不同活動可依 event_id 水平拆分,熱門活動還可獨占分片或資源池。單場 50,000 筆座位資料不是容量難點;大量請求集中搶少數座位造成的鎖等待、連線耗盡和重試風暴才是瓶頸。
第三步:定義 API、資料模型與不變量
核心 API 可以保持精簡:
POST /events/{eventId}/holds
{ seatIds, idempotencyKey, admissionToken }
-> { holdId, status, expiresAt, seats }
POST /holds/{holdId}/checkout
{ paymentMethodToken, idempotencyKey }
-> { orderId, paymentStatus, nextAction }
GET /orders/{orderId}
-> { orderStatus, paymentStatus, seats }最小資料模型:
SeatInventory(event_id, seat_id, price_version, status,
hold_id, hold_expires_at, order_id, version)
Hold(hold_id, event_id, account_id, status, expires_at,
idempotency_key, request_digest, created_at)
Order(order_id, hold_id, account_id, amount_minor, currency,
status, payment_operation_id, created_at)
PaymentOperation(operation_id, order_id, provider_reference,
kind, status, idempotency_key, updated_at)
Outbox(event_id, aggregate_id, event_type, payload, published_at)關鍵不變量包括:
一個 (event_id, seat_id) 最多關聯一筆有效 SOLD 訂單
同一 hold 的所有座位必須屬於同一 event、account 與 expires_at
只有目前的 hold_id 可以把 HELD 轉成 SOLD 或釋出
確認訂單時必須再次檢查價格版本、張數、限購與總金額
同一冪等鍵只能描述一份不可變請求;參數不同時拒絕重用金額使用最小貨幣單位整數。用戶端提交的總價不是權威資料;服務端從鎖定的價格版本重新計算。admission_token 只允許進入售票流程,不代表擁有任何座位。
第四步:用短交易完成成組保留座位
一次請求最多選 6 個座位。交易先依 seatid 固定排序,再以 SELECT ... FOR UPDATE 鎖定對應資料列,降低不同請求反向加鎖造成的死結。若每列狀態為 AVAILABLE,或是已到期的 HELD,就把全部資料列改為相同 holdid 和 expiresat = databasenow + 5 minutes,同時新增 Hold 與 outbox;任何一列已售出或仍被他人有效保留,整筆交易回滾。
也可以使用帶狀態與期限條件的 UPDATE,再確認更新筆數是否等於請求座位數。無論採用哪種寫法,判斷與寫入都必須位於同一權威交易。不能先在 Redis 取得鎖,再非同步寫資料庫,因為鎖成功與資料庫寫入失敗之間會產生雙真源;也不能先讀取「可用」再無條件更新。
PostgreSQL 文件指出,資料列鎖只阻擋同一列的寫入者或加鎖者,並在交易結束時釋放。因此,使用者 5 分鐘結帳時間是資料狀態,不是一筆持續 5 分鐘的資料庫交易。交易提交後立刻釋放連線,後續每次轉換再開啟短交易。
冪等紀錄與保留結果一起提交。若服務已成功保留,卻在回應前當機,用戶端使用同一個 idempotencyKey 重試會取得原本的 holdId;改用新鍵才會參與新一輪競爭。服務還要保存請求摘要,防止同一鍵被換一組座位重用。
第五步:讓到期語意不依賴準時工作
holdexpiresat 使用資料庫時間產生。讀到已到期保留時可以顯示為可用,但真正重新保留仍要在交易內核對期限。建立保留時送出延遲訊息;定時掃描作為補償。兩者只負責盡快把狀態明確改回 AVAILABLE,並非正確性的唯一來源。
釋出操作必須類似:
UPDATE seat_inventory
SET status = 'AVAILABLE', hold_id = NULL, hold_expires_at = NULL
WHERE event_id = :eventId
AND hold_id = :holdId
AND status = 'HELD'
AND hold_expires_at <= database_now;延遲訊息可能重複、遲到或亂序。若舊保留已到期,座位已被新的 holdid 保留,舊訊息條件不符合;若座位已成為 SOLD,狀態也不符合。只依 seatid 執行「釋出」會刪除新使用者的合法保留。
付款的額外驗證可能接近保留期限。本題允許在開始付款授權前,把有效保留的 Hold.status 原子轉換為 PAYMENT_PENDING,最多增加一次有上限的寬限期。寬限數量計入活躍庫存並受單帳戶限制。到期仍未知時進入人工或自動補償流程,不能透過無限續期囤票。
第六步:把未知付款結果納入狀態機
結帳先使用穩定的 paymentoperationid 發起授權,並向付款提供者重用同一冪等鍵。Stripe 公開 API 文件說明,同一冪等鍵的重試會回傳第一次請求保存的結果;PaymentIntent 類型的物件也以生命週期狀態表示可能需要額外驗證或非同步完成的付款。
建議順序如下:
- 在短交易中驗證保留、限購、價格與期限,建立
Order與授權操作;需要寬限時,同時把Hold.status轉為PAYMENT_PENDING。 - 在交易外呼叫付款授權;逾時記為
UNKNOWN,不建立第二個操作。 - 授權確認成功後,交易再次鎖定座位並核對
hold_id,把全部座位改成SOLD、訂單改成CONFIRMED,並寫入 outbox。 - 提交後非同步請款;重複請款沿用相同操作 ID。出票服務只消費已提交的確認事件。
- 若授權成功時保留已無法確認,撤銷授權。若售出交易已提交但請款結果未知,保留座位與訂單,透過付款 webhook、主動查詢與對帳收斂,不能釋出後再次銷售。請款已明確失敗且無法重試時,在出票前以可稽核的補償交易取消訂單並釋出其座位。
這個順序允許短暫出現「座位已售出、請款待確認」,但不會讓兩名買家都取得同一座位。若業務只能立即扣款,扣款成功而售出交易失敗時,必須建立可稽核的撤銷或退款補償;這類付款能力會擴大使用者與營運風險,回答時應明確說明。
訂單與座位狀態都應單調推進。瀏覽器跳到「成功頁」不是出票依據;經驗證的提供者回應、webhook 或主動查詢才能驅動付款狀態。webhook 依提供者事件 ID 去重,且只允許合法狀態轉換。
第七步:設計故障、擴充與降級
- 活動分片不可用: 停止該活動放行與新保留,保留等候室;瀏覽可繼續顯示「暫時無法預訂」,不能切到陳舊副本繼續寫入。
- 快取或推送失敗: 用戶端退回較低頻率的版本化快照;最後仍由資料庫判定保留結果。
- 到期佇列遺失訊息: 條件式重新保留與補償掃描仍能回收座位,並監控到期積壓和最早到期時間。
- 付款提供者變慢: 降低放行與結帳併發,保留未知操作;不能自動切換提供者再扣一次。
- 服務提交後回應遺失: 冪等鍵回傳原保留、訂單或付款操作。
- 單場活動過熱: 活動獨占資料庫資源、依活動限流並縮減非必要讀取;不要把同一次多座位交易拆到多個分片。
- 區域故障: 每場活動只有一個寫入歸屬地與經驗證的故障切換。新主節點接管前必須確認舊主節點無法繼續寫入,避免雙主超賣。
虛擬等候室還要防止權杖複製、重放與繞過:權杖短效、有簽章、綁定活動與帳戶,服務端限制併發工作階段與購票張數;高風險流量進入額外驗證。它可以降低機器人優勢,卻不能單獨證明「真人」或絕對公平。
第八步:用不變量與故障注入驗收
除了功能測試,還要建立可由機器檢查的性質:
count(valid SOLD orders for one seat) <= 1
every CONFIRMED order owns exactly its recorded seats
every SOLD seat points to one CONFIRMED or payment-reconciling order
an expiry action changes only its own hold_id
replaying one idempotent request does not create new business state讓數千個併發用戶端競搶同一座位,預期只有一個有效保留;再讓它們用不同順序爭搶相同的三座位組合,驗證全部成功或全部失敗、固定加鎖順序與死結重試。把系統時間推進到期限附近,交錯執行新保留、到期訊息、付款授權與確認,驗證舊清理工作不能釋出新狀態。
在以下邊界逐點終止程序或遺失回應:保留交易提交後、付款授權回傳前後、座位確認售出後、請款提交後、outbox 發布前後。重複投遞 webhook 與延遲訊息,切斷快取、付款提供者和資料庫主節點。最後用活動級銷售量、鎖等待、條件衝突率、保留到期率、未知付款年齡、隊列等待與放行速率、出票對帳差異判定是否通過,而不是只看 API 回傳 200。
高品質示範回答
「我先把需求拆成流量保護與庫存正確性。200 萬名使用者在 5 分鐘內到達,平均約每秒 6,667 人,入口尖峰還有每秒 50,000 次請求,但庫存只規劃承受每秒 2,000 次保留,所以我會依活動設置虛擬等候室。它在邊緣吸收重新整理,依活動分片與付款鏈路的健康度發出綁定帳戶的短效准入權杖。排隊規則是明確的產品政策;它不負責座位排他。
活動頁與靜態場館圖走 CDN。座位圖採版本化快照加增量,允許數秒陳舊,最後以保留交易為準。每場活動對應一個關聯式資料庫主要寫入分片,不把 50,000 個座位跨分片。保留 API 接受 1 到 6 個排序後的座位編號與冪等鍵。短交易鎖定這些資料列,只有全部可用或已到期時,才一次寫入相同 hold_id 和由資料庫時間產生的 5 分鐘期限;否則全部失敗。交易提交後即釋放資料庫鎖,使用者結帳期間只保留持久狀態。
延遲佇列和掃描器會清理到期保留,但每次釋出都要符合 hold_id、HELD 與期限。如此一來,遲到的舊訊息無法釋出新保留或已售座位。相同冪等鍵與請求摘要和保留一起保存,所以提交後回應遺失只會回傳原結果。
付款方面,本題假設支援授權與請款分離。我先建立穩定付款操作並授權;逾時記為未知,使用同一操作 ID 查詢或重試。授權成功後,再在交易中核對保留、價格、限購與期限,把座位轉成 SOLD、訂單轉成 CONFIRMED,同時寫入 outbox。提交後請款並出票。若授權成功但座位確認失敗就撤銷授權;若座位已確認而請款結果未知,就保留訂單並透過 webhook、查詢和對帳收斂,絕不釋出後再賣一次。
擴充以活動為單位:一般活動共用分片,熱門活動獨占資源;同一活動保留一個寫入歸屬地。資料庫、付款或出票鏈路變慢時降低等候室放行,必要時暫停新保留。驗收時讓數千用戶端搶同一座位和重疊座位組,再注入到期工作遲到、提交後當機、付款回應遺失、重複 webhook、快取與主節點故障。只有持續證明每座位至多一筆有效售出訂單、重放不新增狀態、舊到期工作不修改新保留,設計才算通過。」
常見錯誤
- 使用 Redis 鎖住座位,再非同步寫入資料庫 → 鎖與庫存寫入可能一個成功、一個失敗,形成雙真源 → 讓同一權威資料庫交易完成條件判斷、保留狀態與冪等結果。
- 結帳 5 分鐘一直持有資料列鎖 → 長交易占用連線、阻擋寫入,用戶端離開後也有復原問題 → 資料庫鎖只涵蓋狀態轉換,結帳窗口以持久
hold表示。 - 認為虛擬等候室可以防止超賣 → 它只控制進入人數,已放行使用者仍會爭搶同一資料列 → 庫存交易另外保證排他。
- 到期工作只依座位編號直接釋出 → 遲到訊息可能刪除新保留或已售狀態 → 同時符合
hold_id、狀態與期限才可更新。 - 座位圖顯示可用就直接收款 → 讀取模型允許陳舊,付款時座位可能已被保留 → 先取得權威保留,再進入付款。
- 付款逾時立即釋出並改用新鍵重試 → 第一次操作可能已成功,座位會重複銷售或使用者被重複扣款 → 保留未知狀態、重用操作 ID,並透過 webhook、查詢與對帳收斂。
- 把熱門活動的每個座位隨機分片 → 一次成組保留跨分片,原子性成本大增 → 先依活動歸屬寫入分片,只有量測證明單活動容量不足時才加入更複雜的庫存分區。
- 宣稱嚴格全球 FIFO → 網路延遲、重連、裝置切換和機器人都會改變可觀察順序 → 定義隊列粒度、身分、重連與風險政策,並公開可驗證的公平規則。
- 只壓測平均吞吐量 → 開賣尖峰集中於少數座位,平均分布掩蓋鎖等待與重試風暴 → 壓測熱點、重疊座位組、瞬時尖峰與相依服務降速。
追問與應對
追問一:如果改成自由席,只需要保證同一票種不超賣呢?
把逐座位狀態換成 InventoryBucket(eventid, tierid, available, held, sold, version)。保留交易用條件更新保證 available >= quantity,一次扣減數量並建立 hold;到期與確認仍依 hold_id 冪等轉移計數。高衝突票種可拆成多個庫存桶來分散寫入熱點,但必須由配額控制面分配桶容量,且所有桶的總和不能超過真實庫存。不再需要原子選取具體座位後,資料模型較簡單;付款未知與到期競態仍然存在。
追問二:付款授權平均需要 90 秒,額外驗證可能超過 5 分鐘,如何避免保留被長期拖住?
開始付款前檢查剩餘時間,不足時不讓使用者進入驗證。進入後把保留轉成只有一次、具上限的 PAYMENT_PENDING 寬限狀態,並分別限制單一帳戶和全場處於此狀態的數量。寬限到期仍未知時停止新增動作並主動查詢付款提供者;確認未授權才釋出,確認已授權則完成或撤銷。用成功率與庫存周轉資料決定 5 分鐘和寬限時間,而不是允許用戶端反覆延長。
追問三:主要寫入區域在開賣時失聯,是否立即切換到另一區域?
不能只因逾時就啟動第二個寫入主節點。先停止該活動放行與新保留,透過共識租約、資料庫故障切換或隔離舊主節點,證明只剩一個寫入者,再讓新主節點從已確認的日誌位置接管。故障窗口內允許排隊和唯讀降級,不接受無法證明唯一性的保留。復原後對照訂單、座位與付款操作,才重新提高放行速率。
追問四:如何提高公平性並限制機器人,又不讓風控成為庫存正確性的相依條件?
售前使用已驗證帳戶、限購、速率限制、裝置與行為訊號;高風險工作階段增加挑戰。排隊權杖要有簽章、短效並綁定活動與帳戶,重複工作階段依政策合併或拒絕。開賣規則可採粗粒度進入時間的 FIFO,或替預排隊使用者隨機分配位置。無論風控是否漏判,資料庫仍執行相同的保留、限購與訂單不變量;風控失效影響分配公平,不應造成超賣。
追問五:為什麼不使用既有的分散式鎖服務保護每個座位?
如果權威庫存已位於支援條件更新與短交易的關聯式資料庫,再增加鎖服務會引入鎖狀態與庫存狀態的雙寫、租約到期與 fencing 邊界。資料庫資料列鎖或條件更新能把排他判斷與持久寫入放在同一交易中,較為簡單。只有庫存跨越無法共同執行交易的資料庫,或臨界區保護外部資源時,才評估分散式鎖;即使使用鎖,持久層仍需要版本或 fencing token 拒絕遲到的持有者。