想像一個常見的排程踩坑情境:Worker 領了一筆「退款 Job」並打 API 給金流商,結果因為網路抖動,連線一直卡著沒斷開。

30 秒過去,這筆 Job 的處理租約(Lease)到期,另一個 Worker 進來接手。新 Worker 先打金流 API 查帳,確認剛剛那筆錢其實已經退成功了,就把資料庫裡的 Job 狀態更新為 succeeded(已結案)。

偏偏這時候,原本卡住的第一個 Worker 終於收到 Socket Timeout 錯誤。它的 catch 區塊完全不知道這筆 Job 已經被別人接手結案了,直接照著預設邏輯下了一行 UPDATE,把已經結案的 Job 硬生生改回「等待重試」。客人的錢明明已經退了,系統卻準備再退第二次。

就算你平常只跑單一顆 Worker Process,在重新部署(Rolling Update)新舊程序交接,或是非同步 I/O 逾時沒做好 Abort 的情況下,這種「前人覆寫後人」的 Race Condition 一樣會發生。

本篇的解法很明確:為每次領取動作加上單調遞增的世代編號(attempt / Fencing Token)。誰來領取或接管 Job,就會把 attempt 加一;後續任何更新 Job 狀態的 SQL,都必須帶上自己手上的 attempt。資料庫只認當代號碼牌,直接從寫入端擋掉舊程序的髒寫入。

但這道防線有它的物理邊界:attempt 只能阻止舊 Worker 改壞我們自家的資料庫,無法撤回它稍早已經送出門的金流 API 請求。錢到底退了沒,最終還是得靠金流商的對帳結果說了算。

同一筆退款,需要兩種不同的身分

延續前幾篇的架構:我們用 MySQL 實作任務佇列,並透過同一個供應商派送出口來控管對金流商的請求。前面的待辦索引解決了怎麼快速撈出待處理的 Job,外呼預算則用配額限制打出去的流量;這一篇則要補上最後一塊拼圖:當 Job 因為逾時被其他 Worker 接管後,誰才有資格把結果寫回資料庫?(注意:多加這個接管機制並不會增加金流商給我們的 API 額度,接手後的「查帳」與「重送」一樣要共用原本的流量配額。)

在設計 refund_jobs 資料表時,至少要把這幾個欄位的職責切開:

operation_key(業務冪等鍵):這筆退款在業務上的唯一識別碼(綁定原付款單、金額與幣別)。不管中間換了幾個 Worker 接手,只要是處理同一筆退款,這個值就絕對不變。

attempt(執行版號 / 號碼牌):用來標記目前是第幾次被 Worker 領走。例如前一個 Worker 領取時拿到 7,逾時後新 Worker 接管就遞增變成 8。每次領取或接管時,都必須在同一個 Row Lock(FOR UPDATE)保護下嚴格遞增,不重設也不重複使用。

state(任務狀態):排程目前的進度,例如 ready、running、succeeded、manual_review。特別注意:如果金流商只回傳「已收單處理中(Pending)」,還不能直接改成 succeeded。

lease_until(租約到期時間):允許其他 Worker 接管的時間點(統一用 DB 的 UTC 時間判斷)。只要超過這個時間還沒結案,就允許其他 Worker 進來搶著接管。

next_action(下一步動作):標記 Worker 拿到這筆 Job 後要幹嘛。第一次領取時是「直接發起退款」;但如果是半路接管別人卡在 running 的 Job,下一步就必須設為「先去跟金流商查帳(reconcile)」。

千萬別把 attempt 拼進傳給金流商的 Idempotency Key 裡! 如果前一個 Worker 傳 refund-123-7,接手的 Worker 傳 refund-123-8,金流商可能把它們當成兩筆獨立的請求,失去同鍵去重的保護;最後是否重複退款,仍取決於供應商對原付款與退款金額等限制。記住一個鐵則:attempt 是對內用來擋掉舊 Worker 髒寫入的號碼牌;operation_key 才是對外讓金流商認出「這是同一筆退款」的身分證。 兩者絕對不能混用。

把這兩個欄位拆開,在看 Log 追問題時也很直覺:當你看到同一個 operation_key 底下出現了 3 個不同的 attempt,一眼就能看出「這筆任務被領取或接管過 3 次」,而不是「客人被退了 3 次錢」。

三十秒到了,只代表「開放搶單」,不代表舊 Worker 立刻失效

這裡我們定下一個簡單明確的規則:租約到期(lease_until),只代表允許其他 Worker 進來競爭接管;真正奪走前任 Worker 寫入資格的瞬間,是新 Worker 成功提交 Transaction、把 attempt 從 7 刷成 8 的那一刻。

這意味著:如果 30 秒已經到了,但剛好還沒半個人進來接管,原本的 Worker A 依然可以拿著 attempt = 7 把確認完的退款結果寫進資料庫。但只要 Worker B 早一步完成接管(把版號升到 8),A 手上的 7 號牌就瞬間作廢。這是刻意設計的機制——因為在分散式系統裡,你永遠無法保證「時間一到,遠端的舊程序就會乖乖立刻停手」。

就像 AWS Builders’ Library 在 Leader Election 文章中提醒的:程序很可能在「檢查自己還有沒有租約」之後的下一毫秒發生 GC 停頓或網路延遲;等它醒來繼續往下跑時,租約早就過期了。如果只靠「寫入前先 SELECT 看一下鎖還是不是我的」來保護,根本擋不住檢查與寫入之間的時間差(TOCTOU)。

真正的生死判定,必須直接壓在最後寫入資料庫的那一刻。 不論是「結案(Complete)」還是「接管(Takeover)」,在更新時都必須鎖定同一筆 Job:如果 A 早一步結案,狀態變成 succeeded,B 就無法接管;反之,如果 B 早一步接管並把 attempt 刷成 8,A 隨後的條件更新(WHERE attempt = 7)就會直接失敗。注意:這裡的 Row Lock 只維持在領取與回寫的極短 Transaction 內;中間打金流 API 等待回應的那幾秒,絕對不能咬著資料庫的鎖與連線不放。

接管提交之後,舊 attempt 才失去寫入資格 A 取得 attempt 7 後暫停。期限到達只允許 B 嘗試接管;B 提交 attempt 8 後,A 的舊世代更新被拒絕。B 先核對原退款再結案;本地寫入檢查不會取消遠端已送出的請求。租約到期開放搶單,attempt 決定寫入權同一 operation_key 持續不變;以下是 B 先完成接管的順序1A 取得 attempt 7短交易 COMMIT 後才打金流 API;A 等待回應時卡住。2到達 lease_untilB 可以競爭接管;尚未換代,A 仍可提交有效結果。3B 提交 attempt 8B 接管 COMMIT 成功,先查帳,確認退款後才結案。4A 恢復,帶 attempt 7 寫回目前版號已是 8;A 結案或 Timeout 重排皆更新 0 筆。邊界:拒絕舊世代寫入,不會撤回已送出的遠端退款。

1. A 取得 attempt 7

短交易 COMMIT 後才打金流 API;A 等待回應時卡住。

2. 到達 lease_until

B 可以競爭接管;尚未換代,A 仍可提交有效結果。

3. B 提交 attempt 8

B 接管 COMMIT 成功,先查帳,確認退款後才結案。

4. A 恢復,帶 attempt 7 寫回

目前版號已是 8;A 結案或 Timeout 重排皆更新 0 筆。

若 A 先完成,B 就不能接管已結案工作。所有轉移都在同一筆工作上裁決;遠端結果另行核對。

這套機制跟我們先前在影片轉檔防範舊 Worker 覆寫的原理一模一樣,但「退款」多了一個致命的物理限制:影片多轉了一次,舊的輸出檔大不了直接丟掉不用;但已經完成退款的 API 請求,絕對不可能靠資料庫擋下一條 UPDATE 就把錢收回來。

領取與回寫,必須遵守同一套條件

以下以 MySQL 8.4(InnoDB)展示核心 SQL 設計片段,省略參數綁定、錯誤處理與完整 Schema,不能直接當成生產 Worker。

1. 首次領取(Claim)

必須在同一個 DB 連線的極短 Transaction 內,用主鍵執行 SELECT … FOR UPDATE 鎖住該筆 Job,確認狀態仍是 ready 且已到執行時間;接著將 attempt + 1、state 改為 running、押上 lease_until 過期時間,拿到新的 attempt 後立即 COMMIT。 請注意:只有 COMMIT 成功的那一刻,Worker 才算真正拿到處理權;前面用一般 SELECT 撈出待處理清單時,都還不算數。

2. 逾時接管(Takeover)

同樣走主鍵 SELECT … FOR UPDATE 的鎖定路徑,只是檢查條件換成「狀態仍為 running 且 lease_until 已過期」。鎖住並再次確認狀態無誤後,一樣把 attempt + 1、展延 lease_until,並強制將 next_action 設為 reconcile(先查帳)。

邊界防呆: 如果在 COMMIT 領取交易時遇到網路閃斷,導致 Worker 不確定自己到底有沒有領取成功,這時必須立刻放棄執行,絕對不能直接打金流 API,一切留給下一輪排程去檢查 DB 裡的真實狀態。更不能因為沒收到 COMMIT 回應就自己重試再把 attempt 加一,或是單憑去 DB 查到 attempt 數字剛好符合預期,就自作聰明以為是自己鎖成功的(可能剛好被別台機器接管走到同一個數字)。

根據 MySQL 的 Locking Reads 官方文件,光是靠 InnoDB 的 FOR UPDATE Row Lock,就足以讓單筆 Job 的交接嚴格排隊(序列化),在這個階段根本不需要為了防撞而額外引入 Redis 分散式鎖。就算未來為了提高吞吐量加上 SKIP LOCKED 來略過被鎖住的資料列,也只是加速「領取」的並行度,後面「回寫」時的版號檢查依然一步都不能省。

3. 確認退款完成後的回寫(Complete)

拿到金流商明確的退款成功結果後,必須透過以下帶條件的 SQL 來結案:

UPDATE refund_jobs
SET state = 'succeeded',
    provider_refund_id = :verified_refund_id,
    lease_until = NULL
WHERE id = :job_id
  AND state = 'running'
  AND attempt = :my_attempt;

這條更新 refund_jobs 的 SQL,必須跟「更新我們自家訂單/退款明細表」包在同一個 DB Transaction 裡。只有當這條 SQL 確實更新到 1 筆資料(Affected rows = 1)時,才允許修改業務退款表並 COMMIT;如果更新筆數是 0,代表這次沒有完成狀態轉移,必須立刻 ROLLBACK 整個交易,再重新去 DB 讀取最新狀態;不能直接斷定是被接管,也不能據此判定退款失敗。實作時務必確認兩次寫入共用同一個 DB Connection / Transaction Context,千萬別讓 ORM 的另一個 Repository 偷偷從連線池拿新連線各自 Commit。

當更新回傳 0 筆 時,例如有兩種可能:一是這筆 Job 已經被別人接管(attempt 變了);二是其實前一次重試早就把狀態改成 succeeded 了,只是當時網路斷掉沒收到回應。遇到後者,只要重新查一次 DB 裡的最終狀態與 provider_refund_id 就能確認;但無論是哪一種,都絕對不能把 WHERE 條件拿掉硬寫進去。另外,MySQL UPDATE 文件有區分 matched rows(符合條件)與 changed rows(實際值有變動),實作時要注意 DB Driver 的設定;不過因為我們成功時一定會把 running 改成 succeeded(狀態值必然改變),不會踩到「同值更新導致 changed rows 為 0」的坑。

這套 WHERE id + state + attempt 的鐵三角防線,也必須一併套用在「失敗重排(Retry)」、「標記錯誤」與「轉人工審核(manual_review)」的所有 UPDATE 語句上。 如果只保護成功路徑,文章開頭那個「遲到的 Timeout 把已結案改回重試」的鬼故事還是會發生。

你可能會發現:為什麼 WHERE 條件裡沒有加上 AND lease_until > NOW()? 因為在我們的設計裡,「時間」只決定誰可以來搶單接管,「版號(attempt)」才決定誰有資格寫入。況且 MySQL 日期函式文件也寫得很清楚,NOW() 抓的是 SQL Statement 開始執行的時間,並不適合拿來當作精密的「交易提交瞬間」檢查。所有接管時間都由 Primary DB 以 UTC 判定,避免各 Worker 的本機時鐘各說各話。主庫時鐘若跳動,仍可能讓接管過早或延後,影響多大取決於跳動幅度與排程,不能保證只差幾秒。但只要同一列上的 attempt 檢查仍成立,時鐘跳動就不會讓舊版號穿過新世代的寫入防線。

被擋下的結果,仍可能包含真的退款

當 Worker B 接管成功後,第一步永遠是拿著原本的 operation_key 去向金流商查帳(Reconcile)。 查帳的方式必須看金流商提供什麼 API 而定——例如用我們先前存下來的金流商退單號(provider_refund_id)、我們傳過去的商戶單號(Merchant Reference),或是直接拉出該筆付款單底下的所有退款紀錄來比對;千萬不能一廂情願假設每一家金流商都有提供「用 Idempotency Key 查退款狀態」的 API。

如果金流商明確回覆「已退款成功」,B 就可以安心拿著手上的新 attempt 把工單結案。

如果金流商回覆「處理中」,B 就把工單排入下一輪繼續查帳。

最危險的是「查無紀錄」: 這時絕對不能馬上斷定「剛剛沒退到錢」就直接重打一次退款!你必須先考慮金流商那邊是否有讀寫分離的同步延遲(錢剛退完,但查詢 API 還查不到),或是前一個 Worker 發出去的請求其實還在金流商內部排隊。

當原請求是否仍可能執行尚未釐清時,只有在金流商的 API 規範明確保證「帶相同冪等鍵與相同參數可以安全重送」,且還在冪等鍵的有效期限內,我們才允許自動重送退款請求。以 Stripe API v1 的冪等機制為例,他們的 Idempotency Key 也有保存期限(例如 24 小時後可能清除);你不能拿一把半年前產生的 Key,還天真地以為重送過去不會重複退款。實務上仍須讀自己的供應商契約,不能直接套用 Stripe 的保證。

那如果舊的 Worker A 在失去寫入權之後,才終於收到金流商回傳「退款成功」的 HTTP Response 怎麼辦? 雖然 A 的 UPDATE refund_jobs 會因為 attempt 已被換代而被擋下來,但絕對不能把這份真實的外部收據直接當垃圾丟掉! 正確的做法是:把這份回應寫進一張獨立、可去重的「外部回執紀錄表(Observation / Audit Log)」,讓目前正在接管的 Worker B 或是背景對帳排程讀取並核實這份證據,再依當前狀態結案。記住:「留存外部證據」跟「修改 Job 狀態」是兩回事,A 可以把收據留下來,但沒有權限去改 refund_jobs 的狀態。同理,如果金流商透過 Webhook 通知退款結果,也必須走相同的狀態機防線,避免 Webhook 處理邏輯寫得太隨便,把已經結案的工單又退回待處理。

底線原則:沒有可靠核對方式,也沒有仍有效的冪等保證時,狀態未知的退款停止自動重送,轉為 manual_review。 若可靠核對已能確定原退款未發生、原請求也不會稍後生效,才可依供應商契約與原業務授權決定後續操作;單次「查無紀錄」不符合這個條件。 在資料庫裡多加一個 lease_until 欄位,並不會讓金流商黑盒子裡的爛帳自動變得可判斷。

租約長度,決定了當機後的「接管等待成本」

延續前幾篇「單次金流 API Timeout 設為 3 秒」的假設,這裡我們暫時將租約期限(lease_until)設為 30 秒,並讓排程器每 5 秒 掃描一次過期的工作。這只是推演參數,不能直接當成生產環境的建議值。

在排程器與資料庫都正常、且不考慮鎖等待的理想狀態下,如果 Worker A 剛領到工作就立刻 Crash 當機,接手的 Worker B 大約需要等 30 到 35 秒 才會進場開始查帳。而且這還只是「開始接管」的等待時間,後面還要加上打金流 API 查帳與排隊的耗時,才是客人真正看到「退款完成」的總時間。

租約時間長短,是一場「誤判率」與「恢復速度」的 Trade-off:

  • 縮短成 5 秒: 當機時雖然能更快被接管,但只要遇到正常的排程小延遲或 GC 停頓,就很容易觸發假警報(False Positive)提早搶單,白白浪費一堆打去金流商查帳的 API 額度。

  • 拉長成 5 分鐘: 雖然能大幅減少誤判交接,但萬一機器真的掛掉,那筆退款就得在原地乾等好幾分鐘才有人來救。

實務上要設幾秒,必須根據你們家「單次 API 耗時的 P99 分布」、「部署重啟(Rolling Update)的交接時間」以及「產品能容忍退款卡住多久(SLO)」來決定。

第一版為什麼不做自動續約?

第一版刻意不實作「自動展延租約(Heartbeat / Lease Renewal)」: 我們的原則是「現在能立刻打出去的工作才領」,絕不預先領取一大批 Job 囤在記憶體裡排隊。如果查帳後發現還在處理中、需要排下一次查帳,就把下次執行時間寫回 DB,然後直接結束當前的 Worker Task 釋放資源。除非未來真的遇到單次處理必須耗時極長的任務,我們才會考慮加入帶著 attempt 條件的自動續約機制(同時還得驗證「續約」與「搶單接管」互撞時的邊界條件)——況且,就算 Worker 的 Heartbeat 還活著,也只代表那條回報路徑仍活著,並不能證明遠端的金流操作已經完成。

有人可能會問:「為什麼不乾脆把 DB Transaction 包住整個 HTTP 請求,直接靠 FOR UPDATE 的 Row Lock 擋住 Worker B 就好?」 這樣做確實能省掉判斷 attempt 版號的麻煩,但代價極高:只要外部金流商一變慢,你的 DB Transaction 跟連線池就會被長時間霸佔卡死;而且萬一金流商退款成功後 Worker 剛好當機,本地的 DB Transaction 照樣會 Rollback,依然會留下「外部已退款、內部卻不知情」的爛帳。因此,我們寧可選擇「極短 DB 交易 + 明確的 attempt 交接」,乖乖承擔事後查帳的開發成本。

驗收測試,必須讓「舊 Worker 真的復活回來亂寫」

在寫測試時,如果只是把 Worker A 直接砍掉(Kill Process)、看到 Worker B 成功接手結案就收工,那你根本沒有測到這篇設計的防線。以下幾組是在測試環境必須驗的競爭情境:

測試「殭屍 Worker 晚到覆寫」(核心防線)

先讓 Worker A 領取任務拿到 attempt = 7,在它發出金流請求後、準備寫回 DB 前,強制將 A 暫停(Pause / 設中斷點)。接著在隔離測試資料上將 lease_until 設為已到期(不改主庫系統時鐘),讓 Worker B 進場接管拿到 attempt = 8 並 COMMIT,完成查帳並把狀態改為 succeeded 結案。最後讓 A 恢復執行——此時無論 A 想執行「正常結案」、「失敗重排(Retry)」還是「轉人工處理」,這三種 UPDATE 的影響筆數都必須是 0 筆(Affected rows = 0),絕對不能把已完成的退款紀錄改退回去。

測試「租約過期但無人接管,原 Worker 仍可結案」

把順序倒過來:時間已經超過 lease_until,但還沒人來搶單,這時讓 A 先一步成功更新為 succeeded,隨後 B 才進來嘗試接管。此時 B 應該要看到工單已結案而直接放棄退出。這個測試專門用來保護「租約到期只代表開放搶單,不代表原 Worker 立刻失效」的設計規則,防止未來接手維護的同事誤把 lease_until > NOW() 加進回寫條件裡。

測試「外部已退款但回應斷線」與「本地 Commit 後斷線」

用 Mock 金流伺服器(Fake Provider)模擬「實際已退款成功,但故意切斷 HTTP 回應」。驗證 B 接管時有乖乖沿用原本的 operation_key 去查帳,接管動作本身不會觸發第二次退款;且 A 稍後若收到遲來的成功回應,也能寫入外部回執紀錄表供核對。另外還要模擬「本地 DB 結案 COMMIT 成功後瞬間斷線」,驗證重試機制在遇到 UPDATE 影響 0 筆時,懂得重新查詢 DB 的最終狀態來確認已結案,不會傻傻再退一次錢。

上線後的維運監控指標(Metrics)

上線後必須重點監控四個指標:接管發生次數(Takeover Count)、舊 attempt 被擋下的次數、卡在未結案狀態最久的工單時間(Oldest Pending Age),以及查帳 API 佔總流量的比例。

如果平常沒事也頻繁觸發接管:先去查是不是機器發生 GC 卡頓、租約時間設太短,或是 Worker 一次預領了太多消化不完的工作。

如果未結案工單卡住的時間超過產品承諾的 SLO:這時才需要去評估要不要加做自動續約、拆分排程角色,或是改用獨立的 Message Queue。但請記住:就算未來換成 RabbitMQ 或 Kafka,這套「對外固定 operation_key、對內檢查 attempt 版號」的防呆邏輯,依然一行都不能省。

回到文章開頭那個準備重送退款的鬼故事,要修好它其實非常具體:把專案裡所有 UPDATE refund_jobs 的 SQL 統統翻出來,確認每一條 Worker 回寫語句的 WHERE 都帶上了當前的 attempt;最後再補上一支「B 結案後 A 才醒來寫入」的自動化整合測試。能讓新 Worker 順利接手,只做完了災難復原的一半;確保已被換代的舊 Worker 絕對改不動新 Worker 的決定,才是完整的防線。

隨堂思考題

情境: Worker A 領取工作拿到 attempt = 12,處理途中租約(lease_until)已經過期,但此時 Worker B 還沒進來接管。剛好在這一刻,A 收到了金流商驗簽通過的「退款完成」回覆。

依照本文的狀態機規則,A 這時候還能不能直接把工單結案?

如果 B 已經早一步接管、把資料庫裡的 attempt 刷成 13(但 B 還沒去查帳),A 收到這份成功回覆時又該怎麼處理?請分別說明「Job 狀態更新」與「外部金流回執」這兩件事的處理方式。

參考解答思路

B 尚未接管時: 可以結案。 因為資料庫裡的版號還是 12,A 直接用 WHERE state = ‘running’ AND attempt = 12 就能成功更新為 succeeded 並提交交易;只要 A 先一步 Commit 完成,隨後進場的 B 就會看到工單已結案而放棄接管。

B 已經接管(版號變為 13)時: A 的狀態更新必須失敗,但外部證據必須留下來。

Job 狀態更新: A 帶著 attempt = 12 去執行 UPDATE refund_jobs 時,會因為版號不符(Affected rows = 0)被資料庫直接擋掉,無權修改工單狀態。

外部金流回執: A 絕對不能把這份真實的成功收據丟掉,應該將它寫入獨立的「外部回執紀錄表(Observation Log)」,讓目前持有 13 號牌的 Worker B 或對帳排程核實這份證據後,依相同防線完成結案。

無論發生哪一種順序,對外永遠共用同一個 operation_key;租約過期與 attempt 換代只負責管理內部寫入權,永遠不會因此多生出第二筆退款授權。