iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Claude AI

買了 Claude Code,然後呢?系列 第 26 篇

Day 26|CPU 正常,服務卻卡住了,Claude 能查出為什麼嗎?

  • 分享至 

  • xImage
  •  

故障那一輪的實測曲線(原始數據繪製):執行緒在一分鐘內從 1 條慢慢爬到 94 條,CPU 多數時間貼近 0;工程師和 Claude 正在看板前查證

CPU 沒有忙,取消訂單卻等了快半分鐘。這時再加一台機器,還是先找哪段程式卡住?

以前團隊遇過類似的非同步問題,處理很久,那種難查的感覺我還記得,細節卻已經記不清楚。這次不還原舊事故,我在前面的 .NET 訂單教學服務加了一段等待,重新做一場能留下證據的調查。

Day 24 把線索接給 Claude,Day 25 把結果放回 Dashboard。今天是第四幕的下一步:Claude 能不能用這些線索找出原因,改完後,服務又是否真的好了?

一個人操作沒事,大家一起用就卡住

取消訂單前,服務先等一段客戶資料查詢。教學環境用 Task.Delay(500) 模擬半秒等待,沒有真的連資料庫。單次操作看起來不奇怪,我改用 96 個併發工作者,取消 384 張不同訂單。

結果只有 321 筆拿到 HTTP 200,另外 28 筆逾時、35 筆連線被重設。整批花了近 50 秒;負載期間 CPU 平均只相當於一個核心的 4.4%。這個數字表示沒有持續吃滿 CPU,不是整台電腦的使用率。

沒在忙著計算,為什麼還是沒空接工作? 我沒有把故障答案交給 Claude,只給它症狀、時間範圍、原始碼和需求,請它先查。

先讓 Claude 找證據,不急著給修改權限

Day 24 查的是一個原因已知的慢下游,用來確認線索有沒有接好;今天這一題,Claude 不知道答案。

沿用 Day 24 的入口,Claude 經唯讀 MCP 呼叫 gcx 查 Grafana,再用另一個受限工具讀程式與執行緒堆疊。查到的結果回到模型,由它決定下一個查詢;我沒有先挑好幾行 Log 貼給它。Grafana 裡是這次故障紀錄的重放,堆疊則另開一輪、用同版程式擷取,避免診斷工具干擾計時。

給 Claude 的調查要求可以濃縮成這段:

服務在併發取消時變慢,CPU 卻不忙。先提出至少兩個原因,各寫出什麼結果會推翻它。使用限定時間範圍的指標、Log、堆疊與當時版本程式查證;查不到就列缺件。不要修改程式,也不要把客戶端逾時當成工作未完成。

四個嫌疑,各自先寫好怎樣算翻案

它沒有直接給答案,先列了四個嫌疑,每個都附上「查到什麼就推翻它」:

嫌疑 Claude 事先寫的推翻條件 實際查到 判定
CPU 被吃滿 最慢那段,CPU 遠低於一個核心 最慢的 44 秒幾乎都是 0,整段最高 55% 排除
GC 或記憶體壓力 heap 小而平穩,Log 沒有 OOM heap 全程約 1~18 MB;Log 只有 3 筆預期內的業務警告 排除
鎖或通知拖慢請求 鎖內工作很短,通知不在請求路徑上 鎖內只做字典與檔案附加;通知經佇列送出,沒有失敗紀錄 排除
執行緒被同步等待占住 待處理工作接近 0,或執行緒一開始就有 96 條 見下 支持

最後一個嫌疑的推翻條件很具體:如果一開始就有 96 條執行緒可用,問題就不是「人手不夠」。指標畫出的卻是相反的樣子:

故障那一輪的實測指標:執行緒幾乎每秒只多一條,待處理工作一路堆到近 180 件,CPU 大多貼近 0
實測圖,資料就是重放給 Claude 查的那一輪。Claude 用 1 秒間隔查詢,峰值與每 100 ms 的原始取樣略有差異。

執行緒從 1 條開始,中段幾乎每秒只多 1 條(24 → 69),將近 50 秒才補到九十幾條;同一段時間,待處理工作一路堆到近 180 件。Claude 還查到有 27 秒,完成數只從 2,659 增加到 2,926,一秒約 10 件,幾乎停住。

CPU 貼近 0,工作卻越排越多:執行緒不是在算,而是在等。等什麼?Claude 打開堆疊,多條執行緒停在 Task.SpinThenBlockingWait,往上都是 CustomerHistory.Prepare()。再讀程式第 220 行:

public static void Prepare() { FetchAsync().GetAwaiter().GetResult(); } // FetchAsync 是 await Task.Delay(500)

非同步的半秒查詢,被同步等了回來,等待期間一直占著執行緒。堆疊是「這條執行緒取樣當下走到哪裡」的呼叫紀錄,不是錯誤訊息;只搜到 .GetResult() 還不夠,指標的排隊、堆疊的停點、程式的那一行,三者對上才成立。

這就是 ThreadPool starvation,執行緒集區飢餓:現有工作者被占住,新工作暫時找不到可用的人手,不一定是執行緒數已達上限。Microsoft 的診斷教學同樣建議把執行緒變化、CPU 與堆疊放在一起看,不能用單一指標下結論。官方診斷教學

答案三次都一樣,前兩次我卻不敢收

上面是第三輪調查(63.5 秒、15 回合、7 次觀測查詢)。前兩輪 Claude 也指向同一行,結論寫得一樣篤定,我卻沒有收下,理由就寫在它自己列的缺件裡:

  • 第一輪:堆疊檔 843 行、約 10 萬字元,「讀取超出輸出上限……所以沒用堆疊檔交叉驗證」。結論只靠指標和程式,少了最直接的一環。
  • 第二輪:我補上分頁,它讀到了堆疊,卻標出一個怪處:完成數「開頭重置(8696→18)是否是重放接縫,未確認」。我回頭查,是我讓每輪重放共用同一組標籤,上一輪的資料混了進來;那張卡片甚至寫出「13:54:80」這種不存在的時間。

錯的不是推理,而是我餵給它的線索。 2026 年 Kim 等人分析五個模型、1,675 次雲端根因調查,最常見的失敗正是誤讀資料與探索不完整。答案猜對,不代表證據站得住;這兩次能被抓到,是因為我要求它把讀不到、看不懂的地方列成缺件。第三輪改成每輪獨立標籤、查詢都限定該輪,才是上面那張表。

Claude 也有判斷過頭的地方:它因為 heap 不大、沒有 OOM Log,就把 GC 列為排除,但這次沒收完整 GC 暫停資料。我接受的是「同步等待有直接依據」,不是「其他原因全被排除」。

診斷卡的風險欄還留了一句話,後面會用到:改成 await 後,同一張訂單的併發取消「可能都讀到未取消狀態」,修好後「要用同一訂單的併發取消測重複通知」。

第一版修法很快,但還不能接受

接著換一個 Claude 工作階段,只給故障程式、需求與診斷卡。它能修改的只有兩個教學 C# 檔案,建置工具也只能執行固定的 dotnet build;沒有任意 shell,也不能改測試。

這些限制由自訂 MCP 的檔案白名單與固定命令執行。Claude CLI 關閉內建工具,只開這組 MCP,不是只在提示裡拜託它不要亂改。

第一版最重要的修改只有一行。看的是等待方式,半秒查詢仍然存在:

// 原本:請求的執行緒同步等結果
CustomerHistory.Prepare(); // 內部是 FetchAsync().GetAwaiter().GetResult()

// Claude 第一版:等待時不持續占住這條執行緒
await CustomerHistory.FetchAsync();

這一版 Claude 花 29.7 秒、8 回合、US$0.09。重跑相同負載,兩輪都是 384 筆全部成功,p95 約 0.56~0.58 秒。看起來可以收工了。

修法說明裡,Claude 又提了一次:讀取、判斷、寫入不是原子操作,可能重複通知;「這個競態原本就存在,我沒有處理,因為它超出最小修法」,並把要不要修列為需要我決定的事。它警告過,也問過;是我給的「最小修改」讓它停在那裡。 而 384 筆全綠的數字,正讓人想直接按下接受。

我把它的警告照字面做成測試。訂單規格本來就寫著:真正取消一次,只能產生一次通知。 讓 16 個請求同時取消同一張新訂單。

結果 16 個都說自己完成了狀態轉換,接收端也真的收到 16 筆通知。

速度恢復了,功能卻還沒過關。 不同訂單的壓測抓不到同單競態;「重複按一次」的依序測試,也不等於「大家同時按」。

第二版:不能各自拿著舊答案往下做

我把同單測試的實際結果交回 Claude(30.4 秒、9 回合、US$0.10)。這次它把查詢放在前面,等完之後,再用同一把鎖完成重新讀取、判斷與更新。

原本並不是完全沒鎖:讀取、寫入各自都有保護,但兩步之間仍可以插進另一個請求。 這項缺陷原本就存在,不能說是 await 憑空創造;提高併發後,這次測試讓它現形了。

兩個請求各拿舊狀態會重複通知;等待後以同一把鎖重新讀取、判斷與更新,才有一致結果
示意圖。鎖只包讀取、判斷與更新,不包非同步等待,也不包發送通知。

修正版的關鍵在 TryCancel。看下面的 current = _d[id] 是否和後面的判斷、更新處於同一把鎖內:

// handler:先等查詢完成,再依最新狀態決定
await CustomerHistory.FetchAsync();
store.TryCancel(id, out var current, out var result, out var transitioned);

// OrderStore.TryCancel 內部
lock (_g)
{
    current = _d[id];
    result = Cancellation.Cancel(current);
    transitioned = Cancellation.Transitioned(current, result);
    if (transitioned) _d[id] = result.Order;
}
// 只有 transitioned 為 true 的請求,才會在鎖外排入通知。

這是記憶體教學服務的修法,不能把 C# 鎖直接套成跨多台服務的保證。正式系統若用資料庫或多副本,還要依儲存方式處理一致性;本篇也沒有驗證「更新狀態後、通知入列前程序崩潰」的缺口。

這次,速度與訂單規則一起驗

修後兩輪維持相同的 384 張訂單、96 併發及半秒等待,再各做一次同單 16 併發。這次驗的是 Claude 真正修改後的程式,不是另準備一份答案版。

核對項目 故障版本 第一版:改 await 第二版:補原子操作
不同訂單取得 HTTP 200 321/384 兩輪皆 384/384 兩輪皆 384/384
客戶端 p95 約 30 秒 0.56~0.58 秒 0.57~0.59 秒
同單 16 併發,實際通知數 未測 兩輪皆 16 筆 兩輪皆 1 筆
此次接受結果 未通過 未通過 通過本次條件

第二版每輪 13 項檢查全過,包含取消狀態、退款旗標、接收端逐筆對帳、重複取消、已出貨拒絕、授權與不存在訂單,以及新增的兩項同單條件。16 個同單請求都回 200,只有一個 transitioned=true,其餘表示已取消,不再通知。

所以,這次是真的修好了:同一批 384 張訂單,從近 30 秒逾時、63 筆失敗,變成兩輪全部成功、p95 約 0.6 秒;同一張訂單同時取消 16 次,也只送出一次通知。

下次遇到卡住,我會怎麼用 Claude?

這次真正帶得走的,是一段有次序的合作:先讓 Claude 查指標和堆疊,找到值得改的那一行;再讓它修改;最後用原本的需求驗收,不能只挑變快的數字。

在自己的 .NET 服務,可以先保留事故時間範圍、部署版本與負載描述,用 dotnet-stack report -p <PID> 取得當下堆疊,再讓 Claude 交叉核對。若沒有 Grafana,也可以先用 dotnet-counters 蒐集 runtime 指標;工具入口可以不同,但不能只給一句「服務很慢」。取得診斷資料須沿用團隊的存取規範。

教學附件另附不呼叫模型的重跑入口,從 repo 根目錄執行:

python days/day26/lab-threadpool/validate_claude_repair.py --workspace days/day26/lab-threadpool/claude-final-source

它會建置封存版本並重跑負載與同單案例。先看新產生的 summary.json 裡各項 checks,再看 same-order.json 的回應與接收數;不要只看畫面最後一行完成。

Claude 查因三輪與兩版修復的 CLI 回報費用合計約 US$0.78;沒有記人工分鐘,因此不能宣稱總工作省了多少。

回到一開始:CPU 正常,為什麼服務還是卡住?

因為工作卡在等待,不是計算。Claude 查出了那一行,第二版也真的修好了;但這篇真正的答案在另外兩處:證據站不站得住,要看它列出的缺件;修法算不算數,要看原本的承諾有沒有重驗。

換一題也一樣嗎?我另外用兩個不是我出題的第三方題庫考它:根因題庫 RCAEval 40 次中,39 次第一名就找對出問題的服務;但 o11y-bench 裡要把幾個訊號拼起來的調查題,評分平均只有 0.68(滿分 1)。題庫成績 2026 年的 ORCA-bench、TDAD 也指向同一件事:值班題目越接近真實越難答對,修好問題時也常弄壞原本通過的測試。

現階段不能整個交給 AI:讓它查因、動手修,由固定程式驗收,再由人決定要驗什麼、接不接受。

還有一件事沒說完:故障那一輪有 63 筆請求沒拿到成功回應,事後對帳,384 張訂單卻都已取消、也送出了通知。呼叫端看到的失敗,不一定是真的沒做。下一次再遇到逾時,直接重送是在補做,還是在重做?


參考資料:


上一篇
Day 25|看著 Dashboard,要怎麼知道訂單卡在哪?
下一篇
Day 27|通知逾時了,能不能請 Claude 直接重送?
系列文
買了 Claude Code,然後呢? 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言