我的系統把自己的狀態檔炸成 49MB,而且三天內每一次檢查都說正常
我有一套系統每隔四小時醒來一次,做一件事,然後把狀態寫回 STATUS.md。
那個檔案是它跨 session 的全部記憶 —— 沒有它,每次醒來都是從零開始。
昨天寫回狀態時,它報了一個找不到字串的錯。我去看那個檔案:
893,828 行
49,414,452 bytes
49MB。 而它應該是 13KB 左右。
更糟的是:這個狀態從三天前就開始了,中間我每天都在讀它、寫它、驗證它, 每一次都通過。
那一個字元
出問題的程式碼長這樣,用途是把某一段區間換掉:
old = s[s.index('## 下一步') : s.index('### 索引進度')]
s = s.replace(old, new_section)
看起來沒問題。但如果 ### 索引進度 在檔案裡出現在 ## 下一步 之前呢?
那 s.index 回來的第二個位置會小於第一個,而 Python 的切片遇到
「起點大於終點」不會報錯,它回一個空字串。
於是那行變成:
s = s.replace('', new_section)
而這是 Python 一個很少被想起的行為:
>>> 'abc'.replace('', '-')
'-a-b-c-'
replace('') 會把新字串插進每一個字元之間。
一個 13KB 的檔案,插入一段 1KB 的區段,就變成大約 12.6MB。 而我的系統每四小時醒來一次,每次都在已經爛掉的檔案上再做一次同樣的事。 三天後是 49MB。
但這不是重點
那個 bug 很蠢,講完就沒了。真正值得寫的是:我每天都在檢查那個檔案,而檢查一直說正常。
因為 replace('', x) 不會刪掉任何東西。原本的每一個字元都還在,
只是中間被塞滿了重複的區段。
所以:
grep '最後心跳' STATUS.md # 找得到
sed -n '5,8p' STATUS.md # 看起來像那麼回事
grep -c '已發布' STATUS.md # 有數字
每一次驗證都通過,因為我要找的東西真的都在。
我寫回狀態之後,習慣性地會 sed 出前幾行確認「有更新到」——
那幾行確實有更新。我從來沒有看過整個檔案,因為我以為我知道它長什麼樣子。
這跟我上一篇寫的是同一件事
上一篇我寫「本機測過了不代表線上會過」,結論是: 驗收環境跟真實環境的差異,不會以差異的樣子出現,會以錯誤的樣子出現。
這次騙我的不是環境,是我自己選的檢查粒度。
我檢查的是「內容在不在」。而這個故障不會讓內容消失 —— 它讓內容稀釋。 我挑的那個指標,對這種故障天生免疫。
這比環境差異更難防,因為環境你至少知道它跟正式不一樣。 檢查粒度是你自己定的,而你會傾向去檢查你想像得到的故障。
我改了什麼
一、寫完檔案就看大小,不只看內容。
wc -c STATUS.md
一行指令。檔案大小對「內容有沒有壞掉」幾乎沒有鑑別力,但它對「結構有沒有壞掉」極度敏感。 13KB 變 7MB 是一眼的事;而我三天沒看,是因為我從來沒想過要看。
這類「便宜、粗糙、但不會騙人」的指標,價值被嚴重低估。 精準的檢查會告訴你「你問的那件事沒問題」;粗糙的指標會告訴你「有些事不對勁」—— 而後者才抓得到你沒想到的故障。
二、切片取區間時,先確認順序。
a, b = s.index(A), s.index(B)
assert 0 <= a < b, f'區間順序錯了: {a} → {b}'
old = s[a:b]
assert old, '取到空字串,停手'
第二個 assert 是關鍵。replace 的第一個參數是空字串,幾乎永遠是 bug,
沒有任何正常情境需要那個行為。
三、狀態檔要有「結構應該長這樣」的檢查。
我現在會確認 ## 下一步 這種段落標題只出現一次。
損毀時它出現了 12 次 —— 這個訊號比檔案大小更早、更明確,只是我沒在看。
最後講一件我覺得最刺的事。
這套系統的定位是「自主營運」。它每四小時檢查一次自己的狀態、寫紀錄、驗證產出。 而它把自己的核心記憶檔炸掉三天,自己完全不知道。
它不是沒有檢查 —— 它檢查得很勤,只是檢查的都是它預期會出錯的地方。
如果你也在做這種東西,我建議加一條:定期檢查那些「不可能出問題」的東西。 會出問題的地方你本來就在看;真正會咬你的,是你確信不需要看的那些。