淺談微服務基建的邏輯

ThoughtWorks發表於2017-11-27

這篇文章主要目的是面向初接觸微服務的朋友簡單介紹微服務基礎建設所需要的各個模組以及緣由。

起點

首先,我們得有一個“服務”。根據定義,我們可以把每個服務例項都視作一個黑盒。這個盒子有著明確的輸入點和輸出點,並且(理想情況下)僅通過這些輸入和輸出點和外界產生關聯。每個服務例項會擁有專屬的網路地址、獨立的計算資源,並且獨立部署。客戶端通過訪問服務例項的地址來呼叫服務 API。不同服務也可以相互呼叫。

配置管理器:統一管理配置

在微服務體系中,每個服務都獨立部署和執行,團隊可以根據需要自行選擇增加和減少計算資源。一個服務可能會跑多個例項,每個服務例項都會需要做配置。為了方便統一調整配置,我們可以把配置中心化,每個服務例項都去找配置管理器(Configuration Manager)拿配置。當配置更新的時候,我們也可以讓服務例項再去拿新的配置。

服務名冊:解耦主機地址

這也引出了一個問題:網路地址(比如 IP)很容易因為擴容、維護而變動,呼叫者難以實時獲知可用的地址。

鑑於此,我們可以把網路地址抽象成不容易變動的概念,比如給每個服務一個固定的名字。網際網路使用 DNS 來解決這個問題,對應到微服務基建裡面就是服務名冊(Service Registry)。

每個服務例項在執行期間,都會以心跳的形式向服務名冊傳送註冊資訊,包括服務的 ID 、訪問地址以及健康狀況。這樣,需要訪問服務的時候,客戶端就可以先問服務名冊拿可用的例項地址,然後再訪問例項來呼叫服務。除了更好地定位例項地址,服務名冊還可以在某些例項下線、維護或升級的時候把其臨時從名冊中去掉,讓服務不斷線。

服務之間的呼叫也是如此,先找名冊拿網路地址,再進行呼叫。

API 閘道器:入口和路由

找名冊要地址,然後呼叫服務 API,這些是每個客戶端都會去做的瑣事,我們完全可以把這些事情抽象、集中,把服務的 API 整合到一個大的中心點,然後把要地址和呼叫服務 API 這樣的細節封裝起來,所有客戶端都只跟這個中心點對話,不再直接訪問單個服務。

從結構上看,這個中心點把整個架構劃分成了內外兩部分,內部是所有的服務,客戶端則在外部,中心點站在中間。它作為內外的唯一通道,被順理成章地命名作“API 閘道器”(API Gateway),有時候也被稱做“邊緣服務”(Edge Service)。

API 閘道器作為唯一出入口,又佔據了最前沿的有利位置,所以有時還會承載別的公共功能,比如我們馬上會提到的鑑權。

鑑權服務:身份和許可權問題

順著這個架構繼續開發,我們會遇到新的問題:不方便的鑑權。

鑑權(Auth)包括了兩個部分:身份認證(Authentication)和許可權驗證(Authorization)。身份認證關心的是“你是誰”,許可權驗證關心的是“你能不能做某件事”。

身份和許可權都是高度中心化的概念。

對於一個系統來說,使用者的身份必須是統一的。不能說這個使用者在做這個事情的時候是張三,做那個事情的時候是李四。此外,使用者的認證狀態也應該是統一的。不能說使用者訪問這個服務的時候是已登入認證,訪問另一個服務時又是未登入狀態。所以,只能有一個身份認證方。

許可權稍微複雜一點。和身份不同,許可權通常分成兩種類別:功能許可權和資料許可權。這樣的劃分對應了現實世界中常見的許可權模式:你的角色決定了你的職能,而職能範圍通常由附加條件來限制。比如,你是一個法官,對案件有裁決權,但是你是 A 區的法官,只能判 A 區的案子。再比如,某個快餐門店的經理有權看員工的詳細資料,但是隻能看自己門店的員工資料。

兩種許可權都由全域性的規則來確定,而不掌握在執行部門。比如,誰來判案,取決於法律,而不取決於法院。誰能檢視誰的資料,也不由資料保管部門決定,而由規章制度決定。

在現實的情況中,組織可能會有專門的稽核部門來驗證許可權,但對那些不是特別敏感的許可權,企業會讓各個部門自行驗證。不過不管誰來執行驗證,都必須拿著同一份規章制度,不能各說各話。這份制度必須由中心機構來統一制定、維護。也就是說,許可權的管理也應該中心化。

明確鑑權中心化之後,我們就可以開發一個公用的鑑權服務,執行身份認證和許可權驗證。下一個問題是:誰來發起鑑權?

所有服務的呼叫都要求呼叫者明確自己的身份,所以自然身份認證越靠前越好。作為出入口的 API 閘道器自然是發起身份認證的不二之選。許可權驗證則稍微複雜,完全值得另起一文詳述。此處我們暫時假定許可權驗證也由 API 閘道器來發起。

訊息中介:非同步和通知

開發繼續進行,一切風平浪靜,技術上暫時沒有什麼問題。不過,業務上有一個問題需要解決。

比如,我們做一個線上商城,要求在訂單成功建立的一刻,倉庫就要啟動備貨和發貨的流程。問題是,訂單和倉儲是兩個服務,不同團隊在負責,而且從關注點來說,訂單服務並不關心倉儲相關的問題,所以訂單服務不可能在建立訂單的時候去主動通知倉儲服務。倉儲服務只能定時輪詢訂單服務,看看有沒有新的訂單。這不僅麻煩,而且實時性不夠。

仔細想想,我們會發現這種需求很常見,資訊的產生者並不知道(也不關心)誰會對資訊產生興趣。比如我們可能會有一個監控服務需要實時展示產品銷量,有一個 BI 服務需要獲取客戶購買產品的資訊來做分析,等等。既然這是一個常見需求,我們不妨把它模式化,形成一個機制:資訊產生者把通知發出來,收到通知的人再確定是否需要採取行動。

這就意味著我們需要再引入一箇中心化的公共服務:訊息中介(Message Broker)。當某個事件發生的時候(比如使用者啟用成功、訂單建立成功),服務可以朝訊息佇列發一條訊息。而其他服務可以訂閱這些訊息,並針對這些訊息做出反應。

比如,倉儲服務可以訂閱訂單建立成功的訊息。這樣,訂單成功建立後,訂單服務將這個訊息發到訊息中介,訊息中介通知倉儲服務,倉儲服務一看,就問訂單服務要新的訂單資訊,最後,啟動出庫流程。

訊息中介除了能廣播事件之外,還能做非同步呼叫。把同步的呼叫轉化成非同步的回撥。針對呼叫時間長和不要求實時結果的呼叫,可以增加效能,提升體驗。

前置後端:優化前端開發

走到這裡,其實體系已經比較完備。現在的問題是,如何讓微服務基建結構和研發團隊常見的結構更好地對應起來。這要求我們從康威定律的角度來看待整個基建的設計。

在圍繞使用者和價值的軟體研發流程中,我們常用使用者歷程和使用者故事來捕捉和跟蹤價值的實現。一個使用者故事通常會包含一個有明確邊界、明確驗收標準和明確價值的業務步驟。

問題在於,支撐一個故事有前後兩端的研發工作,二者是不同步的。前端由業務流程和設計來驅動,希望按順序產出;後端則由業務資源和建模來驅動,希望按模組來產出。

比如說,前端常常會因為設計的原因調整自己需要的欄位,而後端從建模的角度並沒有這個需要,也沒有動力頻繁地去跟隨前端的調整,使得前端不得不在不穩定的網路條件下傳輸多餘的資訊,佔用了寶貴的網路頻寬。

此外,前端呈現某個業務步驟的時候,有兩種資訊不屬於當前必備資訊,但常常需要和必要資訊一起展示。一種是狀態資訊,比如當前的登入狀態和使用者名稱,短訊息的數量等等。一種是垂直相關的資訊,比如在展示文章的時候順便展示一下相關的文章。

這就要求前端在呼叫主服務的同時還要再呼叫多個不同的服務。且不說這些服務有可能會有呼叫超時、出錯的可能,僅僅是多出來一堆非同步請求,就已經足夠讓前端效率降低一大截了。

在微服務體系下,這些問題更加嚴重,因為現在不僅僅是前後端的差別,不同服務還由不同團隊負責。這些團隊的訴求和日程不一,很難做到前端所需要的快速響應。

這些問題和麻煩可能會催生一個“緩衝帶”,比如後端出專人來負責對接前端的需要,或者前端派駐一個人到後端來談需求。按康威定律,這種溝通體系,久而久之,很容易以軟體的形式沉澱下來,形成一個專屬的中間層。

要調和前後端的不同步是不可能的,而這種中間層是自然催生的解決方案,可以保留。新的問題是,它的職責是什麼?應該把它放在哪?應該由誰來維護?

分析下來,其責任有二。第一是解耦前後端的工作,降低相互的影響。前端需要的東西可以寫在中間層裡,讓它頻繁變化也沒有關係。後端如果還沒有準備好,前端也可以在這一層模擬假的資料,不至於被阻塞。第二則是提升前端的執行效率。前端可以把所需要的多個服務的東西統一彙總,一次拿完,免得發多個請求。

放置的位置則在 API 閘道器之內,讓它可以享有 API 閘道器所帶來的好處和保護。

最後是維護問題。按照“誰主張,誰舉證”的原則,既然有了這個中間層,好處讓前端得了,那麼,理論上應該由前端來維護。

這樣,一個主要為前端服務的中間層就定義好了。不同型別的前端(桌面、移動)可能會有不同的需要,為了避免中間層的碎片化,我們可以讓各個中間層都特定的前端型別緊密耦合,比如桌面專用、移動專用。如此,每個中間層都像是某型別前端的專享後端,所以“前置後端”(Backend-for-Frontend,簡稱 BFF)也因此得名。

迴路熔斷器:提高容錯度

現在,除錯也方便了,我們又繼續開發。一開始沒有什麼問題,但部署到預生產環境的時候,又一個問題出現了:整個體系的容錯度很低。一個小錯誤容易被層層傳遞和放大,導致整個體系的崩潰。

我們都知道,程式設計最麻煩的就是遠端呼叫。本地呼叫大部分時候結果是“成功”或“失敗”,但遠端呼叫則很可能是“無響應”。“無響應”有可能是正常的,對方可能稍後會給你結果,也可能是因為對方已經死了,沒法給你響應。最壞的結果,就是門口擠滿了人,大家都在等你給結果,而你也在等別人給結果,資源全部佔用來等,什麼也做不了。

不過,遠端呼叫是無法避免的。在微服務體系中,這個問題被進一步放大。這是因為微服務的模組化以服務為單位,而每個服務獨立部署和運維,使得服務之間的呼叫成了家常便飯。

在這種嚴峻的情況下,我們必須從架構上儘量提高整個服務體系的容錯度,讓個別服務的問題不至於影響到全域性。

具體的做法,則是給遠端呼叫加一個熔斷閾值檢查,當呼叫超時次數超過閾值時,就不再呼叫,直接返回錯誤。過一段時間之後,再把閾值恢復,嘗試繼續呼叫,重複前面的過程。這個機制就是迴路熔斷,而這個工具則是迴路熔斷器(Circuit Breaker)。

除了隔離已經出錯的服務例項,熔斷器還有一個重要的功能是提供備用方案。雖然我們把所有業務都拆成了服務,但服務有高低貴賤之分。有一些服務屬於關鍵服務,一旦出問題,則整個流程無法繼續,有一些則屬於分支服務,即便錯了,也不會影響大局。

比如說,購買商品的時候,常常會根據使用者的習慣和當前正在購買的東西做一些推薦。負責推薦的服務出問題的話,大不了就不推薦了,不應該影響使用者正常的購買流程。同理,如果是線上點餐的地址定位服務出問題了,我們也應該允許使用者手動選擇餐廳進行點餐——體驗雖然不佳,但至少正常的流程仍然可以走完。基於這個考慮,熔斷器應該為非必要的服務呼叫提供備用方案,儘量保證核心流程的順暢。

有了迴路熔斷器,遠端呼叫出錯的問題就從一定程度上緩解了。結合迴路熔斷器和對熔斷閾值變化的監控,開發者可以更容易地發現問題,並及時採取行動。

負載均衡器:提升服務彈性

要正式上線,我們還必須做好負載均衡(Load Balancing,下簡稱 LB),提升整個服務的彈性。要做負載均衡,從理論上有兩種方式:

客戶端負載均衡(Client-Side LB):由客戶端來決定如何分散請求。 中間方負載均衡(Mid-Tier LB):由 DNS、閘道器等中間方來決定如何分散請求。

現在,服務名冊中已經有了服務及其對應的例項地址列表,所以客戶端的負載均衡最簡便的方式就是把地址拉下來,然後依次或者隨機選擇可用的地址。中間方的負載均衡則選擇面較多,從最外層的 DNS 到閘道器都可以不同程度地去按需要去做。

擴充套件基建

現在,微服務基建基本完成了。如果有需要,我們可以對這個基建進行擴充套件。在做擴充套件時,架構師應該注意區分哪些東西應該中心化,哪些東西應該由服務自行決定。 比如說,在本文提到的基建之中,(幾乎是)強制完全中心化的模組有:

  • 配置管理
  • 服務名冊
  • 訊息佇列

其中,配置管理和服務名冊是所有服務都需要的基礎設施,必然需要統一。訊息佇列和日誌收集都是為了跨服務的操作和追蹤,也必須中心化。

半中心化的模組則有:

  • 路由
  • 鑑權

路由和鑑權都必須統一,我們前面討論過。不過,微服務可能會向外界暴露“自用”和“客用”等多套公共 API(比如快遞公司內部使用的物流 API 和開放給第三方使用的物流 API),所以可能會有兩個 API 閘道器,對應會有兩套 API 目錄和兩套鑑權體系,所以,它們是“半中心化”。

這些都是中心化、半中心化的選擇範例。每一次中心化的選擇都可能會讓整個架構變得死板,失去靈活性,所以,我們在設計和擴充套件基建的時候應該特別注意這個問題。

除了中心化的選擇之外,架構發展的另一個關注點,是讓業務保持“黑盒”。

我們把每個服務之間的關聯抽取了出來,也把許可權的定義和驗證抽取了出來,每個服務變得簡單而純粹,成了“純業務式服務”,等同於一個僅包含了業務規則的黑盒。這樣,不管服務和模組再多,也沒有影響。業務的重用性也很高。

總而言之,搭建好了微服務的必要設施之後,剩下的就要根據實際情況和專案經驗來繼續調整了。比如,我們可能會選擇把很多功能合併到一層,以避免過度分層所帶來的不必要的效能損失,或者對整個基建進行一些細節微調。只要把控好“中心-自理”和“業務-非業務”之間的關係,這個基礎設施就能健康地發展。

微服務基建總結

總結此文,微服務的基建應該包括如下一些元件(按請求流中的出場順序):

  • 配置管理:配置集中管理。
  • API 閘道器:對外的 API 總目錄;API 依賴關係;發起鑑權。
  • 服務名冊:服務的註冊和發現。
  • 鑑權服務:提供鑑權服務:認證身份,驗證功能許可權。
  • 前置後端:按前端的需求拆解請求、呼叫服務,並彙總、轉換結果。
  • 訊息中介:全域性通知機制;非同步呼叫機制。
  • 迴路熔斷:隔離出問題的服務並等待其恢復;提供備用方案。
  • 負載均衡:避免服務過載。

需要說明的是,這些元件的組合形式,具體拆分形式,是否需要,都需要結合實際專案和團隊的情況來調整。本文權作拋磚引玉,請讀者知悉。

相關文章