併發、鎖衝突與重試
兩個寫入碰到同一批資料列時,可能在資料庫層撞在一起。這件事一直都存在,改變的是:模組現在把撞車當成一種正常、有名字的結果,而不是伺服器錯誤。這一頁就是那份合約:哪些失敗是暫時性的、伺服器已經替你做了什麼,以及真的傳到你手上的那一種該怎麼處理。
什麼算暫時性
只有兩種 MySQL 狀況:1213 死結,與 1205 鎖等待逾時。對單一 database transaction 而言,兩者在運作上意義相同:錯誤傳到你手上之前,該 transaction 已回滾(死結由 InnoDB 自己回滾;鎖等待逾時由伺服器在回應前回滾),該 transaction 沒有任何部分落地,而一般 transactional endpoint 可以重跑相同且具冪等性的工作。
其他都是真正的錯誤,不會被重試。
IaC apply 是 document-level 例外。它採 per-line best effort,不是一個 transaction:retryable_lock_conflict 只回滾失敗行(或 line-0 maintenance),其他行可能已提交,而且 apply 會繼續。請讀回目前狀態,再走全新的 plan/review/apply;絕不可原樣重播舊 apply body 或 hash。見 plan 與 apply。
伺服器重試死結;鎖等待逾時第一次就浮出
擁有自己 transaction 的寫入路徑都包了一層有上限的重試——但這層重試現在只涵蓋死結(1213):三次嘗試、每次之間 rollback,並採線性退避加上抖動,讓兩個相撞的寫入者錯開,而不是同步地再撞一次。死結是即時偵測到的,行程內重跑很便宜,所以這類競爭大多不會傳到你這邊。
鎖等待逾時(1205)不一樣:它浮出水面時,你的請求已經把資料庫的整個等待窗口堵滿了。以前行程內再重跑會把那段等待放大約三倍——遠超過多數 client 與 proxy 的時限——所以 1205 現在第一次發生就回同一個可重試的 409。對你這邊的合約完全相同(暫時性、沒有資料落地、退避後重試);只是伺服器不再霸著你的連線慢慢重試慢的那一種。
涵蓋的路徑是:建立資料表、欄位異動(新增、更新,以及新增欄位所觸發的資料列遷移)、資料列的建立與更新、批次 sort-order 鎖、授權寫入路徑,以及觸發器執行器。同一套「上鎖並清理錯誤」的紀律後來也延伸到資料表的 settings PATCH 合併、聊天室 client-access 通行碼設定、rules 更新通道、callback token 與 public-read token 的撤銷、IaC executor 各自獨立的 per-line settings transaction,以及附件 complete-upload 的 blob 寫入——這些路徑以前要嘛回一個光禿禿的 500,要嘛更糟:無聲地弄丟一筆寫入。
競爭現在會等,而不是說謊
其中兩條路徑值得呼叫端特別注意:
- Settings PATCH(三個 scope 的
default-permissions/column-acl)以前是整包 read-modify-write、完全沒有鎖。兩個併發 PATCH 改不同的 key,兩邊都拿到200,晚 commit 的把早的那個 key 無聲蓋回去——收緊成「僅管理者可見」的欄位 ACL 可能在作者被告知已儲存的同時,退回全員可見。現在合併改成在資料表列鎖下一次只合一個 key,schema 寫入端也在同一把鎖下重讀,所以加欄位撞上 settings PATCH 也蓋不掉column_mapping。看得見的後果是:競爭時 settings PATCH 可能等鎖(最長到資料庫的等待窗口),然後回可重試的409——取代以前那個又快又假的200。 - Token 撤銷(callback token 與 public-read token)以前遇到鎖衝突會把撤銷弄丟、同時回一個沒有解釋的
500——token 還活著;對 public-read token 來說,這代表匿名讀取通道仍然開著。現在撤銷遇到衝突會重試,仍然輸的話回可重試的409、token 原封不動——所以把那個409讀成「撤銷還沒有發生」,重試到拿到200為止。
你仍然可能看到的 409
當衝突真的傳到一般 transactional endpoint——死結三次重試都輸了,或鎖等待逾時第一次發生——請求會回:
{ "detail": "Concurrent write conflict (lock); please retry the request." }狀態碼是 409。請把它讀成「再試一次」,不是「你的請求有問題」。這次沒有寫進任何東西。
409 在這個 API 上是多用途的。唯一性規則違反、不允許的狀態轉換、被暫存的核准、樂觀鎖版本不符、鎖衝突,全都用 409,而其中只有最後一種值得原樣重試。請依回應內容分支,不要只看狀態碼:鎖衝突就是 detail 等於上面那句 please-retry 的那一種,而在 command 通道上,是 error 等於 command_lock_conflict 的那一種。
對這些 transactional endpoints,重試時請自己加上退避與抖動,並且保留你的冪等鍵。如果介面接受冪等鍵,重用它正是重點:那才是讓重試安全的東西。這項指示不適用上方的 per-line IaC 訊號。
樂觀鎖:不能原樣重試的那個 409
鎖衝突是資料庫拒絕把兩個寫入者排序。version_conflict 剛好相反:那筆寫入完全排得動,伺服器拒絕它是因為在你讀取與寫回之間,那一列已經動過了。退避沒有用——你送出的版本已經不存在,同一份請求再送幾次都是同樣的結果。復原方式是重讀。
每一條資料列更新通道都接受 expected_version,一個 ≥ 1 的整數,也就是你讀到的 version。比對是在該列自己的 SELECT … FOR UPDATE 重讀之下進行的,所以比對本身不會跟旁邊落地的 commit 賽跑。在人工 REST 與 bulk lanes,不傳這個欄位就維持最後寫入者獲勝;agent 工具則會在這個選填的 earlier-read guard 之後,再加入強制的 proposal-version pin,詳見下方。
四條通道帶著同一把鎖,卻用四種方式回答:
| 通道 | 鎖放在哪 | 版本不符時會怎樣 |
|---|---|---|
PUT .../records/{record_id} | body 欄位 | 409,內容是 {"error": "version_conflict", "message", "current_version", "expected_version"}。沒有寫入任何東西 |
POST .../records/bulk——同步、不可分割 | actions[].expected_version | 409 version_conflict,另帶 action_index 與 record_id,而且整批回退——連已經套用成功的動作也一起 |
POST .../records/bulk-update——非同步工單 | updates[].expected_version | 送出時仍然回 200 + ticket_id。只有衝突的那一筆被跳過,記在輪詢到的工單 errors[] 裡,終局狀態是 completed_with_errors |
| Agent prepare/direct update | 預設 direct policy 下在 custom_tables_update_record、開啟確認時在 custom_tables_prepare_update_record 的 expected_version | Earlier-read 不符時回 {"error": "version_conflict", "current_version": N, "expected_version": M} 機器可讀 JSON。Proposal 鑄出後若再 drift,則改以 proposal_mismatch 拒絕。Token-only execute 工具沒有 expected_version 參數 |
非同步通道在結構上不可能對單筆回 409——它早就回了 200 與一張工單——所以只看 HTTP 狀態碼分支的客戶端,會把「有項目被跳過」的批次讀成乾淨成功。請輪詢工單。批次操作有完整 payload、逐筆錯誤的 100 筆上限,以及核准 gate 在同步通道上換掉的那個不一樣的 409(record_changed_during_staging)。
這把鎖是更新才有的事。DELETE .../records/{record_id} 沒有這個欄位;批次裡的 insert 或 delete 動作若帶上 expected_version,會在任何東西執行之前就被擋下:422 expected_version is only valid on update actions — an insert has no prior version and delete carries no data to guard。
Agent lane 有兩個不同視窗。從較早讀取到 prepare(或 direct)呼叫,expected_version 是選填 guard:proposal 若依賴先前讀到的值,助理就傳該版本;不符時在鑄出 proposal 前回 version_conflict。從 proposal 到後續 token-only execute,伺服器即使在選填參數被省略時,也會一律釘住當下 target version;確認時加鎖並重查,drift 則回 proposal_mismatch。因此省略 expected_version 只會讓 earlier-read 視窗沒有 guard,不會讓使用者確認期間恢復成最後寫入者獲勝。Bulk 家族不暴露這個選填參數,但它的 server-owned confirmation state 仍會釘住 target versions。詳見 Agent 工具箱。
Confirmation recovery 不是 mutation retry
單獨一句 affirmative 永遠不足以重建或重複一筆寫入。若相符 proposal 已遺失、過期、取消,或從未跨過 durable-visibility boundary,該 turn 不會執行 mutation;伺服器只會建立或替換成一份新的 inert PREPARED proposal,必須先顯示給使用者,再由後續 Human turn 明確確認。若相同 request 已到達 COMPLETED,另一句單獨 affirmative 只會回 already_completed,不會再次執行。Direct policy(requires_confirmation_for_ct_action=false)仍在 table/row lock 與保留 fence 下執行,不是最後寫入者獲勝。若要刻意重複同一 business change,使用者必須明確重述要求再確認新的 proposal,或在該政策下發出新的直接呼叫。詳見完整 confirmation state machine。
Command 會把它寫在錯誤封包裡
Command 執行通道會用資料列鎖把授權房間序列化,所以同一個 command 的併發呼叫本來就會競爭。這裡的衝突會用標準的 program error 封包回來,並帶一個型別化的代碼:
{
"error": "command_lock_conflict",
"retryable": true,
"phase": "execute",
"message": "Concurrent write conflict (lock); please retry the request."
}由此有兩件事。第一,這個 command 沒有壞掉,不要因此停用它,也不要把它當成定義錯誤來告警。第二,同一個冪等鍵可以立刻重用:執行失敗的路徑會釋放預約,就是為了讓合法的重試能拿到它。
觸發器會重試自己的動作
一次 trigger run 遇到暫時性衝突時,會重試它的動作,而不是讓整個 run 失敗。這之所以安全,理由執行紀錄與重試已經寫過:進度是逐 action 保存的,而已完成的 receipt 會依穩定的 action id 被跳過,所以重試是續跑,不是把副作用重做一遍。
以前真正會痛的是排程動作。一個暫時性衝突就會讓 run 在第一次嘗試被記成失敗,而那個業務動作被無聲地丟掉;在每日排程上,這代表那天的工作就是沒做,而且沒有明顯的東西會告訴你。現在排程動作會重試,一樣有上限、一樣採抖動退避。不可重試的錯誤仍然照舊讓 run 失敗。
錯誤內容不再洩漏查詢語句
資料庫驅動的錯誤在轉成字串時,會把 SQL 語句與它的參數一起帶出來。任何從那個字串衍生出來的東西,以前都會把兩者一起交給呼叫端:
(pymysql.err.OperationalError) (1205, 'Lock wait timeout exceeded...')
[SQL: SELECT id FROM custom_tables WHERE id = %(table_id)s FOR UPDATE]
[parameters: {'table_id': 'ef0af4fb-...'}]現在客戶端看得到的訊息會在單一收斂點被清理:暫時性鎖衝突變成 please-retry 的 409,其他驅動錯誤變成 500 "Database operation failed.",細節留在伺服器日誌。這也包含客戶端會輪詢的批次工單 error_message 欄位,所以以前會把原始 SQL 顯示給終端使用者的輪詢端,現在拿到的是清理過的訊息。
如果你曾經在解析那些字串:請停手。它們從來就不是合約,而且現在也沒有了。
客戶端該怎麼做
- 把「
409+ please-retry detail」或「error: "command_lock_conflict"」視為可重試。其他回409的都是業務衝突:修正請求,不要迴圈重試。version_conflict尤其代表要重讀那一列、重建 payload——原樣重送永遠不會成功。 - 用指數退避加抖動。死結在伺服器端已經快速試過三次,立刻補一發很可能輸掉同一場競爭;鎖等待逾時則已經完整等過一輪鎖窗口,馬上再打只是排到同一個持鎖者後面。
- 只要介面接受冪等鍵,各次嘗試都保留同一個。
- 自己的重試也要設上限,超過就把真正的失敗呈現給使用者。單一資料表上持續的競爭是資料模型問題,通常是每次寫入都會鎖到的同一個熱點父列,再怎麼重試都解決不了。