微服務漫遊指南(一)
最近幾年“微服務”這個詞可謂是非常的火爆,大有席捲天下的態勢。幾乎所有公司都在按照自己的理解實施微服務,大公司也在逐步地把自己龐大的程式碼庫通過一定的策略逐步拆分成微服務。不過如果你在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反向代理到對應機器上。
這兩種方案各有優劣,很多時候是共存的,這樣可以取長補短。客戶端服務發現的缺點是,所有語言都需要一個服務發現的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需要經歷的步驟:
- 客戶端根據介面文件,填好必要的資料到某個物件中
- 客戶端把改物件按照協議要求進行序列化
- 傳送請求
- 服務端根據協議反序列化
- 服務端把反序列化的資料填充到某個物件中
- 服務端進行處理,把結果按照通訊協議序列化併發送
- 客戶端按照通訊協議反序列化資料到某個物件中
可以看到,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……還有好多好多,我會在下一章細細道來