微服務漫游指南(一)

最近幾年“微服務”這個詞可謂是非常的火爆,大有席卷天下的態勢。幾乎所有公司都在按照自己的理解實施微服務,大公司也在逐步地把自己龐大的代碼庫通過一定的策略逐步拆分成微服務。不過如果你在Google上搜一下,你會發現“微服務”這個名詞很難有一個明確的定義,不同的人,不同的業務,不同的架構,他們在不同的維度聊“微服務”。
不過總的來說,大家都比較認同的是:“微服務”的核心是把一個大的系統拆解成一系列功能單一的小系統,每個系統可以單獨進行部署。這樣的好處是顯而易見的:

  • 由于單一職責,每個微服務的開發測試會更簡單
  • 開發語言和技術方案不受限制,可以發揮不同團隊的特長
  • 故障可以控制在單個系統之中
  • “服務化”使得復用更加便捷

如果要一一列舉,還能列舉很多很多的優點??傊?,微服務看起來還是非常美好的。但是隨著各個公司對微服務的不斷實踐,發現事實也不是那么美好,微服務的實施同時也引入了很多新的亟待解決的問題。這些問題并不代表微服務缺陷,而應該算是引入新技術的“代價”——任何技術升級都是有代價的。
我想通過本文,帶你一起來討論和學習這些代價,這對你更加深入理解微服務至關重要。每個section我都盡量細化,讓你知道How&Why,避免空洞的概念羅列,同時也會給出具體的解決方案。

熵與服務治理

熵是物理學中的一個名詞:

熵是系統的混亂程度的度量值,熵越大,意味著系統越混亂

當你把系統中的模塊當成子系統拆分出來,必然會引入“混亂”。最簡單的,以前調用一個功能就是import一個包,然后調用包的方法即可,僅僅是一個函數調用。編譯器保證被調用的方法一定存在,同時保證參數的類型和個數一定匹配。調用是沒有開銷的,僅僅是把函數指針指到子模塊的函數入口即可。但是一旦進行微服務拆分,子模塊變成了一個獨立部署的系統,調用方式將發生很大的變化,變得很復雜。

服務發現

首先,服務間通信基本都是依靠RPC,編譯器無法幫你保證你調用的正確性了,函數簽名、參數類型、返回類型等等,這些都需要你親自和服務提供方進行口頭溝通(wiki、文檔等)。而且更重要的是,你需要提前知道對應服務的IP和端口號才能進行RPC。當然,你依然可以提前人肉溝通好你依賴的服務的IP和端口號,然后以配置文件的方式告之你的進程。但由于大部分微服務都以集群的方式來部署,一個集群里有多臺服務器都在提供服務,因此你可能會得到一個IP+PORT的列表。你依然可以將這個列表寫到配置文件里,但是問題也隨之而來:

  • 如果依賴的服務器宕機了怎么辦?
  • 怎么判斷某臺服務器是否正常?
  • 該服務器所在集群擴容了怎么辦?

這幾個問題都是在實際中會經常遇到的,某個服務會隨著業務量的增長而承受更大的壓力,于是會進行橫向擴展(也就是加機器),這時該集群的服務器就從x臺變成x+k臺。如果你把集群的IP+PORT寫到配置文件中,那么新增的IP+PORT你將無法獲知,你的請求壓力依然會落到之前的機器上。對調用方來說似乎無所謂,但是對于服務提供方來說便是巨大的隱患。因為這意味著它的擴容雖然增加了機器但實際上并沒有生效(因為調用方還是call的原來的機器)。

解決這個問題的辦法就是——服務發現。我們需要一個單獨的服務,這個服務就像DNS一樣,使得我能通過別名獲取到對應服務的IP+PORT列表。比如你可以發送GET serviceA,然后該服務返回給你serviceA集群的所有機器的IP+PORT。
當你拿到一系列IP之后,你又會面臨另一個問題,到底使用哪個IP呢?這里就會出現另一個我們經常聽到的名詞——負載均衡。通常情況下,我們希望請求能均勻的分散到所有機器上,這樣不至于使得某臺機器負載過大而另一臺機器壓力過小。我們就需要盡可能公平的使用這些IP,因此需要引入一些算法來幫助我們選擇:

  • 輪詢(加權輪詢)
  • 隨機(加權隨機)

為什么會有加權輪詢、加權隨機?這很可能是因為我們實際的物理機配置不一樣,雖然都在一個集群,有些是8核CPU有些是4核,內存也有差異,加權算法使得我們可以人為配置哪些機器接受請求多一些哪些少一些。
還有一些特殊場景,我們希望相同特征的請求盡量落到同一臺服務器,比如同一個用戶的請求我們可能希望它落到固定的某臺機器(雖然這么做不太合理,這里僅舉例),我們也可以在負載均衡算法上做文章,使得我們的目的達成。
另一個問題是,依賴的服務可能會宕機,如果我們的負載均衡算法剛好選中了該IP,那么很顯然我們這次請求將會失敗。因此我們的服務發現需要盡量保證它存儲的是最新的、健康的服務的IP+PORT。怎么來完成這個工作呢?——服務注冊、健康檢查。
服務注冊是說,每當新啟動一個服務進程時,它會主動告訴“服務中心”:“Hi,我是serviceX集群的一個實例,我的IP是a.b.c.d我的端口是xxxx?!边@樣,當客戶端去服務中心查找serviceX的ip地址時,就能查到最新實例的IP了。換句話說,我們的服務發現自動支持集群的擴容了!

不過任何集群都可能會出現各式各樣的故障,比如說停電,機器死機,甚至是系統資源被惡意程序耗盡導致正常進程被kill等等。這時,我們希望服務中心能及時地把這些故障機器的IP從集群中移除,這樣客戶端就不會使用到這些有問題的服務器了。這便是健康檢查。由于服務掛掉都是因為各種各樣的突然因素,因此不可能由服務本身在進程異常時主動上報,只能有服務中心來進行定期的檢測。一般來說,health check有兩種方法:

  • ping
  • HeartBeat

對于第一種方法,如果能ping通該臺機器,我們就認為服務是健康的。當然,這是一種很不準確的檢測方法,它只能保證機器不宕機,但是并不知道該臺機器上實際進程的運行情況,有可能進程已經被kill掉。因此ping只是一種比較簡便但不夠準確的檢測方式:

  • ping不通,一定不健康
  • ping通,可能不健康

另一種方式是服務中心定期去curl某個服務的指定接口,根據接口返回值來確認服務的狀態。這種方式更合理,它能夠真正檢測到某臺服務器上進程的狀態,包括進程死鎖導致服務無響應等。這種方式如果curl失敗,那就一定可以說明服務不健康。對于不健康的服務,服務中心可以根據一定的策略把它的IP摘除,這樣使得客戶端能夠最大可能拿到可用的服務IP。
為什么上面說“根據一定策略”摘除,而不是直接摘除呢?因為curl是網絡請求,curl不通有可能是網絡抖動,也有可能是對端服務器由于某些原因使得CPU占用率突然飆高,導致響應變慢或超時,但是可能很快就恢復了。因此對于摘除,也需要有一定的重試策略。
但是截至目前,我們忽略了一個非常嚴重的問題,那便是“服務中心”也是一個服務,掛了怎么辦?誰又來告訴我們服務中心的IP?這么一想似乎又回到了解放前…其實不然。
這里先要說一說,服務發現其實有兩種方案。我們上面說的是客戶端服務發現,也就是每次客戶端發送請求前先去服務中心獲取IP并在本地通過負載均衡算法選取其一。其實還有另一種方案,是服務端服務發現。
服務端服務發現是這樣的:客戶端調用serviceA時使用固定的一個IP,比如10.123.123.10/proxy/serviceA/real_uri。而在服務端會有專門的服務來代理這個請求(比如Nginx)。根據URI它可以識別出你要調用的服務是serviceA,然后它找到serviceA的可用IP,通過預設的負載均衡算法直接把rewrite后的請求IP:Port/real_uri反向代理到對應機器上。
這兩種方案各有優劣,很多時候是共存的,這樣可以取長補短??蛻舳朔瞻l現的缺點是,所有語言都需要一個服務發現的SDK,既然是SDK那發版之后再想升級就難了…服務端服務發現的缺陷是,它是個單點,一旦掛了對整個公司都是災難性的。
這里你又會問了,客戶端服務發現也需要向“服務中心”去取IP列表,那個服務中心不也可能成為單點嗎?確實如此!因此一般需要客戶端緩存服務中心的結果到本地文件,然后每次去本地文件讀取service->[ip:port,]的映射關系,然后定期輪詢服務中心看映射關系是否發生變化,再更新本地文件。這樣,即使服務中心掛掉,也不至于造成災難性的后果。還有一種方式,干脆服務中心只做推送,服務中心把service -> [ip:port]的映射作為配置文件推送到所有服務器上,客戶端直接去讀本地文件即可,不再需要輪詢了。如果有新機器加入或者被摘除,服務中心重新進行推送即可。

很多團隊和服務發現解決方案甚至使用上了強一致性的etcd來做存儲,我個人認為這并不妥當。所有分布式系統當然都希望一致性越強越好,但是一定能夠分辨業務對一致性的要求,是必須強一致否則系統無法運行,還是最終一致即可但是期望越快越好。我認為服務發現并不是一個要求強一致性的場景,引入etcd只是徒增復雜性并且收效甚微。

你看,對于實施微服務來說,單純地想調用別的服務的方法,就有這么多需要解決的問題,而且每個問題深入下去都還有很多可優化的點,因此技術升級確實代價不小。但是開源軟件幫助了我們,不是嗎?由于服務發現的普遍性,開源界已經有很多成熟的解決方案了,比如JAVA的Eureka,比如Go的Consul等等,它們都是功能強大的”服務中心“,你通過簡單地學習就能快速使用到生產環境中了。

服務發現就完了嗎?當然不是了,上面說的僅僅是技術層面的東西,實際上還有很多細節內容,這些細節設計才決定著服務發現系統的擴展性和易用性。比如,如果有多機房,服務名怎么統一?換句話說,對于訂單服務,廣州機房的client希望拿到廣州機房的訂單服務集群的IP而不是巴西機房的,畢竟跨機房訪問的延時是很高的。除了多機房問題,另一個問題是多環境問題。大多數公司都會有這么三個相互隔離的環境:生產環境、預覽環境、開發測試環境。預覽環境和生產環境一樣,就是為了模擬真實的線上環境,唯一的不同是預覽環境不接入外部流量而已。對于多機房、多環境,其實有個簡便的方法,就是把服務名都設計成形如serviceX.envY,比如order.envGZ、order.envTest、order.envPre…客戶端在啟動時需要根據自身所在環境提前實例化服務發現組件,后續請求都自動附加上實例化參數做為后綴。

陡增流量

我們的系統一定會有個承壓閾值,QPS高于這個閾值后,平均響應時間和請求數就成正比關系,也就是說請求越多平均響應時間越長。如果遇到公司做活動,或者業務本身就是波峰波谷周期性特別明顯的場景,就會面臨流量陡增的情況。當流量發生陡增時,服務的整體響應時間將會變長;而與此同時,用戶越是感覺響應慢越急于反復重試,從而造成流量的暴漲,使得本身就已經很長的響應時間變得更長,使得服務502。
這是一個可怕的惡性循環,響應越慢,流量越大,流量越大,響應更慢,直至崩潰。如果你的服務是整個系統的核心服務,并不是可以被降級的服務(我們后面會聊降級),比如鑒權系統、訂單系統、調度系統等等,如果對陡增的流量沒有一個應對方式,那么很容易就會崩潰并且蔓延至整個系統,從而導致整個系統不可用。
應對方式其實也很簡單,就是限流。如果某個服務經過壓力測試后得出:當QPS達到X時響應的成功率為99.98%,那我們可以把X看做是我們的流量上限。我們在服務中會有一個專門的限流模塊作為處理請求的第一道閥門。當流量超過X時,限流模塊可以pending該請求或者直接返回HTTP CODE 503,表示服務器過載。也就是說,限流模塊最核心的功能就是保證同一時刻應用正在處理的請求數不超過預設的流量上限,從而保證服務能夠有比較穩定的響應時間。
那么限流模塊應該怎么實現呢?最簡單的就是計數器限流算法。不是要保證QPS(Query Per Second)不大于X嗎,那我是不是只需要有一個每隔一秒就會被清零的計數器,在一秒鐘內,每來一個請求計數器就加一,如果計數器值大于X就表明QPS>X,后續的請求就直接拒絕,直到計數器被清零。這個算法很容易實現,但是也是有弊端的。我們實際上是希望服務一直以一個穩定的速率來處理請求,但是通過計數器我們把服務的處理能力按照秒來分片,這樣的弊端是,很可能處理X個請求只需要花費400ms,這樣剩下600ms系統無事可干但一直拒絕服務。這種現象被稱為突刺現象。然而你可以說,這個算法是沒問題的,因為這個閾值X是開發人員自己配置的,他設置得不合理。不過作為算法提供方,當然需要考慮這些問題,不給用戶犯錯的機會豈不是更好?事實上,把服務按照秒來劃分時間片本身也不是很合理,為什么計數器的清零周期不是100ms呢,如果設置為Query Per Millisecond是不是更合理?Microsecond是不是更精確?當然,以上問題只是在極端情況下會遇到,絕大多數時候使用計數器限流算法都沒有問題。

限流的另一種常用算法是令牌桶算法。想象一個大桶,里面有X個令牌,當且僅當某個請求拿到令牌才能被繼續處理,否則就需要排隊等待令牌或者直接503拒絕掉。同時,這個桶中會以一定的速率K新增令牌,但始終保證桶中令牌最多不超過X。這樣可以保證在下一次桶中新增令牌前,同時最多只有X個請求正在被處理。然而突刺現象可能依然存在,比如短時間內耗光了所有令牌,在下一次新增令牌之前的剩下時間里,只能拒絕服務。不過好在新增令牌的間隔時間很短,因此突刺現象并不會很突出。并且突刺現象本身就很少見,因此令牌桶算法是相比于計數器更好也更常見的算法。不過你也可以看到,不同的算法來進行限流,本質上都是盡量去模擬“一直以一個穩定的速率處理請求”,不過只要這個模擬間隔是離散的,它始終都不會完美。
對于限流來說,業界其實也有比較多的成熟方案可選,比如JAVA的Hystrix,它不僅有限流的功能,還有很多其它的功能集成在里面。對于Golang來說有golang.org/x/time里的限流庫,相當于是準標準庫。

我們到目前為止聊的應對陡增流量都是從服務提供方的角度來說的,目的是保證服務本身的穩定性。但是同時我們也可以從服務調用方的角度來聊聊這個問題,我們叫它——熔斷。當然熔斷并不是單純針對陡增流量,某些流量波谷時我們也可能需要熔斷。

當作為服務調用方去調用某個服務時,很可能會調用失敗。而調用失敗的原因有很多,比如網絡抖動,比如參數錯誤,比如被限流,或者是服務無響應(超時)。除了參數錯誤以外,調用方很難知道到底為什么調用失敗。這時我們考慮一個問題,假設調用失敗是因為被依賴的服務限流了,我們該如何應對?重試嗎?
顯然這個問題的答案不能一概而論,得具體看我們依賴的服務是哪種類型的服務,同時還要看我們自身是哪種服務。
我們先來看一種特殊的場景,即我們(調用方)是一個核心服務,而依賴是一個非核心服務。比如展示商品詳情的接口,這個接口不僅需要返回商品詳情信息,同時需要請求下游服務返回用戶的評價。假如評價系統頻繁返回失敗,我們可以認為評價系統負載過高,或者遇到了其它麻煩。而評價信息對于商品詳情來說并不是必須的,因此為了減少評價系統的壓力,我們之后可以不再去請求評價系統,而是直接返回空。
我們不再請求評價系統這個行為,稱之為熔斷,這是調用方主動的行為,主要是為了加快自己的響應時間(即使繼續請求評價系統,大概率依然會超時,什么返回都沒有,還白白浪費了時間,不如跳過這一步),不過同時也能減少對下游的請求使下游的壓力減小。
當我們進行熔斷之后,原本應該返回用戶的評價列表,現在直接返回一個空數組,這個行為我們稱之為降級。因為我們熔斷了一個數據鏈路,那么之后的行為就會和預期的不一致,這個不一致就是降級。當然,降級也有很多策略,不一定是返回空,這個需要根據業務場景制定相應的降級策略。
另一個典型的場景是,非核心服務調用核心服務,比如一個內部的工單系統,它可能也需要展示每個工單關聯的訂單詳情。如果發現訂單系統連續報錯或者超時,此時應該怎么辦?最好的辦法就是主動進行熔斷!因為訂單系統是非常核心的系統,在線業務都依賴于它,沒有它公司就沒法賺錢了!而工單系統是內部系統,晚一些處理也沒關系,于是可以進行熔斷。雖然這可能導致整個工單系統不可用,但是它不會增加訂單系統的壓力,期望它盡可能保持平穩,也就是那句話:“我只能幫你到這里了”。不過實際上到底能不能進行自我毀滅式的熔斷依然要根據業務場景來定,不是想熔斷就熔斷的,有些業務場景可能也無法接受熔斷帶來的后果,那么就需要你和相關人員制定降級策略plan B。
總之,熔斷和降級就是調用方用來保護依賴服務的一種方式,很多人都會忽略它。但這正如你家里的電路沒有跳閘一樣,平時感覺不到有啥,一旦出事兒了后果就不堪設想!
那么,我們到底什么時候需要進行熔斷?一般來說,我們需要一個專門的模塊來完成這個工作,它的核心是統計RPC調用的成功率。如果調用某個服務時,最近10s內有50%的請求都失敗了,這可以作為開啟熔斷的指標。當然,由于依賴的服務不會一直出問題(畢竟它也有穩定性指標),因此熔斷開啟需要有一個時間段,在一段時間內開啟熔斷。當一段時候過后,我們可以關閉熔斷,重新對下游發起請求,如果下游服務恢復了最好,如果依然大量失敗,再進入下一個熔斷狀態,如此往復…
前面提到的JAVA用于限流的模塊Hystrix,它也集成了熔斷的功能,而且它還多了一個叫半熔斷的狀態。當失敗率達到可以熔斷的閾值時,Hystrix不是直接進入熔斷狀態,而是進入半熔斷狀態。在半熔斷狀態,有一部分請求會熔斷,而另一部分請求依然會請求下游。然后經過二次統計,如果這部分請求正常返回,可以認為下游服務已經恢復,不需要再熔斷了,于是就切換回正常狀態;如果依然失敗率居高不下,說明故障還在持續,這時才會進入真正的熔斷狀態,此時所有對該下游的調用都會被熔斷。
Hystrix的半熔斷狀態可以有效應對下游的瞬時故障,使得被熔斷的請求盡可能少,從熔斷狀態回復到正常狀態盡可能快,這也意味著服務的可用性更高——一旦進入熔斷狀態就回不了頭了,必須等熔斷期過了才行。
實現熔斷功能并不像實現限流一樣簡單,它復雜得多:

  • 熔斷需要介入(劫持)每個RPC請求,才能完成成功率的統計
  • 需要提供方便的接口供用戶表達fallback邏輯(降級)
  • 最好能夠做到無感知,避免用戶在每個RPC請求之前手動調用熔斷處理函數

由于熔斷和降級的功能對用于來說更像是一種函數的鉤子,它不僅要求功能完備,更需要簡單易用,甚至是不侵入代碼。也就是說,熔斷模塊不僅在實現上有一定技術難度,在易用性設計上也很有講究。一個很容易想到的并且能夠將易用性提升的方法就是wrap你的http庫,比如提供特殊的http.Post、http.Get方法,它們的簽名和標準庫一致,不過在內部集成了熔斷的邏輯。當然,像Hystrix一樣使用一個對象來代理執行網絡請求,也是一種不錯的思路。

在熔斷和降級方面,業界主要的比較成熟的方案就是Netflix的Hystrix,其它語言也很多借鑒Hystrix做了很多類似的庫,比如Go語言的Hystrix-go??梢钥隙ǖ氖牵障蘖骱腿蹟嗟裙ぷ?,真正落地實施時還有很多困難和可以優化的點,這里只是帶你簡單游覽一番。


我們講了服務發現和注冊,服務限流和熔斷降級,這些概念伴隨著微服務而出現,因此我們需要解決它。但是仔細想一下,為什么實施了微服務,就會遇到這些問題?實際上最根本的原因是,微服務松散的特性使得它缺少一個全局的編譯器。單體應用中添加和使用一個模塊,直接編寫代碼即可,編譯器可以來幫你做剩下的事情,幫你保證正確性。而微服務架構中,各個服務間都是隔離的,彼此不知道對方的存在,但又需要用到對方提供的方法,因此只能通過約定,通過一個中心來互相告知自己的存在。同時在單體應用中,我們可以很容易地通過壓測來測試出系統的瓶頸然后來進行優化。但是在微服務架構中,由于大多數時候不同服務是由不同部門不同組來開發,把它們集成起來是一件很費勁的事情。你只能通過全鏈路壓測才能找到一個系統的瓶頸,然而實施全鏈路壓測是非常困難的,尤其是在已有架構體系上支持全鏈路壓測,需要非常深地侵入業務代碼,各種trick的影子表方案…全鏈路壓測是另一個非常龐大的話題,跟我們的話題不太相關,因此我不打算在這里長篇大論,但是很明確的一點是:由于無法實施全鏈路壓測,所以微服務中我們只能進行防御性編程,我們必須假設任何依賴都是脆弱的,我們需要應對這些問題從而當真正出現問題時不至于讓故障蔓延到整個系統。因此我們需要限流,需要熔斷,需要降級。

所以你可以看到,很多技術并不是憑空出現的,當你解決某個問題時,可能會引入新的問題。這是一定的,所有技術的變革都有代價。不過要注意,這和你邊改Bug邊引入新Bug并不一樣:P。

服務間通信

我們上面一起聊了微服務之間如何相互發現(相當于實現了編譯器的符號表),也聊了當出錯時怎么保護下游和自我保護。但是微服務的核心是服務間的通信!正是服務間通信把小的服務組合成一個特定功能的系統,我們才能對外提供服務。接下來我們來聊一聊服務間通信。
由于不同的服務都是獨立的進程,大多數都在不同的機器,服務間通信基本都是靠網絡(同一臺機器的IPC就不考慮了)。網絡通信大家都知道,要么是基于面向有連接的TCP,要么是面向無連接的UDP。絕大多數時候,我們都會使用TCP來進行網絡通信,因此下面的討論我們都默認使用TCP協議。
一說到通信協議,很多人腦海中可能就會跳出一個名詞:RESTful。然而RESTful并不是一個協議,而是基于HTTP協議的一種API設計方式。使用RESTful意味著我們使用HTTP協議進行通信,同時我們需要把我們的業務按照資源進行建模,API通過POST DELETE PUT GET四種方法來對資源進行增刪改查。由于絕大多數企業的用戶都是通過瀏覽器或者手機APP來使用服務的,因此我們可以認為:

對用戶直接提供服務時,通信協議一定要使用HTTP

既然一定需要用HTTP(1.1)那就用吧,似乎沒有討論通信協議的必要了?不,當然有必要了!
首先我們需要了解的一個事實是,絕大部分直接和用戶打交道的接口都是聚合型接口,它們的工作大多是收集用戶請求,然后再去各下游系統獲取數據,把這些數據組合成一個格式返回給用戶。后面的章節我們會詳細討論這種API接口,我們稱之為API Gateway,這里先不深入。不過從中你可以發現,僅僅是API Gateway和客戶端直接通信被限制使用HTTP協議,API Gateway和它后面的各個微服務并沒有限制使用哪種通信協議。
不過讓我們先拋開不同協議的優劣,先來看一下發起一次RPC需要經歷的步驟:

  1. 客戶端根據接口文檔,填好必要的數據到某個對象中
  2. 客戶端把改對象按照協議要求進行序列化
  3. 發送請求
  4. 服務端根據協議反序列化
  5. 服務端把反序列化的數據填充到某個對象中
  6. 服務端進行處理,把結果按照通信協議序列化并發送
  7. 客戶端按照通信協議反序列化數據到某個對象中

可以看到,RPC需要根據協議進行大量的序列化和反序列化。但是通信協議是給機器看的,只有接口文檔才是給程序員看的。每次調用一個下游服務都需要對照文檔組裝數據,服務方也必須提供文檔否則沒有人知道該如何調用。換句話說

在RPC中,接口文檔是必須存在的

既然接口文檔存在,實際上問題就簡化了,因為我們可以寫一個很簡單的代碼生成器根據文檔生成調用接口的代碼。既然程序員只關心接口文檔的參數,剩下的代碼都可以自動生成,那么通信協議使用什么就無所謂了,只要調用方和服務提供方使用一樣的協議即可。既然用什么通信協議無所謂了,而且不論協議多復雜反正代碼也能自動生成,那為什么不使用性能更好的傳輸協議呢?

所以你可以看到,具體使用什么通信協議其實是一個自然選擇的過程,反正都是面向接口文檔利用生成器編程,選擇性能更好的協議屬于免費的午餐,那當然選性能好的協議了。不過這并不代表你值得花精力去開發一個擁有極致卓越性能的協議,因為:

  • 耗時大部分都是網絡傳輸和IO,協議多些字節解碼多費點時間只是小意思
  • 生態,小眾的協議很難利用現有的基礎設施

總之,在API Gateway背后的微服務之間,選用高性能的傳輸協議基本是免費的午餐,因此我們應該一開始就使用某種協議。業界有很多開源的高性能通信協議,比如Google的ProtoBuf(簡稱PB)和Facebook貢獻給Apache的Thrift,這兩個協議都是被廣泛使用于生產環境的。
不過很多人不知道gRPC和PB的區別。gRPC其實是個服務框架,可以理解為一個代碼生成器。它接收一個接口文檔,這個文檔用PB的語法編寫(也稱為IDL),輸出對應的server端和client端的代碼,這些代碼使用PB協議來對數據進行序列化。而對于Thrift,我們通常沒有這種混淆,因為thrift序列化方法一直是和與其配套的代碼生成器同時使用的。

在我們選定協議之后,服務間通信就告一段落了嗎?當然不是!可以說微服務相關的技術棧都是圍繞服務間,后面還有很多需要解決的問題。
比如在單體應用中,加入我們發現一個漏洞,修復的方法是讓獲取訂單詳情的函數增加驗證用戶的token。此時我們需要改動獲取訂單詳情的函數簽名以及它的內部實現,同時在各個調用處都加傳token參數,然后通過編譯即可。但是在微服務中,由于系統間是隔離的,單個服務的改動別的服務無法感知,上線也不是同步的。這意味著如果我修改了接口簽名并重新上線后,所有依賴于我的服務將會立刻失敗!因為根據之前的接口定義生成的client對數據的序列化,此時新的server端無法成功反序列化出來。
當然,這個問題gRPC和Thrift也早已經考慮到。它們的IDL讓你在定義接口時,不僅要給出參數名和類型,同時還需要編號。這個編號就用來保證序列化的兼容性。也就是說,只要你更新接口定義是通過在結構體后面增加參數而不是刪除或者修改原參數類型,那么序列化和反序列化是兼容的。所以解決上面問題的方法也很簡單,只需要在原來定義的結構體后面增加一個Token字段即可,服務端做兼容。傳了Token的就驗Token,沒傳Token的依然可以按照老邏輯運行,只是你需要統計哪些上游還沒有更新,然后去逐個通知他們。

到這里你也能發現微服務架構面臨的一個比較嚴峻的問題,想要全量升級某個服務是非常困難的,想要整個系統同時升級某個服務是幾乎不可能的。

gRPC和Thrift都是非常常用的RPC框架,它們的優劣其實并不太明顯,如果一個比另一個在各方面都強的話,就不需要拿來比了…Thrift由于時間更長,支持的語言更多功能更齊全;而gRPC更年輕,支持的語言更少,但是gRPC集成了Google出品的一貫作風,配套設施和文檔、教程非常齊全。當然它們還有很多性能上的差異,但是這些差異大多是由對應語言的geneator造成的,并不是協議本身。所以實際上你可以隨意選擇一個,只要整個公司統一就行,我個人更建議gRPC。

我們上面的討論也講了,我們在升級服務接口時需要統計哪些上游還在用過時的協議,方便我們推動對方升級。由于不同接口定義都不一樣,差異化很大,以現有的架構幾乎無法實現旁路追蹤,只能在服務端進行埋點,在反序列化之后服務端自己來判斷,從而統計出需要的信息。有沒有更好的辦法呢?我們后面再聊。

Tracing

我們上面說了很多和微服務息息相關的點,比如限流,比如熔斷,比如服務發現,比如RPC通信。但如果僅僅是這些,你會覺得整個系統還是很模糊,很零散,你不知道一個請求通過API Gateway之后都調用了哪些服務——因為你缺少一個全局的視圖。
對于單體應用來說,最簡單的全局視圖就是backtrace調用棧。通過在某個函數中輸出調用棧,可以在運行時打印出從程序入口運行到此的層層調用關系。哪個模塊被誰調用,哪個模塊調用了誰,都一目了然(其實backtrace的輸出一般也不太好看…)。更強大一點的,比如說JAVA編寫的程序,通過在eclipse中安裝插件CallGraph,就能靜態分析出各個對象和方法的調用關系,并以圖像來展示,非常直觀。但是對于微服務來說,下游服務無法打印出它上游服務的backtrace,也沒有任何編譯器能把所有服務的代碼合并起來做靜態分析。因此對于微服務來說,要得到調用關系的視圖并不容易。
Google在一篇名為Dapper的論文中,提出了一種方法用于在微服務系統中“繪制”調用關系視圖。不過拋開具體的論文,我們自己其實也能很容易地把tracing劃分出三個比較獨立的部門:

  • 業務埋點
  • 埋點日志存儲
  • Search+可視化UI

但是事實上調用鏈路追蹤是個很復雜的系統,而不單單是某個微服務中的一個模塊,它是重量級的。不像之前說的限流、熔斷等可以通過引入一個開源庫就能實現,它的復雜性體現在:

  • 業務埋點是個藝術活,怎么樣才能是埋點負擔最小同時埋點足夠準確。另一方面,就像之前提到服務升級的話題,在微服務中一旦代碼上線后,想再全量升級是非常困難的。埋點收集的數據要足夠豐富,但是太豐富又會給業務帶來負擔,必須提前規劃好哪些是必要的,這很難
  • 一旦系統規模做大,RPC調用是非常多的,埋點收集數據將非常多,需要一個穩定的存儲服務。這個存儲不僅要能承載海量數據,同時需要支持快速檢索(一般來說就是ES)
  • 需要單獨的界面能夠讓用戶根據某些條件檢索調用鏈路,并進行非常直觀的圖形化展示

Dapper最重要的其實就是它提出了一種日志規范,如果每個業務埋點都按此標準來打日志,那么就可以以一種統一的方式通過分析日志還原出調用關系。一般來說,Tracing有以下幾個核心概念:

  • Trace: 用戶觸發一個請求,直到這個請求處理結束,整個鏈路中所有的RPC調用都屬于同一個Trace
  • Span: 可以認為一個RPC請求就是一個Span,Span中需要附帶一些上下文信息支持后續的聚合分析
  • Tag: Tag是Span附帶的信息,用于后續的檢索。它一般用來把Span分類,比如db.type="sql"表示這個RPC是一個sql請求。后續檢索時就可以很容易把進行過sql查詢的請求給篩出來

這里只是簡單列舉了Tracing系統最重要的三個概念。如果一條日志包含了 traceID spanID Tag,相信你也能很容易地利用它們繪制出請求調用鏈路圖。當然,這其實也不用你自己來實現,業界已經有比較成熟的開源方案了,比如twitter開源的zipkin和Uber開源Go的jaeger(jaeger已經進入CNCF進行孵化了,進入CNCF意味著它通??梢宰鳛榉植际?、云計算等領域的首選方案)。但是它們和之前所說的各種限流或者熔斷組件不一樣,它們并不是一個庫,而是一個整體的解決方案,需要你部署存儲和Dashbord,也提供給你SDK進行埋點。但是由于Docker的存在,實際上部署也非常簡單(Docker我們后面會細聊)。

然而,jaeger和zipkin也各有各的不足,比如它們薄弱的UI。因此還有很多類似的項目正在被開發??紤]到通用性,所以業界一開始就先出了一個OpenTracing項目(也進入了CNCF),它可其實是一個interface定義。它致力于統一業務埋點收集數據的API和數據格式,這樣使得大家可以把中心放到展示層等其他方面。由于有了一致的數據,用戶也能隨意切換到別的系統。jaeger和zipkin都實現了OpenTracing規范。

不過總的來說,服務鏈路跟蹤是一個很龐大的工作,有很多需要優化和訂制的地方。如何快速響應用戶的查詢,這依賴于高性能的存儲引擎。隨著數據量的增加,存儲的容量也會成問題。當然,展示是否直觀,是否能從Tag或者Log里挖出更多信息,也是非常重要的。一般來說,這都需要一個團隊深入去做。Tracing實際上是一個比較深的領域,要做好不容易,這里也就不深入下去了,感興趣可以從Dapper開始看起。


這篇文章已經很長了,但實際上微服務中還有非常多的topic沒講。即使我們講過的topic,大多也是泛泛而談,比如服務發現系統其實就是一個非常復雜的系統。每一個點都值得我們程序員去學習鉆研。
在后續的文章中我會接著講監控、日志等在微服務中應用。微服務體系有這么多需要解決的問題,但實際上更重要的問題是,如何交付系統,這涉及到持續集成和持續部署相關話題。在現有的架構體系中,持續集成和部署并不是一件容易的事情,很多時候它們可能會讓運維同學疲于奔命,因此我們會講到Docker到底是如何解決這些問題,以及簡單聊一聊Docker的原理。Docker的出現給微服務架構插上了翅膀,使得微服務以更快的速度普及。但是所有團隊都會面臨微服務帶來的新問題,而這些問題實際上并沒有被系統的解決。Docker使得一個個的微服務就像一個函數一樣簡單,但是正如單體應用是由一系列函數按一定邏輯組合而成,我們的系統也是由一系列微服務構建而成。這種組合函數的工作并不會消失,只是從單體應用中的Controller遷移到了容器編排,我們會看到Swarm和Kubernates是如何解決這些問題的。Kubernates是一個革命性的軟件,它的抽象使得我們前面聊的Topic可以有更先進更純粹的解決方案,比如服務網格ServiceMesh……還有好多好多,我會在下一章細細道來

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容