客服剛查到「退款完成」,客人重新整理訂單,畫面卻回到「處理中」。兩人各截一張圖,都覺得對方看錯了。
工程師查主庫,退款狀態確實已更新。Redis 裡還留著上一版訂單摘要,TTL 剩下二十多秒。有人提議把 TTL 從三十秒改成三秒,讓客人少等一點。
這是延續訂單系列的虛構案例,時間與流量都是教學假設。問題在於:客人剛確認過的結果,是否允許下一次讀取又退回舊版?如果答案是不允許,三秒也不符合需求。
先讓退款確認頁繞過快取,用主庫的新快照回答;只有容許短暫落後的訂單清單,才保留 Redis 摘要。系統維持模組化單體、MySQL InnoDB 單主庫,接下來要補的是這兩個入口各自的讀取承諾。
同一筆訂單,可以有不同的讀取承諾
上一篇處理退款 worker 找待辦的查詢。本篇把視線移到客人眼前:退款完成的本地紀錄已提交,接著要怎麼顯示?
這裡的「退款完成」,指系統依金流可核對的結果,已將本地退款紀錄更新為完成。它不表示每家銀行都已把款項顯示在持卡人的帳上。若供應商結果仍未知,就應顯示核對中,快取策略無法補出不存在的確認。
先為兩個入口約定不同用途。
訂單清單用來找訂單。它可以顯示帶有「資料取得時間」的摘要,容許短暫落後;使用者進入明細時重新查詢。清單不能把舊摘要說成即時進度,也不能靠它決定是否真的執行退款。
退款明細用來確認目前已知的處理結果。這個案例要求:本地完成紀錄提交後才開始的新查詢,必須能看到該次更新或更晚的版本。代價是這個入口每次都讀主庫,主庫不可用時回報「暫時無法確認」,不能偷偷用舊快取回答「仍在處理」。
找訂單:清單摘要
容許舊值,標示資料取得時間。進入明細重新確認;有量測收益才快取。
確認結果:退款明細
提交後開始的新查詢,讀同一主庫新快照;較舊版本不能覆蓋新回應。無法確認時如實回報。
執行退款
後端重新驗證權限、狀態與冪等條件。舊按鈕不能授予執行許可。
這是產品與工程共同選的契約,不是 Redis 的預設行為。Microsoft 的 Cache-Aside 文件明確提醒,這個模式不保證快取與資料庫一致。命中率很高,也可能只是在快速回答昨天的狀態。
明細繞過 Redis,還要把整條路徑接對
最小方案讓退款明細直接讀同一個 MySQL 主庫,使用請求開始後建立的新讀取快照。不能在資料存取層被默默路由到延遲複本,也不能沿用早已開始的長交易。
MySQL 8.4 的一致性讀文件說明,預設 REPEATABLE READ 的同一交易會沿用第一次一致性讀建立的快照。因此,「SQL 打到主庫」本身仍不足以保證看到另一個交易剛提交的更新。本案例採用短請求、獨立讀取交易或 autocommit 查詢,且前述提交已完成,才開始這次讀取。
應用程式還有其他舊副本。明細回應可使用 Cache-Control: no-store,避免符合規範的 HTTP 快取保存這次回應;MDN 的指令說明也區分了 no-store 與需要重新驗證的 no-cache。這個標頭不會替你清掉前端狀態管理中的舊物件。進入明細、切換訂單或確認更新時,前端都要走約定的查詢,而不是直接沿用清單資料。
即使兩次都讀主庫,回應也可能逆序抵達。先發出的請求讀到版本 41,後發出的請求讀到版本 42;42 先到畫面,41 晚一點才回來。如果照抵達順序覆蓋,狀態仍會回退。
本案例替每筆退款紀錄保留單調遞增的 revision,狀態與 revision 在同一筆交易更新。明細回傳退款 ID、revision 與狀態;同一筆退款的較舊 revision 不覆蓋畫面已知的新版本。這是應用設計建議,並非 MySQL 自動替 API 提供的版本欄位。也不能用「完成」比「處理中」大之類的字串排名代替版本,日後可能還有更正或爭議狀態。
這個方案只承諾本案例正常單主庫下的讀取順序。跨區切換、資料回復與不同世代的 revision,需要另外設計,不能沿用這幾段話就宣稱全系統強一致。
TTL 到期,沒有結束所有競爭
清單若確實值得快取,可以先使用固定三十秒 TTL,命中時不延長期限,資料變更提交後刪除相關快取。這是案例的起始設定,仍須依實際流量調整。快取鍵也要包含租戶、使用者或授權範圍與查詢條件;命中快取不免除權限檢查。
但是「先提交,再刪除」還有一種交錯:
- 清單請求 A 沒命中,開始從資料庫讀到舊摘要。
- 退款更新提交;寫入端刪除清單快取。
- A 稍後才把手上的舊摘要放回 Redis,從此刻重新計算三十秒 TTL。
所以,三十秒 TTL 限制的是這次快取項目放入後的存活時間,不能直接當成「每次資料庫提交後三十秒內,所有讀者一定更新」的保證。讀取來源、執行時間、回填競爭與失效失敗都會影響結果。若使用滑動到期,持續命中還可能延長舊值的存在。
對本案例的清單,我們接受這種有限用途的舊摘要,顯示資料取得時間,明細則保持前一節的獨立路徑。摘要的時間要跟著那次資料庫讀取保存,不能在第 3 步回填 Redis 時重新蓋上現在時間,否則舊狀態會被標成剛取得。診斷時分開記錄讀取開始與回填時刻:兩者的差距能直接看出 A 在回填前卡了多久;它們都不能冒充資料庫提交時間。若產品要求清單也有嚴格的新鮮度上限,就要把上限的起算點、超時回填的拒絕方式與失敗時回應一起定義;在這些驗證完成前,清單也先讀主庫。
至於清單上殘留的「申請退款」按鈕,按下後仍由後端在權威資料上重新驗證資格、狀態與冪等條件。已退款的案件應回傳可理解的既有結果,不因快取顯示可操作而再退款一次。前端隱藏按鈕只能改善體驗,不能充當交易保護。
省下多少查詢,值得多一套失效流程?
另一個合理選擇,是清單與明細暫時全部讀主庫。它少掉失效、快取鍵與回填競爭,代價是資料庫要承擔全部讀取。早期資料量小、索引合適、讀取仍在預算內,這往往更容易驗收。
假設尖峰每秒有 100 次讀取,其中清單 80 次、明細 20 次。清單的有效命中率若為 90%,平均每秒約有 80 × (1 − 0.9) + 20 = 28 次讀取落到主庫。這只是忽略其他查詢、失效尖峰與請求合併的粗估;90% 需要量測,不能由「用了 Redis」推導。
若是每個人都查自己的訂單、條件又很分散,命中率可能很低,卻仍多一次網路呼叫與一套故障處理。本案例只有在隔離壓測顯示清單造成明顯讀取負擔,而且快取能實際降低它時,才保留清單快取。未達到這個條件,就選全走主庫。
也要量 Redis 失效時的情況。正常時主庫每秒接 28 次,不代表它能瞬間接住 100 次;不能只在高命中率下驗效能。明細需要保留容量,清單可以限流、縮小可查範圍或回報暫時無法取得,但不能在沒有標示的情況下,把過期摘要包裝成剛確認的結果。
當明細本身也壓垮主庫,再評估帶版本門檻的讀取、可等待複寫進度的路由,或專用讀模型。那些方案都有新的等待、復原與驗收成本。本篇先保留便宜且說得清楚的邊界:快取用在容許舊值的入口。
驗收時,把「看過新值又退回去」排進測試
這篇沒有執行 MySQL、Redis 或壓測;以下是實作時要完成的驗證計畫,不能當成已通過的結果。
在隔離環境固定一筆退款,讓主庫從版本 41 的處理中更新到 42 的完成。等提交成功後,才送新的明細查詢;確認它使用主庫的新快照,回到 42 或更晚版本。故意保留 Redis 的 41,再驗一次,答案應相同。測試紀錄至少要串起請求 ID、實際連到的資料庫實例、讀取交易邊界與回傳 revision。否則只看 SQL 文字,分不出路由誤接副本,還是 pool 裡沿用了舊交易。
接著攔住第一個明細回應,讓第二個較新版本先回來,再放行第一個。驗收畫面不退回 41,而非只檢查兩次 API 都是 200。再測切換不同退款 ID,避免把上一筆的 revision 門檻誤套到下一筆。
清單測試要重現前述 A 讀舊值、更新提交、刪除快取、A 回填的順序。確認介面如實呈現摘要時間,進入明細仍重新確認;舊按鈕送出的操作也被後端正確判定。最後停掉 Redis,量主庫負載與明細延遲,再停掉主庫,確認明細不會降級成一個看起來成功的舊答案。
拿到那兩張互相矛盾的截圖時,先比對退款 ID 與 revision,再查回應走的是清單快取還是明細主庫。同一筆退款的 42 已經顯示,41 卻又蓋上來,就去查前端回應順序;新明細請求本身仍回 41,才往快照與資料庫路由追。把退回舊值的位置找出來,比先把 TTL 改短更有用。
練習
產品提出:退款完成後,客人回到訂單清單,也不希望同一筆退款又顯示處理中。你會取消這個清單的快取,還是讓畫面保留已確認的新版本?選一個作為第一版,並說明重新開啟瀏覽器或換裝置時,承諾是否仍然成立。
參考思路
取消這個入口的快取,搭配主庫新快照與回應順序保護,成本較容易說清楚,但讀取量增加。保留已確認版本可改善同一頁面的體驗,卻不自動涵蓋換裝置或遺失本地狀態。若承諾要跨裝置成立,不能只靠瀏覽器記憶;應重新評估整條清單讀取路徑與容量。這道題的答案取決於承諾範圍,而非誰用了更多快取技巧。
延伸閱讀:快取讀寫模式與失效問題。閱讀模式比較時,仍須逐一檢查它的同步、失敗與並行情境;模式名稱本身不構成一致性證明。
