拆錯的微服務比單體更難救:一個後端工程師對容器化與服務拆分的實戰反省
後端工程師,Go / .NET / Vue。做過交易所撮合、金流串接、後台系統與高併發秒殺。務實派——用容器化追求環境一致與可重複部署,也在組織與規模真的需要時才拆微服務,被線上事故教過不少次。
那一年我們把一個跑得好好的單體,拆成了七個微服務。三個月後的一個週五晚上,一筆使用者儲值卡在「金流回呼收到了、但訂單狀態沒更新」的狀態,我從手機收到告警,打開 Grafana,看著七個服務的日誌像七條沒交集的河,花了快兩個小時才拼湊出那筆請求到底死在哪——它在「訂單服務」呼叫「帳務服務」時逾時了,帳務服務其實成功扣款了,但訂單服務沒收到回應,於是重試,於是扣了兩次。
那個晚上我趴在筆電前手動對帳、寫補償腳本退款的時候,腦中只有一個念頭:這個問題,在拆分之前根本不存在。
這篇就是這個念頭的延伸。我做過、也維護過拆分的系統之後,對「微服務」這三個字反而越來越謹慎。我想誠實地講:什麼時候該拆、什麼時候不該拆,拆了之後要承受什麼,以及容器化這件事為什麼跟拆不拆完全是兩回事。
先承認一個不太流行的觀點:大多數情況,單體就夠了
我先把結論放前面,免得後面被誤會成在反對微服務。我的立場是:對大多數團隊和產品,一個結構良好的單體(monolith)就很夠用了,而且更好維護。
我說的「結構良好」很重要。它不是指那種所有東西攪在一起、改一個地方爆三個地方的大泥球。我指的是模組邊界清楚、依賴方向乾淨的單體——訂單就是訂單、帳務就是帳務,彼此透過明確的介面互動,只是它們剛好跑在同一個行程裡、共用同一次部署。
這種單體有一個被嚴重低估的好處:一筆業務從頭到尾,是一次資料庫交易。要嘛全部成功、要嘛全部回滾。我前面講的那個「扣兩次款」的鬼故事,在單體裡是不會發生的——因為訂單寫入和帳務扣款在同一個 transaction 裡,逾時了就一起 rollback,乾乾淨淨。
我後來常跟比較資淺的同事說一句話:你拆掉的不只是程式,你拆掉的是 ACID。這句話他們當下通常沒什麼感覺,等到第一次半夜對帳就懂了。
微服務真正解決的問題,是組織,不是技術
那微服務到底解決什麼?我的答案是:它解決的是組織與規模的問題,不是「讓系統變得比較高級」。
具體一點。當你的團隊大到一個程度——比如說三、四十個工程師、五六個小隊——所有人擠在同一個 repo、同一條部署管線上,光是合併衝突、互相卡發布,就能把產能吃掉一大半。這時候把系統沿著團隊邊界切開,讓每個小隊有自己的服務、自己的部署節奏,是真的會提升整體速度的。它解決的是「人」的問題。
另一個正當理由是擴展需求差異巨大。我做過秒殺系統,搶購那一瞬間,下單入口的流量是平常的幾百倍,但同一個系統裡的「會員資料」「歷史訂單查詢」流量根本沒動。把下單那條熱路徑獨立出來、單獨水平擴展到幾十個實例,而不用陪著它把整個單體也複製幾十份,這是合理的拆分——因為這兩塊的擴展曲線是真的不一樣。
但請注意這兩個理由的共同點:它們都是被外部壓力逼出來的,是先有了「團隊大到協作卡住」或「某一塊流量真的撐不住」的事實,才去拆。如果你是個五六人的小團隊、產品還在找方向,硬拆微服務,你不會得到上面任何一個好處,只會把原本一次穩穩的函式呼叫,變成一次可能逾時、可能失敗、可能重複的跨網路呼叫。
我那次七拆的失敗,本質就是這個——我們當時根本沒有那個規模的協作壓力,是看了太多架構文章,覺得「成熟的系統就該長這樣」,為了拆而拆。
拆分不是降低複雜度,是把複雜度搬家
這是我最想糾正的一個誤解。很多人講微服務時,語氣像是在「降低複雜度」——把大東西拆成小東西,每個小東西比較簡單,所以整體比較簡單。
錯。你是把複雜度從程式內部,搬到了服務之間。而服務之間的複雜度,比程式內部難對付得多。
原本一個函式呼叫,在語言層面保證會回傳、會即時、不會「成功了但你不知道」。拆成跨服務之後,每一次呼叫都活在分散式系統的陰影下,你得開始認真處理這些:
- 網路的不可靠:呼叫會逾時、會失敗、會重複送達。你得做重試,但重試又帶來重複執行的風險,所以下游介面得設計成冪等(idempotent)。我那次扣兩次款,根因就是帳務介面當初沒做冪等。
- 分散式的資料一致性:跨服務的業務沒辦法靠一個資料庫交易解決。你得引入 Saga、補償交易、或事件驅動最終一致性——每一種都比 BEGIN/COMMIT 複雜十倍。
- 可觀測性整個變難:一個請求橫跨好幾個服務,你要追它的軌跡,得靠 trace id 一路串起來。沒先把這個做進去,出事時你就是瞎子,像我那晚一樣對著七條日誌乾瞪眼。
- 部署與版本相容:服務 A 改了介面,服務 B 還沒跟上,正式環境就炸了。你得認真做介面版本化,灰度發布時得讓新舊版本能共存。
這些不是書上的名詞,每一條我都用線上事故換過教訓。它們是真實的、持續的成本——不是拆的時候付一次,是往後每一天都在付。
我現在會怎麼決定拆不拆
被燒過之後,我的判準變得很簡單:沿著真正獨立的業務邊界、和真正不同的擴展需求去拆,其餘一律先不拆。
舉幾個我會拆的例子。金流、撮合引擎這種核心,它們對可靠性和延遲的要求跟其他模組完全不在一個檔次——撮合要的是極低延遲、單執行緒的確定性順序,金流要的是極高的一致性保證和獨立的稽核。這種東西獨立出來是合理的,因為它的需求、它的部署節奏、它的擴展策略,本來就跟「後台管理介面」這種東西天差地遠。把它跟一堆 CRUD 綁在同一個部署裡,反而委屈了它。
再來是我前面提的秒殺熱路徑,擴展曲線差異大,拆。
那什麼情況我不拆?「程式碼變多了,想整理一下」——這個不拆。這時候該做的是把單體內部的模組邊界劃清楚,把依賴方向理乾淨,而不是把模組邊界升級成網路邊界。整理程式碼的報酬,遠遠不需要付分散式系統那筆稅。
這裡有個反直覺的點,我想特別強調:一個內部邊界清楚的好單體,將來真要拆的時候,反而拆得動;一個攪成一團的爛單體,你硬拆只會拆出一團更難收拾的分散式爛泥。所以先把邊界想清楚,永遠比急著拆重要。邊界是設計問題,部署形態只是落地問題——很多人把順序搞反了。
容器化的價值,跟微服務一點關係都沒有
這是另一個常被綁在一起講、但其實該分開的東西。容器化(Docker 那一套)老是跟微服務一起出現,搞得很多人以為「容器化 = 走向微服務的第一步」。
不是。容器化的價值是獨立成立的,它解決的是環境一致和部署可重複這兩件事——讓「我這邊明明可以跑」這句話從此消失。它把作業系統、依賴、執行環境通通封進一個可重現的映像檔,你在筆電上跑的,跟在正式環境跑的,是同一個東西。
這個價值,單體一樣享受得到。我有好幾個系統至今都是單體,但全部容器化。Go 在這點上特別舒服,編譯出來就是一個靜態執行檔,塞進一個 scratch 或 alpine 的極小映像檔,幾 MB 就能跑,啟動快、攻擊面小。我用容器化跑單體,從來不覺得有哪裡矛盾。
至於 Kubernetes,我的態度更保守一點。它是個很強的編排工具,但它本身就是一套需要學習和維運的分散式系統。如果你只有兩三個容器、流量也平穩,上 k8s 往往是拿一台坦克去輾螞蟻——你省下的部署力氣,會被你花在維護 k8s 本身的力氣吃光,甚至倒貼。我會在「容器數量、擴縮需求、團隊真的吃得住它的維運」這幾件事都成立之後,才考慮它。
所以請把這兩個問題分開問:「要不要容器化」和「要不要微服務」,是兩條獨立的決策線。前者幾乎總是值得,後者要非常謹慎。
如果真的要拆,這幾件事我會死守
假設你真的踩在該拆的線上了——團隊規模到了、邊界也想清楚了——那我用血換來的幾條底線,會從第一天就守住:
- 每個服務只擁有自己的資料。絕對不要多個服務共用一張表、互相直接讀寫對方的資料庫。一旦共用資料庫,你以為自己拆了,其實只是把單體的耦合藏到資料層,是最糟的那種假拆分。
- 服務之間所有呼叫,一律假設它會失敗。重試、逾時、熔斷從一開始就設計進去,下游介面一律做成冪等。我那筆扣兩次款,就是沒守這條的代價。
- 可觀測性第一天就要做進去,不是出事後才補。結構化日誌、一路串接的 trace id、基本的延遲與錯誤率指標。這東西平常看起來很無聊,但它就是你半夜唯一的手電筒。
- 介面要版本化,改動要相容。不要默默改掉一個欄位,讓別人的服務在正式環境替你發現這件事。
這幾條沒有一條是「可選的最佳實踐」,它們是分散式系統的入場費。付不起,就別進場。
寫在最後
回頭看那次七拆的失敗,最讓我難受的不是那兩個小時的 debug、也不是手動退款的尷尬,而是我意識到:我們付出了分散式系統的全部代價,卻一個好處都沒拿到。我們的團隊沒有大到需要獨立部署,我們的流量也沒有大到需要分開擴展。我們只是想讓系統「看起來比較專業」,結果讓它變得比較脆弱。
工程師這幾年我最大的成長,有一部分不是學會做什麼,而是學會忍住不做什麼。微服務、Kubernetes、事件驅動,這些都是好工具,但工具的意義永遠取決於你要解決的問題。當問題還不存在的時候就把工具搬進來,你引進的不是解法,是一個你親手製造、又得親手維護的新問題。
現在每次有人興沖沖跟我說「我們也來拆微服務吧」,我都會先問一句:你現在到底卡在哪?如果答不上來一個具體的痛,那答案通常就是——先別拆,把你那個單體的邊界,好好想清楚。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。