重點摘要
- 三輪 Code Review 共找出 20+ 個問題,從 NPE 連鎖到種子資料欄位名稱全錯
- Kafka Seeder 寫了三個版本,每次重寫都是對「事件驅動架構正確入口」理解的加深
- 能走事件流就走事件流:API → Kafka → Consumer → DB,每一層都有可追蹤、可重試的意義
- 21 個 Java 容器沒有 JVM 記憶體限制,用
JAVA_TOOL_OPTIONS一行解決,不需改 Dockerfile
這是 多通路電商 OMS 系統開發過程中的一天工作紀錄。系統整合了 Momo、Shopee、Yahoo 等電商平台,透過 Kafka 事件流處理訂單同步、退貨與統計。今天的目標:完成 feature/stats-pipeline 分支上的所有待辦修復,讓系統能順利 docker compose up,並驗證端對端資料流。
三輪 Code Review:每一輪都有新發現
第一輪:已知清單上的 7 個問題
進入狀態之前就有一份清單,分為 Critical、Warning、Info 三個等級:
| 等級 | 問題 | 修復方式 |
|---|---|---|
| Critical | OrderUpsertConsumer .get() NPE 連鎖 | 改 .path() + 加 orderDataJson null 守衛 |
| Critical | daily_statistics.id 缺 NOT NULL | 加約束 + DEFAULT partition |
| Critical | ReturnUpsertConsumer 未寫 stats dirty marker | 新增 Redis ZSET 寫入 |
| Warning | DailyStatisticsService early return 留舊資料 | 改成刪除過時的 stats 列 |
| Warning | enum 預設值小寫 'pending' | 改大寫 'PENDING',與 JPA EnumType.STRING 對齊 |
| Warning | RetryJobConsumer MissingNode cast | 加 .isObject() 判斷再 cast |
其中 enum 大小寫這個問題值得特別說明。Java 的 @Enumerated(EnumType.STRING) 在讀取時呼叫 Enum.valueOf(),這個方法是 case-sensitive 的。資料庫預設值寫 'pending',但 enum 常數叫 PENDING,啟動時不會出錯,但一讀到有 DEFAULT 值的列就會拋 IllegalArgumentException。
第二輪:種子資料是另一個地雷區
Schema 修完了,以為大功告成,結果種子資料(02-seed-data.sql)是第二個地雷區:
- BCrypt hash 是假的:
$2a$10$dummyhashfordevonly...根本不是有效的 BCrypt hash,Spring Security 的passwordEncoder.matches()永遠回傳 false,登入 100% 失敗。 - 訂單狀態小寫:
'completed'、'shipped'— 和上面一樣的 case-sensitive 問題,這次在資料列而不是 DEFAULT 值。 - daily_statistics 欄位名稱全錯:用了
order_count、total_amount這些不存在的欄位名,docker compose up的 DB 初始化階段會直接 fail。
這些問題的共同特徵是:compile time 抓不到,schema validate 也抓不到。Hibernate 的 ddl-auto: validate 只單向檢查「entity 中有 mapping 的欄位是否存在於 DB」,不會反向驗證 SQL 腳本的正確性。唯一的防護是跑起來測試。
第三輪:21 個容器,一個 JVM 記憶體問題
系統在開發機上跑 21 個 Java 容器(Spring Boot services),沒有任何 JVM heap 限制。JVM ergonomic sizing 預設使用系統 RAM 的 25%,7.4GB 可用 RAM 很快就會不夠。
解法是在 docker-compose.yml 每個服務加 JAVA_TOOL_OPTIONS:
environment:
JAVA_TOOL_OPTIONS: "-Xmx256m -XX:+ExitOnOutOfMemoryError"
JAVA_TOOL_OPTIONS 是 JDK 標準環境變數,JVM 啟動時自動讀取,不需要修改 Dockerfile 的 ENTRYPOINT。-XX:+ExitOnOutOfMemoryError 讓容器在 OOM 時立刻崩潰(而不是卡死),對 Docker 的 restart: unless-stopped 友好,等於有了自動恢復機制。
Seeder 的三次重寫:對事件驅動架構的理解之旅
今天最有收穫的插曲。目標是「準備一個 Docker 服務,打假訂單資料,確認整體資料流順暢」。這個任務看起來很簡單,結果寫了三個版本。
第一版:直接打 Kafka(被打槍)
第一直覺:用 kafka-python 直接連 kafka:9092,組好 ORDER_UPSERT 訊息送到 order.process topic。快速、直接。
問題:系統對外只有 API,直接操作 Kafka 是繞過了系統設計的邊界。內部基礎設施不應該是外部系統的接入點。
第二版:打 POST /api/orders(沒走事件流)
改用 REST API。先 login 拿 JWT,再 POST /api/orders。
問題:OrderController.createOrder() 是直接寫資料庫,跳過了整個 Kafka pipeline。Stats dirty marker 不會被寫入,DailyStatisticsService 不會被觸發,daily_statistics 表不會更新。雖然訂單進了 DB,但「整體資料流」沒有跑通。
第三版:新增正確的 API 端點(走完整事件流)
在 UserOrderController 新增 POST /api/user/orders,接收訂單資料後發布 ORDER_UPSERT 到 Kafka,回傳 202 Accepted:
POST /api/user/orders (帶 JWT)
→ 查 Channel → Platform(取得 platformId)
→ 組 ORDER_UPSERT 訊息(header + body + hash)
→ kafkaTemplate.send("order.process", ...)
→ 回傳 202 Accepted
接著:
Kafka order.process
→ OrderUpsertConsumer(Redis 去重 → INSERT/UPDATE)
→ stats dirty marker 寫入 Redis ZSET
→ StatsRecalcHandler(定時掃)
→ DailyStatisticsService.recalculate()
→ daily_statistics 更新
端到端,一條不少。
為什麼「能走事件流就走事件流」不只是口號
三次重寫讓這個原則從抽象變得具體。走事件流的好處不只是「解耦」這個詞能涵蓋的:
| 層面 | 直接寫 DB | 走 Kafka 事件流 |
|---|---|---|
| 可追蹤性 | 只有 DB record | Kafka UI 可看完整訊息歷史,帶 traceId |
| 錯誤處理 | 拋 exception,呼叫方看到 500 | 失敗走 task.failed → retry → task.dlt |
| 去重 | 需要自己實作 | Consumer 有 Redis + DB 兩層去重 |
| 統計觸發 | 需要額外呼叫 | Consumer 自動寫 dirty marker,批次計算 |
| 一致性 | 邏輯分散在多處 | 無論來源(channel job / API),走同一套邏輯 |
最後一點是最重要的:一致性。不管訂單是從 Shopee channel job 來的,還是透過 API 手動新增的,都走同一個 OrderUpsertConsumer,同一套去重邏輯,同一套 stats pipeline。系統裡沒有「繞過」的快捷路徑。
今日修改摘要
| 檔案 | 類型 | 說明 |
|---|---|---|
| 01-schema.sql | Bug Fix | NOT NULL、DEFAULT partition、enum 大小寫 |
| 02-seed-data.sql | Bug Fix | BCrypt hash、訂單狀態大小寫、daily_statistics 欄位名稱 |
| OrderUpsertConsumer | Bug Fix | .get() → .path(),移除 unused import |
| ReturnUpsertConsumer | Bug Fix | 加 stats dirty marker、移除 unused import |
| DailyStatisticsService | Bug Fix | early return 時刪除過時 stats 列 |
| OrderService | Bug Fix | NOT NULL 欄位的 null 守衛 |
| docker-compose.yml | Infra | 所有 21 個 Java 容器加 JAVA_TOOL_OPTIONS |
| UserOrderController | Feature | 新增 POST /api/user/orders → Kafka pipeline |
| docker/test-data-generator/ | Feature | Python seeder,透過 API 打假訂單 |
結語:追蹤路徑比結果更重要
今天花最多時間的不是寫 code,而是「把對的事情弄清楚」。Seeder 寫了三個版本,不是因為技術難,而是因為對系統的理解在逐漸深化。
一個好的事件驅動系統,它的「正確入口」只有一個。找到那個入口,比快速把功能做出來更重要。這條原則同樣適用於大系統的任何角落:追蹤路徑比結果更重要,因為你下次出問題的時候,你需要知道訊息從哪裡來、往哪裡去。
能走事件流就走事件流。能用快取盡量快取。這不是教條,是讓大系統在出問題時還能被追蹤、被診斷、被修復的保險。