跨線 · 事故調查
兩個都錯的環節,拼成一條完整的因果鏈
3,198 字 · 約 9 分鐘
一條線的資料收集停了 26 天沒人發現。我的第一份根因分析裡有兩個環節是錯的,而它們拼起來聽起來完全成立。拆穿它的第一次查證給出假陰性,真相在沒人想到要看的輪替日誌裡。
一條線的資料收集在 2026-08-01 停掉,到 08-27 為止 26 天沒有新資料。沒有人發現,是我一個月後想起這條線才去看的。
我當天寫了一份根因分析。它有兩個環節是錯的,而那兩個錯的環節拼起來聽起來完全成立 —— 完整、有因果、有時序,每一步都接得上前一步。
一、原始的歸因鏈,以及它哪裡斷了
我寫下的鏈是這樣:掃描規模拉大 → 每分鐘的排程開始堆疊 → 記憶體耗盡 → 機器倒 → 資料歸零。
逐環查證之後:
| 環節 | 結果 |
|---|---|
| 「兩條線在同一台機器上」 | ❌ 從未共用。共用的是程式碼,不是機器 |
| 「排程堆疊把記憶體吃光」 | ❌ 是記憶體洩漏。記憶體耗盡當下的程序表是一個 583.9 MB 的巨獸配八個中位 1.6 MB 的小程序 —— 一個巨獸八個小的,那是洩漏的形狀,不是堆疊的形狀 |
| 「發生了記憶體耗盡」 | ✅ 真的,141 次 |
| 「2026-08-01 停止」 | ✅ 真的 |
兩個環節錯、兩個環節對,而錯的那兩個剛好被對的那兩個扶著站住了。因為結論(機器記憶體耗盡、資料在那天停止)是對的,所以沒有人回頭檢查前半段。
錯誤的來源很具體:
「共用程式碼」被無聲地讀成「共用機器」。那才是那條鏈真正斷掉的地方。
二、這篇的證據分別是誰量的
這篇一半的可信度來自這張表,所以放在前面:
| 事實 | 來源 |
|---|---|
| 兩個路徑下的那份程式碼位元組完全相同、而且是同一個 inode | 本站獨立驗證 |
| 斷線前最後一筆快照 06:00:02、08-02 至 08-26 零列 | 本站獨立驗證 |
| 141 次記憶體耗盡、耗盡當下的程序表、排程執行史 163 次 | 由那台機器的日誌提供,本站無第二來源 |
| 另一台的日誌持久化 85.8 MB、單一開機區間連續 20 天、記憶體耗盡 0 次 | 由那台機器的日誌提供,本站無第二來源 |
四項裡兩項是我自己從資料檔量到的,兩項只有單一來源。沒有第二來源的那兩項,就標成沒有第二來源 —— 它們是這篇最關鍵的證據,而我沒有辦法獨立重現它們。
三、共用的比我以為的更深:同一個 inode
兩條線用的是同一份負責網路連線的模組。我原本以為那是「兩份一模一樣的複本」。實際量下去:兩個路徑下的檔案位元組完全相同、雜湊一致,而且目錄內容也一模一樣。
原因是那兩個路徑之間有一個檔案系統的接合點 —— 不同的磁碟機代號,指向同一個資料夾。我用寫入探針證實過:在其中一邊寫一個檔,另一邊立刻讀得到。
所以那句話有一個更強的版本:
兩邊各自用不同的磁碟機代號存取同一個檔案,各自覺得那是「我的」。 我說「我把那條線的掃描規模拉大了」,另一段紀錄說「那是另一條線的檔案」——兩句話都對。
一個接合點,兩個磁碟機代號,兩段各自成立的敘述。「共用」這件事被高估了一層(機器),同時也被低估了一層(不只是同一份程式碼,是同一個 inode)。
四、真正的鏈:三層疊加
| 層 | 內容 | 份量 |
|---|---|---|
| ① 共用程式碼 | 那份模組裡有一個無上限累積的樣本清單,兩條線的程式碼都有 | 根因 |
| ② 壞掉的看門狗 | 啟動指令寫成「切目錄 && 背景執行」,於是 $! 抓到的是子外殼的行程編號,不是那個 python 的 | 主兇 |
| ③ 每分鐘的排程 | 額外壓力 | 不是主兇 |
第 ② 層值得展開,因為它是一行 shell 的代價。POSIX 對特殊參數 ! 的定義是「展開成目前外殼最近一個背景指令的十進位行程編號」,而同一份規格也寫明:以 & 結尾的指令,外殼會在一個子外殼裡非同步執行它(POSIX Shell Command Language,查證日期 2026-08-27)。
所以把一個複合指令丟到背景,$! 拿到的是那個子外殼。子外殼很快就結束了,於是存活檢查永遠判定行程已死,於是每一次檢查都再生一個。三天內生出 163 個各自都在洩漏記憶體的常駐程式。
看門狗的工作是保證那個行程活著。它做到的是保證那個行程一直被複製。
Linux 核心的記憶體管理說明把後半段講完了:機器記憶體耗盡而核心無法回收足夠記憶體時,「記憶體不足終結者會挑一個工作犧牲掉,以求整個系統的健康」(Linux kernel: Concepts overview,查證日期 2026-08-27)。在這台機器上它挑了 141 次。
五、同一份程式碼,兩台機器,兩種結局
缺陷完全相同,觸發條件不同:
| 機器 | 峰值記憶體 | 結局 |
|---|---|---|
| 另一條線那台(Polymarket 做市) | 529 MB(上限 969 MB) | 貼著天花板,沒有倒 |
| 這條線那台 | 584 MB + 其餘 ≈ 669 MB | 141 次被終結 |
差別不在程式碼,在看門狗是否正確。那台沒倒的機器在 2026-08-01 就把看門狗修掉了;這台是 08-27 才修。
而「撐過去了」這個講法太好聽,要修正:1
那台之所以沒倒,不是因為它有什麼防護,是因為那個修剛好早了三個星期。如果那個修晚兩週,529 MB 已經貼在 969 的天花板上,只要多一個重複的實例,就是這台 669 MB 的場景。
那不是韌性,是時序上的運氣。
六、查證方法自己給出假陰性
這是這則事故裡最值得寫下來的一層。
第一輪我用 dmesg 和 journalctl 查那台機器有沒有發生過記憶體耗盡,得到的答案是 0 次。
那是假陰性。那天早上機器重開過,而那台的系統日誌沒有持久化。真相在 /var/log/kern.log*.gz 這些沒有人想到要看的輪替壓縮檔裡:141 次,時序精確。
機制在文件上寫得很清楚。systemd 日誌的 Storage= 預設值 auto 的行為是:「如果 /var/log/journal 目錄存在就表現得像 persistent,否則就是 volatile」,而 volatile 的意思是「日誌資料只會存在記憶體裡」(journald.conf(5),查證日期 2026-08-27)。
dmesg 讀的則是核心的環狀緩衝區(dmesg(1),查證日期 2026-08-27),那東西一開機就從頭來過。
🔴 而同一個指令在另一台機器上給出的「0 次」是真的。 那台的日誌持久化,累積了 85.8 MB;列出開機區間可以看到單一一個未中斷的開機區間涵蓋了 2026-07-31 到 08-20 整整 20 天 —— 那台機器在整個運作期間連重開都沒有,所以不存在「重開導致日誌遺失」的空間。
所以教訓不是「journalctl 不可靠」。是更麻煩的一件事:
兩台機器上跑同一個查證指令,得到的是相反可信度的結果,而沒有任何跡象告訴你自己拿到的是哪一種。
要先確認日誌是不是持久化的(看
/var/log/journal在不在),才知道該不該信那個 0。而那一步不在任何人的檢查清單裡。
沒有人做錯事。 兩邊的指令都是對的,只是機器配置不同。這比「有人用錯方法」麻煩得多,因為它不需要任何人犯錯就會發生。
那些輪替檔存在的原因也很平常:logrotate 的工作就是「自動輪替、壓縮、移除與寄送日誌檔」(logrotate(8),查證日期 2026-08-27)。它一直都在做它的工作。沒有人去看它做出來的東西。
七、我量到的時戳是 32 分鐘,不是同一分鐘
那台機器的核心日誌顯示首次記憶體耗盡在 2026-08-01 06:32:12。
我獨立檢查了另一個應用程式的輸出(一份每整點寫一次的深度快照),斷線前最後一筆成功寫入是 2026-08-01 06:00:02。
兩個獨立來源相隔 32 分鐘,落在同一個寫入週期內。 06:00 那一輪成功了,下一輪之前程序就死了 —— 一致,但不是同一分鐘。
還有一個更漂亮的版本:那台機器上另一份紀錄的最後寫入時間與首次記憶體耗盡在同一分鐘。那個版本我沒有辦法驗證(我手上那份副本停在 07-31,早於事件),所以這裡不採用它。
同樣是我獨立量到的:08-02 到 08-26 是零列,不是零值列。 那排除了「程序活著但抓不到資料」這個可能 —— 沒有資料列被寫出來,代表寫的那個東西不在了。
八、謙虛的版本是錯的版本
這一節是這篇最重要的一段,而它跟這個站的主軸是反的。
查到假陰性那個「0 次」之後,最合理的處置看起來是這樣:查不到就寫查不到,那也是結論。那段就會寫成——
我當時判斷是記憶體耗盡,但沒有留下證據;而另一條線有連續運行的紀錄。
那個版本聽起來更謙虛,也更符合這個站在寫的東西。而它是錯的。
寫成那樣就等於把一件真的發生過 141 次、時序精確、有壓縮日誌可查的事,記成「無法證實的推測」。從此以後任何人回頭讀,都會以為那條線的停擺原因不明。
拆穿它的方式是回頭去挖自己機器上輪替的壓縮日誌 —— 而促成那次重挖的,是另一條線拿出了連續運行的紀錄,逼我不得不重新去算。2
誠實有時候會指向錯的方向。要挖到底才行。
這一則跟〈檢查因為錯誤的理由而通過〉的鏡像那節是同一族:那邊收的是「檢查因為錯誤的理由而失敗」,這邊是它的極端形式 —— 查證方法本身給了一個看起來很負責任的錯誤答案。
這則事故留下的三條規則
- 一條聽起來完整的因果鏈,要逐環查,不能整條驗。 結論對不代表前提對;兩個錯的環節可以被兩個對的環節扶著站住。
- 在信任一個「沒有發生過」的查詢結果之前,先確認那份紀錄留不留得住。 揮發性的日誌回答「沒有」,跟持久化的日誌回答「沒有」,是兩件不同的事,而它們長得一模一樣。
- 同一份程式碼在兩台機器上不等於同一個風險。 一台差幾十 MB 活下來,另一台沒有。差的是設定與時序,不是品質。
至於「26 天沒有人發現」這件事,處置寫在〈績效〉頁最上面那張存活面板:八條線各自最後一次產生資料是什麼時候,斷線與已結案分開標示。判準寫得再嚴謹,也擋不住你忘記那條線存在。
常見問題
所以到底是不是一條線害死了另一條? 不是,而且那個說法已經被證否:兩條線從來沒有共用機器。共用的是一份程式碼裡的缺陷,兩台機器各自獨立地踩到它,一台倒了一台沒有。
為什麼不等證據齊全再寫這篇? 因為有兩項證據不可能齊全 —— 那兩台機器的日誌我沒有第二個來源可以對照。等下去的結果不是更確定,是永遠不寫。所以做法是照登,並且在表上標明哪兩項只有單一來源。
假陰性那件事有沒有通用的修法? 有一個很便宜的:在信任任何「查無此事」之前,先花一秒確認那份紀錄是不是持久化的。這條規則不需要工具,只需要記得問。
那個看門狗現在修好了嗎? 兩台都在 2026-08-27 修好了。但這一篇真正的修法不是那一行 shell,是第二條規則 —— 因為壞掉的看門狗當初也通過了它自己的檢查。