跨邊界的語意流失,與 monolith 一直有吸引力的原因

每次接手一個拆得很細的系統,過一陣子都會冒出同一句抱怨:明明每個服務都寫得不差,組起來卻像一坨難動的東西。直覺會去怪某個寫爛的 service,但多數時候根因不在那。真正掉的東西是語意,它在跨服務邊界的那一刻被壓扁了。

邊界把語意壓成 bytes

在同一個 process 裡,Order 的欄位、不變條件,以及它跟 Payment 的關係,都活在編譯器看得到的範圍內。結構一改,工具會立刻指出受影響的 caller;重構、改名,或把欄位從 optional 改成 required 時,語意仍有人幫忙盯著。

跨過服務邊界後,Order 會先被序列化成 JSON、protobuf 或其他 wire format,到對面再反序列化;傳輸途中只剩一串 bytes,原本的語意得靠契約補回來。那份契約可能是不一定同步的 OpenAPI、一段 README,或某個工程師三年前在 Slack 留下的說明。型別系統看不到邊界另一端,自然無法替兩邊核對。

原本由工具自動處理的工作,過了邊界就退回成人工。欄位改名時編譯器不會報錯,下游可能要等到 runtime 收到某筆資料才壞;要確認欄位還有沒有人使用,也沒有 IDE 的 find usages,只能翻 log、問人,再承擔猜錯的風險。資料結構遷移因此從一次可驗證的重構,變成跨團隊協調與逐步上線。語意仍然存在,只是離開工具能驗證的地方,改由人腦追蹤。

monolith 的吸引力不是懶

這也說明了 monolith 為什麼一直有人想回去:語意能留在同一套型別系統與工具鏈裡,並不等於團隊在技術上偷懶或不懂分散式。

在一個 monolith 裡,從 HTTP handler 一路到資料層,Order 都還是那個 Order。改它的定義,編譯器陪你走完每一個 caller;想拔掉一個欄位,find usages 告訴你還有誰在碰;要把一段邏輯搬家,IDE 的 refactor 一鍵到位。這些不是小確幸,是大型系統還能持續演進的根本。系統老化源於你逐漸不敢動它。monolith 把「敢動」這件事的成本壓得很低,因為工具一直站在你這邊。

這也解釋了為什麼有些團隊拆成 microservices 之後反而更慢。他們以為買到的是獨立部署跟團隊自治,實際上付掉的,是原本白白享有的那層語意驗證。每一條服務邊界,都是一個工具不再幫你把關的地方。邊界開得越多,落回人工協調的面積就越大。一個 well-factored 的 monolith,常常比一套邊界亂畫的 microservices 好維護得多,差別就在語意還在不在工具的射程內。

拆之前先問值不值

講這些不是要你別拆服務。有些邊界該拆,理由很硬:團隊要獨立部署不想互相卡、某段工作負載的 scaling 特性跟其他人完全不同、某塊東西的故障必須隔離不能拖垮全站。這些都是好理由,該拆就拆。

重點是順序。拆之前先直說一句:這條邊界,要我犧牲多少語意換?如果一條邊界把兩個高度耦合、天天一起改的東西切開,你等於是把編譯器原本幫你頂住的工作,整碗倒回給跨團隊的人工協調,這筆帳通常不划算。

就算決定要拆,語意也不是只能認賠。它可以靠 shared model 接回來一部分:跨邊界共用的 schema、生出多語言型別的 IDL、還有契約測試,讓 provider 一改壞掉原有結構,CI 就先替 consumer 喊痛,而不是等 production 的某筆資料去發現。這些東西不會讓跨服務變得跟同 process 一樣安全,但它們是在邊界上重新長出一點工具支援,把語意從純人工的深淵拉回來一些。差別在於,你是有意識地在補這層,還是假裝邊界不用成本。

邊界適合畫在語意損失最小的地方,沿著耦合最弱、契約最穩定的縫切開,跨邊界要交換的意義較少,工具接不住的部分也會縮小。若只為對齊團隊結構或讓架構圖好看,硬把兩塊高度相關的東西拆開,得到的往往是一套長期需要補洞的分散式系統。

補充筆記

  • 大型系統難組合,根因常是語意在跨邊界被壓成 bytes 與隱含契約,不是某個元件爛。
  • 過了服務邊界,型別系統就失效,驗證、重構、遷移全退回人工,錯誤被推遲到 runtime。
  • monolith 的吸引力不是偷懶,是語意還活在同一套工具鏈裡,工具能幫你驗證與重構。
  • 拆服務前先問這條邊界值得犧牲多少語意;shared schema 與契約測試能接回一部分。
  • 邊界要畫在語意損失最小的地方,沿最弱的耦合縫切,不是照組織圖好看的地方切。

觀點 Sheng,內文 Claude 協助 · 列入 20260613 blog 翻新計劃,新漆未乾。