退款程式每次只拿 20 筆。剛上線時很快,幾個月後,worker 還沒呼叫金流,就先在資料庫前等了半秒。

工程師把一次領取量從 20 改成 10,延遲沒明顯下降。因為查詢交回來的十筆,可能是從幾十萬筆候選裡挑出來的。

這是一個虛構事故,數字不是實測。先把候選查詢固定下來,再檢查資料庫為了交出這二十筆,實際走過多少資料。只看到 LIMIT 20,還不能判斷這條路徑便宜。

歷史紀錄要留著,worker 不必每次重讀

上一篇把晚到付款轉成持久退款待辦。現在沿用同一個模組化單體與 MySQL InnoDB 主庫,先保留一個 worker,不增加 Kafka 或新的服務。這是系列第 7 篇,讀過前篇的情境即可,不預設已完成練習。

假設待辦表累積 200 萬筆,其中 199 萬筆已結束,1 萬筆仍可排程。可排程工作裡,有 9,800 筆還沒到重試時間,200 筆現在可以處理。這些是教學資料分布。

我們為這次查詢定義三個欄位:

  • state:ready 代表可以進入派工候選;running、succeeded、manual_review 都不能被這條查詢拿走。
  • next_attempt_at:下一次允許嘗試的時間,非空值,以 UTC 儲存;首次工作也要寫入明確時間。
  • id:唯一主鍵,用來打破相同到期時間的排序平手。

ready 不等於退款已完成,也不等於所有失敗都能再送。結果未知、已過供應商冪等保證期限的案件,要沿用前篇的核對流程;不能為了讓佇列清空,就把它改回 ready。

本案例的排程政策是「在現在可執行的工作中,先取預定執行時間最早的一批」。因此排序依 next_attempt_at, id,而不是最早建立的訂單。一次查詢使用同一個明確的截止時間;主機時鐘也要有一致的管理方式。

把條件、順序與上限寫在同一條查詢

以下是要帶到 MySQL 8.4 驗證環境的設計片段,本文沒有執行資料庫實驗,也不提供虛構的 EXPLAIN 數字。:cutoff_utc 由應用程式綁定成 UTC 時間。

SELECT id, next_attempt_at
FROM refund_jobs
WHERE state = 'ready'
  AND next_attempt_at <= :cutoff_utc
ORDER BY next_attempt_at, id
LIMIT 20;

這條查詢只找候選,不帶付款內容、錯誤堆疊或整份 JSON。取得候選後,再由派工流程確認狀態並取得處理權。這樣即使表裡的歷史資料很寬,找下一批工作的需求仍然清楚。

對應的索引候選是:

CREATE INDEX idx_refund_ready_due
ON refund_jobs (state, next_attempt_at, id);

先固定 state = 'ready',再沿這個狀態之下的到期時間範圍前進;相同時間以 id 排序。這是我們希望最佳化器採用的讀取方式,是否真的採用,要看執行計畫。

MySQL 8.4 的複合索引文件說明,索引欄位的順序會影響可用的最左前綴。這裡把狀態放前面,是因為這條查詢固定查一種狀態;「區分度最高的欄位永遠放第一個」並不足以決定順序。

候選查詢對應的索引順序先固定 ready 狀態,再找截止時間以前的 next_attempt_at,最後用 id 排定相同時間。這是希望索引提供的讀取路徑,實際使用方式須由執行計畫確認。找到候選並不等於領取成功。(state, next_attempt_at, id)把查詢的條件與順序,放進一條可驗證的讀取路徑01 state = ready先限定可排程狀態,讓已完成紀錄留在其他狀態區段。02 next_attempt_at ≤ cutoff在 ready 裡找已到期的時間範圍,先取最早排定的一批。03 ORDER BY next_attempt_at, id · LIMIT 20時間相同時以 id 排定;最多回傳 20 筆,不代表只讀了 20 筆。找到候選 → 原子領取 → 提交 → 呼叫金流;候選查詢不授予執行權。

01 固定 ready

已完成紀錄留在其他狀態區段。

02 只找已到期工作

next_attempt_at 不晚於同一次查詢的截止時間。

03 依到期時間、id 排序

最多回傳 20 筆。實際讀取量仍要檢查執行計畫。

找到候選後,還要原子領取並提交,才能呼叫金流。

索引設計對齊的是候選查詢;它不保證計畫一定採用,也不取代工作領取與退款冪等。

若只有 (next_attempt_at, id),很早就完成的工作也可能排在時間範圍前端,必須再檢查 state 才能排除。若只有 (state),雖然可縮到 ready,仍未替到期範圍與排序提供同一條連續路徑。這兩種都可能在小資料量下夠用,但本文的 200 萬筆分布值得比較。

官方 ORDER BY 文件也指出,前段欄位在 WHERE 固定時,索引有機會滿足後段排序;最後仍由成本判斷決定。InnoDB 的次要索引本來就包含主鍵,這裡明寫 id 是為了讓設計與排序契約一眼對得起來,不是宣稱省略 id 就一定得排序。

這個決定也有寫入成本。工作從 ready 改 running、重排 next_attempt_at 時,相關索引都需要維護;索引還要占空間。不能因為三個欄位都會查,就替每個欄位各加一個索引,再假定它們等同這個組合。

快速找到候選,不代表已經取得工作

兩個程序可能同時讀到相同的 20 個 id。即使原本只設定一個 worker,重啟交接或重複啟動也可能發生重疊。因此這條 SELECT 的結果不能直接當成呼叫退款 API 的許可。

最小方案仍可沿用單 worker,但需要在短交易中重新確認 ready 與到期條件,原子地領取工作並留下執行紀錄。領取提交之後才呼叫金流;程序中斷後,復原流程要區分「還沒呼叫」與「可能已呼叫但結果未知」。僅將 running 改回 ready 並重新產生冪等鍵,會破壞前篇的退款保護。

未來若增加多個 worker,可評估在短領取交易中使用 FOR UPDATE SKIP LOCKED。MySQL 鎖定讀文件把它定位為適合部分佇列表競爭的工具:被其他交易鎖住的列會被略過,因此看到的不是一致完整集合,也不保證嚴格先到先處理。它只處理列鎖,而且不適用 statement-based replication;部署前得核對複寫配置。

本篇不把多 worker 的租約、接管與遠端去重塞進索引範例。這些是增加並行度前必須補齊的契約;索引解決的是找工作時讀哪些資料,無法替代執行權與退款結果的判斷。

驗收要故意放入大量「今天不用處理」的資料

只塞 20 筆 ready,再看到查詢很快,幾乎沒有驗到這次設計。要重現的是歷史資料占絕大多數、少量工作到期的形狀。

先在隔離測試庫準備前述 200 萬筆分布,讓完成資料的到期時間也落在過去;同時準備一組目前完全沒有到期工作的資料。比較原索引與新索引下相同查詢的執行計畫,確認實際走了哪個索引、在哪裡篩選、是否額外排序。

在這個隔離庫中,先用固定截止時間執行下面兩條。日期只是測試 fixture 的一部分,要與造資料的基準一致;比較索引前後不要改用 NOW(),讓另一批工作在途中變成到期。

EXPLAIN FORMAT=TREE
SELECT id, next_attempt_at
FROM refund_jobs
WHERE state = 'ready'
  AND next_attempt_at <= '2026-10-17 00:00:00'
ORDER BY next_attempt_at, id
LIMIT 20;

EXPLAIN ANALYZE FORMAT=TREE
SELECT id, next_attempt_at
FROM refund_jobs
WHERE state = 'ready'
  AND next_attempt_at <= '2026-10-17 00:00:00'
ORDER BY next_attempt_at, id
LIMIT 20;

第一份是計畫與估計,第二份才真的執行查詢。官方文件列出的實際指標包括各節點回傳列數、loops 與時間。從最下層往上看:用哪個索引、範圍是否包含 ready 與截止時間、Filter 前後剩幾列,再看上面有沒有 Sort。若 Limit 只交出二十筆,下層卻讀了大量歷史資料再過濾,縮成 LIMIT 10 未必能省下主要成本。

actual time 是節點取得首列與完成的時間,單位為毫秒;節點時間包含子節點,不能沿樹一路加總。loops 大於一時也要一起判讀,不能把每次迭代的平均值當成整次總量。這些列數是執行器節點的輸出,不是實體磁碟讀取次數。本文沒有產生這兩份輸出;不能把預期的 Index range scan 寫成已驗證結果。

沒有到期工作的那組尤其值得看:回傳零列,底層是否仍讀完一大批舊資料?保留這份計畫,再增加已結束歷史紀錄,才能看出查詢成本是否跟著歷史量長大。測試只在隔離環境執行;EXPLAIN ANALYZE 不是無副作用的解說命令,別隨手換成帶鎖或修改資料的語句。

然後刻意改兩種條件:另建一組更大的 ready 集合,將其中到期工作從 200 筆增加到 5 萬筆,以及讓多筆 next_attempt_at 完全相同。前者驗證積壓時的計畫與延遲,後者驗證 id 排序是否穩定。完成一批後重新取當下最前面的一批,不能用 OFFSET 20 跳頁;前批狀態改變後,候選集合已縮小,offset 可能把尚未處理的工作跳過。

這裡採用一個待驗證的工程門檻:在代表性資料、既定機器與負載下,候選查詢 p95 不超過 50 ms,且不能隨「已結束歷史紀錄」增加而反覆掃過它們。50 ms 是本案例預算,不是 MySQL 的保證。p95 要另外重複執行正常查詢來量,單次 EXPLAIN ANALYZE 不能提供延遲分布。量測時保留版本、資料分布、計畫、冷暖快取與並行條件;若仍慢,再判斷是讀取量、鎖等待、I/O 還是別的瓶頸。

找得快之後,還要算能不能做完

假設到期工作平均每秒新增 2 筆,金流停擺 10 分鐘,先累積 1,200 筆。恢復後若實際完成率是每秒 5 筆,且新增仍是每秒 2 筆,理想清空時間約為 1,200 ÷ (5 − 2) = 400 秒。這忽略了重試與限流,是教學估算;每秒 5 筆也必須量測,不能從 LIMIT 20 推出來。

若完成率只有每秒 1 筆,索引再快,積壓仍會增加。此時應檢查供應商限流、單筆處理時間與安全並行能力。若到期工作量持續讓主庫讀寫競爭超出預算,再考慮將活躍派工資料與歷史紀錄分開,或引入專用佇列;代價是新增同步、恢復與對帳責任。這些升級要有量測理由。

另一個合理選擇,是早期先保留簡單查詢與少量索引,等量測顯示資料分布改變再加複合索引。它省下寫入與維運成本,但要事先定義延遲門檻、保留計畫基準,並安排大表建索引的風險評估。不能等退款已經卡住,才發現測試資料一直只有二十筆。

回到開頭,先找出 worker 的第一條 SELECT,保存有到期工作、沒有到期工作各一份執行計畫。若花時間的是底層掃描與過濾,改 LIMIT 只是改了最後交回幾筆;應先修讀取路徑,再量它是否真的回到 50 ms 的預算內。

練習

產品希望客服也能用同一張表,查「某張訂單的所有退款紀錄,最新的在前」。你會直接沿用 (state, next_attempt_at, id),還是另外設計讀取路徑?寫出查詢條件、排序與索引候選,並說明一種讓你決定暫時不加索引的情況。

參考思路

客服查詢通常以 order_id 等值篩選,依 created_at 與 id 倒序;它沒有固定 state,也沒有到期範圍。可評估 (order_id, created_at, id),並以實際查詢計畫確認排序與讀取成本。若每張訂單的退款紀錄極少,既有 order_id 索引讀完後排序已在預算內,便可能先不加新組合。兩個入口使用同一張表,不代表它們適合共用同一個索引。