題幹與適用場景
發票明細 invoice_items/{id} 包含說明和金額。Alice 與 Bob 都讀到版本 7;Alice 先儲存為版本 8, Bob 之後仍根據版本 7 送出。如果服務直接執行無條件 UPDATE,Bob 會覆蓋 Alice 的結果,兩人卻都 收到成功回應,這就是本題要避免的遺失更新。
本題限定一個 HTTP JSON API、一個支援條件更新的關聯式資料庫,以及不會重複使用的資源 ID。每個 回應只有一個規範 JSON 表示;每次業務欄位變更都會推進資源版本並產生新的強 ETag。衝突預期不多, 因此採用樂觀並行控制,不在使用者編輯期間持有資料庫鎖。範圍包含 PUT、PATCH 與 DELETE, 不討論多人即時協作編輯演算法。
核心能力屬於後端:把用戶端讀到的版本、HTTP 前置條件和資料庫的原子寫入串成可驗證的契約。下文 使用 PostgreSQL 風格 SQL;其他資料庫要提供等價的條件更新與影響列數檢查。只在控制器比較版本, 再執行無條件更新,仍會留下檢查與寫入之間的競爭視窗。
面試官考察點
第一個訊號是能否畫出遺失更新的交錯順序,並指出最後寫入者勝出不代表業務正確。強回答會要求每次 讀取可變資源時都回傳版本憑證,寫入請求必須證明自己根據目前版本。
第二個訊號是 HTTP 語意是否準確。服務在 GET 回應中回傳強 ETag,用戶端在修改請求中傳送 If-Match。If-Match 使用強比較;舊標籤或弱標籤不能通過。缺少必要前置條件時可回傳 428 Precondition Required,標籤不符時回傳 412 Precondition Failed。409 Conflict 留給未由 HTTP 前置條件表達的業務衝突。
第三個訊號是資料庫檢查必須與寫入原子完成。建議條件同時比對 id 與 version,並在同一個陳述式 中更新業務欄位和遞增版本。影響列數為 0 才表示資源已刪除或版本已過期;先查版本再無條件寫入屬於 TOCTOU 競爭。
最後看衝突復原和邊界。樂觀並行控制不能取代冪等鍵,也不能保護跨多列述詞。高競爭資源持續回傳 412 時,應改變競爭策略,不能讓用戶端無限立即重試。
回答前需要澄清的問題
- 衝突單位是整項資源還是單一欄位? 整項版本最容易證明正確,但兩人修改不同欄位也會衝突。
若這類衝突頻繁,可拆小聚合、提供意圖明確的操作介面,或定義欄位層級合併規則。
- 哪些寫入方法必須帶版本? 本題對
PUT、PATCH和DELETE都要求If-Match。批次匯入、
背景工作和管理指令也必須使用相同版本協定,不能保留旁路寫入。
- 衝突後由誰決定合併? 人工編輯通常顯示「原值、我的修改、目前值」再讓使用者選擇;機器操作
只有在合併函式滿足業務不變量時才自動重算和重試。
- 衝突率與延遲預算是多少? 偶發衝突適合樂觀方案。熱門庫存或競價紀錄可能更適合單一原子業務
操作、短交易悲觀鎖或單一寫入者佇列。
- 用戶端會不會因逾時重送同一請求? 版本檢查處理不同意圖的競爭;冪等鍵處理同一意圖的傳輸
重試。兩種風險都要涵蓋。
- 版本是否涵蓋跨列規則? 單列版本只避免同一資源被覆蓋。若規則是「一個班次至少一人在崗」,
需要 Serializable、共同鎖定列或資料庫約束,不能只替每列加版本。
30 秒回答框架
「我會讓 GET 回傳目前資源和強 ETag,例如版本 7 對應 \"invoice-item-42-v7\"。用戶端儲存時 把它放進 If-Match。服務缺少該標頭就回傳 428,收到舊標籤就回傳 412。真正的防線在資料庫:用 一個 UPDATE ... WHERE id = ? AND version = 7 同時寫業務欄位並把版本加一;回傳 0 列表示衝突 或刪除,不能先查再無條件更新。成功回應帶版本 8 的新 ETag。用戶端遇到 412 後重新讀取,顯示差異 並由使用者合併;同一請求逾時重送則另用冪等鍵處理。最後用兩個並行用戶端固定交錯,斷言只有一個 版本 7 寫入成功。」
分步驟深入解答
先寫出錯誤歷史:
Alice: GET item 42 -> amount=10000, version=7
Bob: GET item 42 -> amount=10000, version=7
Alice: PUT amount=10500 -> unconditional UPDATE -> success
Bob: PUT amount=9800 -> unconditional UPDATE -> success
Final: amount=9800; Alice's accepted update is lost建議契約讓讀取回傳強 ETag。標籤是用戶端必須原樣帶回的不透明值,不應包含敏感資訊,也不能取代 驗證身分和授權:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "invoice-item-42-v7"
{"id":42,"description":"Consulting","amountCents":10000,"version":7}Alice 的更新把這個標籤放進 If-Match:
PUT /invoice-items/42 HTTP/1.1
Content-Type: application/json
If-Match: "invoice-item-42-v7"
{"description":"Consulting","amountCents":10500}服務先完成正常的身分、權限和請求格式檢查,再評估前置條件。若 API 規定所有修改都必須是條件請求, 缺少 If-Match 回傳 428,並說明用戶端應重新讀取後帶標籤重試。若標籤不是目前強 ETag,就不執行 修改並回傳 412。HTTP 的強比較要求兩邊都不是 W/ 弱標籤,且不透明標籤逐字元相同;不能把 W/"invoice-item-42-v7" 當成成功比對。
通過 HTTP 檢查後,資料庫仍必須再次原子兌現相同版本條件:
UPDATE invoice_items
SET description = $1,
amount_cents = $2,
version = version + 1
WHERE id = $3
AND version = $4
RETURNING id, description, amount_cents, version;$4 是從已驗證 ETag 取得的預期版本 7。回傳一列表示寫入成功,回應包含版本 8 的表示和新 ETag。 回傳 0 列時再依授權邊界讀取目前紀錄:不存在就回傳 404,仍存在就回傳 412。ID 不重複使用的假設 讓刪除與重建不會偽裝成舊資源。無論如何分類,0 列路徑都不能改寫資料。
不能先執行 SELECT version,比對通過後再做不帶版本的 UPDATE。另一個交易可以在兩個陳述式之間 提交,讓剛通過檢查的請求覆蓋新值。即使控制器已比較 ETag,SQL 的 WHERE version = $4 仍是最終 正確性邊界。ORM 的並行權杖也應產生等價的條件更新,並把 0 列轉成並行衝突。
狀態碼要與失敗原因對齊:
428 Precondition Required:用戶端沒有提供 API 要求的條件。412 Precondition Failed:提供了If-Match,但目前表示不符合條件。404 Not Found:在不洩漏資源存在性的授權策略下,資源已不存在。409 Conflict:請求通過版本前置條件,但觸發另一項業務狀態衝突,例如已結算發票禁止修改。
收到 412 後,互動式用戶端重新 GET,保留三份值:使用者最初讀取的值、準備送出的值和伺服器 目前值。只有目前值相對原值沒有改動的欄位,才可能安全套用使用者修改;其他欄位顯示衝突。整項資源 共用一個版本會保守拒絕不相交欄位的並行修改。若這成為真實瓶頸,可拆分資源邊界或提供 POST /invoice-items/42/adjust-amount 這類攜帶業務意圖的原子操作,不能默默改成最後寫入者勝出。
冪等鍵解決另一種問題。假設 Alice 的版本 7 更新已提交為版本 8,但成功回應在網路中遺失;原請求 重送時,舊 If-Match 會失敗。若請求帶穩定冪等鍵,服務可以回傳第一次已儲存的結果。版本憑證偵測 「兩個不同修改都根據版本 7」,冪等紀錄辨識「同一修改傳輸了兩次」,兩者不能互換。對無法可靠 證明請求已執行過的寫入,嚴格回傳 412 比依相似請求猜測成功安全。
樂觀方案假設衝突少。若某項資源的衝突率持續很高,重讀和人工合併會浪費容量並惡化體驗。庫存扣減 可用 UPDATE ... SET stock = stock - $1 WHERE stock >= $1 直接表達單列不變量;必須讀取後決策的 短操作可用列鎖;需要循序處理的聚合可分區到單一寫入者。選擇取決於衝突率、操作是否可交換、等待 預算和不變量範圍。
驗證要固定並行歷史,不能只依序點兩次 API。兩個用戶端都先讀取版本 7,用屏障同時送出不同金額, 斷言只有一個請求成功,另一個得到 412,最終版本為 8,且內容來自成功者。還要涵蓋缺標頭回傳 428、 弱 ETag 不符合、成功回應帶新 ETag、舊標籤持續失效、更新與刪除競爭,以及回應遺失後使用同一冪等 鍵重播。線上監控各端點的前置條件缺失率、412 比例、自動重試次數和最終放棄率;異常上升通常表示 熱點或有用戶端沒有更新版本。
高品質示範回答
「我先把衝突邊界定為一筆發票明細。兩位使用者都拿到版本 7,後一個儲存不能無條件覆蓋前一個, 所以讀取回應會帶強 ETag,所有 PUT、PATCH、DELETE 都必須用 If-Match 帶回。缺標頭回傳 428;帶回的標籤已過期就回傳 412,不執行寫入。
HTTP 層比較標籤還不夠。我會在資料表保存每資源版本,讓持久層原子執行 UPDATE invoice_items SET ..., version = version + 1 WHERE id = ? AND version = ? RETURNING ...。Alice 先以版本 7 成功後,該列變為版本 8;Bob 的相同條件影響 0 列,因此不會覆蓋。成功回應回傳新 ETag。0 列後依 權限讀取一次,用 404 區分刪除,其餘作為 412,但任何分支都不能補做無條件更新。
用戶端收到 412 會重新讀取,比較原值、我的修改和目前值,讓使用者解決真正衝突。若不同欄位經常 互相阻擋,我會拆小資源或設計意圖明確的原子操作。請求逾時重送則另用冪等鍵:它辨識同一意圖的 重複傳輸,版本號辨識不同意圖根據同一舊狀態。
我會用兩個連線和屏障,讓它們都讀取版本 7 後同時送出,斷言只有一個成功、另一個 412、最終版本 正好是 8。再測缺少 If-Match、弱標籤、刪除競爭和回應遺失重播。若線上 412 比例長期很高, 我會從熱點和資源邊界著手,評估條件業務更新、短交易鎖或單一寫入者,不能只增加重試次數。」
常見錯誤
- 只在應用層先查版本再更新 → 檢查與寫入之間仍能插入另一個提交 → **把版本放進同一個
UPDATE 的 WHERE 條件並檢查影響列數。**
- 缺少
If-Match時仍繼續寫入 → 新用戶端受保護,舊用戶端仍能默默覆蓋資料 → **把條件寫入
設為 API 契約,缺標頭回傳 428。**
- 把弱 ETag 當成
If-Match成功 → 標準要求強比較,弱標籤不會符合 → **替可修改表示產生
符合語意的強 ETag。**
- 所有衝突都回傳 409 → 用戶端無法區分 HTTP 前置條件失敗與業務狀態衝突 → **版本不符回傳
412,獨立業務衝突再使用 409。**
- 收到 412 就自動原樣重試 → 相同舊標籤不會變新,無條件換標籤又可能覆蓋別人 → **重新讀取並
重新計算,或讓使用者合併。**
- 認為冪等鍵已經避免並行覆蓋 → 兩個不同修改使用不同鍵時仍根據同一舊版本 → **冪等鍵與版本
條件分別處理重複傳輸和並行意圖。**
- 替每列加版本就宣稱所有不變量安全 → 跨列寫入偏斜不會觸發同一列版本衝突 → **依不變量範圍
選擇 Serializable、共同競爭點或約束。**
- 熱點上無限立即重試 → 失敗請求同步重撞並放大負載 → **測量衝突率,改用原子業務操作、短鎖
或單一寫入者。**
追問及應對
追問一:PATCH 修改不同欄位也要衝突嗎?
整項版本代表任何欄位變更都會推進版本,因此兩個不相交補丁也會衝突。這是安全且容易解釋的預設值。 若資料證明誤衝突很多,可以把獨立生命週期拆成子資源,或讓操作攜帶欄位基線並定義三方合併。欄位級 權杖會降低誤衝突,卻增加權杖數量、組合不變量和稽核難度,不能只為減少 412 就引入。
追問二:用戶端逾時後重送,舊 ETag 已失效怎麼辦?
修改請求攜帶穩定冪等鍵,服務把鍵、請求摘要、預期版本和第一次結果一起保存。相同鍵與相同請求重播 時回傳第一次結果;相同鍵搭配不同請求則拒絕。若沒有可靠冪等紀錄,不能只因目前欄位「看起來相同」 就假定第一次請求成功,應回傳 412 並讓用戶端讀取目前狀態。
追問三:可以直接使用 updated_at 當版本嗎?
只有它由資料庫產生、每次相關修改都變更、精度足以區分連續寫入且所有節點語意一致時才可能安全。 時間戳精度截斷或遺漏寫入路徑會讓兩個版本擁有相同值。每資源遞增整數或資料庫原生列版本通常更容易 形成無歧義的相等比較;對外仍可編碼成不透明 ETag。
追問四:衝突率很高時怎麼調整?
先依資源和操作統計 412,確認是單一熱點、過大的衝突邊界,還是用戶端長時間離線。可交換的增減操作 改成資料庫原子運算式;短而不可交換的決策可用列鎖;必須嚴格排序的一組命令可依資源鍵進入單一寫入 者。這些方案以等待、吞吐或架構複雜度換取較少失敗,應由實際衝突與延遲資料觸發。
追問五:單列版本為什麼防不住寫入偏斜?
寫入偏斜中的兩個交易讀取同一個跨列述詞,卻更新不同紀錄,各自的版本條件都能成功。版本只證明 「我更新的這一列沒有變化」,沒有證明共享述詞仍成立。應把不變量投影到共同計數列、鎖定共同 guard、 建立資料庫約束,或使用 Serializable 偵測無法序列化的相依性。