先說讀到後面最容易漏掉的那一點:沒有扣走任何錢。沒有錢包被重複扣款,沒有信用卡被扣費,也沒有任何訂閱額度被花在從未發生過的工作上。
壞掉的是螢幕上的一個數字。在部分帳戶上,你自己帳戶頁面裡的用量明細讀數偏高——我們量到最嚴重的一個,是它本應彙總的帳本的 2.4 倍。帳本自始至終都是對的。每月上限也是對的,那是從完全不同的地方讀取的。這是一個顯示瑕疵,現已修復,成因如下。
一把在找沒人寫過的東西的鑰匙
由你的操作引發的工作——更新每日用量摘要、入帳一筆推薦獎勵、扇出一則通知——並不在你的請求內部完成。請求寫下一則訊息,背景取用程式稍後取走它;正是這一點讓緩慢的副作用不會拖慢一個快速的頁面。
這種安排的危險在於同一份工作被做兩次,因為訊息中介在出現小故障後會毫不猶豫地重新投遞同一則訊息。防線是一個收件匣:在處理之前,取用程式寫入一列帶有該訊息鍵的紀錄來認領這則訊息,而唯一索引使第二次認領成為不可能。
我們的認領是基於一個配對——訊息所屬的租戶,加上訊息鍵——並用訊息信封中攜帶的租戶識別碼去查找這個配對,而信封裡的那個值是有內容的。
認領紀錄裡根本沒有租戶識別碼。每一列都是空的,而且從一開始就是空的。實體本身的文件原話就寫著:框架會在插入時蓋上這個值。它從來沒有蓋過。在我們發現問題當天對正式環境的量測:收件匣的 3,344 列全部為空,而生產端的 1,451 列中有 1,174 列攜帶真實租戶——這恰恰是沒人懷疑讀取端的原因。
於是查找要的是(租戶,鍵),存下來的列卻是(無,鍵),永遠不可能相符。
什麼都沒有失敗,所以它才跑了數週
一個永遠不可能相符的查找,聽起來應該在第一次嘗試時就拋出錯誤。它做的恰恰相反。
認領判定這則訊息是新的。隨後插入撞上唯一索引,被當作重複而拒絕——這是正確的,因為那一列本來就在。一個後備分支捕捉了這次拒絕,並作了善意的解讀:一定是另一次投遞認領了它,這裡沒什麼要做的。信封停留在「仍待處理」的狀態,而既然仍待處理,下一次投遞就把它取走並再做了一遍。
每個取用程式都在大約每四十五秒重新處理自己的積壓,永無止境。有二十六個在這樣做。一列正式環境紀錄在抵達 333 小時後才完成,而在這段時間的大部分裡它都在一遍遍地「完成」。
它實際破壞了什麼
那二十六個取用程式中,有四個是累加而非覆寫。這四個正是用量頁面所讀取的計費投影,問題也正是在那裡浮上檯面。
它們本來有一道專門針對這種情況的保護,寫於八月那次單一事件被重複套用九次到儀表板的事故之後。這道保護會在處理之前把紀錄標記為成功,這樣重投遞就能找到標記並停下。標記每次都被正確寫入。而下一次認領用同一把壞掉的鑰匙去找它,找不到。這條保證從發布那天起就是空的。
這件事沒有波及到什麼,值得精確列出來,因為「計費投影」聽起來令人警覺,而事實並不:
- 帳本是對的。 每一次 AI 呼叫都恰好寫入一筆帶有自身價格快照的計費紀錄,而這條路徑上沒有任何東西會寫入它們。
- 沒有錢包變動。 餘額來自帳本,從不來自摘要。
- 沒有上限變動。 預算守衛讀取的是一個獨立計數器,那才是你本月花了多少的唯一事實來源。沒有人因為一個虛高的數字被提前切斷,也沒有不該通過的人通過。
唯一讀取受影響摘要的,是你自己帳戶頁面上的用量歷史——你的總額,以及這些花費的去向明細。這就是全部影響範圍,而它所做的就是讀數偏高。
沒人盯著的那一半
同一個壞掉的查找還坐在第二個地方:訊息失敗時執行的路徑。在那裡它從未找到本該更新的那一列,而緊挨著它下面是一個帶著「實務上不可達」註解的分支——在每一次失敗時都回傳「此失敗為終態」。
於是重試計數從未前進,也沒有任何毒訊息進入過死信佇列。整整一層錯誤處理是關閉的,藏在一條告訴每個讀到它的人「這裡沒什麼要檢查」的註解後面。
修正
兩處查找現在都以取用群組內的訊息鍵為鍵,這本來就是唯一索引所強制的內容。訊息鍵衍生自產生它們的物件識別碼,因此在結構上不可能跨租戶衝突。
那段聲稱會蓋值的文件被更正而不是刪除。那句話本身就是陷阱,刪掉它就什麼也留不下來,沒法表明終於有人核對過。
九個測試把這個行為釘住。其中六個在舊程式碼上失敗,我們把舊程式碼放回去驗證過。
事後的清理
虛高的紀錄會自我修復。有一個工作程序每小時從帳本重建每日摘要,涵蓋兩天的視窗。它一直在執行,也一直在輸:每小時跑一次,對上每四十五秒重新套用一次的取用程式,這不是一場公平的賽跑。
取用程式不再重跑之後,它就贏了。仍在那個視窗內的兩天,在修正上線後立刻從帳本重建完成。更早的兩天已經落在視窗之外,那上面剩下的是我們自己的一個帳戶,外加別處一處可忽略的差異——所以我們把它們留在那裡,而不是為了追它而在正式環境裡放寬視窗。把這件事寫下來,好過暗示這次清掃是徹底的。
我們從中學到的
去重鍵必須映射索引實際強制的內容,而不是程式碼以為自己寫下的內容。這把鑰匙的兩半由不同欄位供給,而且從來沒有任何一半被拿去和另一半核對過。現在兩邊都核對了,而證明這一點的測試,在那個錯誤的版本上會變紅。