從未到來的完成事件:兩次靜默的丟失
逐項完成事件關閉了整個串流,且用戶端在啟動工作後才訂閱。兩者都遺漏了通知,但工作本身卻成功了。
一位使用者回報某個下載按鈕一直無法完成。載入圖示一直轉個不停。最直觀的猜測是渲染失敗了,所以我開始尋找錯誤。結果沒有任何錯誤。檔案存在於物件儲存空間中,資料庫的資料列也指向它,而且用相同的輸入在本機重現時,也產生了逐位元組完全相同的產物。這項工作成功了三次。只有通知遺失了,而且是透過兩種獨立的方式遺失的。
日誌的真實內容
這個 pipeline 很普通:client 發布一個 job,server 回傳 202 並在背景進行渲染,然後在一個 pub/sub channel 上發布一個完成事件。一個 server-sent events endpoint 會將該事件分發給所有已訂閱的瀏覽器分頁。
我將發布時間戳記與 SSE 連線的視窗進行比對。每個連線在關閉時都會記錄其持續時間,所以開始時間就是關閉時間減去持續時間。
SSE window (server log) completion published received
13:36:04 - 13:36:33 -> 13:36:44 no subscriber
13:37:14 - 13:41:25 -> 13:41:32 no subscriber
13:45:59 - 13:49:41 -> 13:50:07 no subscriber
三次事件,三次都發布到空的頻道。Redis 的 pub/sub 機制不會為缺席的訂閱者將訊息加入佇列,所以訊息在發布的當下就消失了。這一點在一小時內就弄清楚了。花了更長時間才讓人接受的是,兩個不同的錯誤正產生相同的症狀。
同一份日誌中的另一個細節,結果比遺失本身更重要:連線的持續時間極不平均。2.58s、28.59s、5.79s、35.2s。一個具有 20 秒心跳和 30 分鐘會話上限的長時串流,不應該在 2.58 秒後就關閉。
狀況一:單項完成即關閉整個串流
此系統中的狀態碼是整數,其末三碼用於編碼一個階段。一個輔助程式會判斷一個狀態是否為終端狀態:
func isDone(s Status) bool {
r := s % 1000
return r == 500 || r == 600 || r == 800
}
SSE 迴圈使用該輔助函式來決定何時停止串流並返回。在此之前,曾新增了一個代表「單一音軌完成渲染」的新狀態,其編號為 5600。沒有人注意到 5600 % 1000 等於 600。
因此,每個成功的單項完成都被歸類為終止狀態,處理常式也隨之返回。串流關閉了。一項測試使這種不對稱性變得非常明顯:
status=5600 (item done) isDone=true terminal=true stream closes
status=5910 (item failed) isDone=false terminal=false stream stays
失敗會保持連線存活,成功則會終止連線。如果使用者將九個項目排入佇列,第一個成功就會關閉串流,瀏覽器會等待其一秒鐘的重新連線延遲,而在該時間視窗內發布的每個完成事件都會消失。不一致的連線持續時間就是這個 bug,它在生產環境中已出現數週,看起來不過是不穩定的網路問題。
這個修復很小,而重要的是後半部分:
func isSSETerminal(s Status) bool {
if s == StatusItemDone {
return false
}
return (isDone(s) && s >= StatusFirstPhaseDone) ||
s == StatusError || s == StatusFailed
}
我保留了 isDone 不動,因為其他呼叫者依賴它,並且我寫了一個測試,遍歷從 0 到 10000 的每個整數,並斷言新的述詞除了在那個特定值之外,都與舊的表達式相符。將這個決策提取到一個具名函式中,才使得這個測試成為可能。原本它是一個內嵌的布林值,位於一個 230 行的處理函式中,該處理函式還掌管了 HTTP 回應寫入器、資料庫讀取和速率限制器。
總的來說,這個教訓不只關乎一個錯誤的常數。從一個識別符的算術運算中推導出控制流程,意味著每個新的識別符都有可能在遠處悄悄地改變行為。如果在狀態定義中有一個 terminal bool 欄位,就不可能寫出這種錯誤。
第二個遺失點:在開始工作後才訂閱
第二個遺失點發生在客戶端。其順序是:發布工作、等待 202 回應,然後開啟 SSE 連線並註冊關注。
當渲染需要一段時間時,這個方法是可行的。但當工作執行得很快時,它就會失敗。這批次中有一個項目只有單一區段,其渲染在 1.5 秒內完成,遠早於瀏覽器註冊訂閱。那個項目每次都失敗,而有 30 或 200 個區段的項目幾乎總是成功。這個錯誤看起來是資料相依的,這就是為什麼它存活了這麼久:它只在那個沒人認為有什麼特別的輸入上重現。
顯而易見的修復方法是先訂閱。我就是這麼做的,但僅僅調整順序是不夠的,其原因值得一提,因為我在實作時因此吃足了苦頭。客戶端一旦其待處理集合 (pending set) 為空,就會關閉連線,而這個檢查會在每則訊息上執行。伺服器在連線開啟的瞬間,會傳送一個初始狀態框架 (initial status frame)。因此,單獨提早開啟連線會產生以下流程:連線、接收初始框架、待處理集合為空、關閉。競爭條件又馬上出現了。提早訂閱和維持訂閱的有效性是一項變更,而不是兩項。
為何顯而易見的備援機制反而讓情況更糟
由於完成事件可能會遺失,直覺的作法是增加一個復原路徑。我設計了兩種,但都捨棄了。
第一種是客戶端輪詢:發布請求後,每隔幾秒輪詢一次結果端點,直到答案出現。這看起來很穩健,直到你注意到輪詢計時器和工作 ID 存在哪裡。它們存在瀏覽器記憶體中。最初的錯誤報告指出,項目「即使重新整理數次」也無法下載。重新整理正好會摧毀備援機制所依賴的狀態。這個備援機制涵蓋了所有情況,唯獨漏掉了被回報的那一種。
第二種是伺服器端重播:將每個結果儲存在一個以工作為鍵的雜湊表中,當客戶端連線時,讀取該雜湊表並重播所有找到的結果。原則上,這在重新整理後仍能運作。但在實務上,它製造了一個比原先修復的那個更糟的失敗。因為客戶端現在會在發布請求前就訂閱,所以項目在連線時總是在待處理集合中。先前執行的過時結果會被重播,與該待處理項目匹配,並被視為那個甚至還沒發送的請求的答案。使用者會默默地收到一個舊的產物,並相信它是新的。一個無止盡的旋轉圖示是使用者看得到的壞結果。一個看起來正確的錯誤檔案則更糟。
還有第三個問題:SSE 處理器與其他消費者共用,其中一個消費者會將任何以 failed 結尾的狀態歸類為自身的失敗。重播儲存的個別項目失敗會導致不相關的操作顯示錯誤橫幅。
因此,備援機制被完全捨棄,而精力則投入到從一開始就不要遺失事件。在開始工作前訂閱、在面板開啟時保持訂閱開啟,並停止在個別項目完成時關閉串流。一個比主要路徑更弱的復原路徑並不是冗餘。它是在相同條件下會失敗的第二個東西,還外加一類新的錯誤。
尚未涵蓋的部分
移除備援機制意味著接受較小的保證,而且值得將其點明,而非假裝沒這回事。這是即時傳送的可靠性,而非持久的完成性。如果伺服器在渲染中途重新啟動,或 pub/sub 連結短暫中斷,或瀏覽器在背景分頁中凍結太久而錯過畫面,通知仍然會遺失。
取代備援機制的是客戶端的逾時設定。30 分鐘後,該項目會轉為錯誤狀態,使用者可以重試。這比自動復原的體驗更差,但它誠實地反映了系統實際的承諾。對於引發這一切的單一區段項目,重試會花費 1.5 秒。
同一次審查中還產生了兩項較小的調整。串流的工作階段上限必須超過客戶端的逾時時間,否則伺服器會先掛斷,並製造出導致事件遺失的重新連線間隙。而且,以每個使用者為鍵值的連線限制器,其 TTL 比新的工作階段長度短,這意味著一個即時連線會在其自身的計數器中過期,並讓額外的連線超過限制。這兩項都不在最初的計畫中。兩者都是透過詢問逾時設定的變更還會影響到什麼而發現的。
常見問答
這是在反對使用 pub/sub 來做完成通知嗎? 不是。Pub/sub 對於「立即通知瀏覽器」來說,是個合理的傳輸方式。錯誤在於將它視為儲存空間。如果你需要答案在重新整理後依然存在,那麼答案必須存放在某個持久的地方,並且可以透過身分識別來擷取,這意味著需要一個帶有可重構 id 的真實端點,而不是一個由客戶端碰巧正在等待的任何東西作為鍵值的重播。
為什麼不只修復客戶端,而跳過伺服器端的變更? 因為這兩個錯誤是獨立的。提早訂閱修復了快速完成的工作。但它對於串流在第一次成功時就關閉的問題毫無幫助,而這正是導致批次處理失敗的原因。單獨修復其中任何一個,都會留下一個可重現的失敗。
你是如何找到像終端狀態那樣的錯誤? 尋找與設計不符的持續時間。串流被設定為存活 30 分鐘,但卻在 3 秒後就關閉了。這個差距一直存在於日誌中,並被解讀為網路不穩定。記錄下長連線關閉的原因,而不僅僅是記錄它關閉了。
一個帶有狀態表的工作佇列是否能避免這一切? 可能可以,而且那是正確的長期架構:提交時返回一個工作 id,一個隨時可讀的狀態,以及將推送通道作為一種最佳化,而非得知結果的唯一途徑。這是一個比修復線上錯誤更大的變更,而且明確地將其延後,而不是在壓力下草草建置一半,是值得的。