用 moon-bbq-backend 練習拆微服務
拿正在跑的 moon-bbq-backend 當練習對象,實際拆出一個 stats-service:盤點候選、設計事件合約、部署到 Render,最後老實檢討——這次拆分對專案本身到底有沒有實質好處。
下篇,講實作。判斷標準、模組化 vs 微服務的概念、業界案例寫在上篇〈微服務到底是什麼:跟模組化的差別,跟該不該拆的判斷〉,這篇假設你已經看過那篇的判斷框架,直接套用在真實專案上。動手前的本機多服務環境準備寫在〈Docker Compose 入門筆記〉。
目標:不生一個空專案硬套教科書範例,而是拿手邊已經在跑、有真實流量結構的
moon-bbq-backend(Go/Gin + Redis + WebSocket)當練習對象,實際體會「拆」這件事的判斷與代價。
一、盤點 moon-bbq-backend
原始檔案:
internal/api/handlers/bbq_handler.go(687 行)—— Hub、WS、Redis 搶位邏輯、REST 快照端點internal/api/handlers/bbq_bots.go(245 行)—— 假人排程器
依照上篇的三個判斷標準分類:
✅ 好的候選 1——訪客統計
位置:bbq_handler.go HandleBBQWS,每次有人 WS 連進來就同步呼叫:
database.RDB.SAdd(visitorCtx, redisKeyPrefix+"bbq:visitors:unique", uid)
database.RDB.Incr(visitorCtx, redisKeyPrefix+"bbq:visitors:total")
還有對應的 GetStats 端點(讀這兩個值,回傳 uniqueVisitors / totalVisits / realOnline)。
為什麼是好候選:
- 完全不影響座位/聊天的核心功能,連前端都沒有呼叫這支統計端點
- 符合標準 2、3:可以容忍失敗或延遲,而且現在莫名其妙擠在每個人連線的熱路徑上,多兩次同步 Redis 呼叫
- 規模小,適合當第一個練習:學會「事件式(fire-and-forget)」這種最簡單的微服務溝通模式——主服務發一個事件就不用等回應
✅ 好的候選 2——Bot 排程器
位置:整個 bbq_bots.go。目前直接操作 Redis + 呼叫 GlobalHub.Broadcast / BotLeave channel,跟主服務共享同一份資料。
為什麼是好候選:
- 責任跟核心座位同步完全不同(「決定要不要生一個假人」vs「即時同步真人狀態」)
- 掛掉沒關係,最多就是桌子看起來比較空
但拆的時候要注意:不能只是把程式碼搬到另一個 process,卻還是連同一個 Redis 亂改 key——那不是微服務,是「兩個進程碰同一份資料庫」的反模式,誰都能改到誰的資料,出事很難追。適合當第二個練習:學「服務對服務的同步呼叫」,規模比訪客統計大,而且不是丟個事件就好,得真的設計一組內部 API 讓 bot 服務「命令」主服務做事,更接近真實系統。
❌ 不該拆的——Hub / WS / 搶位邏輯
位置:Hub.Run()、claimSpotScript(Lua 原子搶位腳本)。
為什麼不該拆:這是核心熱路徑,需要低延遲、強一致性(Lua script 保證「搶位」這個操作是原子的,不會有兩人同時搶到同一位子的競態)。拆出去只會多一層網路延遲,卻換不到任何好處。這是刻意舉的反例——不是所有東西都該微服務化,判斷力的一部分就是知道什麼時候「不拆」才是對的決定。
⚠️ 可拆但不值得——REST 查詢端點
位置:GetTables、GetTableSpots。
技術上可以獨立成一個「查詢服務」,但它們本來就是輕量的 Redis 讀取(HLEN / HGetAll),拆開除了增加一次網路跳轉、沒有額外好處。先跳過,除非未來查詢邏輯變複雜或需要獨立擴容。
二、建議的練習順序
Phase 1 — 訪客統計:事件式(fire-and-forget)✅ 已完成上線
把 HandleBBQWS 裡那兩行同步 Redis 呼叫,改成丟一個事件出去就不管(例如寫進 Redis pub/sub 或一個輕量 queue),由一個獨立的「統計服務」消費事件、自己更新計數、自己提供 GetStats 查詢端點。主服務完全不需要等它回應,它掛了也不影響任何使用者看得到的功能。
學到的概念:非同步事件、eventual consistency、「核心服務不該為了周邊功能等待」。
實際怎麼做的
合約:一個 Redis Pub/Sub 頻道 moon-bbq:bbq:events:visitor。主服務只負責 PUBLISH userId,新服務負責 Subscribe 之後自己做 SAdd(去重)+ Incr(累計)。兩邊對這個頻道名稱、訊息格式(裸的 userId 字串,沒包 JSON)有共識,但沒有任何型別檢查幫你擋——這是 Pub/Sub 這類鬆耦合合約的代價,改名字要兩邊程式碼一起改,對不上不會報錯,只會安靜地失聯。
程式碼變動:
bbq_handler.go的HandleBBQWS:把database.RDB.SAdd(...)+database.RDB.Incr(...)兩行,改成一行database.RDB.Publish(ctx, "moon-bbq:bbq:events:visitor", uid)- 移除
GetStatshandler 跟routes.go裡對應的/statsroute——這支端點的所有權整個轉移給新服務,主服務不再需要知道「訪客統計」這件事存在 - 新增
cmd/stats-service/main.go:一個獨立的mainpackage,自己的Subscribe迴圈 + 自己的 Gin server,只掛/health跟/api/v1/bbq/stats兩支路由 - 新增
Dockerfile.stats:結構跟主服務的Dockerfile幾乎一樣,唯一差別是編譯目標從./cmd/server換成./cmd/stats-service——同一個 repo、同一份原始碼,靠「編譯哪個cmd/目錄」決定產出哪個服務的執行檔,之後要拆更多服務就是重複這個模式(新增cmd/xxx/main.go+Dockerfile.xxx)
部署:兩個完全獨立的 Render Web Service,同一個 GitHub repo、同一個 main 分支,主服務照舊讀根目錄的 Dockerfile,新服務額外指定 Dockerfile Path 為 Dockerfile.stats。兩個服務的 REDIS_URL 都指向同一個正式站 Upstash——這不是反模式,因為現在資料所有權切乾淨了:bbq:visitors:* 這兩把 key 只有新服務會寫,主服務只發事件,沒有兩邊互搶同一筆資料的問題。新服務刻意不加入 Render keep-alive 保活排程,因為沒人在即時等它的回應,冷啟動幾秒完全可以接受。
結果:上線後 https://moon-bbq-backend-1.onrender.com/api/v1/bbq/stats 直接讀到正式站原本累積的資料(不是從 0 開始),證明新服務接的是同一份 Redis、事件真的有從主服務傳過來。
Phase 2 — Bot 排程器:服務對服務的同步呼叫
-
定義合約:主服務新增一組不對外開放的內部 API(用共用 secret header 驗證):
POST /internal/bbq/bot-move{tableId, spotId, persona}POST /internal/bbq/bot-leave{sessionId}
這兩支把現有 MOVE/LEAVE 的邏輯包一層,bot 服務完全不碰 Redis,只透過這組 API 操作。
-
拆成獨立服務:新的
bbq-bot-service輪詢主服務現有的GET /tables判斷各桌人數,決定要不要加/減 bot,決定了就打上面那組內部 API。獨立部署,自己的 env(TARGET_API_URL、INTERNAL_API_KEY)、自己的 health check。main.go移除StartBotScheduler()的呼叫。
學到的概念:服務邊界設計、內部 API 合約、服務間認證、耦合 vs 獨立部署的取捨。
Phase 3(進階,選做)— 把 Bot 排程器也改成非同步
Phase 2 是同步 REST call,練完之後可以試著把「bot 服務決定要移動」改成丟事件到 Redis pub/sub 或訊息佇列,主服務訂閱後才真的執行——這樣會碰到微服務更核心的難題:服務掛掉時事件會不會遺失、要不要做 retry / dead-letter、如何確保不重複執行。
學到的概念:訊息佇列、至少一次 vs 恰好一次語意、失敗重試設計。
三、老實檢討:這次拆分對 moon-bbq 本身有沒有實質好處
答案接近「沒有」。拆之前那兩行 SAdd/Incr 本來就沒有檢查錯誤、不會 block 住 WS 連線,原本的版本就已經有「失敗不影響核心功能」這個特性,拆分並沒有真的多創造出容錯能力。拆完之後多付出的成本卻是實打實的:多一個 Render 服務要顧、多一個 Dockerfile 要維護,而且 moon-bbq:bbq:events:visitor 這串字串變成兩個檔案之間沒有編譯期檢查的合約,改錯字兩邊沒同步改,不會報錯,只會安靜地資料對不起來。真正省下來的東西(不用等統計邏輯執行完才回應、可以獨立重啟)在這個流量規模下感覺不到差異。
老實講,原本那個「同步寫兩行 Redis」的版本,對這個專案來說就是夠好的解法,拆分某種意義上是為了拆而拆。這次值得留下的東西是練到的判斷力跟 docker-compose 實作經驗,不是「moon-bbq 因此變得更好」——這兩件事在這個案例裡是分開的,值得誠實面對,而不是為了合理化拆分而硬找理由。上篇提到的 Segment、Prime Video 案例也是同樣的教訓,只是他們的代價是真金白銀,這次的代價只是多一份 Dockerfile。
四、一句話總結
拆微服務不是「把程式碼搬家」,是先找出「容忍失敗、責任獨立、不該擠在熱路徑」的那一小塊,設計好它跟主服務之間的合約(事件或 API),再談部署。核心的、需要強一致性的熱路徑(像座位搶位)反而是最不該拆的部分。更重要的是:拆分要有真實驅動力,不是預設選項——這次 moon-bbq 的練習,誠實地說,拆不拆對專案本身影響都不大,這個反差本身就是這次練習最值得寫進文章的地方。