Skip to Content
核心概念併發與重試

併發、鎖衝突與重試

兩個寫入碰到同一批資料列時,可能在資料庫層撞在一起。這件事一直都存在,改變的是:模組現在把撞車當成一種正常、有名字的結果,而不是伺服器錯誤。這一頁就是那份合約:哪些失敗是暫時性的、伺服器已經替你做了什麼,以及真的傳到你手上的那一種該怎麼處理。

什麼算暫時性

只有兩種 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,並採線性退避加上抖動,讓兩個相撞的寫入者錯開,而不是同步地再撞一次。死結是即時偵測到的,行程內重跑很便宜,所以這類競爭大多不會傳到你這邊。已確認的 agent 寫入與 direct policy 下的 agent 寫入都只會嘗試一次,所以死結會立刻以 write_conflict 工具結果浮現(Concurrent write conflict (lock); please retry the request.)。不會有任何東西自行重試這筆 agent 寫入。在 direct policy 下,重試就是一次新的工具呼叫,並經過一輪新的授權流程。開啟確認時,這個衝突會被存成該 proposal 的最終結果:同一輪再呼叫會重播同一個 write_conflict,之後的回合只回一句「好」會得到 already_completed,除了拒絕之外的任何其他訊息(包含明確重述的請求)都會產生新的 proposal。

鎖等待逾時(1205)不一樣:它浮出水面時,你的請求已經把資料庫的整個等待窗口堵滿了。以前行程內再重跑會把那段等待放大約三倍——遠超過多數 client 與 proxy 的時限——所以 1205 現在第一次發生就回同一個可重試的 409。對你這邊的合約完全相同(暫時性、沒有資料落地、退避後重試);只是伺服器不再霸著你的連線慢慢重試慢的那一種。

涵蓋的路徑是:建立資料表、欄位異動(新增、更新與刪除)、資料列的建立與更新,以及單一使用者的授權寫入。另有三條路徑跑自己的有限迴圈,連 1205 也會重試,因為在那裡重跑是安全的:批次 sort-order 鎖(在寫入任何東西之前取得)、觸發器執行器(按 action 續跑),以及核准套用 worker(每筆暫存的資料列寫入、以及暫存批次的每一列,各最多三次嘗試)。agent 的 command 執行工具遇到可重試的 command_lock_conflict(1205 也算)時,也會以相同的 idempotency key 重送,總共最多嘗試三次。自後端 5.22.0 起,背景新增欄位的工作也能撐過鎖衝突:死結在 worker 內重跑,1205 或重跑後仍發生的死結則留給下一次狀態輪詢接續,直到第三次以鎖衝突結束的嘗試,才讓這次新增失敗(見背景新增欄位)。同一套「上鎖並清理錯誤」的紀律後來也延伸到資料表的 settings PATCH 合併、資料表名稱/描述 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。請把它讀成「再試一次」,不是「你的請求有問題」。同步型別轉換失敗時,該 transaction 沒有寫入:轉換後資料列、歷史與 schema 會在母表鎖內一起 commit,並在鎖內檢查目前規則。REST 對不超過 10,000 筆資料列的表同步轉換;超過時先提交 schema 再於背景轉換,不屬於這項同步保證。IaC 則同步轉換。對已有資料列的表新增 stored 欄位及刪除欄位仍分階段 commit(新增計算欄位只 commit 一次):同步新增會連同第一批回填提交 schema,超過 500 筆 live 紀錄(預設門檻)的 REST 新增則會立即連同持久的工作紀錄提交 schema,之後才在背景回填,刪除欄位則先移除 schema 再清理資料列。因此這兩種操作後續衝突時仍可能留下部分進度;重試前請重新讀取欄位與資料列。

409 在這個 API 上是多用途的。唯一性規則違反、不允許的狀態轉換、被暫存的核准、樂觀鎖版本不符、鎖衝突與 date_interval_exclusion_retry,全都用 409,而其中只有最後兩種——加上在 command 通道上帶 retryable: true 的 program error,例如 source_version_changed 或 source_link_changed——值得原樣重試。請依回應內容分支,不要只看狀態碼:鎖衝突就是 detail 等於上面那句 please-retry 的那一種;在 command 通道上,是 detail.error 等於 command_lock_conflict 的那一種;在標籤刪除或標籤的資料表集合取代上,則是 tag assignments changed while acquiring table roots; retry the request(請求在鎖定該標籤的資料表期間,另一個寫入者把資料表指派給了這個標籤;沒有寫入任何東西)。期間規則的重試則是 detail.error 等於 date_interval_exclusion_retry 的那一種:日期區間 rule 在寫入等待期間被改變,沒有寫入任何東西。

對這些 transactional endpoints,重試時請自己加上退避與抖動,並且保留你的冪等鍵。如果介面接受冪等鍵,重用它正是重點:那才是讓重試安全的東西。這項指示不適用上方的 per-line IaC 訊號。

423 代表等待,不是立刻重試

423 既不是鎖衝突,也不是業務錯誤。schema 遷移持有一張表期間,資料列寫入會回 423 Table is currently locked for operation: <operation>,欄位異動回 423 Table is currently locked for migration operation: <operation>,callback 寫入路由則回 423 Table is currently locked for a migration。只要該表有待處理或套用中的暫存變更,欄位新增或更新也會回 423 {"detail": {"error": "approval_holds_pending", "pending_count": N}},資料列數不超過 10,000 筆的欄位刪除同樣如此;超過這個數量時,欄位刪除會回 200 並在背景執行,同樣的拒絕只會出現在遷移結果裡。由 require_approval 規則暫存的資料列建立、更新或刪除,也可能在遷移於請求自身的鎖檢查之後才取得該表時回 423 {"detail": {"error": "table_locked", "message": "table is undergoing a schema migration; retry shortly"}},所以請把 detail 當成字串或物件兩種都可能來讀。遷移鎖會標明持有它的操作(add_column、update_column、delete_column、diagnosis、test_data_insertion、資料表 purge 期間的 delete_table 與 unlink_table,或根層的 crossref_repair),並在取得或最後一次續期 10 分鐘後過期;背景新增欄位或型別變更遷移在執行期間會為它續期,行程崩潰遺留的鎖會在這 10 分鐘內自行清除。請等遷移完成(GET .../tables/{table_id}/migration/status 會顯示 lock_info.operation 與 expires_at;見遷移)或先處理核准,再重試;立刻進入重試迴圈只會白白消耗請求。

樂觀鎖:不能原樣重試的那個 409

鎖衝突是資料庫拒絕把兩個寫入者排序。version_conflict 剛好相反:那筆寫入完全排得動,伺服器拒絕它是因為在你讀取與寫回之間,那一列已經動過了。退避沒有用——你送出的版本已經不存在,同一份請求再送幾次都是同樣的結果。復原方式是重讀。

下面四條資料列更新通道都接受 expected_version,一個 ≥ 1 的整數(在 REST 與 bulk 通道上,更小的值是 422 驗證錯誤),也就是你讀到的 version。upsert 與 callback 寫入路徑沒有 expected_version 欄位,也沒有版本防護。在 REST、bulk 與 direct policy 的 agent lane,比對是在該列自己的 SELECT … FOR UPDATE 重讀之下進行的,所以比對本身不會跟旁邊落地的 commit 賽跑;開啟確認時 agent 的 prepare 呼叫則在 prepare 當下比對,沒有那把鎖,受鎖保護的檢查是確認執行時的版本 pin。在人工 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_version409 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_versionEarlier-read 不符時回 {"error": "version_conflict", "message", "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。

saved view 與標籤完全沒有版本防護。PATCH .../views/{view_id} 與 PATCH .../table-tags/{tag_id} 不接受任何版本欄位,也不看 If-Match header,所以最後寫入者獲勝。它們的名稱唯一性(view 是在表上按建立者,標籤是按 scope)會在寫入前先檢查,並由資料庫約束兜底,所以兩個並行請求以同一名稱建立或改名時,輸家可能回 500 Internal Server Error,而不是 409。重試前請重新列出 view 或標籤。

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 封包回來,並帶一個型別化的代碼:

{ "detail": { "error": "command_lock_conflict", "phase": "execute", "step": null, "path": null, "message": "Concurrent write conflict (lock); please retry the request.", "retryable": true, "details": {} } }

由此有兩件事。第一,這個 command 沒有壞掉,不要因此停用它,也不要把它當成定義錯誤來告警。第二,同一個冪等鍵可以立刻重用:執行失敗的路徑會釋放預約,就是為了讓合法的重試能拿到它。

觸發器會重試自己的動作

一次 trigger run 遇到暫時性衝突時,會重試它的動作,而不是讓整個 run 失敗。這之所以安全,理由執行紀錄與重試已經寫過:進度是逐 action 保存的,而已完成的 receipt 會依穩定的 action id 被跳過,所以重試是續跑,不是把副作用重做一遍。

以前真正會痛的是排程動作。一個暫時性衝突就會讓 run 在第一次嘗試被記成失敗,而那個業務動作被無聲地丟掉;在每日排程上,這代表那天的工作就是沒做,而且沒有明顯的東西會告訴你。現在排程動作會重試,最多三次嘗試、採線性抖動退避,而且鎖等待逾時(1205)在這裡也會重試,因為已完成的 action 在續跑時會被略過。不可重試的錯誤仍然照舊讓 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 顯示給終端使用者的輪詢端,現在拿到的是清理過的訊息。建立與刪除資料表、新增/更新/刪除欄位、建立/更新/刪除資料列、新增與移除 moderator 的路由,以及規則的 PUT(Failed to update rules: …),仍可能把非預期例外以舊格式回報成 400 "Failed to <action>: <exception text>",例如 Failed to update column: …。聊天室範圍的資料列 DELETE 沒有這層包裝;而在資料表、資料列、新增欄位與刪除欄位的路由上,於資料庫寫入本身內部拋出的非預期例外會改回 500,其 detail 就是原始的例外文字。兩者都不是驗證規則,所以請把文字只當診斷資訊。

Redis 中斷現在是有型別的 503,不是帶著驅動程式文字的 500。 Custom Tables 把資料表層級的 schema 鎖、背景工作 ticket 與冪等標記放在 Redis。自後端 5.21.0 起,Redis client 錯誤若在上述被清理過的寫入操作內部浮現,會回 503,detail 是物件、不含驅動程式文字,回應也不帶 Retry-After 標頭:

{ "detail": { "error": "dependency_unavailable", "message": "Operation temporarily unavailable; retry." } }

在 REST API 中,會在這層包裝內碰到 Redis 的路由是自己取得資料表鎖的那幾條:新增欄位(POST …/tables/{table_id}/columns,三種 scope)、刪除欄位(資料表最多 10,000 筆有效資料列;超過時刪除在背景執行,結果記錄在 migration 結果中),以及單表 purge 的 execute(POST …/tables/{table_id}/purge?ticket=…,三種 scope),它會鎖住被 purge 的資料表與 cascade 會改寫的每一張表。5.21.0 以前它們回 500,detail 是原始的 client 例外文字。這兩個欄位操作最先取得的就是這把鎖,所以若中斷讓它拿不到,資料表保持不變;如同上面的鎖衝突,重試前請先重新讀取資料表的欄位。purge 不一樣:它的一次性 ticket 在取得任何鎖之前就已消耗,所以 503 回來時 ticket 已經用掉,原樣重送會得到 404 purge_ticket_not_found;請重新 preview 並重新同意(見軟刪除、回收桶與版本歷史)。資料表不會被銷毀,但原本還是 live 的表此時已被移到垃圾桶。若讀取 ticket 時 Redis 本來就連不上,那次讀取在包裝之外,purge 則回通用的 500 {"detail": "Internal Server Error"}。其他路由不會被改寫成這個 503:例如欄位 PATCH 刻意不在這層包裝內,tag purge 把這個失敗包成 500 purge_partial_failure;包裝之外的 Redis 失敗仍維持該路由自己對非預期錯誤的回應(上述的 400 Failed to <action>: … 形式,或通用的 500 {"detail": "Internal Server Error"})。

如果你曾經在解析那些字串:請停手。它們從來就不是合約,而且現在也沒有了。

客戶端該怎麼做

  1. 把「409 + please-retry detail」、「error: "command_lock_conflict"」、帶 retryable: true 的其他 command program error(source_version_changed、source_link_changed)、「error: "date_interval_exclusion_retry"」(期間規則在寫入等待期間被改變;沒有寫入任何東西),或標籤通道的 tag assignments changed while acquiring table roots; retry the request 視為可重試。其他回 409 的都是業務衝突:修正請求,不要迴圈重試。version_conflict 尤其代表要重讀那一列、重建 payload——原樣重送永遠不會成功。
  2. 用指數退避加抖動。死結在伺服器端已經快速試過三次,立刻補一發很可能輸掉同一場競爭;鎖等待逾時則已經完整等過一輪鎖窗口,馬上再打只是排到同一個持鎖者後面。
  3. 只要介面接受冪等鍵,各次嘗試都保留同一個。
  4. 自己的重試也要設上限,超過就把真正的失敗呈現給使用者。單一資料表上持續的競爭是資料模型問題,通常是每次寫入都會鎖到的同一個熱點父列,再怎麼重試都解決不了。
  5. 把 detail.error 為 dependency_unavailable 的 503 視為可用同樣退避重試:伺服器不給等待時間(沒有 Retry-After),所以請自行選擇延遲,並像其他重試一樣設上限。purge 是例外:它的 ticket 已經用掉,重試前請先重新 preview。
Last updated on