<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>診斷 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E8%A8%BA%E6%96%B7/</link><description>Recent content in 診斷 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 03 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E8%A8%BA%E6%96%B7/index.xml" rel="self" type="application/rss+xml"/><item><title>配額耗盡的症狀落在申請最頻繁的元件、成因在持有最久的那個</title><link>https://tarrragon.github.io/blog/report/resource-exhaustion-symptom-vs-holder/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/report/resource-exhaustion-symptom-vs-holder/</guid><description>&lt;h2 id="論述基礎與限制">論述基礎與限制&lt;/h2>
&lt;p>本卡處理的故障類別是共享配額耗盡——一種有限、可計數、由多個元件共用的資源被佔到無法再配發。程序表格位、檔案描述子、連線池的連線、資料庫的鎖、共用 API key 的 rate limit 額度都屬於這一類。論述基礎是其中一種資源的實測案例，抽出的歸因順序在機制上同樣適用於其餘幾種（依據與限制見本節末）。&lt;/p>
&lt;p>案例來自一次 macOS 使用者程序額度耗盡。可觀察到的症狀是：一批性質不同的操作同時開始失敗，錯誤訊息都指向 &lt;code>fork&lt;/code> 失敗；失敗最密集的是一組用多層 shebang 寫的 hook 腳本，它們每次執行要連續開三個程序；記憶體充足、CPU 閒置、所有已經在跑的應用程式運作正常。&lt;/p>
&lt;p>我當下用這些事實串出的解釋是：hook 呼叫頻率高、每次開三個程序、於是把自己的額度耗光了。這條推論鏈的每一個組成事實都經得起查證，結論卻是錯的。實際成因是一個連續執行十天的 Android 模擬器累積了 2008 具殭屍程序，佔掉使用者額度 2666 個名額裡的四分之三。它跟 hook 沒有任何關係。它十天內 fork 了兩千多次、平均每小時八次，遠少於 hook 的呼叫頻率；差別在於這兩千多次沒有任何一次歸還，於是佔用量隨時間線性累積，而 hook 開的程序秒級內就還回去、佔用量始終接近零。&lt;/p>
&lt;p>指認它用了一行指令——把所有殭屍依父程序 PID 聚合，輸出只有一行，2008 具全部掛在同一個 PID 底下。這行查詢在推論鏈成形之前就可以執行，成本相同。&lt;/p>
&lt;p>限制：單一實測案例。案例本身是程序表格位這一種資源，卡片對其他配額（檔案描述子、連線池、port、鎖、rate limit 額度）的推廣是依機制類比推導的，沒有跨資源類型的量測。本卡的適用前提有三項——故障形態是配額耗盡而非單一元件內部錯誤、系統保存了持有者紀錄、以及配發器不挑受害者（前提落空時的處置見核心原則段）。第二項落空時（例如無 allocation tracking 的記憶體洩漏）仍要回到假說與對照的路線。&lt;/p>
&lt;hr>
&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>共享配額耗盡時，症狀出現在哪個元件由它的申請頻率決定，配額被誰佔走由申請頻率乘上持有時間決定。持有時間的跨度遠大於申請頻率的跨度，因此佔用量的排序多半由持有時間主導，而症狀的位置不受它影響。&lt;/strong> 兩個量共用申請頻率這個因子，所以它們並非互相獨立；分開它們的是持有時間——申請頻率的差距以倍數計且有上界（沒有元件能無限快地申請），而持有時間從幾毫秒到永不歸還、跨度沒有上界。乘積因此由後者主導。&lt;/p>
&lt;p>這個乘積關係是排隊理論的 Little&amp;rsquo;s Law 在配額上的形態。持有時間可以趨近無限，於是一個申請得並不頻繁的元件，只要不歸還，佔用量就隨時間線性累積到吃光配額；而申請頻繁的元件多半是短命的，用完就還，正因為還得快，它才需要一直申請。於是「一直在申請的那個」與「一直沒歸還的那個」在多數情況下是不同的元件，而先撞到上限報錯的是前者——它申請得最頻繁，因此最可能在配額見底的那一刻正好在申請。&lt;/p>
&lt;p>這個方向有一個成立前提，而它要用正向查詢確認、不用否定式判定：&lt;strong>這個配發器在耗盡的那一刻做什麼？&lt;/strong> 答案是三選一——把失敗回給下一個申請者（先到先得）、從既有持有者裡挑一個收回、或讓申請者排隊等。挑一個問「有沒有受害者選擇策略」的話，沒看到就等於通過，而這一步判錯會讓整條路線的預期反向。&lt;/p>
&lt;p>答案是第一種時，本卡描述的方向成立。答案是第二種時，配發器已經替你做了一次持有者歸因，症狀反而會直接指向持有者——Linux 的 OOM killer 依 RSS 挑對象、連線池驅逐持有最久的那條、依租戶用量節流的 rate limiter 都屬於這一類，此時診斷順序仍然適用但「症狀指向的不是成因」這個預期要反過來。答案是第三種時，耗盡表現為延遲而非錯誤，要先確認自己看的是不是同一種故障。&lt;/p>
&lt;p>還有一種例外要先認出來：申請者與持有者重合的情形，此時症狀與成因同位、直覺歸因剛好會對。兩種長相最常見。&lt;/p>
&lt;p>一種是釋放漏掉——資源的回收寫在正常路徑上而例外路徑繞過了它（&lt;code>try&lt;/code> 裡開、&lt;code>finally&lt;/code> 外面關），或是每處理一筆記錄就開一個新的連線／檔案／子程序而回收依賴整批結束。&lt;/p>
&lt;p>另一種更常見、也更容易被誤判：&lt;strong>申請頻率沒變，持有時間退化了。&lt;/strong> 一個每秒被呼叫數百次的熱門端點，單次持有連線的時間從兩毫秒變成八百毫秒（索引掉了、下游變慢、查詢計畫翻了），佔用量因此漲了四百倍，而它同時仍是申請最頻繁的那個。這一種的修法既不是回收也不是擴容——要回去看持有時間為什麼變長。&lt;/p>
&lt;p>兩種長相都仍要走同一套診斷順序，因為「症狀指向的元件是不是持有者」這件事本身要用持有者紀錄來確認，而確認它與指認別人是同一個動作、同樣一行查詢。把例外的可能性當成可以跳過查詢的理由，等於用結論決定要不要驗證結論。&lt;/p>
&lt;p>不可見有三個成分。&lt;/p>
&lt;p>&lt;strong>推論鏈的每個組成事實都為真。&lt;/strong> 症狀確實集中在那個元件、那個元件確實高頻申請、錯誤確實是申請失敗。由真事實串成的解釋讀起來與正確的解釋外觀相同，因此自洽度無法作為它正確的證據。這與 &lt;a href="../new-data-shape-silently-changes-analysis/">#250&lt;/a> 的「錯誤輸出與正確輸出外觀相同」是同一種訊號同構，發生在解釋層。&lt;/p>
&lt;p>&lt;strong>症狀是推送到眼前的證據，持有者紀錄是要主動發起的查詢。&lt;/strong> 錯誤訊息不請自來、失敗的操作自己會浮現，而查持有者需要先想到系統保存了這份紀錄。哪一邊先發生由收益結構決定，而非由診斷者的仔細程度決定——這是 &lt;a href="../annotation-with-no-local-payoff-needs-a-trigger/">#249&lt;/a> 描述的同一種機制作用在診斷順序上。&lt;/p>
&lt;p>&lt;strong>囤積者在常規監控上是平的。&lt;/strong> 殭屍程序不佔記憶體也不耗 CPU、洩漏的檔案描述子不產生流量、閒置的連線不出現在慢查詢報表裡。監控的涵蓋面若不含那一項配額，「所有指標正常」與「沒有監控這一項」給出的訊號完全相同，這是 &lt;a href="../lint-scope-must-be-explicit-fact/">#221&lt;/a> 的零 error 歧義在監控層的形態。三個成分合起來的效果是：手上有一個自洽的解釋、沒有任何指標反駁它、而唯一能推翻它的那份紀錄沒有人打開。&lt;/p>
&lt;hr>
&lt;h2 id="同一個結構在其他配額上的形態">同一個結構在其他配額上的形態&lt;/h2>
&lt;p>程序表格位的案例裡，症狀與成因分屬不同元件這件事一眼可辨（報錯的是 hook、囤積的是模擬器）。其他配額上的同一個結構比較難認出來，因為申請者與持有者常常在同一個程式裡，只是走不同的程式碼路徑。以下幾種形態是從機制推出來的，不是實測案例——它們的作用是讓讀者對照得到自己的系統，證據地位低於前面那個案例。&lt;/p>
&lt;p>&lt;strong>連線池&lt;/strong>：會遇到的系統形態是 request handler 在持有連線的期間呼叫外部服務——開啟交易、寫入、接著等第三方 API 回應、拿到結果才 commit。這條路徑的請求量可能只佔全部的百分之幾，但每一筆把連線壓住數百毫秒到數秒。同一個服務裡的一般查詢每秒申請幾百次、各佔用幾毫秒。池子見底時報錯的是後者（它們申請得最頻繁，最先拿不到連線），而池子是被前者佔住的。觸發事件通常是第三方變慢——它自己沒有故障、只是回應時間從 200ms 變成 3s，而這個變化在自己的監控上不留痕跡。&lt;/p>
&lt;p>&lt;strong>共用 API key 的 rate limit&lt;/strong>：一組服務共用一把 key（方便計費與輪替），其中一個是每晚跑的對帳批次。批次上線後開始在配額裡佔走固定的一大塊，互動請求隨後陸續收到 &lt;code>429&lt;/code>。這則形態值得單獨記下的是它的第三拍——為什麼沒有被及時發現：批次的用量不出現在任何人看的儀表板上，因為它成功完成、不產生錯誤，而監控盯的是互動路徑的錯誤率。等到互動請求開始被拒，症狀完整落在使用者面前那條路徑上，而那條路徑的用量從頭到尾沒有變過。止血是把批次移到離峰或給它獨立的 key。擴容能不能撐過去取決於批次佔走的那一塊與增量的相對大小，而在指認出批次之前，沒有人知道這個比值——這一點比「擴容無效」更接近實情，也解釋了為什麼加額度有時候看起來管用、過幾天又復發。&lt;/p>
&lt;p>這則形態同時是修法那一節所倚賴的持有者紀錄的反例：共用一把 key 時，配額系統記錄的 principal 只有一個桶，批次與互動請求在那份紀錄裡分不開。讓它隱形的機制原因是紀錄的粒度不足，儀表板只是把結果呈現出來而已。&lt;/p>
&lt;p>&lt;strong>鎖&lt;/strong>：長交易持有的行鎖是同一個形態的極端版本——持有時間可以是幾分鐘，而等它的短交易每秒申請數百次。多數資料庫在這裡提供了現成的持有者紀錄（PostgreSQL 的 &lt;code>pg_locks&lt;/code> 對 &lt;code>pg_stat_activity&lt;/code>），因此它是本卡的修法最容易套用的一種。&lt;/p>
&lt;hr>
&lt;h2 id="修法">修法&lt;/h2>
&lt;p>&lt;strong>失敗涉及任何可計數的共享資源時，量配額是預設動作，不是判斷之後才做的動作。&lt;/strong> 辨識形狀的判準（失敗的操作在功能上互不相關、唯一共同點是都要申請同一種資源）用來提高警覺，不用來決定要不要量——因為判成「其實相關」是零成本的，而案例本身的誤判正是走這條路：fork 失敗集中在 hook，於是「這些操作都由 hook 觸發、彼此相關」，量配額就被跳過了。&lt;/p>
&lt;p>舉證方向要反過來：量過會留下一組數字（用量 X / 上限 Y），判成不必量只留下一句話。因此判成不適用時要說出理由是哪一種——這個資源沒有上限，或量過而用量離上限還遠（附上那兩個數）。給不出來就先量，成本是一行指令。&lt;/p>
&lt;p>&lt;strong>查系統保存的持有者紀錄，這一步排在因果推論之前。&lt;/strong> 多數共享資源都有一份直接記錄持有關係的欄位：程序表的 ppid、&lt;code>lsof&lt;/code> 的持有 PID、鎖表的 holder、配額系統的 principal、連線池的 checkout owner。最後一個是後端服務最常用得上的——多數連線池實作會記下每條連線目前被哪個執行緒或請求借走（checkout），這份紀錄通常從管理端點或 metrics 讀得到，而它正是「誰佔住連線」的權威答案。這類查詢的成本固定，且不依賴任何關於「誰在耗資源」的假設。能用一行查詢指認持有者時，查詢先跑，推論留給查詢覆蓋不到的部分。&lt;/p>
&lt;p>這份紀錄的可用性有兩個前提，兩個都會產生看起來很有把握的錯誤答案。&lt;strong>查詢要在耗盡進行中執行&lt;/strong>：它讀的是當下的持有狀態而非歷史，間歇型耗盡（尖峰時 pool timeout、事後才去查）拿到的是空集合，而空集合讀起來像「沒有持有者」。這種情況要改成持續取樣或打開該元件的洩漏偵測。&lt;strong>聚合欄位的粒度要對應得上可回收的單位&lt;/strong>：紀錄的持有者常常粗於實際的持有者——容器裡不收屍的 PID 1、reactive stack 共用的 reactor thread、多個服務共用的同一把 API key，都會讓所有洩漏聚合成同一桶，而聚合出來的那個桶讀起來與真正的元凶完全一樣。&lt;/p>
&lt;p>&lt;strong>依持有者聚合後看分佈，而不是看單一最大值。&lt;/strong> 這個分岔決定了後續全部的工作方向，所以它要留下數字而非一個標籤——記下&lt;strong>頭名佔配額的比例&lt;/strong>與&lt;strong>前三名的累計比例&lt;/strong>，兩個數就夠。「集中」與「均勻」這兩個詞誰都可以套上去，而想擴容的人會說均勻、想結案的人會說集中，兩邊都不必舉證；寫下比例之後，當初的判斷才有得回查、有得反駁。案例本身就是用數字說話的：頭名佔四分之三。&lt;/p>
&lt;p>頭名佔多數時，先照上一段排除粒度問題再認定元凶。排除的動作同樣要有產物，而判別的不是數量是&lt;strong>來源的同質性&lt;/strong>：記下那個 holder 底下的東西&lt;strong>來自幾個彼此無關的程式&lt;/strong>。案例裡 qemu 底下的 2008 具殭屍全部由它自己 fork，來源是一個，它就是元凶；&lt;code>launchd&lt;/code> 底下的孤兒來自整台機器上所有結束過的父程序、共用 API key 底下是好幾個各自獨立的服務、reactor thread 底下是無關的請求，來源都是多個，那就是共用容器、要往下一層再聚合一次。用數量判會反過來——元凶名下的東西通常比共用容器還多。這比「確認過不是共用容器」可靠，因為後者的零動作預設答案就是「不是」。&lt;/p>
&lt;p>前幾名的比例接近時，代表確實是總量需求成長到超過配額，該走的是擴容或分流而非回收。&lt;/p>
&lt;p>走到均勻那一側時還有一次取捨：擴容是把配額本身調大，分流是把持有者拆到不同的配額池去（獨立的連線池、獨立的 API key、獨立的執行緒池）。判準是這些持有者的用量會不會互相干擾——用量彼此獨立、只是加總超標，擴容就夠；其中一類的尖峰會擠掉另一類（批次擠掉互動、背景任務擠掉使用者請求），那麼擴容只是把同一場競爭推到更大的池子裡，要走分流。&lt;/p>
&lt;p>&lt;strong>取用持有時間欄位。&lt;/strong> &lt;code>ps&lt;/code> 的 &lt;code>ELAPSED&lt;/code>、連線的 age、鎖的 acquired_at。累積型故障的發作時機與觸發原因在時間上完全脫鉤——十天前開的模擬器讓今天的操作失敗，於是任何從「今天做了什麼」出發的推論都會落空。持有時間是把兩者接回去的欄位，也是分辨「持續囤積」與「瞬時尖峰」的依據。&lt;/p>
&lt;p>&lt;strong>把「提高上限」與「回收配額」在論述上分開。&lt;/strong> 調高上限通常最快、且立即讓症狀消失，因此容易被當成修法。它的實際作用是延後下一次撞牆的時間點，被佔住的配額一個都沒有因此釋放。兩者都可以做，但要指得出這一次做的是哪一個。&lt;/p>
&lt;p>&lt;strong>囤積者的候選集可以事先列出，這是這張卡唯一能在故障之前執行的動作。&lt;/strong> 條件是「長時間常駐」與「生命週期內反覆申請該資源」同時成立，其餘的判讀訊號都要等系統落地並開始出事才觀察得到。&lt;/p>
&lt;p>在開發環境裡符合這個描述的清單相當固定——模擬器與虛擬機、開發伺服器與檔案監看器、language server、長跑的容器 runtime。服務端的對應形態是另一組：常駐的 message consumer、對帳與報表批次、長連線的 worker 與 WebSocket handler、排程器，以及任何在請求生命週期之外持有資源的背景任務。設計階段可用的問法是「這個元件會不會在一次請求結束之後還握著什麼」——會的話它就進候選集，而候選集的用途是故障當下先查它們，不是預先斷定誰有問題。&lt;/p>
&lt;hr>
&lt;h2 id="跟其他原則的關係">跟其他原則的關係&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="../hypothesis-replacement-inherits-no-scrutiny/">#248 推翻一個假說之後，替補者是在驗屍的空檔裡上位的&lt;/a>：本卡限定它的適用邊界。#248 處理的是「已經有一個候選解釋在手上」時它憑什麼被接受，答案是要有自己的對照。本卡補在更前面一步——有一類故障不必先建假說，因為系統保存了一份直接指認&lt;strong>持有者&lt;/strong>的紀錄，它把成因的搜尋範圍從全系統縮到一個元件，而這一步比檢驗任何假說都便宜。要留意的是持有者不等於成因：連線池的持有者是那個 handler，成因可能在第三方的延遲上。因此兩張卡的順序是先用紀錄縮範圍，「它為什麼不放、是不是被上游拖住的受害者」再回到 #248 的紀律。&lt;/li>
&lt;li>&lt;a href="../lint-scope-must-be-explicit-fact/">#221 檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查&lt;/a>：本卡是它在監控層的形態。#221 說的是規則涵蓋哪些檔案是一個獨立於規則內容的 fact，而零 error 與「不在作用域」給出同一個訊號；監控的涵蓋面同樣是獨立的 fact，「所有指標正常」與「沒有量測這一項配額」同樣不可區分。差別在於作用域至少有一個可以打開的住址（一個路徑常數，儘管 #221 的重點正是沒有人去審視它涵蓋得齊不齊），而監控的涵蓋面散在各個儀表板上、連住址都沒有。&lt;/li>
&lt;li>&lt;a href="../new-data-shape-silently-changes-analysis/">#250 資料多出一種形狀時，既有分析邏輯靜默換語意&lt;/a>：同屬「錯誤產物與正確產物外觀相同」的家族，層次不同。#250 的對象是分析邏輯的輸出（合法標籤、無錯誤值），本卡的對象是診斷者的解釋（每個事實都真、與已知證據不衝突）。共同的處置方向也相同——引入一個獨立於產物本身的來源來驗，在 #250 是總量守恆檢查，在本卡是持有者紀錄。&lt;/li>
&lt;li>&lt;a href="../annotation-with-no-local-payoff-needs-a-trigger/">#249 對當下段落沒有收益的標註不會自發發生&lt;/a>：本卡借用它的解釋框架。#249 的結論是行為由收益結構決定、不由仔細程度決定，該卡的對象是寫作時的標註；本卡把同一個框架用在診斷順序上——症狀是零成本送達的證據、持有者查詢要主動發起，於是先讀症狀是預設路徑。本卡的第一條修法是照 #249 的形狀寫的觸發式（「故障形狀符合配額耗盡時，第一個動作是量配額」），其餘幾條是排序與注意事項——依 #249 自己的判準，那幾條屬於效力較弱的形態，留著是因為它們回答的是「查到之後怎麼判讀」，而那個時點已經有觸發器了。&lt;/li>
&lt;li>&lt;a href="../judgment-rules-must-specify-their-trace/">#243 要求執行者做判定的規則，要一併規定判定留下什麼痕跡&lt;/a>：本卡的修法段受它管轄，而且曾經整段違反它。初版的六條修法要求執行者判斷「形狀符不符合」「分佈均不均勻」「是不是共用容器」「配發器挑不挑受害者」，四個判定的省力答案都是否定式、產物都是空的——現在的版本改成留下數字（用量與上限、頭名佔比、holder 底下的單位數、耗盡時的行為三選一），那是 #243「痕跡要是值不是標籤」那一節的直接套用。&lt;/li>
&lt;li>&lt;a href="../review-miss-diagnose-design-vs-execution-gap/">#153 Review 漏抓先分 design gap 與 execution gap&lt;/a>：同屬歸因分流卡，分流軸不同。#153 分的是「框架缺 frame」與「框架有但沒跑」，本卡分的是「誰在申請」與「誰在持有」。兩者的共同教訓是分流要在修法之前——分類判錯時，投入的工作量與修復進度無關。&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>失敗的操作在功能上互不相關，唯一的共同點是都要申請同一種資源。&lt;/li>
&lt;li>已經在執行的元件全部正常，新啟動或新申請的失敗。&lt;/li>
&lt;li>手上的因果解釋由數個各自為真的觀察串成，而其中沒有任何一個直接指認持有者。&lt;/li>
&lt;li>錯誤訊息集中在某個元件，而那個元件的職責與該資源的長期持有無關。&lt;/li>
&lt;li>資源監控全綠，而故障的形態是資源不足。&lt;/li>
&lt;li>查得出「誰在頻繁使用這項資源」，查不出「誰現在持有多少」。&lt;/li>
&lt;li>想到的第一個修法是提高上限。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<h2 id="論述基礎與限制">論述基礎與限制</h2>
<p>本卡處理的故障類別是共享配額耗盡——一種有限、可計數、由多個元件共用的資源被佔到無法再配發。程序表格位、檔案描述子、連線池的連線、資料庫的鎖、共用 API key 的 rate limit 額度都屬於這一類。論述基礎是其中一種資源的實測案例，抽出的歸因順序在機制上同樣適用於其餘幾種（依據與限制見本節末）。</p>
<p>案例來自一次 macOS 使用者程序額度耗盡。可觀察到的症狀是：一批性質不同的操作同時開始失敗，錯誤訊息都指向 <code>fork</code> 失敗；失敗最密集的是一組用多層 shebang 寫的 hook 腳本，它們每次執行要連續開三個程序；記憶體充足、CPU 閒置、所有已經在跑的應用程式運作正常。</p>
<p>我當下用這些事實串出的解釋是：hook 呼叫頻率高、每次開三個程序、於是把自己的額度耗光了。這條推論鏈的每一個組成事實都經得起查證，結論卻是錯的。實際成因是一個連續執行十天的 Android 模擬器累積了 2008 具殭屍程序，佔掉使用者額度 2666 個名額裡的四分之三。它跟 hook 沒有任何關係。它十天內 fork 了兩千多次、平均每小時八次，遠少於 hook 的呼叫頻率；差別在於這兩千多次沒有任何一次歸還，於是佔用量隨時間線性累積，而 hook 開的程序秒級內就還回去、佔用量始終接近零。</p>
<p>指認它用了一行指令——把所有殭屍依父程序 PID 聚合，輸出只有一行，2008 具全部掛在同一個 PID 底下。這行查詢在推論鏈成形之前就可以執行，成本相同。</p>
<p>限制：單一實測案例。案例本身是程序表格位這一種資源，卡片對其他配額（檔案描述子、連線池、port、鎖、rate limit 額度）的推廣是依機制類比推導的，沒有跨資源類型的量測。本卡的適用前提有三項——故障形態是配額耗盡而非單一元件內部錯誤、系統保存了持有者紀錄、以及配發器不挑受害者（前提落空時的處置見核心原則段）。第二項落空時（例如無 allocation tracking 的記憶體洩漏）仍要回到假說與對照的路線。</p>
<hr>
<h2 id="核心原則">核心原則</h2>
<p><strong>共享配額耗盡時，症狀出現在哪個元件由它的申請頻率決定，配額被誰佔走由申請頻率乘上持有時間決定。持有時間的跨度遠大於申請頻率的跨度，因此佔用量的排序多半由持有時間主導，而症狀的位置不受它影響。</strong> 兩個量共用申請頻率這個因子，所以它們並非互相獨立；分開它們的是持有時間——申請頻率的差距以倍數計且有上界（沒有元件能無限快地申請），而持有時間從幾毫秒到永不歸還、跨度沒有上界。乘積因此由後者主導。</p>
<p>這個乘積關係是排隊理論的 Little&rsquo;s Law 在配額上的形態。持有時間可以趨近無限，於是一個申請得並不頻繁的元件，只要不歸還，佔用量就隨時間線性累積到吃光配額；而申請頻繁的元件多半是短命的，用完就還，正因為還得快，它才需要一直申請。於是「一直在申請的那個」與「一直沒歸還的那個」在多數情況下是不同的元件，而先撞到上限報錯的是前者——它申請得最頻繁，因此最可能在配額見底的那一刻正好在申請。</p>
<p>這個方向有一個成立前提，而它要用正向查詢確認、不用否定式判定：<strong>這個配發器在耗盡的那一刻做什麼？</strong> 答案是三選一——把失敗回給下一個申請者（先到先得）、從既有持有者裡挑一個收回、或讓申請者排隊等。挑一個問「有沒有受害者選擇策略」的話，沒看到就等於通過，而這一步判錯會讓整條路線的預期反向。</p>
<p>答案是第一種時，本卡描述的方向成立。答案是第二種時，配發器已經替你做了一次持有者歸因，症狀反而會直接指向持有者——Linux 的 OOM killer 依 RSS 挑對象、連線池驅逐持有最久的那條、依租戶用量節流的 rate limiter 都屬於這一類，此時診斷順序仍然適用但「症狀指向的不是成因」這個預期要反過來。答案是第三種時，耗盡表現為延遲而非錯誤，要先確認自己看的是不是同一種故障。</p>
<p>還有一種例外要先認出來：申請者與持有者重合的情形，此時症狀與成因同位、直覺歸因剛好會對。兩種長相最常見。</p>
<p>一種是釋放漏掉——資源的回收寫在正常路徑上而例外路徑繞過了它（<code>try</code> 裡開、<code>finally</code> 外面關），或是每處理一筆記錄就開一個新的連線／檔案／子程序而回收依賴整批結束。</p>
<p>另一種更常見、也更容易被誤判：<strong>申請頻率沒變，持有時間退化了。</strong> 一個每秒被呼叫數百次的熱門端點，單次持有連線的時間從兩毫秒變成八百毫秒（索引掉了、下游變慢、查詢計畫翻了），佔用量因此漲了四百倍，而它同時仍是申請最頻繁的那個。這一種的修法既不是回收也不是擴容——要回去看持有時間為什麼變長。</p>
<p>兩種長相都仍要走同一套診斷順序，因為「症狀指向的元件是不是持有者」這件事本身要用持有者紀錄來確認，而確認它與指認別人是同一個動作、同樣一行查詢。把例外的可能性當成可以跳過查詢的理由，等於用結論決定要不要驗證結論。</p>
<p>不可見有三個成分。</p>
<p><strong>推論鏈的每個組成事實都為真。</strong> 症狀確實集中在那個元件、那個元件確實高頻申請、錯誤確實是申請失敗。由真事實串成的解釋讀起來與正確的解釋外觀相同，因此自洽度無法作為它正確的證據。這與 <a href="../new-data-shape-silently-changes-analysis/">#250</a> 的「錯誤輸出與正確輸出外觀相同」是同一種訊號同構，發生在解釋層。</p>
<p><strong>症狀是推送到眼前的證據，持有者紀錄是要主動發起的查詢。</strong> 錯誤訊息不請自來、失敗的操作自己會浮現，而查持有者需要先想到系統保存了這份紀錄。哪一邊先發生由收益結構決定，而非由診斷者的仔細程度決定——這是 <a href="../annotation-with-no-local-payoff-needs-a-trigger/">#249</a> 描述的同一種機制作用在診斷順序上。</p>
<p><strong>囤積者在常規監控上是平的。</strong> 殭屍程序不佔記憶體也不耗 CPU、洩漏的檔案描述子不產生流量、閒置的連線不出現在慢查詢報表裡。監控的涵蓋面若不含那一項配額，「所有指標正常」與「沒有監控這一項」給出的訊號完全相同，這是 <a href="../lint-scope-must-be-explicit-fact/">#221</a> 的零 error 歧義在監控層的形態。三個成分合起來的效果是：手上有一個自洽的解釋、沒有任何指標反駁它、而唯一能推翻它的那份紀錄沒有人打開。</p>
<hr>
<h2 id="同一個結構在其他配額上的形態">同一個結構在其他配額上的形態</h2>
<p>程序表格位的案例裡，症狀與成因分屬不同元件這件事一眼可辨（報錯的是 hook、囤積的是模擬器）。其他配額上的同一個結構比較難認出來，因為申請者與持有者常常在同一個程式裡，只是走不同的程式碼路徑。以下幾種形態是從機制推出來的，不是實測案例——它們的作用是讓讀者對照得到自己的系統，證據地位低於前面那個案例。</p>
<p><strong>連線池</strong>：會遇到的系統形態是 request handler 在持有連線的期間呼叫外部服務——開啟交易、寫入、接著等第三方 API 回應、拿到結果才 commit。這條路徑的請求量可能只佔全部的百分之幾，但每一筆把連線壓住數百毫秒到數秒。同一個服務裡的一般查詢每秒申請幾百次、各佔用幾毫秒。池子見底時報錯的是後者（它們申請得最頻繁，最先拿不到連線），而池子是被前者佔住的。觸發事件通常是第三方變慢——它自己沒有故障、只是回應時間從 200ms 變成 3s，而這個變化在自己的監控上不留痕跡。</p>
<p><strong>共用 API key 的 rate limit</strong>：一組服務共用一把 key（方便計費與輪替），其中一個是每晚跑的對帳批次。批次上線後開始在配額裡佔走固定的一大塊，互動請求隨後陸續收到 <code>429</code>。這則形態值得單獨記下的是它的第三拍——為什麼沒有被及時發現：批次的用量不出現在任何人看的儀表板上，因為它成功完成、不產生錯誤，而監控盯的是互動路徑的錯誤率。等到互動請求開始被拒，症狀完整落在使用者面前那條路徑上，而那條路徑的用量從頭到尾沒有變過。止血是把批次移到離峰或給它獨立的 key。擴容能不能撐過去取決於批次佔走的那一塊與增量的相對大小，而在指認出批次之前，沒有人知道這個比值——這一點比「擴容無效」更接近實情，也解釋了為什麼加額度有時候看起來管用、過幾天又復發。</p>
<p>這則形態同時是修法那一節所倚賴的持有者紀錄的反例：共用一把 key 時，配額系統記錄的 principal 只有一個桶，批次與互動請求在那份紀錄裡分不開。讓它隱形的機制原因是紀錄的粒度不足，儀表板只是把結果呈現出來而已。</p>
<p><strong>鎖</strong>：長交易持有的行鎖是同一個形態的極端版本——持有時間可以是幾分鐘，而等它的短交易每秒申請數百次。多數資料庫在這裡提供了現成的持有者紀錄（PostgreSQL 的 <code>pg_locks</code> 對 <code>pg_stat_activity</code>），因此它是本卡的修法最容易套用的一種。</p>
<hr>
<h2 id="修法">修法</h2>
<p><strong>失敗涉及任何可計數的共享資源時，量配額是預設動作，不是判斷之後才做的動作。</strong> 辨識形狀的判準（失敗的操作在功能上互不相關、唯一共同點是都要申請同一種資源）用來提高警覺，不用來決定要不要量——因為判成「其實相關」是零成本的，而案例本身的誤判正是走這條路：fork 失敗集中在 hook，於是「這些操作都由 hook 觸發、彼此相關」，量配額就被跳過了。</p>
<p>舉證方向要反過來：量過會留下一組數字（用量 X / 上限 Y），判成不必量只留下一句話。因此判成不適用時要說出理由是哪一種——這個資源沒有上限，或量過而用量離上限還遠（附上那兩個數）。給不出來就先量，成本是一行指令。</p>
<p><strong>查系統保存的持有者紀錄，這一步排在因果推論之前。</strong> 多數共享資源都有一份直接記錄持有關係的欄位：程序表的 ppid、<code>lsof</code> 的持有 PID、鎖表的 holder、配額系統的 principal、連線池的 checkout owner。最後一個是後端服務最常用得上的——多數連線池實作會記下每條連線目前被哪個執行緒或請求借走（checkout），這份紀錄通常從管理端點或 metrics 讀得到，而它正是「誰佔住連線」的權威答案。這類查詢的成本固定，且不依賴任何關於「誰在耗資源」的假設。能用一行查詢指認持有者時，查詢先跑，推論留給查詢覆蓋不到的部分。</p>
<p>這份紀錄的可用性有兩個前提，兩個都會產生看起來很有把握的錯誤答案。<strong>查詢要在耗盡進行中執行</strong>：它讀的是當下的持有狀態而非歷史，間歇型耗盡（尖峰時 pool timeout、事後才去查）拿到的是空集合，而空集合讀起來像「沒有持有者」。這種情況要改成持續取樣或打開該元件的洩漏偵測。<strong>聚合欄位的粒度要對應得上可回收的單位</strong>：紀錄的持有者常常粗於實際的持有者——容器裡不收屍的 PID 1、reactive stack 共用的 reactor thread、多個服務共用的同一把 API key，都會讓所有洩漏聚合成同一桶，而聚合出來的那個桶讀起來與真正的元凶完全一樣。</p>
<p><strong>依持有者聚合後看分佈，而不是看單一最大值。</strong> 這個分岔決定了後續全部的工作方向，所以它要留下數字而非一個標籤——記下<strong>頭名佔配額的比例</strong>與<strong>前三名的累計比例</strong>，兩個數就夠。「集中」與「均勻」這兩個詞誰都可以套上去，而想擴容的人會說均勻、想結案的人會說集中，兩邊都不必舉證；寫下比例之後，當初的判斷才有得回查、有得反駁。案例本身就是用數字說話的：頭名佔四分之三。</p>
<p>頭名佔多數時，先照上一段排除粒度問題再認定元凶。排除的動作同樣要有產物，而判別的不是數量是<strong>來源的同質性</strong>：記下那個 holder 底下的東西<strong>來自幾個彼此無關的程式</strong>。案例裡 qemu 底下的 2008 具殭屍全部由它自己 fork，來源是一個，它就是元凶；<code>launchd</code> 底下的孤兒來自整台機器上所有結束過的父程序、共用 API key 底下是好幾個各自獨立的服務、reactor thread 底下是無關的請求，來源都是多個，那就是共用容器、要往下一層再聚合一次。用數量判會反過來——元凶名下的東西通常比共用容器還多。這比「確認過不是共用容器」可靠，因為後者的零動作預設答案就是「不是」。</p>
<p>前幾名的比例接近時，代表確實是總量需求成長到超過配額，該走的是擴容或分流而非回收。</p>
<p>走到均勻那一側時還有一次取捨：擴容是把配額本身調大，分流是把持有者拆到不同的配額池去（獨立的連線池、獨立的 API key、獨立的執行緒池）。判準是這些持有者的用量會不會互相干擾——用量彼此獨立、只是加總超標，擴容就夠；其中一類的尖峰會擠掉另一類（批次擠掉互動、背景任務擠掉使用者請求），那麼擴容只是把同一場競爭推到更大的池子裡，要走分流。</p>
<p><strong>取用持有時間欄位。</strong> <code>ps</code> 的 <code>ELAPSED</code>、連線的 age、鎖的 acquired_at。累積型故障的發作時機與觸發原因在時間上完全脫鉤——十天前開的模擬器讓今天的操作失敗，於是任何從「今天做了什麼」出發的推論都會落空。持有時間是把兩者接回去的欄位，也是分辨「持續囤積」與「瞬時尖峰」的依據。</p>
<p><strong>把「提高上限」與「回收配額」在論述上分開。</strong> 調高上限通常最快、且立即讓症狀消失，因此容易被當成修法。它的實際作用是延後下一次撞牆的時間點，被佔住的配額一個都沒有因此釋放。兩者都可以做，但要指得出這一次做的是哪一個。</p>
<p><strong>囤積者的候選集可以事先列出，這是這張卡唯一能在故障之前執行的動作。</strong> 條件是「長時間常駐」與「生命週期內反覆申請該資源」同時成立，其餘的判讀訊號都要等系統落地並開始出事才觀察得到。</p>
<p>在開發環境裡符合這個描述的清單相當固定——模擬器與虛擬機、開發伺服器與檔案監看器、language server、長跑的容器 runtime。服務端的對應形態是另一組：常駐的 message consumer、對帳與報表批次、長連線的 worker 與 WebSocket handler、排程器，以及任何在請求生命週期之外持有資源的背景任務。設計階段可用的問法是「這個元件會不會在一次請求結束之後還握著什麼」——會的話它就進候選集，而候選集的用途是故障當下先查它們，不是預先斷定誰有問題。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<ul>
<li><a href="../hypothesis-replacement-inherits-no-scrutiny/">#248 推翻一個假說之後，替補者是在驗屍的空檔裡上位的</a>：本卡限定它的適用邊界。#248 處理的是「已經有一個候選解釋在手上」時它憑什麼被接受，答案是要有自己的對照。本卡補在更前面一步——有一類故障不必先建假說，因為系統保存了一份直接指認<strong>持有者</strong>的紀錄，它把成因的搜尋範圍從全系統縮到一個元件，而這一步比檢驗任何假說都便宜。要留意的是持有者不等於成因：連線池的持有者是那個 handler，成因可能在第三方的延遲上。因此兩張卡的順序是先用紀錄縮範圍，「它為什麼不放、是不是被上游拖住的受害者」再回到 #248 的紀律。</li>
<li><a href="../lint-scope-must-be-explicit-fact/">#221 檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查</a>：本卡是它在監控層的形態。#221 說的是規則涵蓋哪些檔案是一個獨立於規則內容的 fact，而零 error 與「不在作用域」給出同一個訊號；監控的涵蓋面同樣是獨立的 fact，「所有指標正常」與「沒有量測這一項配額」同樣不可區分。差別在於作用域至少有一個可以打開的住址（一個路徑常數，儘管 #221 的重點正是沒有人去審視它涵蓋得齊不齊），而監控的涵蓋面散在各個儀表板上、連住址都沒有。</li>
<li><a href="../new-data-shape-silently-changes-analysis/">#250 資料多出一種形狀時，既有分析邏輯靜默換語意</a>：同屬「錯誤產物與正確產物外觀相同」的家族，層次不同。#250 的對象是分析邏輯的輸出（合法標籤、無錯誤值），本卡的對象是診斷者的解釋（每個事實都真、與已知證據不衝突）。共同的處置方向也相同——引入一個獨立於產物本身的來源來驗，在 #250 是總量守恆檢查，在本卡是持有者紀錄。</li>
<li><a href="../annotation-with-no-local-payoff-needs-a-trigger/">#249 對當下段落沒有收益的標註不會自發發生</a>：本卡借用它的解釋框架。#249 的結論是行為由收益結構決定、不由仔細程度決定，該卡的對象是寫作時的標註；本卡把同一個框架用在診斷順序上——症狀是零成本送達的證據、持有者查詢要主動發起，於是先讀症狀是預設路徑。本卡的第一條修法是照 #249 的形狀寫的觸發式（「故障形狀符合配額耗盡時，第一個動作是量配額」），其餘幾條是排序與注意事項——依 #249 自己的判準，那幾條屬於效力較弱的形態，留著是因為它們回答的是「查到之後怎麼判讀」，而那個時點已經有觸發器了。</li>
<li><a href="../judgment-rules-must-specify-their-trace/">#243 要求執行者做判定的規則，要一併規定判定留下什麼痕跡</a>：本卡的修法段受它管轄，而且曾經整段違反它。初版的六條修法要求執行者判斷「形狀符不符合」「分佈均不均勻」「是不是共用容器」「配發器挑不挑受害者」，四個判定的省力答案都是否定式、產物都是空的——現在的版本改成留下數字（用量與上限、頭名佔比、holder 底下的單位數、耗盡時的行為三選一），那是 #243「痕跡要是值不是標籤」那一節的直接套用。</li>
<li><a href="../review-miss-diagnose-design-vs-execution-gap/">#153 Review 漏抓先分 design gap 與 execution gap</a>：同屬歸因分流卡，分流軸不同。#153 分的是「框架缺 frame」與「框架有但沒跑」，本卡分的是「誰在申請」與「誰在持有」。兩者的共同教訓是分流要在修法之前——分類判錯時，投入的工作量與修復進度無關。</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>失敗的操作在功能上互不相關，唯一的共同點是都要申請同一種資源。</li>
<li>已經在執行的元件全部正常，新啟動或新申請的失敗。</li>
<li>手上的因果解釋由數個各自為真的觀察串成，而其中沒有任何一個直接指認持有者。</li>
<li>錯誤訊息集中在某個元件，而那個元件的職責與該資源的長期持有無關。</li>
<li>資源監控全綠，而故障的形態是資源不足。</li>
<li>查得出「誰在頻繁使用這項資源」，查不出「誰現在持有多少」。</li>
<li>想到的第一個修法是提高上限。</li>
</ul>
]]></content:encoded></item></channel></rss>