題目與適用情境
請設計 POST /orders 的冪等契約。行動裝置可能在不穩定的網路中逾時並自動重試,閘道也可能在連線中斷後重送請求;無論同一個邏輯操作送達一次或多次,伺服器都只能建立一張訂單。相同請求成功後應能重播第一次的結果,同一個冪等鍵若對應不同訂單參數則必須拒絕。
本題假設呼叫端透過 Idempotency-Key 傳入一次邏輯操作的隨機識別碼,伺服器使用 PostgreSQL。建立訂單的資料庫寫入與待送事件可以放在同一個本機交易中;付款、庫存等外部系統無法參與這個交易。API 支援多租戶,回應可能包含需要授權才能查看的訂單資訊。
範圍包含請求去重、並行競爭、交易邊界、結果重播、過期策略與下游副作用。跨區域資料庫複寫、付款業務本身的狀態機與訊息系統選型不展開。這題適合後端職缺,因為核心能力是把 API 語意落實成資料庫限制、失敗復原與可觀測的請求流程,而非只畫一張跨元件架構圖。
面試官考察重點
第一個訊號是能否區分「重試身分」與「業務身分」。冪等鍵代表呼叫端眼中的一次操作,不能取代登入狀態、租戶隔離或訂單的業務唯一鍵。伺服器至少要用 (tenantid, endpoint, idempotencykey) 建立範圍;若只用裸鍵查詢,兩個租戶碰巧使用同一字串時,可能讀到彼此的結果。
第二個訊號是把並行正確性交給唯一限制,而非應用程式的時序。SELECT 查不到後再 INSERT,會讓兩個請求同時以為自己是第一次。唯一索引搭配 INSERT ... ON CONFLICT 才能讓資料庫仲裁唯一擁有者。請求指紋也必須參與判斷,否則呼叫端誤用舊鍵時,伺服器會把舊訂單回應當成新訂單結果。
第三個訊號是能解釋提交邊界。訂單、冪等結果與 outbox 事件必須一起提交:提交前失敗就全部回復;提交成功但 HTTP 回應遺失,重試會讀取已完成紀錄並重播。付款與庫存呼叫放在交易之外,由 outbox 消費者傳送,並把衍生的冪等識別碼繼續傳給下游。
好的回答還會說明鍵要保留多久、並行中的第二個請求如何回應、哪些錯誤應保存、如何復原長任務,以及怎麼監控鍵衝突與卡住的操作。只說「用 Redis 加鎖」仍未處理鎖過期、程序崩潰、結果重播與資料庫提交後的競態。
回答前需要釐清的問題
- 怎麼定義重複請求? 本題把同一租戶、同一端點與同一冪等鍵視為同一次操作;若業務已有不可重複的
checkout_id,還應在訂單資料表增加獨立唯一限制。 - 呼叫端能否提供資源 ID? 若呼叫端擁有穩定的訂單 ID,可以改用
PUT /orders/{clientOrderId};HTTP 語意本身將 PUT 定義為冪等。由伺服器產生訂單 ID 時,POST 搭配冪等鍵較適合。 - 建立訂單是否只包含資料庫工作? 若能在一個短交易內完成,建議使用單一交易方案。若必須執行數十秒的工作流程,需改為可見的
IN_PROGRESS紀錄、租約與接管權杖,不能長時間占用資料庫交易。 - 相同結果需要精確到什麼程度? 本題保存第一次的 HTTP 狀態碼與可安全重播的回應內容。若回應包含短期簽章或即時欄位,應只保存資源 ID,再依明確契約重建回應。
- 冪等鍵要保留多久? 至少涵蓋用戶端可能重試或離線補送的最長期間。若業務要求超過這段期間仍絕不重複,還需要永久業務唯一鍵;只延長快取 TTL 不能取代業務限制。
- 哪些失敗應被記住? 參數驗證失敗發生在取得冪等所有權之前,不保存結果;交易內可確定的業務拒絕可以保存並重播;基礎設施錯誤造成交易回復時不留下完成紀錄,允許重試。
30 秒回答架構
「我會把冪等鍵限制在租戶與 POST /orders 端點內,對正規化後的訂單參數計算請求指紋。資料庫用 (tenantid, endpoint, idempotencykey) 唯一限制仲裁並行請求。第一個請求在同一交易裡寫入冪等紀錄、訂單與 outbox,最後保存狀態碼及回應內容;提交後才回應。重複請求若指紋相同就重播結果,不同則回傳衝突。即使交易提交後回應遺失,也不會再建立訂單。外部付款與庫存由 outbox 非同步觸發,並繼續使用衍生的冪等鍵。鍵的保留時間涵蓋最大重試期間,永久防重則再靠訂單的業務唯一限制。」
分步深入解答
先定義協定。要求通過驗證的呼叫端,為每次「建立一張訂單」的意圖產生高熵鍵,所有重試都沿用同一個鍵;新訂單必須換新鍵。鍵不包含電子郵件、手機號碼等敏感資料。伺服器限制長度與字元集,但不能把「鍵看起來隨機」當成授權依據。HTTP POST 預設不具備 PUT 的冪等語意,安全重試來自這份應用層契約。
請求指紋要從伺服器驗證後的業務欄位產生,不能直接雜湊原始 JSON 位元組。欄位順序、無意義空白與預設值不應讓同一請求產生不同指紋;追蹤 ID、時間戳記等非業務欄位也應排除。可以將正規化 DTO、端點版本與影響訂單結果的欄位依穩定順序編碼,再計算摘要。摘要用來發現鍵誤用,不是簽章或授權機制。
最小資料模型如下,具體 SQL 僅用來表達限制:
CREATE TABLE idempotency_requests (
tenant_id text NOT NULL,
endpoint text NOT NULL,
idempotency_key text NOT NULL,
request_fingerprint text NOT NULL,
response_status integer,
response_body jsonb,
resource_id uuid,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, endpoint, idempotency_key)
);短交易方案的正常流程如下:
- 在交易外完成身分、鍵格式與請求參數驗證,產生指紋。
- 開啟交易,執行
INSERT ... ON CONFLICT DO NOTHING RETURNING ...嘗試取得鍵。 - 成功插入的請求是擁有者;它建立訂單、寫入 outbox,並把狀態碼、回應內容與訂單 ID 更新到冪等紀錄,再一次提交。
- 未插入的請求讀取既有紀錄。指紋不同則回傳
409 idempotencykeyreused;指紋相同則回傳第一次保存的狀態碼與回應內容,也可附加自訂的重播標記。 - HTTP 回應只在提交成功後送出。若連線在提交後中斷,下一次請求會走第 4 步,不會再建立訂單。
PostgreSQL 的唯一索引會讓相同鍵的並行插入等待,之後得到唯一的插入或衝突結果。實作要為等待設定有限的逾時:短時間內等到第一個交易提交,就直接重播;等待逾時則回傳明確的 idempotencyinprogress 與重試建議。不能先在應用層 SELECT 再決定是否插入,也不能在衝突時覆寫既有指紋或結果。
這個方案的關鍵不變條件是:訂單資料列可見時,對應的冪等結果與 outbox 事件也已可見;交易回復時三者都不可見。因此可以逐項推導故障:建立訂單前程序崩潰會回復,重試重新競爭;訂單已提交但回應遺失會重播;同鍵不同參數不會執行;兩個同時抵達的相同請求只有一個能通過唯一限制完成寫入。對可確定的業務拒絕,例如折價券已失效,可以在交易中保存穩定錯誤回應,避免重試得到不同含義;資料庫連線中斷等未提交故障則不保存。
若建立流程無法維持短交易,改用兩階段狀態機。第一筆短交易提交 INPROGRESS、leaseexpiresat 與遞增的 attempttoken,API 回傳 202,或讓同鍵請求查詢狀態。租約到期後,新執行者以 compare-and-swap 取得更大的權杖;所有完成更新都必須驗證權杖,避免舊執行者恢復後覆寫新結果。訂單仍要有業務操作 ID 的唯一限制,副作用仍透過 outbox 或下游冪等鍵執行。沒有接管權杖的「狀態加 TTL」會讓新舊執行者同時運作,只是延後重複發生的時間。
外部付款、庫存與通知不能夾在本機資料庫交易內。擁有者要與訂單一起寫入 OrderCreated outbox 紀錄,提交後由消費者投遞。消費者依事件 ID 去重;呼叫付款或庫存 API 時,使用由訂單 ID 與操作類型衍生的穩定鍵。如此,消費者的至少一次投遞不會變成至少一次扣款。若下游不支援冪等,就需要本機狀態機、查詢對帳或人工補償,不能宣稱 outbox 自動消除了外部重複。
過期策略必須來自重試契約。Stripe 的公開實作會在一段保留期間後允許清除鍵,這只表示鍵可以有生命週期,不能當成所有系統通用的時長。伺服器應讓 expiresat 涵蓋行動裝置離線佇列、閘道重試與人工補送的最長期間;清除後再用舊鍵會被視為新操作。若 checkoutid 在業務上永遠只能產生一張訂單,應在 orders 資料表建立永久唯一限制,即使冪等紀錄已刪除也能擋下重複。
Redis 可以快取已完成結果,不能獨自承擔正確性邊界。SET NX 成功後,程序可能在訂單提交前或提交後崩潰,鎖的 TTL 也可能在慢請求完成前到期;快取也無法原子提交訂單與 outbox。建議讓資料庫唯一限制決定「只能建立一次」,快取只用來減少熱門重播的讀取壓力,並以資料庫紀錄為真實來源。
驗證不能只送兩次循序請求。至少要涵蓋:50 個並行相同鍵只得到一張訂單與一個 outbox 事件;相同鍵不同參數回傳衝突;在提交後、送回應前注入斷線,重試仍回傳原訂單;交易回復後相同鍵能成功重試;鍵過期後的行為符合契約;兩個租戶使用相同裸鍵互不影響;消費者重複收到事件不會重複扣款。監控要記錄新建、重播、參數衝突、並行等待逾時、最久未完成狀態、交易回復、outbox 積壓與過期清除量,但日誌不能包含冪等鍵對應的敏感請求內容。
高品質示範回答
「我會先約定一個冪等鍵只代表某個租戶在 POST /orders 上的一次建立意圖。用戶端第一次請求產生鍵,逾時重試繼續使用同一個;伺服器對驗證後的訂單欄位與 API 版本產生穩定指紋。同鍵同指紋可以重播,同鍵不同指紋直接回報衝突。
資料庫中會建立 (tenantid, endpoint, idempotencykey) 主鍵。請求先完成參數驗證,再用 INSERT ... ON CONFLICT DO NOTHING 競爭所有權。成功的請求在一個短交易裡建立訂單、寫入 outbox、保存第一次的狀態碼與回應內容,接著提交並回應。失敗的請求不能再建立訂單:等前一個交易結束後讀取紀錄,指紋相同就回傳原結果,等待超過內部上限就告知呼叫端操作仍在處理。即使交易已提交但回應遺失,下一次重試看到的仍是同一張訂單。
付款與庫存不會在這個交易內同步呼叫。我會由 outbox 消費者投遞,並為每個下游操作使用由訂單 ID 衍生的冪等鍵。冪等紀錄只保留到最大重試期間,因此若結帳工作階段在任何時間都只能建立一張訂單,我還會替訂單的 checkout_id 增加永久唯一限制。
最後我會以並行、斷線與重複訊息做故障注入,驗證訂單、冪等結果和 outbox 的數量,而非只檢查 HTTP 200。線上重點觀察重播率、同鍵參數衝突、並行等待逾時、交易回復與 outbox 積壓。」
常見錯誤
- 先查詢是否存在,再插入訂單 → 兩個請求能同時查到不存在並各自寫入 → 用資料庫唯一限制與原子衝突處理仲裁擁有者。
- 只用冪等鍵查紀錄 → 租戶之間可能碰撞,甚至洩漏別人的回應 → 把驗證後的租戶與端點納入範圍,重播時再次授權。
- 同鍵直接回傳舊結果,不比較參數 → 呼叫端誤用鍵時會把舊訂單當成新訂單 → 保存正規化請求指紋,不一致就回傳明確衝突。
- 訂單先提交,冪等結果稍後才寫 → 兩次寫入之間崩潰會留下訂單卻沒有去重紀錄 → 把訂單、結果與 outbox 放入同一資料庫交易。
- 把付款呼叫放進資料庫交易 → 網路逾時會延長鎖定時間,且付款成功與本機回復無法原子化 → 交易內寫 outbox,交易外用下游冪等鍵執行。
- 只用帶 TTL 的 Redis 鎖 → 程序崩潰與鎖過期後無法判斷訂單是否已提交,也無法重播結果 → 資料庫限制保正確,快取只做加速。
- 把所有錯誤都快取 → 參數修正後仍會重播舊的驗證錯誤,暫時性故障也失去重試機會 → 取得所有權前的驗證失敗不保存,區分穩定業務結果與未提交故障。
- 清除冪等紀錄後仍承諾永久防重 → 過期鍵會被視為新操作 → 公開保留期間,並以業務唯一鍵滿足永久不變條件。
追問與應對
追問一:第一個請求還沒提交,第二個請求該回傳什麼?
短交易方案中,唯一索引衝突會讓第二個插入等待。資料庫等待與整個 API 都要設定短於用戶端逾時的界線;若第一筆交易及時提交,第二個請求讀取並重播。若等待達到界線,回傳可識別的 idempotencyinprogress 與重試時間,不另外建立訂單。長任務則使用已提交的 IN_PROGRESS 狀態與查詢 API,不讓 HTTP 請求長期占用連線。
追問二:服務在提交成功後、寫入 HTTP 回應前崩潰怎麼辦?
這正是保存結果的原因。訂單、outbox 與回應快照已在同一交易中提交;用戶端重試時會發生唯一鍵衝突,伺服器驗證指紋後回傳保存的狀態碼與回應內容。若只保存「處理過」布林值,伺服器仍不知道該回傳哪張訂單,呼叫端也無法區分成功與未知。
追問三:同一冪等鍵帶入不同金額怎麼辦?
回傳 409 idempotencykeyreused,同時記錄衝突指標,不執行訂單建立。不能以新參數覆寫舊紀錄,也不能默默回傳舊訂單。指紋必須涵蓋金額、幣別、商品、收件資訊參照等會改變業務結果的欄位,並包含 API 或正規化規則版本。
追問四:長任務執行者取得租約後暫停,租約被別人接管,舊執行者又恢復時怎麼辦?
每次接管都遞增 attempt_token。訂單狀態變更、完成結果寫入與可控的下游任務都帶上該權杖,並以「只有目前權杖能更新」為條件。舊執行者恢復後寫入會失敗,再讀取新狀態並退出。下游無法驗證權杖時,仍要依賴穩定業務操作 ID 的冪等介面或對帳補償;租約本身無法阻止已送出的外部副作用。
追問五:為什麼不能只替 orders.checkout_id 增加唯一限制?
業務唯一限制能阻止第二張訂單,適合作為永久防線,但它沒有定義同鍵不同參數是否衝突、第一次回傳了什麼狀態碼、並行處理中如何回應,也不一定涵蓋沒有 checkout_id 的呼叫端。冪等紀錄提供請求層協定與結果重播,業務唯一限制保護領域不變條件;兩者處理的層次不同。