退款程式每次只拿 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 的複合索引文件說明,索引欄位的順序會影響可用的最左前綴。這裡把狀態放前面,是因為這條查詢固定查一種狀態;「區分度最高的欄位永遠放第一個」並不足以決定順序。
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 索引讀完後排序已在預算內,便可能先不加新組合。兩個入口使用同一張表,不代表它們適合共用同一個索引。
