大廠都在玩的微服務,小團隊如何應用?

微服務是否適合小團隊是個見仁見智的問題。迴歸現象看本質,隨著業務複雜度的提高,單體應用越來越龐大,就好像一個類的代碼行越來越多,分而治之,切成多個類應該是更好的解決方法。


大廠都在玩的微服務,小團隊如何應用?


所以一個龐大的單體應用分出多個小應用也更符合這種分治的思想。當然微服務架構不應該是一個小團隊一開始就該考慮的問題,而是慢慢演化的結果,謹慎過度設計尤為重要。

公司的背景是提供 SaaS 服務,對於大客戶也會有定製開發以及私有化部署。經過 2 年不到的時間,技術架構經歷了從單體到微服務再到容器化的過程。

單體應用時代

早期開發只有兩個人,考慮微服務之類的都是多餘。不過由於受前公司影響,最初就決定了前後端分離的路線,因為不需要考慮 SEO 的問題,索性就做成了 SPA 單頁應用。

多說一句,前後端分離也不一定就不能服務端渲染,例如電商系統或者一些匿名即可訪問的系統,加一層薄薄的 View 層,無論是 PHP 還是用 Thymeleaf 都是不錯的選擇。

部署架構上,我們使用 Nginx 代理前端 HTML 資源,在接收請求時根據路徑反向代理到 Server 的 8080 端口實現業務。


大廠都在玩的微服務,小團隊如何應用?


接口定義

接口按照標準的 Restful 來定義:

  • 版本,統一跟在 /api/ 後面,例如 /api/v2。
  • 以資源為中心,使用複數表述,例如 /api/contacts,也可以嵌套,如 /api/groups/1/contacts/100。
  • url 中儘量不使用動詞,實踐中發現做到這一點真的比較難,每個研發人員的思路不一致,起的名字也千奇百怪,都需要在代碼 Review 中覆蓋。
  • 動作支持,POST / PUT / DELELE / GET ,這裡有一個坑,PUT 和 PATCH 都是更新,但是 PUT 是全量更新而 PATCH 是部分更新,前者如果傳入的字段是空(未傳也視為空)那麼也會被更新到數據庫中。
  • 目前我們雖然是使用 PUT 但是忽略空字段和未傳字段,本質上是一種部分更新,這也帶來了一些問題,比如確有置空的業務需要特殊處理。
  • 接口通過 swagger 生成文檔供前端同事使用。

持續集成(CI)

團隊初始成員之前都有在大團隊共事的經歷,所以對於質量管控和流程管理都有一些共同的要求。

因此在開發之初就引入了集成測試的體系,可以直接開發針對接口的測試用例,統一執行並計算覆蓋率。

一般來說代碼自動執行的都是單元測試(Unit Test),我們之所以叫集成測試是因為測試用例是針對 API 的,並且包含了數據庫的讀寫,MQ 的操作等等。

除了外部服務的依賴基本都是符合真實生產場景,相當於把 Jmeter 的事情直接在 Java 層面做掉了。

這在開發初期為我們提供了非常大的便利性。但值得注意的是,由於數據庫以及其他資源的引入,數據準備以及數據清理時要考慮的問題就會更多,例如如何控制並行任務之間的測試數據互不影響等等。

為了讓這一套流程可以自動化的運作起來, 引入 Jenkins 也是理所當然的事情了。


大廠都在玩的微服務,小團隊如何應用?


開發人員提交代碼進入 Gerrit 中,Jenkins 被觸發開始編譯代碼並執行集成測試,完成後生成測試報告,測試通過再由 reviewer 進行代碼 Review。

在單體應用時代這樣的 CI 架構已經足夠好用,由於有集成測試的覆蓋,在保持 API 兼容性的前提下進行代碼重構都會變得更有信心。

微服務時代

服務拆分原則

從數據層面看,最簡單的方式就是看數據庫的表之間是否有比較少的關聯。例如最容易分離的一般來說都是用戶管理模塊。

如果從領域驅動設計(DDD)看,其實一個服務就是一個或幾個相關聯的領域模型,通過少量數據冗餘劃清服務邊界。

單個服務內通過領域服務完成多個領域對象協作。當然 DDD 比較複雜,要求領域對象設計上是充血模型而非貧血模型。

從實踐角度講,充血模型對於大部分開發人員來說難度非常高,什麼代碼應該屬於行為,什麼屬於領域服務,很多時候非常考驗人員水平。

服務拆分是一個大工程,往往需要幾個對業務以及數據最熟悉的人一起討論,甚至要考慮到團隊結構,最終的效果是服務邊界清晰, 沒有環形依賴和避免雙向依賴。

框架選擇

由於之前的單體服務使用的是 Spring Boot,所以框架自然而然的選擇了 Spring Cloud。

其實個人認為微服務框架不應該限制技術與語言,但生產實踐中發現無論 Dubbo 還是 Spring Cloud 都具有侵入性。

我們在將 Node.js 應用融入 Spring Cloud 體系時就發現了許多問題,也許未來的 Service Mesh 才是更合理的發展道路。


大廠都在玩的微服務,小團隊如何應用?


該圖取自純潔的微笑公眾號

這是典型的 Spring Cloud 的使用方法:

  • Zuul 作為 Gateway,分發不同客戶端的請求到具體 Service。
  • Eureka 作為註冊中心,完成了服務發現和服務註冊。
  • 每個 Service 包括 Gateway 都自帶了 Hystrix 提供的限流和熔斷功能。
  • Service 之間通過 Feign 和 Ribbon 互相調用,Feign 實際上是屏蔽了 Service 對 Eureka 的操作。

上文說的一旦要融入異構語言的 Service,那麼服務註冊,服務發現,服務調用,熔斷和限流都需要自己處理。

再有關於 Zuul 要多說幾句,Spring Cloud 提供的 Zuul 對 Netflix 版本的做了裁剪,去掉了動態路由功能(Groovy 實現)。

另外一點就是 Zuul 的性能一般,由於採用同步編程模型,對於 IO 密集型等後臺處理時間長的鏈路非常容易將 Servlet 的線程池佔滿。

所以如果將 Zuul 與主要 Service 放置在同一臺物理機上,在流量大的情況下,Zuul 的資源消耗非常大。

實際測試也發現經過 Zuul 與直接調用 Service 的性能損失在 30% 左右,併發壓力大時更為明顯。

現在 Spring Cloud Gateway 是 Pivotal 主推的,支持異步編程模型,後續架構優化也許會採用,或是直接使用 Kong 這種基於 Nginx 的網關來提供性能。

當然同步模型也有優點,編碼更簡單,後文將會提到使用 ThreadLocal 如何建立鏈路跟蹤。

架構改造

經過大半年的改造以及新需求的加入,單體服務被不斷拆分,最終形成了 10 餘個微服務,並且搭建了 Spark 用於 BI。

初步形成兩大體系,微服務架構的在線業務系統(OLTP) + Spark 大數據分析系統(OLAP)。

數據源從只有 MySQL 增加到了 ES 和 Hive。多數據源之間的數據同步也是值得一說的話題,但內容太多不在此文贅述。


大廠都在玩的微服務,小團隊如何應用?


服務拆分我們採用直接割接的方式,數據表也是整體遷移。因為幾次大改造的升級申請了停服,所以步驟相對簡單。

如果需要不停服升級,那麼應該採用先雙寫再逐步切換的方式保證業務不受影響。

自動化部署

與 CI 比起來,持續交付(CD)實現更為複雜,在資源不足的情況我們尚未實現 CD,只是實現執行了自動化部署。

由於生產環境需要通過跳板機操作,所以我們通過 Jenkins 生成 jar 包傳輸到跳板機,之後再通過 Ansible 部署到集群。


大廠都在玩的微服務,小團隊如何應用?


簡單粗暴的部署方式在小規模團隊開發時還是夠用的,只是需要在部署前保證測試(人工測試 + 自動化測試)到位。

鏈路跟蹤

開源的全鏈路跟蹤很多,比如 Spring Cloud Sleuth + Zipkin,國內有美團的 CAT 等等。

其目的就是當一個請求經過多個服務時,可以通過一個固定值獲取整條請求鏈路的行為日誌,基於此可以再進行耗時分析等,衍生出一些性能診斷的功能。

不過對於我們而言,首要目的就是 Trouble Shooting,出了問題需要快速定位異常出現在什麼服務,整個請求的鏈路是怎樣的。

為了讓解決方案輕量,我們在日誌中打印 RequestId 以及 TraceId 來標記鏈路。

RequestId 在 Gateway 生成表示唯一的一次請求,TraceId 相當於二級路徑,一開始與 RequestId 一樣,但進入線程池或者消息隊列後,TraceId 會增加標記來標識唯一條路徑。

舉個例子,當一次請求會向 MQ 發送一個消息,那麼這個消息可能會被多個消費者消費,此時每個消費線程都會自己生成一個 TraceId 來標記消費鏈路。加入 TraceId 的目的就是為了避免只用 RequestId 過濾出太多日誌。

實現如圖所示:


大廠都在玩的微服務,小團隊如何應用?


簡單的說,通過 ThreadLocal 存放 APIRequestContext 串聯單服務內的所有調用。

當跨服務調用時,將 APIRequestContext 信息轉化為 Http Header,被調用方獲取到 Http Header 後再次構建 APIRequestContext 放入 ThreadLocal,重複循環保證 RequestId 和 TraceId 不丟失即可。

如果進入 MQ,那麼 APIRequestContext 信息轉化為 Message Header 即可(基於 RabbitMQ 實現)。

當日志彙總到日誌系統後,如果出現問題,只需要捕獲發生異常的 RequestId 或是 TraceId 即可進行問題定位。


大廠都在玩的微服務,小團隊如何應用?


經過一年來的使用,基本可以滿足絕大多數 Trouble Shooting 的場景,一般半小時內即可定位到具體業務。

運維監控

在容器化之前,採用 Telegraf + InfluxDB + Grafana 的方案。Telegraf 作為探針收集 JVM,System,MySQL 等資源的信息,寫入 InfluxDB,最終通過 Grafana 做數據可視化。

Spring Boot Actuator 可以配合 Jolokia 暴露 JVM 的 Endpoint。整個方案零編碼,只需要花時間配置。

容器化時代

架構改造

因為在做微服務之初就計劃了容器化,所以架構並未大動,只是每個服務都會建立一個 Dockerfile 用於創建 docker image。


大廠都在玩的微服務,小團隊如何應用?


涉及變化的部分包括:

  • CI 中多了構建 docker image 的步驟。
  • 自動化測試過程中將數據庫升級從應用中剝離單獨做成 docker image。
  • 生產中用 Kubernetes 自帶的 Service 替代了 Eruka。

理由下文一一道來。

Spring Cloud&Kubernetes 融合

我們使用的是 Redhat 的 OpenShift,可以認為是 Kubernetes 企業版,其本身就有 Service 的概念。一個 Service 下有多個 Pod,Pod 內即是一個可服務單元。

Service 之間互相調用時 Kubernetes 會提供默認的負載均衡控制,發起調用方只需要寫被調用方的 ServiceId 即可。

這一點和 Spring Cloud Fegin 使用 Ribbon 提供的功能如出一轍。也就是說服務治理可以通過 Kubernetes 來解決,那麼為什麼要替換呢?

其實上文提到了,Spring Cloud 技術棧對於異構語言的支持問題,我們有許多 BFF(Backend for Frontend)是使用 Node.js 實現的。

這些服務要想融合到 Spring Cloud 中,服務註冊,負載均衡,心跳檢查等等都要自己實現。

如果以後還有其他語言架構的服務加入進來,這些輪子又要重造。基於此類原因綜合考量後,決定採用 OpenShift 所提供的網絡能力替換 Eruka。

由於本地開發和聯調過程中依然依賴 Eruka,所以只在生產上通過配置參數來控制:

  • eureka.client.enabled 設置為 false,停止各服務的 Eureka 註冊。
  • ribbon.eureka.enabled 設置為 false,讓 Ribbon 不從 Eureka 獲取服務列表。
  • 以服務 foo 為例,foo.ribbon.listofservers 設置為 http://foo:8080,那麼當一個服務需要使用服務 foo 的時候,就會直接調用到 http://foo:8080。

CI 的改造

CI 的改造主要是多了一部編譯 docker image 並打包到 Harbor 的過程,部署時會直接從 Harbor 拉取鏡像。另一個就是數據庫的升級工具。

之前我們使用 Flyway 作為數據庫升級工具,當應用啟動時自動執行 SQL 腳本。

隨著服務實例越來越多,一個服務的多個實例同時升級的情況也時有發生,雖然 Flyway 是通過數據庫鎖實現了升級過程不會有併發,但會導致被鎖服務啟動時間變長的問題。

從實際升級過程來看,將可能發生的併發升級變為單一進程可能更靠譜。此外後期分庫分表的架構也會使隨應用啟動自動升級數據庫變的困難。

綜合考量,我們將升級任務做了拆分,每個服務都有自己的升級項目並會做容器化。

在使用時,作為 run once 的工具來使用,即 docker run -rm 的方式。並且後續也支持了設定目標版本的功能,在私有化項目的跨版本升級中起到了非常好的效果。

至於自動部署,由於服務之間存在上下游關係,例如 Config,Eruka 等屬於基本服務被其他服務依賴,部署也產生了先後順序。基於 Jenkins 做 Pipeline 可以很好的解決這個問題。

小結

以上的每一點都可以深入的寫成一篇文章,微服務的架構演進涉及到開發,測試和運維,要求團隊內多工種緊密合作。

分治是軟件行業解決大系統的不二法門,作為小團隊我們並沒有盲目追新,而是在發展的過程通過服務化的方式解決問題。

從另一方面我們也體會到了微服務對於人的要求,以及對於團隊的挑戰都比過去要高要大。未來仍需探索,演進仍在路上。

相關推薦

推薦中...