Spring Boot OMS Code Review 實戰:20 個 Bug 與事件驅動架構的一課

重點摘要

  • 三輪 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 三個等級:

等級 問題 修復方式
CriticalOrderUpsertConsumer .get() NPE 連鎖.path() + 加 orderDataJson null 守衛
Criticaldaily_statistics.id 缺 NOT NULL加約束 + DEFAULT partition
CriticalReturnUpsertConsumer 未寫 stats dirty marker新增 Redis ZSET 寫入
WarningDailyStatisticsService early return 留舊資料改成刪除過時的 stats 列
Warningenum 預設值小寫 'pending'改大寫 'PENDING',與 JPA EnumType.STRING 對齊
WarningRetryJobConsumer MissingNode cast.isObject() 判斷再 cast

其中 enum 大小寫這個問題值得特別說明。Java 的 @Enumerated(EnumType.STRING) 在讀取時呼叫 Enum.valueOf(),這個方法是 case-sensitive 的。資料庫預設值寫 'pending',但 enum 常數叫 PENDING,啟動時不會出錯,但一讀到有 DEFAULT 值的列就會拋 IllegalArgumentException

第二輪:種子資料是另一個地雷區

Schema 修完了,以為大功告成,結果種子資料(02-seed-data.sql)是第二個地雷區:

  1. BCrypt hash 是假的$2a$10$dummyhashfordevonly... 根本不是有效的 BCrypt hash,Spring Security 的 passwordEncoder.matches() 永遠回傳 false,登入 100% 失敗。
  2. 訂單狀態小寫'completed''shipped' — 和上面一樣的 case-sensitive 問題,這次在資料列而不是 DEFAULT 值。
  3. daily_statistics 欄位名稱全錯:用了 order_counttotal_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 recordKafka 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.sqlBug FixNOT NULL、DEFAULT partition、enum 大小寫
02-seed-data.sqlBug FixBCrypt hash、訂單狀態大小寫、daily_statistics 欄位名稱
OrderUpsertConsumerBug Fix.get().path(),移除 unused import
ReturnUpsertConsumerBug Fix加 stats dirty marker、移除 unused import
DailyStatisticsServiceBug Fixearly return 時刪除過時 stats 列
OrderServiceBug FixNOT NULL 欄位的 null 守衛
docker-compose.ymlInfra所有 21 個 Java 容器加 JAVA_TOOL_OPTIONS
UserOrderControllerFeature新增 POST /api/user/orders → Kafka pipeline
docker/test-data-generator/FeaturePython seeder,透過 API 打假訂單

結語:追蹤路徑比結果更重要

今天花最多時間的不是寫 code,而是「把對的事情弄清楚」。Seeder 寫了三個版本,不是因為技術難,而是因為對系統的理解在逐漸深化。

一個好的事件驅動系統,它的「正確入口」只有一個。找到那個入口,比快速把功能做出來更重要。這條原則同樣適用於大系統的任何角落:追蹤路徑比結果更重要,因為你下次出問題的時候,你需要知道訊息從哪裡來、往哪裡去。

能走事件流就走事件流。能用快取盡量快取。這不是教條,是讓大系統在出問題時還能被追蹤、被診斷、被修復的保險。

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *