重點摘要
- 表面看是 OOM crash loop,真因是 Spring DI 失敗 — 16 個 Java 容器每 3 分鐘 die 一次,累積 ~180 次重啟。
- AI 一連 4 次「以為修好其實沒」,被自己的 grep 騙了:OutOfMemoryError 抓到的是 JVM 啟動印出的 -XX:+ExitOnOutOfMemoryError env var 字串,不是真的 OOM exception。
- 真正解開的轉折是「人類的業務直覺糾正方向」 — 我問 AI 「retry-job 不就是把訊息丟回去嗎,為什麼要查 DB?」,AI 才回去看代碼發現是別的 service 設計錯了。
- 人機協作的甜蜜點:AI 挖技術細節,人類提業務 frame,兩個能力反過來會卡住。
這篇是一次真實的除錯紀錄。起點是「CCBOT 沒回應」這種小事,沿途碰上 simpleec 電商系統的 channel-job crash loop、Spring DI 失敗偽裝成 OOM、retry-job 反覆修不對,中間 AI 跟我來回交鋒 7 次才把問題真正解開。寫下來主要是想討論一件事:AI 除錯到底比人快在哪裡、人類業務直覺又補了什麼,以及這個 GAP 怎麼填。
一切從 CCBOT 沒回應開始
那天我在 Telegram 對 ccbot(我自己寫的 Claude Code Telegram bridge)發訊息,沒回應。問 AI:「幫我看看你的 CCBOT 有沒有問題,怎麼現在好像沒有回應了?」
AI 第一步 systemctl --user status ccbot,結果發現 service 是 inactive (dead) since 2026-05-14 22:34:09。已經死掉兩天。AI 接著推理出原因:那個時間點正好是我在 tmux session 開始搭一個 Kiro 版的 telegram bot,當時為了避免兩個 bot 搶同一個 chat,我手動 stop 了 ccbot — 然後忘了開回來。
順手讓 AI 重啟 ccbot,順帶查一下系統健康度。然後就看到:
load average: 85.25, 81.85, 80.28
8 個 CPU core 的機器跑到 load 85+ 是失控。docker ps 一看,我的 simpleec 電商 OMS 系統 50 個容器裡,有十幾個 channel-job container 的狀態是 Up 21 seconds / Up About a minute / Up 3 minutes。container 一直在重啟,沒一個穩定。
第一次以為修好:Kafka 調參(其實沒)
AI 翻了 channel-job 的 application.yml,發現一個經典坑:Spring Kafka consumer 完全沒設 max-poll-records 跟 fetch-max-bytes。預設值是 500 records / 52 MB,意思是一個 poll() 就可能塞 52 MB 訊息進 heap。channel-job 容器配 -Xmx256m,扣掉 Spring Boot + Hibernate + otel-agent 的基底,剩餘 heap 根本撐不住單次 poll 的爆衝。
這是真實問題,我也讓 AI 一次修了 6 個 *-job 的 yml,把 max-poll-records 壓到 5-10,fetch-max-bytes 砍到 1 MB,順便把 16 個 channel-* 容器的 -Xmx256m 升到 -Xmx384m 給點 buffer。AI 還順手抓到一個 Lombok bug:SeedTestOrdersHandler 寫 fakeChannel.isActived(),但欄位是 Boolean(包裝類),Lombok @Data 對包裝類產生的 getter 是 getActived() 不是 isActived(),從 4/9 起 build 就壞了,但用 quick-redeploy 單服務 build 從沒抓到。
build 過了,docker compose 重啟,5 分鐘觀察 — channel-* 都 Up 2 minutes,RestartCount=0,heap 用量 122-190 MB / 384 MB,看起來健康。AI 報告「OOM crash loop 修好了 ✅」,我也信了。
一小時穩定觀察揭穿假象
我說「先觀察 1 小時穩定再 commit」,AI 開一個 background watch 每 15 分鐘檢查。1 小時後通知回來,結果震驚:
| 時間點 | 不穩定的 channel-* 數 |
|---|---|
| T+15min | 8 個「Up About a minute」(剛重啟) |
| T+30min | 1 個「Up 2 seconds」(剛 crash) |
| T+45min | 10 個「Up About a minute」 |
| T+60min | 2 個 (crash) |
每 3 分鐘 die 一次,docker 一直在 restart。AI 重新看 log,grep OutOfMemoryError 抓到 16 個容器全部有「OutOfMemoryError」字串,結論「真的是 OOM,我之前 Xmx 升 384 還不夠」。我點頭準備繼續升 heap。
但 AI 接著做了一件對的事:再 grep 一次,這次用更精確的 pattern java\.lang\.OutOfMemoryError。結果 — 0 個 match。前面抓到的「OutOfMemoryError」全是這一行:
Picked up JAVA_TOOL_OPTIONS: -Xmx384m -XX:+ExitOnOutOfMemoryError
↑ 這 16 個字元被 grep 抓到
JVM 啟動時印出的 JAVA_TOOL_OPTIONS 環境變數,字串裡的 ExitOnOutOfMemoryError 最後 16 字元剛好是 OutOfMemoryError。AI 自己被自己的 grep 騙了兩次。換用 UnsatisfiedDependencyException 重 grep,16 個容器全 match。真兇現身。
真兇:半成品的 Excel 上傳功能
真實死因是 Spring DI 失敗:
UnsatisfiedDependencyException: Error creating bean 'channelJobConsumer'
→ parameter 5: 'processExcelBatchHandler'
→ parameter 0: No qualifying bean of type
'com.simpleec.core.repository.ExcelUploadBatchRepository' available
追下去:有人(可能是另一個 AI session,或我自己忘了)在 channel-job 模組加了一個 ProcessExcelBatchHandler,但它依賴的 ExcelUploadBatchRepository 放在 simpleec-core 模組。問題是 ChannelJobApplication 的 @EnableJpaRepositories 只 scan com.simpleec.channeljob.repository,看不到 core 模組。Spring 啟動找不到 bean → exit 1 → docker restart → 又找不到 → 永久 crash loop。35 天沒人發現,因為 quick-redeploy.sh 每次只 build 單個 service,從沒做過整體 build,所以這個 DI 問題只在 docker container 啟動時才暴露。
AI 提議「補 Spring 的 scan path」修。我看了一眼設計直覺說「不對 — 這個 Excel 功能根本不該在 channel-job 做,channel-job 是『跟外部 API 對接』,Excel 上傳是內部資料源,該丟到 backend-job 走 task.backend topic」。AI 同意,把 4 個 Excel 檔案 park 到 /tmp/excel-feature-parked/,留 README 跟 3 個 revert patch 等原開發者後續按正確架構重做。
但這還沒完。修好 channel-* 後,我順手問「順帶看一下 retry-job」,因為它 RestartCount=1146,意思是 12 小時內重啟超過 1000 次。
第二場拉鋸:retry-job 的反覆修不對
AI 看 log,發現 retry-job 的 DltConsumer(處理 dead letter queue)也是 DI 失敗:依賴 core 模組的 FailedTaskLogRepository,但 retry-job 設計上排除了 DataSource。AI 提議「補 scan + 把 retry-job 也升 384m heap」。
我打斷:「retry-job 不就是把訊息丟回 queue 嗎,為什麼要查 DB?」
這句業務直覺問題改變了 AI 的方向。AI 回去看 RetryJobConsumer.java 跟 DltConsumer.java 對比,發現:
RetryJobConsumer處理task.failed— 純 Redis ZSet 存延遲排程,到時間丟回原 topic,完全沒 DB。設計正確。DltConsumer處理task.dlt— 把死信寫到failed_task_logs表供「人工檢查」(註解明說),只 write 沒 read。
所以「retry-job 不該重」這個設計直覺是對的,但 DltConsumer 確實有合理需求要寫一筆 audit log。修法收斂成:用 JdbcTemplate 寫一行 INSERT,不載 Hibernate(可以省 50-80 MB heap),DltConsumer 改 5 行,RetryJobApplication 移除 DataSourceAutoConfiguration exclude。AI rebuild,等 90 秒,RestartCount=0,Status=running。報告「修好了」。
17 小時後我回來檢查,retry-job RestartCount=1159。又開始 crash 了。
原因:AI 第一次修法不完整。Spring Boot autoconfig 是「貪食」的 — 我們移除了 DataSourceAutoConfiguration exclude 讓 DataSource 能建,但 classpath 上 simpleec-core 還有一堆 JpaRepository class(ChannelRepository、OrderRepository…),Spring 看到就觸發 JpaRepositoriesAutoConfiguration,試圖建 entityManagerFactory 來支撐這些 Repository,但 Hibernate 還是 excluded → bean 不存在 → context refresh 失敗 → 啟動 exit。
關鍵教訓:看 docker 的「Up X seconds」不代表健康。Spring Boot 啟動要 ~6-10 秒,如果在 context refresh 時 crash,container exit 後 docker 立刻 restart,新 instance 的 uptime 立刻變 Up About a minute — 但它正在「啟動 → 失敗 → 再啟動」的循環。AI 之前 90 秒驗證看 Up time 沒看 RestartCount 趨勢,被騙了。
真正的修法是再加一個 exclude:JpaRepositoriesAutoConfiguration.class。告訴 Spring「即使 classpath 有 JpaRepository class,也不要 scan」。改完 rebuild,2 分鐘 RestartCount=0,Spring log 印出 Started RetryJobApplication in 9.585 seconds 跟 RETRY JOB Started (Layer A - Foundation) banner — 這次是真的啟動完成,不是「啟動失敗 + 立刻 restart」的假象。
AI 的角度 vs 我的角度
退一步看,這場 7 個小時的除錯,我跟 AI 各自看到的「問題」完全不同:
| 面向 | AI 看到的 | 我看到的 |
|---|---|---|
| 問題本質 | 系統訊號(load 85、container die、error log) | 業務職責(channel-job 該做什麼、retry-job 該做什麼) |
| 調查工具 | grep、docker stats、jstack 思路、Spring autoconfig 知識 | 「這個 service 設計上應該長什麼樣」的直覺 |
| 修法第一反應 | 「補 scan path / 加 exclude / 升 heap」(技術 patch) | 「這個東西放錯位置了」(架構糾錯) |
| 驗證標準 | RestartCount、Status=running、log 是否有 ERROR | 「跑起來行為符合我預期嗎?」 |
| 盲點 | 被 grep 假陽性騙、被 docker Up time 假象騙、技術細節驅動忘了業務 frame | 不會自己去看 log/code、需要 AI 幫我把抽象設計直覺落地成具體修法 |
關鍵時刻有兩個。第一個是「AI 自己抓到自己的 grep 假象」 — 這需要 AI 對自己之前的結論保持懷疑,願意重新驗證。第二個是「我問了一句業務問題」 — retry-job 不該重,channel-job 該不該碰 DB。這兩種角度互補,缺一個都會卡。
我們的 GAP 在哪
AI 跟人類的 GAP 不對等 — 不是「誰比較強」的問題,是「擅長的層級不同」:
AI 的擅長:在大量資訊裡找 pattern、寫 bash one-liner、記得框架的暗角配置(JpaRepositoriesAutoConfiguration 這種冷門 class 名)。每秒可以掃 100 個檔案、grep 一萬行 log。但 AI 容易過度信任「表面訊號」 — log 裡有 OOM 字樣就以為是 OOM、container Up 1 minute 就以為穩定。AI 也容易跳到「技術修法」,缺少業務 frame 時會給出 over-engineered 答案(channel-job 不能查 DB? 補 scan 就好啦)。
人類的擅長:對「事情該怎樣」有直覺。我看到 retry-job 在查 DB,直覺反應是「不對」 — 不是因為我背 Spring autoconfig,是因為我 15 年寫過很多 message queue retry service,知道 retry 就是把訊息丟回去,不該碰 DB。AI 沒有這種「跨專案累積的設計品味」,它有的是「我訓練時看過的 Spring Boot 範例」,範例裡 retry-job 怎樣它就以為怎樣是合理的。
但人類的弱點也明顯:我不會主動去 grep log、不會耐心讀 100 行 Spring 啟動 trace 找 root cause、也記不住 HibernateJpaAutoConfiguration 跟 JpaRepositoriesAutoConfiguration 的差別。我需要 AI 把我的直覺翻譯成具體修法。
真正的 GAP 不在能力,而在溝通的精準度。當我說「retry-job 不該重」,如果 AI 沒抓到我意思是「不該載 Hibernate」,可能就會修錯方向。當 AI 報告「OOM crash 修好了」,如果我沒追問「真的 1 小時都穩定嗎?」,可能就會在 commit 後 production 出包。
怎麼一起工作得更好
這次經驗給我幾個具體做法:
- 業務 frame 永遠先說 — 啟動 debug 任務前,先告訴 AI「這個 service 設計上是幹嘛的,該做什麼不該做什麼」。AI 才不會跳進「補 scan / 升 heap」的局部修。
- 驗證標準要明確 — 不要說「跑 90 秒看穩不穩」(會被 Up time 假象騙),要說「看 RestartCount 趨勢、看 Spring 啟動 log 有沒有
Started XxxApplication in N seconds」。 - AI 給結論時人要追問「真的嗎」 — 第一次說「OOM 修好了」,我點頭就會直接 commit。改成「我們先觀察 1 小時」這個本能,救了這次 deploy。
- 「以為修好其實沒」要記成 brain — 我有一套 brain file 系統累積各種坑(訓馬筆記裡有詳細介紹)。這次 Spring autoconfig 的雜食陷阱、Lombok Boolean wrapper getter、grep 假陽性、docker Up time 假象,全部寫進 brain,下次任何 Java 專案都會 trigger 警示。
- 技術細節 AI 挖、業務 frame 人類訂、修法兩個一起決 — 不要讓 AI 完全自主,也不要把 AI 當 stack overflow 查工具。最大產出來自「兩個視角持續對話」。
總結
這次除錯從 ccbot 沒回應開始,連鎖挖到 channel-job + retry-job 兩個 Spring DI 真因,過程中 AI 被自己的 grep 騙、被 docker Up time 假象騙、被「修一個 service 就 OK」的局部視角騙。最後修對的關鍵是兩件事 — AI 對自己之前的結論保持懷疑願意重 grep,跟我用業務直覺把方向拉回來。
我也越來越相信:AI 除錯不會取代人類,只會讓「業務直覺好的人」更強。如果你只是想找個工具按 enter 就把 production 修好,AI 會給你看似合理但實質繞遠路的修法。如果你能用業務直覺糾正方向,AI 就是你身邊那個記憶力極好、bash 寫得飛快、能同時跑 8 個 background task 的隊友。
關於 simpleec OMS 系統本身的設計,可以看 多通路電商 OMS 系統實戰系列。前一次我跟 AI 一起做 code review 找到 20 個 bug 的紀錄在這篇。這次的 7 小時除錯,跟前面那次 review 的差別是 — 那次是「靜態看 code」,這次是「跑起來才發現的動態問題」。兩種 AI 協作模式都有用,搭配起來才完整。