論述基礎與限制

本卡處理的故障類別是共享配額耗盡——一種有限、可計數、由多個元件共用的資源被佔到無法再配發。程序表格位、檔案描述子、連線池的連線、資料庫的鎖、共用 API key 的 rate limit 額度都屬於這一類。論述基礎是其中一種資源的實測案例,抽出的歸因順序在機制上同樣適用於其餘幾種(依據與限制見本節末)。

案例來自一次 macOS 使用者程序額度耗盡。可觀察到的症狀是:一批性質不同的操作同時開始失敗,錯誤訊息都指向 fork 失敗;失敗最密集的是一組用多層 shebang 寫的 hook 腳本,它們每次執行要連續開三個程序;記憶體充足、CPU 閒置、所有已經在跑的應用程式運作正常。

我當下用這些事實串出的解釋是:hook 呼叫頻率高、每次開三個程序、於是把自己的額度耗光了。這條推論鏈的每一個組成事實都經得起查證,結論卻是錯的。實際成因是一個連續執行十天的 Android 模擬器累積了 2008 具殭屍程序,佔掉使用者額度 2666 個名額裡的四分之三。它跟 hook 沒有任何關係。它十天內 fork 了兩千多次、平均每小時八次,遠少於 hook 的呼叫頻率;差別在於這兩千多次沒有任何一次歸還,於是佔用量隨時間線性累積,而 hook 開的程序秒級內就還回去、佔用量始終接近零。

指認它用了一行指令——把所有殭屍依父程序 PID 聚合,輸出只有一行,2008 具全部掛在同一個 PID 底下。這行查詢在推論鏈成形之前就可以執行,成本相同。

限制:單一實測案例。案例本身是程序表格位這一種資源,卡片對其他配額(檔案描述子、連線池、port、鎖、rate limit 額度)的推廣是依機制類比推導的,沒有跨資源類型的量測。本卡的適用前提有三項——故障形態是配額耗盡而非單一元件內部錯誤、系統保存了持有者紀錄、以及配發器不挑受害者(前提落空時的處置見核心原則段)。第二項落空時(例如無 allocation tracking 的記憶體洩漏)仍要回到假說與對照的路線。


核心原則

共享配額耗盡時,症狀出現在哪個元件由它的申請頻率決定,配額被誰佔走由申請頻率乘上持有時間決定。持有時間的跨度遠大於申請頻率的跨度,因此佔用量的排序多半由持有時間主導,而症狀的位置不受它影響。 兩個量共用申請頻率這個因子,所以它們並非互相獨立;分開它們的是持有時間——申請頻率的差距以倍數計且有上界(沒有元件能無限快地申請),而持有時間從幾毫秒到永不歸還、跨度沒有上界。乘積因此由後者主導。

這個乘積關係是排隊理論的 Little’s Law 在配額上的形態。持有時間可以趨近無限,於是一個申請得並不頻繁的元件,只要不歸還,佔用量就隨時間線性累積到吃光配額;而申請頻繁的元件多半是短命的,用完就還,正因為還得快,它才需要一直申請。於是「一直在申請的那個」與「一直沒歸還的那個」在多數情況下是不同的元件,而先撞到上限報錯的是前者——它申請得最頻繁,因此最可能在配額見底的那一刻正好在申請。

這個方向有一個成立前提,而它要用正向查詢確認、不用否定式判定:這個配發器在耗盡的那一刻做什麼? 答案是三選一——把失敗回給下一個申請者(先到先得)、從既有持有者裡挑一個收回、或讓申請者排隊等。挑一個問「有沒有受害者選擇策略」的話,沒看到就等於通過,而這一步判錯會讓整條路線的預期反向。

答案是第一種時,本卡描述的方向成立。答案是第二種時,配發器已經替你做了一次持有者歸因,症狀反而會直接指向持有者——Linux 的 OOM killer 依 RSS 挑對象、連線池驅逐持有最久的那條、依租戶用量節流的 rate limiter 都屬於這一類,此時診斷順序仍然適用但「症狀指向的不是成因」這個預期要反過來。答案是第三種時,耗盡表現為延遲而非錯誤,要先確認自己看的是不是同一種故障。

還有一種例外要先認出來:申請者與持有者重合的情形,此時症狀與成因同位、直覺歸因剛好會對。兩種長相最常見。

一種是釋放漏掉——資源的回收寫在正常路徑上而例外路徑繞過了它(try 裡開、finally 外面關),或是每處理一筆記錄就開一個新的連線/檔案/子程序而回收依賴整批結束。

另一種更常見、也更容易被誤判:申請頻率沒變,持有時間退化了。 一個每秒被呼叫數百次的熱門端點,單次持有連線的時間從兩毫秒變成八百毫秒(索引掉了、下游變慢、查詢計畫翻了),佔用量因此漲了四百倍,而它同時仍是申請最頻繁的那個。這一種的修法既不是回收也不是擴容——要回去看持有時間為什麼變長。

兩種長相都仍要走同一套診斷順序,因為「症狀指向的元件是不是持有者」這件事本身要用持有者紀錄來確認,而確認它與指認別人是同一個動作、同樣一行查詢。把例外的可能性當成可以跳過查詢的理由,等於用結論決定要不要驗證結論。

不可見有三個成分。

推論鏈的每個組成事實都為真。 症狀確實集中在那個元件、那個元件確實高頻申請、錯誤確實是申請失敗。由真事實串成的解釋讀起來與正確的解釋外觀相同,因此自洽度無法作為它正確的證據。這與 #250 的「錯誤輸出與正確輸出外觀相同」是同一種訊號同構,發生在解釋層。

症狀是推送到眼前的證據,持有者紀錄是要主動發起的查詢。 錯誤訊息不請自來、失敗的操作自己會浮現,而查持有者需要先想到系統保存了這份紀錄。哪一邊先發生由收益結構決定,而非由診斷者的仔細程度決定——這是 #249 描述的同一種機制作用在診斷順序上。

囤積者在常規監控上是平的。 殭屍程序不佔記憶體也不耗 CPU、洩漏的檔案描述子不產生流量、閒置的連線不出現在慢查詢報表裡。監控的涵蓋面若不含那一項配額,「所有指標正常」與「沒有監控這一項」給出的訊號完全相同,這是 #221 的零 error 歧義在監控層的形態。三個成分合起來的效果是:手上有一個自洽的解釋、沒有任何指標反駁它、而唯一能推翻它的那份紀錄沒有人打開。


同一個結構在其他配額上的形態

程序表格位的案例裡,症狀與成因分屬不同元件這件事一眼可辨(報錯的是 hook、囤積的是模擬器)。其他配額上的同一個結構比較難認出來,因為申請者與持有者常常在同一個程式裡,只是走不同的程式碼路徑。以下幾種形態是從機制推出來的,不是實測案例——它們的作用是讓讀者對照得到自己的系統,證據地位低於前面那個案例。

連線池:會遇到的系統形態是 request handler 在持有連線的期間呼叫外部服務——開啟交易、寫入、接著等第三方 API 回應、拿到結果才 commit。這條路徑的請求量可能只佔全部的百分之幾,但每一筆把連線壓住數百毫秒到數秒。同一個服務裡的一般查詢每秒申請幾百次、各佔用幾毫秒。池子見底時報錯的是後者(它們申請得最頻繁,最先拿不到連線),而池子是被前者佔住的。觸發事件通常是第三方變慢——它自己沒有故障、只是回應時間從 200ms 變成 3s,而這個變化在自己的監控上不留痕跡。

共用 API key 的 rate limit:一組服務共用一把 key(方便計費與輪替),其中一個是每晚跑的對帳批次。批次上線後開始在配額裡佔走固定的一大塊,互動請求隨後陸續收到 429。這則形態值得單獨記下的是它的第三拍——為什麼沒有被及時發現:批次的用量不出現在任何人看的儀表板上,因為它成功完成、不產生錯誤,而監控盯的是互動路徑的錯誤率。等到互動請求開始被拒,症狀完整落在使用者面前那條路徑上,而那條路徑的用量從頭到尾沒有變過。止血是把批次移到離峰或給它獨立的 key。擴容能不能撐過去取決於批次佔走的那一塊與增量的相對大小,而在指認出批次之前,沒有人知道這個比值——這一點比「擴容無效」更接近實情,也解釋了為什麼加額度有時候看起來管用、過幾天又復發。

這則形態同時是修法那一節所倚賴的持有者紀錄的反例:共用一把 key 時,配額系統記錄的 principal 只有一個桶,批次與互動請求在那份紀錄裡分不開。讓它隱形的機制原因是紀錄的粒度不足,儀表板只是把結果呈現出來而已。

:長交易持有的行鎖是同一個形態的極端版本——持有時間可以是幾分鐘,而等它的短交易每秒申請數百次。多數資料庫在這裡提供了現成的持有者紀錄(PostgreSQL 的 pg_lockspg_stat_activity),因此它是本卡的修法最容易套用的一種。


修法

失敗涉及任何可計數的共享資源時,量配額是預設動作,不是判斷之後才做的動作。 辨識形狀的判準(失敗的操作在功能上互不相關、唯一共同點是都要申請同一種資源)用來提高警覺,不用來決定要不要量——因為判成「其實相關」是零成本的,而案例本身的誤判正是走這條路:fork 失敗集中在 hook,於是「這些操作都由 hook 觸發、彼此相關」,量配額就被跳過了。

舉證方向要反過來:量過會留下一組數字(用量 X / 上限 Y),判成不必量只留下一句話。因此判成不適用時要說出理由是哪一種——這個資源沒有上限,或量過而用量離上限還遠(附上那兩個數)。給不出來就先量,成本是一行指令。

查系統保存的持有者紀錄,這一步排在因果推論之前。 多數共享資源都有一份直接記錄持有關係的欄位:程序表的 ppid、lsof 的持有 PID、鎖表的 holder、配額系統的 principal、連線池的 checkout owner。最後一個是後端服務最常用得上的——多數連線池實作會記下每條連線目前被哪個執行緒或請求借走(checkout),這份紀錄通常從管理端點或 metrics 讀得到,而它正是「誰佔住連線」的權威答案。這類查詢的成本固定,且不依賴任何關於「誰在耗資源」的假設。能用一行查詢指認持有者時,查詢先跑,推論留給查詢覆蓋不到的部分。

這份紀錄的可用性有兩個前提,兩個都會產生看起來很有把握的錯誤答案。查詢要在耗盡進行中執行:它讀的是當下的持有狀態而非歷史,間歇型耗盡(尖峰時 pool timeout、事後才去查)拿到的是空集合,而空集合讀起來像「沒有持有者」。這種情況要改成持續取樣或打開該元件的洩漏偵測。聚合欄位的粒度要對應得上可回收的單位:紀錄的持有者常常粗於實際的持有者——容器裡不收屍的 PID 1、reactive stack 共用的 reactor thread、多個服務共用的同一把 API key,都會讓所有洩漏聚合成同一桶,而聚合出來的那個桶讀起來與真正的元凶完全一樣。

依持有者聚合後看分佈,而不是看單一最大值。 這個分岔決定了後續全部的工作方向,所以它要留下數字而非一個標籤——記下頭名佔配額的比例前三名的累計比例,兩個數就夠。「集中」與「均勻」這兩個詞誰都可以套上去,而想擴容的人會說均勻、想結案的人會說集中,兩邊都不必舉證;寫下比例之後,當初的判斷才有得回查、有得反駁。案例本身就是用數字說話的:頭名佔四分之三。

頭名佔多數時,先照上一段排除粒度問題再認定元凶。排除的動作同樣要有產物,而判別的不是數量是來源的同質性:記下那個 holder 底下的東西來自幾個彼此無關的程式。案例裡 qemu 底下的 2008 具殭屍全部由它自己 fork,來源是一個,它就是元凶;launchd 底下的孤兒來自整台機器上所有結束過的父程序、共用 API key 底下是好幾個各自獨立的服務、reactor thread 底下是無關的請求,來源都是多個,那就是共用容器、要往下一層再聚合一次。用數量判會反過來——元凶名下的東西通常比共用容器還多。這比「確認過不是共用容器」可靠,因為後者的零動作預設答案就是「不是」。

前幾名的比例接近時,代表確實是總量需求成長到超過配額,該走的是擴容或分流而非回收。

走到均勻那一側時還有一次取捨:擴容是把配額本身調大,分流是把持有者拆到不同的配額池去(獨立的連線池、獨立的 API key、獨立的執行緒池)。判準是這些持有者的用量會不會互相干擾——用量彼此獨立、只是加總超標,擴容就夠;其中一類的尖峰會擠掉另一類(批次擠掉互動、背景任務擠掉使用者請求),那麼擴容只是把同一場競爭推到更大的池子裡,要走分流。

取用持有時間欄位。 psELAPSED、連線的 age、鎖的 acquired_at。累積型故障的發作時機與觸發原因在時間上完全脫鉤——十天前開的模擬器讓今天的操作失敗,於是任何從「今天做了什麼」出發的推論都會落空。持有時間是把兩者接回去的欄位,也是分辨「持續囤積」與「瞬時尖峰」的依據。

把「提高上限」與「回收配額」在論述上分開。 調高上限通常最快、且立即讓症狀消失,因此容易被當成修法。它的實際作用是延後下一次撞牆的時間點,被佔住的配額一個都沒有因此釋放。兩者都可以做,但要指得出這一次做的是哪一個。

囤積者的候選集可以事先列出,這是這張卡唯一能在故障之前執行的動作。 條件是「長時間常駐」與「生命週期內反覆申請該資源」同時成立,其餘的判讀訊號都要等系統落地並開始出事才觀察得到。

在開發環境裡符合這個描述的清單相當固定——模擬器與虛擬機、開發伺服器與檔案監看器、language server、長跑的容器 runtime。服務端的對應形態是另一組:常駐的 message consumer、對帳與報表批次、長連線的 worker 與 WebSocket handler、排程器,以及任何在請求生命週期之外持有資源的背景任務。設計階段可用的問法是「這個元件會不會在一次請求結束之後還握著什麼」——會的話它就進候選集,而候選集的用途是故障當下先查它們,不是預先斷定誰有問題。


跟其他原則的關係

  • #248 推翻一個假說之後,替補者是在驗屍的空檔裡上位的:本卡限定它的適用邊界。#248 處理的是「已經有一個候選解釋在手上」時它憑什麼被接受,答案是要有自己的對照。本卡補在更前面一步——有一類故障不必先建假說,因為系統保存了一份直接指認持有者的紀錄,它把成因的搜尋範圍從全系統縮到一個元件,而這一步比檢驗任何假說都便宜。要留意的是持有者不等於成因:連線池的持有者是那個 handler,成因可能在第三方的延遲上。因此兩張卡的順序是先用紀錄縮範圍,「它為什麼不放、是不是被上游拖住的受害者」再回到 #248 的紀律。
  • #221 檢查規則的作用域要顯式列舉:零 error 可能是沒被檢查:本卡是它在監控層的形態。#221 說的是規則涵蓋哪些檔案是一個獨立於規則內容的 fact,而零 error 與「不在作用域」給出同一個訊號;監控的涵蓋面同樣是獨立的 fact,「所有指標正常」與「沒有量測這一項配額」同樣不可區分。差別在於作用域至少有一個可以打開的住址(一個路徑常數,儘管 #221 的重點正是沒有人去審視它涵蓋得齊不齊),而監控的涵蓋面散在各個儀表板上、連住址都沒有。
  • #250 資料多出一種形狀時,既有分析邏輯靜默換語意:同屬「錯誤產物與正確產物外觀相同」的家族,層次不同。#250 的對象是分析邏輯的輸出(合法標籤、無錯誤值),本卡的對象是診斷者的解釋(每個事實都真、與已知證據不衝突)。共同的處置方向也相同——引入一個獨立於產物本身的來源來驗,在 #250 是總量守恆檢查,在本卡是持有者紀錄。
  • #249 對當下段落沒有收益的標註不會自發發生:本卡借用它的解釋框架。#249 的結論是行為由收益結構決定、不由仔細程度決定,該卡的對象是寫作時的標註;本卡把同一個框架用在診斷順序上——症狀是零成本送達的證據、持有者查詢要主動發起,於是先讀症狀是預設路徑。本卡的第一條修法是照 #249 的形狀寫的觸發式(「故障形狀符合配額耗盡時,第一個動作是量配額」),其餘幾條是排序與注意事項——依 #249 自己的判準,那幾條屬於效力較弱的形態,留著是因為它們回答的是「查到之後怎麼判讀」,而那個時點已經有觸發器了。
  • #243 要求執行者做判定的規則,要一併規定判定留下什麼痕跡:本卡的修法段受它管轄,而且曾經整段違反它。初版的六條修法要求執行者判斷「形狀符不符合」「分佈均不均勻」「是不是共用容器」「配發器挑不挑受害者」,四個判定的省力答案都是否定式、產物都是空的——現在的版本改成留下數字(用量與上限、頭名佔比、holder 底下的單位數、耗盡時的行為三選一),那是 #243「痕跡要是值不是標籤」那一節的直接套用。
  • #153 Review 漏抓先分 design gap 與 execution gap:同屬歸因分流卡,分流軸不同。#153 分的是「框架缺 frame」與「框架有但沒跑」,本卡分的是「誰在申請」與「誰在持有」。兩者的共同教訓是分流要在修法之前——分類判錯時,投入的工作量與修復進度無關。

判讀徵兆

  • 失敗的操作在功能上互不相關,唯一的共同點是都要申請同一種資源。
  • 已經在執行的元件全部正常,新啟動或新申請的失敗。
  • 手上的因果解釋由數個各自為真的觀察串成,而其中沒有任何一個直接指認持有者。
  • 錯誤訊息集中在某個元件,而那個元件的職責與該資源的長期持有無關。
  • 資源監控全綠,而故障的形態是資源不足。
  • 查得出「誰在頻繁使用這項資源」,查不出「誰現在持有多少」。
  • 想到的第一個修法是提高上限。