最近幾年“微服務(wù)”這個詞可謂是非常的火爆,大有席卷天下的態(tài)勢。幾乎所有公司都在按照自己的理解實施微服務(wù),大公司也在逐步地把自己龐大的代碼庫通過一定的策略逐步拆分成微服務(wù)。不過如果你在Google上搜一下,你會發(fā)現(xiàn)“微服務(wù)”這個名詞很難有一個明確的定義,不同的人,不同的業(yè)務(wù),不同的架構(gòu),他們在不同的維度聊“微服務(wù)”。
不過總的來說,大家都比較認(rèn)同的是:“微服務(wù)”的核心是把一個大的系統(tǒng)拆解成一系列功能單一的小系統(tǒng),每個系統(tǒng)可以單獨進行部署。這樣的好處是顯而易見的:
- 由于單一職責(zé),每個微服務(wù)的開發(fā)測試會更簡單
- 開發(fā)語言和技術(shù)方案不受限制,可以發(fā)揮不同團隊的特長
- 故障可以控制在單個系統(tǒng)之中
- “服務(wù)化”使得復(fù)用更加便捷
如果要一一列舉,還能列舉很多很多的優(yōu)點??傊⒎?wù)看起來還是非常美好的。但是隨著各個公司對微服務(wù)的不斷實踐,發(fā)現(xiàn)事實也不是那么美好,微服務(wù)的實施同時也引入了很多新的亟待解決的問題。這些問題并不代表微服務(wù)缺陷,而應(yīng)該算是引入新技術(shù)的“代價”——任何技術(shù)升級都是有代價的。
我想通過本文,帶你一起來討論和學(xué)習(xí)這些代價,這對你更加深入理解微服務(wù)至關(guān)重要。每個section我都盡量細(xì)化,讓你知道How&Why,避免空洞的概念羅列,同時也會給出具體的解決方案。
熵與服務(wù)治理
熵是物理學(xué)中的一個名詞:
熵是系統(tǒng)的混亂程度的度量值,熵越大,意味著系統(tǒng)越混亂
當(dāng)你把系統(tǒng)中的模塊當(dāng)成子系統(tǒng)拆分出來,必然會引入“混亂”。最簡單的,以前調(diào)用一個功能就是import一個包,然后調(diào)用包的方法即可,僅僅是一個函數(shù)調(diào)用。編譯器保證被調(diào)用的方法一定存在,同時保證參數(shù)的類型和個數(shù)一定匹配。調(diào)用是沒有開銷的,僅僅是把函數(shù)指針指到子模塊的函數(shù)入口即可。但是一旦進行微服務(wù)拆分,子模塊變成了一個獨立部署的系統(tǒng),調(diào)用方式將發(fā)生很大的變化,變得很復(fù)雜。
服務(wù)發(fā)現(xiàn)
首先,服務(wù)間通信基本都是依靠RPC,編譯器無法幫你保證你調(diào)用的正確性了,函數(shù)簽名、參數(shù)類型、返回類型等等,這些都需要你親自和服務(wù)提供方進行口頭溝通(wiki、文檔等)。而且更重要的是,你需要提前知道對應(yīng)服務(wù)的IP和端口號才能進行RPC。當(dāng)然,你依然可以提前人肉溝通好你依賴的服務(wù)的IP和端口號,然后以配置文件的方式告之你的進程。但由于大部分微服務(wù)都以集群的方式來部署,一個集群里有多臺服務(wù)器都在提供服務(wù),因此你可能會得到一個IP+PORT的列表。你依然可以將這個列表寫到配置文件里,但是問題也隨之而來:
- 如果依賴的服務(wù)器宕機了怎么辦?
- 怎么判斷某臺服務(wù)器是否正常?
- 該服務(wù)器所在集群擴容了怎么辦?
這幾個問題都是在實際中會經(jīng)常遇到的,某個服務(wù)會隨著業(yè)務(wù)量的增長而承受更大的壓力,于是會進行橫向擴展(也就是加機器),這時該集群的服務(wù)器就從x臺變成x+k臺。如果你把集群的IP+PORT寫到配置文件中,那么新增的IP+PORT你將無法獲知,你的請求壓力依然會落到之前的機器上。對調(diào)用方來說似乎無所謂,但是對于服務(wù)提供方來說便是巨大的隱患。因為這意味著它的擴容雖然增加了機器但實際上并沒有生效(因為調(diào)用方還是call的原來的機器)。
解決這個問題的辦法就是——服務(wù)發(fā)現(xiàn)。我們需要一個單獨的服務(wù),這個服務(wù)就像DNS一樣,使得我能通過別名獲取到對應(yīng)服務(wù)的IP+PORT列表。比如你可以發(fā)送GET serviceA,然后該服務(wù)返回給你serviceA集群的所有機器的IP+PORT。
當(dāng)你拿到一系列IP之后,你又會面臨另一個問題,到底使用哪個IP呢?這里就會出現(xiàn)另一個我們經(jīng)常聽到的名詞——負(fù)載均衡。通常情況下,我們希望請求能均勻的分散到所有機器上,這樣不至于使得某臺機器負(fù)載過大而另一臺機器壓力過小。我們就需要盡可能公平的使用這些IP,因此需要引入一些算法來幫助我們選擇:
- 輪詢(加權(quán)輪詢)
- 隨機(加權(quán)隨機)
為什么會有加權(quán)輪詢、加權(quán)隨機?這很可能是因為我們實際的物理機配置不一樣,雖然都在一個集群,有些是8核CPU有些是4核,內(nèi)存也有差異,加權(quán)算法使得我們可以人為配置哪些機器接受請求多一些哪些少一些。
還有一些特殊場景,我們希望相同特征的請求盡量落到同一臺服務(wù)器,比如同一個用戶的請求我們可能希望它落到固定的某臺機器(雖然這么做不太合理,這里僅舉例),我們也可以在負(fù)載均衡算法上做文章,使得我們的目的達(dá)成。
另一個問題是,依賴的服務(wù)可能會宕機,如果我們的負(fù)載均衡算法剛好選中了該IP,那么很顯然我們這次請求將會失敗。因此我們的服務(wù)發(fā)現(xiàn)需要盡量保證它存儲的是最新的、健康的服務(wù)的IP+PORT。怎么來完成這個工作呢?——服務(wù)注冊、健康檢查。
服務(wù)注冊是說,每當(dāng)新啟動一個服務(wù)進程時,它會主動告訴“服務(wù)中心”:“Hi,我是serviceX集群的一個實例,我的IP是a.b.c.d我的端口是xxxx?!边@樣,當(dāng)客戶端去服務(wù)中心查找serviceX的ip地址時,就能查到最新實例的IP了。換句話說,我們的服務(wù)發(fā)現(xiàn)自動支持集群的擴容了!
不過任何集群都可能會出現(xiàn)各式各樣的故障,比如說停電,機器死機,甚至是系統(tǒng)資源被惡意程序耗盡導(dǎo)致正常進程被kill等等。這時,我們希望服務(wù)中心能及時地把這些故障機器的IP從集群中移除,這樣客戶端就不會使用到這些有問題的服務(wù)器了。這便是健康檢查。由于服務(wù)掛掉都是因為各種各樣的突然因素,因此不可能由服務(wù)本身在進程異常時主動上報,只能有服務(wù)中心來進行定期的檢測。一般來說,health check有兩種方法:
- ping
- HeartBeat
對于第一種方法,如果能ping通該臺機器,我們就認(rèn)為服務(wù)是健康的。當(dāng)然,這是一種很不準(zhǔn)確的檢測方法,它只能保證機器不宕機,但是并不知道該臺機器上實際進程的運行情況,有可能進程已經(jīng)被kill掉。因此ping只是一種比較簡便但不夠準(zhǔn)確的檢測方式:
- ping不通,一定不健康
- ping通,可能不健康
另一種方式是服務(wù)中心定期去curl某個服務(wù)的指定接口,根據(jù)接口返回值來確認(rèn)服務(wù)的狀態(tài)。這種方式更合理,它能夠真正檢測到某臺服務(wù)器上進程的狀態(tài),包括進程死鎖導(dǎo)致服務(wù)無響應(yīng)等。這種方式如果curl失敗,那就一定可以說明服務(wù)不健康。對于不健康的服務(wù),服務(wù)中心可以根據(jù)一定的策略把它的IP摘除,這樣使得客戶端能夠最大可能拿到可用的服務(wù)IP。
為什么上面說“根據(jù)一定策略”摘除,而不是直接摘除呢?因為curl是網(wǎng)絡(luò)請求,curl不通有可能是網(wǎng)絡(luò)抖動,也有可能是對端服務(wù)器由于某些原因使得CPU占用率突然飆高,導(dǎo)致響應(yīng)變慢或超時,但是可能很快就恢復(fù)了。因此對于摘除,也需要有一定的重試策略。
但是截至目前,我們忽略了一個非常嚴(yán)重的問題,那便是“服務(wù)中心”也是一個服務(wù),掛了怎么辦?誰又來告訴我們服務(wù)中心的IP?這么一想似乎又回到了解放前…其實不然。
這里先要說一說,服務(wù)發(fā)現(xiàn)其實有兩種方案。我們上面說的是客戶端服務(wù)發(fā)現(xiàn),也就是每次客戶端發(fā)送請求前先去服務(wù)中心獲取IP并在本地通過負(fù)載均衡算法選取其一。其實還有另一種方案,是服務(wù)端服務(wù)發(fā)現(xiàn)。
服務(wù)端服務(wù)發(fā)現(xiàn)是這樣的:客戶端調(diào)用serviceA時使用固定的一個IP,比如10.123.123.10/proxy/serviceA/real_uri。而在服務(wù)端會有專門的服務(wù)來代理這個請求(比如Nginx)。根據(jù)URI它可以識別出你要調(diào)用的服務(wù)是serviceA,然后它找到serviceA的可用IP,通過預(yù)設(shè)的負(fù)載均衡算法直接把rewrite后的請求IP:Port/real_uri反向代理到對應(yīng)機器上。
這兩種方案各有優(yōu)劣,很多時候是共存的,這樣可以取長補短??蛻舳朔?wù)發(fā)現(xiàn)的缺點是,所有語言都需要一個服務(wù)發(fā)現(xiàn)的SDK,既然是SDK那發(fā)版之后再想升級就難了…服務(wù)端服務(wù)發(fā)現(xiàn)的缺陷是,它是個單點,一旦掛了對整個公司都是災(zāi)難性的。
這里你又會問了,客戶端服務(wù)發(fā)現(xiàn)也需要向“服務(wù)中心”去取IP列表,那個服務(wù)中心不也可能成為單點嗎?確實如此!因此一般需要客戶端緩存服務(wù)中心的結(jié)果到本地文件,然后每次去本地文件讀取service->[ip:port,]的映射關(guān)系,然后定期輪詢服務(wù)中心看映射關(guān)系是否發(fā)生變化,再更新本地文件。這樣,即使服務(wù)中心掛掉,也不至于造成災(zāi)難性的后果。還有一種方式,干脆服務(wù)中心只做推送,服務(wù)中心把service -> [ip:port]的映射作為配置文件推送到所有服務(wù)器上,客戶端直接去讀本地文件即可,不再需要輪詢了。如果有新機器加入或者被摘除,服務(wù)中心重新進行推送即可。
很多團隊和服務(wù)發(fā)現(xiàn)解決方案甚至使用上了強一致性的etcd來做存儲,我個人認(rèn)為這并不妥當(dāng)。所有分布式系統(tǒng)當(dāng)然都希望一致性越強越好,但是一定能夠分辨業(yè)務(wù)對一致性的要求,是必須強一致否則系統(tǒng)無法運行,還是最終一致即可但是期望越快越好。我認(rèn)為服務(wù)發(fā)現(xiàn)并不是一個要求強一致性的場景,引入etcd只是徒增復(fù)雜性并且收效甚微。
你看,對于實施微服務(wù)來說,單純地想調(diào)用別的服務(wù)的方法,就有這么多需要解決的問題,而且每個問題深入下去都還有很多可優(yōu)化的點,因此技術(shù)升級確實代價不小。但是開源軟件幫助了我們,不是嗎?由于服務(wù)發(fā)現(xiàn)的普遍性,開源界已經(jīng)有很多成熟的解決方案了,比如JAVA的Eureka,比如Go的Consul等等,它們都是功能強大的”服務(wù)中心“,你通過簡單地學(xué)習(xí)就能快速使用到生產(chǎn)環(huán)境中了。
服務(wù)發(fā)現(xiàn)就完了嗎?當(dāng)然不是了,上面說的僅僅是技術(shù)層面的東西,實際上還有很多細(xì)節(jié)內(nèi)容,這些細(xì)節(jié)設(shè)計才決定著服務(wù)發(fā)現(xiàn)系統(tǒng)的擴展性和易用性。比如,如果有多機房,服務(wù)名怎么統(tǒng)一?換句話說,對于訂單服務(wù),廣州機房的client希望拿到廣州機房的訂單服務(wù)集群的IP而不是巴西機房的,畢竟跨機房訪問的延時是很高的。除了多機房問題,另一個問題是多環(huán)境問題。大多數(shù)公司都會有這么三個相互隔離的環(huán)境:生產(chǎn)環(huán)境、預(yù)覽環(huán)境、開發(fā)測試環(huán)境。預(yù)覽環(huán)境和生產(chǎn)環(huán)境一樣,就是為了模擬真實的線上環(huán)境,唯一的不同是預(yù)覽環(huán)境不接入外部流量而已。對于多機房、多環(huán)境,其實有個簡便的方法,就是把服務(wù)名都設(shè)計成形如serviceX.envY,比如order.envGZ、order.envTest、order.envPre…客戶端在啟動時需要根據(jù)自身所在環(huán)境提前實例化服務(wù)發(fā)現(xiàn)組件,后續(xù)請求都自動附加上實例化參數(shù)做為后綴。
陡增流量
我們的系統(tǒng)一定會有個承壓閾值,QPS高于這個閾值后,平均響應(yīng)時間和請求數(shù)就成正比關(guān)系,也就是說請求越多平均響應(yīng)時間越長。如果遇到公司做活動,或者業(yè)務(wù)本身就是波峰波谷周期性特別明顯的場景,就會面臨流量陡增的情況。當(dāng)流量發(fā)生陡增時,服務(wù)的整體響應(yīng)時間將會變長;而與此同時,用戶越是感覺響應(yīng)慢越急于反復(fù)重試,從而造成流量的暴漲,使得本身就已經(jīng)很長的響應(yīng)時間變得更長,使得服務(wù)502。
這是一個可怕的惡性循環(huán),響應(yīng)越慢,流量越大,流量越大,響應(yīng)更慢,直至崩潰。如果你的服務(wù)是整個系統(tǒng)的核心服務(wù),并不是可以被降級的服務(wù)(我們后面會聊降級),比如鑒權(quán)系統(tǒng)、訂單系統(tǒng)、調(diào)度系統(tǒng)等等,如果對陡增的流量沒有一個應(yīng)對方式,那么很容易就會崩潰并且蔓延至整個系統(tǒng),從而導(dǎo)致整個系統(tǒng)不可用。
應(yīng)對方式其實也很簡單,就是限流。如果某個服務(wù)經(jīng)過壓力測試后得出:當(dāng)QPS達(dá)到X時響應(yīng)的成功率為99.98%,那我們可以把X看做是我們的流量上限。我們在服務(wù)中會有一個專門的限流模塊作為處理請求的第一道閥門。當(dāng)流量超過X時,限流模塊可以pending該請求或者直接返回HTTP CODE 503,表示服務(wù)器過載。也就是說,限流模塊最核心的功能就是保證同一時刻應(yīng)用正在處理的請求數(shù)不超過預(yù)設(shè)的流量上限,從而保證服務(wù)能夠有比較穩(wěn)定的響應(yīng)時間。
那么限流模塊應(yīng)該怎么實現(xiàn)呢?最簡單的就是計數(shù)器限流算法。不是要保證QPS(Query Per Second)不大于X嗎,那我是不是只需要有一個每隔一秒就會被清零的計數(shù)器,在一秒鐘內(nèi),每來一個請求計數(shù)器就加一,如果計數(shù)器值大于X就表明QPS>X,后續(xù)的請求就直接拒絕,直到計數(shù)器被清零。這個算法很容易實現(xiàn),但是也是有弊端的。我們實際上是希望服務(wù)一直以一個穩(wěn)定的速率來處理請求,但是通過計數(shù)器我們把服務(wù)的處理能力按照秒來分片,這樣的弊端是,很可能處理X個請求只需要花費400ms,這樣剩下600ms系統(tǒng)無事可干但一直拒絕服務(wù)。這種現(xiàn)象被稱為突刺現(xiàn)象。然而你可以說,這個算法是沒問題的,因為這個閾值X是開發(fā)人員自己配置的,他設(shè)置得不合理。不過作為算法提供方,當(dāng)然需要考慮這些問題,不給用戶犯錯的機會豈不是更好?事實上,把服務(wù)按照秒來劃分時間片本身也不是很合理,為什么計數(shù)器的清零周期不是100ms呢,如果設(shè)置為Query Per Millisecond是不是更合理?Microsecond是不是更精確?當(dāng)然,以上問題只是在極端情況下會遇到,絕大多數(shù)時候使用計數(shù)器限流算法都沒有問題。
限流的另一種常用算法是令牌桶算法。想象一個大桶,里面有X個令牌,當(dāng)且僅當(dāng)某個請求拿到令牌才能被繼續(xù)處理,否則就需要排隊等待令牌或者直接503拒絕掉。同時,這個桶中會以一定的速率K新增令牌,但始終保證桶中令牌最多不超過X。這樣可以保證在下一次桶中新增令牌前,同時最多只有X個請求正在被處理。然而突刺現(xiàn)象可能依然存在,比如短時間內(nèi)耗光了所有令牌,在下一次新增令牌之前的剩下時間里,只能拒絕服務(wù)。不過好在新增令牌的間隔時間很短,因此突刺現(xiàn)象并不會很突出。并且突刺現(xiàn)象本身就很少見,因此令牌桶算法是相比于計數(shù)器更好也更常見的算法。不過你也可以看到,不同的算法來進行限流,本質(zhì)上都是盡量去模擬“一直以一個穩(wěn)定的速率處理請求”,不過只要這個模擬間隔是離散的,它始終都不會完美。
對于限流來說,業(yè)界其實也有比較多的成熟方案可選,比如JAVA的Hystrix,它不僅有限流的功能,還有很多其它的功能集成在里面。對于Golang來說有g(shù)olang.org/x/time里的限流庫,相當(dāng)于是準(zhǔn)標(biāo)準(zhǔn)庫。
我們到目前為止聊的應(yīng)對陡增流量都是從服務(wù)提供方的角度來說的,目的是保證服務(wù)本身的穩(wěn)定性。但是同時我們也可以從服務(wù)調(diào)用方的角度來聊聊這個問題,我們叫它——熔斷。當(dāng)然熔斷并不是單純針對陡增流量,某些流量波谷時我們也可能需要熔斷。
當(dāng)作為服務(wù)調(diào)用方去調(diào)用某個服務(wù)時,很可能會調(diào)用失敗。而調(diào)用失敗的原因有很多,比如網(wǎng)絡(luò)抖動,比如參數(shù)錯誤,比如被限流,或者是服務(wù)無響應(yīng)(超時)。除了參數(shù)錯誤以外,調(diào)用方很難知道到底為什么調(diào)用失敗。這時我們考慮一個問題,假設(shè)調(diào)用失敗是因為被依賴的服務(wù)限流了,我們該如何應(yīng)對?重試嗎?
顯然這個問題的答案不能一概而論,得具體看我們依賴的服務(wù)是哪種類型的服務(wù),同時還要看我們自身是哪種服務(wù)。
我們先來看一種特殊的場景,即我們(調(diào)用方)是一個核心服務(wù),而依賴是一個非核心服務(wù)。比如展示商品詳情的接口,這個接口不僅需要返回商品詳情信息,同時需要請求下游服務(wù)返回用戶的評價。假如評價系統(tǒng)頻繁返回失敗,我們可以認(rèn)為評價系統(tǒng)負(fù)載過高,或者遇到了其它麻煩。而評價信息對于商品詳情來說并不是必須的,因此為了減少評價系統(tǒng)的壓力,我們之后可以不再去請求評價系統(tǒng),而是直接返回空。
我們不再請求評價系統(tǒng)這個行為,稱之為熔斷,這是調(diào)用方主動的行為,主要是為了加快自己的響應(yīng)時間(即使繼續(xù)請求評價系統(tǒng),大概率依然會超時,什么返回都沒有,還白白浪費了時間,不如跳過這一步),不過同時也能減少對下游的請求使下游的壓力減小。
當(dāng)我們進行熔斷之后,原本應(yīng)該返回用戶的評價列表,現(xiàn)在直接返回一個空數(shù)組,這個行為我們稱之為降級。因為我們熔斷了一個數(shù)據(jù)鏈路,那么之后的行為就會和預(yù)期的不一致,這個不一致就是降級。當(dāng)然,降級也有很多策略,不一定是返回空,這個需要根據(jù)業(yè)務(wù)場景制定相應(yīng)的降級策略。
另一個典型的場景是,非核心服務(wù)調(diào)用核心服務(wù),比如一個內(nèi)部的工單系統(tǒng),它可能也需要展示每個工單關(guān)聯(lián)的訂單詳情。如果發(fā)現(xiàn)訂單系統(tǒng)連續(xù)報錯或者超時,此時應(yīng)該怎么辦?最好的辦法就是主動進行熔斷!因為訂單系統(tǒng)是非常核心的系統(tǒng),在線業(yè)務(wù)都依賴于它,沒有它公司就沒法賺錢了!而工單系統(tǒng)是內(nèi)部系統(tǒng),晚一些處理也沒關(guān)系,于是可以進行熔斷。雖然這可能導(dǎo)致整個工單系統(tǒng)不可用,但是它不會增加訂單系統(tǒng)的壓力,期望它盡可能保持平穩(wěn),也就是那句話:“我只能幫你到這里了”。不過實際上到底能不能進行自我毀滅式的熔斷依然要根據(jù)業(yè)務(wù)場景來定,不是想熔斷就熔斷的,有些業(yè)務(wù)場景可能也無法接受熔斷帶來的后果,那么就需要你和相關(guān)人員制定降級策略plan B。
總之,熔斷和降級就是調(diào)用方用來保護依賴服務(wù)的一種方式,很多人都會忽略它。但這正如你家里的電路沒有跳閘一樣,平時感覺不到有啥,一旦出事兒了后果就不堪設(shè)想!
那么,我們到底什么時候需要進行熔斷?一般來說,我們需要一個專門的模塊來完成這個工作,它的核心是統(tǒng)計RPC調(diào)用的成功率。如果調(diào)用某個服務(wù)時,最近10s內(nèi)有50%的請求都失敗了,這可以作為開啟熔斷的指標(biāo)。當(dāng)然,由于依賴的服務(wù)不會一直出問題(畢竟它也有穩(wěn)定性指標(biāo)),因此熔斷開啟需要有一個時間段,在一段時間內(nèi)開啟熔斷。當(dāng)一段時候過后,我們可以關(guān)閉熔斷,重新對下游發(fā)起請求,如果下游服務(wù)恢復(fù)了最好,如果依然大量失敗,再進入下一個熔斷狀態(tài),如此往復(fù)…
前面提到的JAVA用于限流的模塊Hystrix,它也集成了熔斷的功能,而且它還多了一個叫半熔斷的狀態(tài)。當(dāng)失敗率達(dá)到可以熔斷的閾值時,Hystrix不是直接進入熔斷狀態(tài),而是進入半熔斷狀態(tài)。在半熔斷狀態(tài),有一部分請求會熔斷,而另一部分請求依然會請求下游。然后經(jīng)過二次統(tǒng)計,如果這部分請求正常返回,可以認(rèn)為下游服務(wù)已經(jīng)恢復(fù),不需要再熔斷了,于是就切換回正常狀態(tài);如果依然失敗率居高不下,說明故障還在持續(xù),這時才會進入真正的熔斷狀態(tài),此時所有對該下游的調(diào)用都會被熔斷。
Hystrix的半熔斷狀態(tài)可以有效應(yīng)對下游的瞬時故障,使得被熔斷的請求盡可能少,從熔斷狀態(tài)回復(fù)到正常狀態(tài)盡可能快,這也意味著服務(wù)的可用性更高——一旦進入熔斷狀態(tài)就回不了頭了,必須等熔斷期過了才行。
實現(xiàn)熔斷功能并不像實現(xiàn)限流一樣簡單,它復(fù)雜得多:
- 熔斷需要介入(劫持)每個RPC請求,才能完成成功率的統(tǒng)計
- 需要提供方便的接口供用戶表達(dá)fallback邏輯(降級)
- 最好能夠做到無感知,避免用戶在每個RPC請求之前手動調(diào)用熔斷處理函數(shù)
由于熔斷和降級的功能對用于來說更像是一種函數(shù)的鉤子,它不僅要求功能完備,更需要簡單易用,甚至是不侵入代碼。也就是說,熔斷模塊不僅在實現(xiàn)上有一定技術(shù)難度,在易用性設(shè)計上也很有講究。一個很容易想到的并且能夠?qū)⒁子眯蕴嵘姆椒ň褪莣rap你的http庫,比如提供特殊的http.Post、http.Get方法,它們的簽名和標(biāo)準(zhǔn)庫一致,不過在內(nèi)部集成了熔斷的邏輯。當(dāng)然,像Hystrix一樣使用一個對象來代理執(zhí)行網(wǎng)絡(luò)請求,也是一種不錯的思路。
在熔斷和降級方面,業(yè)界主要的比較成熟的方案就是Netflix的Hystrix,其它語言也很多借鑒Hystrix做了很多類似的庫,比如Go語言的Hystrix-go。可以肯定的是,服務(wù)限流和熔斷等工作,真正落地實施時還有很多困難和可以優(yōu)化的點,這里只是帶你簡單游覽一番。
我們講了服務(wù)發(fā)現(xiàn)和注冊,服務(wù)限流和熔斷降級,這些概念伴隨著微服務(wù)而出現(xiàn),因此我們需要解決它。但是仔細(xì)想一下,為什么實施了微服務(wù),就會遇到這些問題?實際上最根本的原因是,微服務(wù)松散的特性使得它缺少一個全局的編譯器。單體應(yīng)用中添加和使用一個模塊,直接編寫代碼即可,編譯器可以來幫你做剩下的事情,幫你保證正確性。而微服務(wù)架構(gòu)中,各個服務(wù)間都是隔離的,彼此不知道對方的存在,但又需要用到對方提供的方法,因此只能通過約定,通過一個中心來互相告知自己的存在。同時在單體應(yīng)用中,我們可以很容易地通過壓測來測試出系統(tǒng)的瓶頸然后來進行優(yōu)化。但是在微服務(wù)架構(gòu)中,由于大多數(shù)時候不同服務(wù)是由不同部門不同組來開發(fā),把它們集成起來是一件很費勁的事情。你只能通過全鏈路壓測才能找到一個系統(tǒng)的瓶頸,然而實施全鏈路壓測是非常困難的,尤其是在已有架構(gòu)體系上支持全鏈路壓測,需要非常深地侵入業(yè)務(wù)代碼,各種trick的影子表方案…全鏈路壓測是另一個非常龐大的話題,跟我們的話題不太相關(guān),因此我不打算在這里長篇大論,但是很明確的一點是:由于無法實施全鏈路壓測,所以微服務(wù)中我們只能進行防御性編程,我們必須假設(shè)任何依賴都是脆弱的,我們需要應(yīng)對這些問題從而當(dāng)真正出現(xiàn)問題時不至于讓故障蔓延到整個系統(tǒng)。因此我們需要限流,需要熔斷,需要降級。
所以你可以看到,很多技術(shù)并不是憑空出現(xiàn)的,當(dāng)你解決某個問題時,可能會引入新的問題。這是一定的,所有技術(shù)的變革都有代價。不過要注意,這和你邊改Bug邊引入新Bug并不一樣:P。
服務(wù)間通信
我們上面一起聊了微服務(wù)之間如何相互發(fā)現(xiàn)(相當(dāng)于實現(xiàn)了編譯器的符號表),也聊了當(dāng)出錯時怎么保護下游和自我保護。但是微服務(wù)的核心是服務(wù)間的通信!正是服務(wù)間通信把小的服務(wù)組合成一個特定功能的系統(tǒng),我們才能對外提供服務(wù)。接下來我們來聊一聊服務(wù)間通信。
由于不同的服務(wù)都是獨立的進程,大多數(shù)都在不同的機器,服務(wù)間通信基本都是靠網(wǎng)絡(luò)(同一臺機器的IPC就不考慮了)。網(wǎng)絡(luò)通信大家都知道,要么是基于面向有連接的TCP,要么是面向無連接的UDP。絕大多數(shù)時候,我們都會使用TCP來進行網(wǎng)絡(luò)通信,因此下面的討論我們都默認(rèn)使用TCP協(xié)議。
一說到通信協(xié)議,很多人腦海中可能就會跳出一個名詞:RESTful。然而RESTful并不是一個協(xié)議,而是基于HTTP協(xié)議的一種API設(shè)計方式。使用RESTful意味著我們使用HTTP協(xié)議進行通信,同時我們需要把我們的業(yè)務(wù)按照資源進行建模,API通過POST DELETE PUT GET四種方法來對資源進行增刪改查。由于絕大多數(shù)企業(yè)的用戶都是通過瀏覽器或者手機APP來使用服務(wù)的,因此我們可以認(rèn)為:
對用戶直接提供服務(wù)時,通信協(xié)議一定要使用HTTP
既然一定需要用HTTP(1.1)那就用吧,似乎沒有討論通信協(xié)議的必要了?不,當(dāng)然有必要了!
首先我們需要了解的一個事實是,絕大部分直接和用戶打交道的接口都是聚合型接口,它們的工作大多是收集用戶請求,然后再去各下游系統(tǒng)獲取數(shù)據(jù),把這些數(shù)據(jù)組合成一個格式返回給用戶。后面的章節(jié)我們會詳細(xì)討論這種API接口,我們稱之為API Gateway,這里先不深入。不過從中你可以發(fā)現(xiàn),僅僅是API Gateway和客戶端直接通信被限制使用HTTP協(xié)議,API Gateway和它后面的各個微服務(wù)并沒有限制使用哪種通信協(xié)議。
不過讓我們先拋開不同協(xié)議的優(yōu)劣,先來看一下發(fā)起一次RPC需要經(jīng)歷的步驟:
- 客戶端根據(jù)接口文檔,填好必要的數(shù)據(jù)到某個對象中
- 客戶端把改對象按照協(xié)議要求進行序列化
- 發(fā)送請求
- 服務(wù)端根據(jù)協(xié)議反序列化
- 服務(wù)端把反序列化的數(shù)據(jù)填充到某個對象中
- 服務(wù)端進行處理,把結(jié)果按照通信協(xié)議序列化并發(fā)送
- 客戶端按照通信協(xié)議反序列化數(shù)據(jù)到某個對象中
可以看到,RPC需要根據(jù)協(xié)議進行大量的序列化和反序列化。但是通信協(xié)議是給機器看的,只有接口文檔才是給程序員看的。每次調(diào)用一個下游服務(wù)都需要對照文檔組裝數(shù)據(jù),服務(wù)方也必須提供文檔否則沒有人知道該如何調(diào)用。換句話說
在RPC中,接口文檔是必須存在的
既然接口文檔存在,實際上問題就簡化了,因為我們可以寫一個很簡單的代碼生成器根據(jù)文檔生成調(diào)用接口的代碼。既然程序員只關(guān)心接口文檔的參數(shù),剩下的代碼都可以自動生成,那么通信協(xié)議使用什么就無所謂了,只要調(diào)用方和服務(wù)提供方使用一樣的協(xié)議即可。既然用什么通信協(xié)議無所謂了,而且不論協(xié)議多復(fù)雜反正代碼也能自動生成,那為什么不使用性能更好的傳輸協(xié)議呢?
所以你可以看到,具體使用什么通信協(xié)議其實是一個自然選擇的過程,反正都是面向接口文檔利用生成器編程,選擇性能更好的協(xié)議屬于免費的午餐,那當(dāng)然選性能好的協(xié)議了。不過這并不代表你值得花精力去開發(fā)一個擁有極致卓越性能的協(xié)議,因為:
- 耗時大部分都是網(wǎng)絡(luò)傳輸和IO,協(xié)議多些字節(jié)解碼多費點時間只是小意思
- 生態(tài),小眾的協(xié)議很難利用現(xiàn)有的基礎(chǔ)設(shè)施
總之,在API Gateway背后的微服務(wù)之間,選用高性能的傳輸協(xié)議基本是免費的午餐,因此我們應(yīng)該一開始就使用某種協(xié)議。業(yè)界有很多開源的高性能通信協(xié)議,比如Google的ProtoBuf(簡稱PB)和Facebook貢獻(xiàn)給Apache的Thrift,這兩個協(xié)議都是被廣泛使用于生產(chǎn)環(huán)境的。
不過很多人不知道gRPC和PB的區(qū)別。gRPC其實是個服務(wù)框架,可以理解為一個代碼生成器。它接收一個接口文檔,這個文檔用PB的語法編寫(也稱為IDL),輸出對應(yīng)的server端和client端的代碼,這些代碼使用PB協(xié)議來對數(shù)據(jù)進行序列化。而對于Thrift,我們通常沒有這種混淆,因為thrift序列化方法一直是和與其配套的代碼生成器同時使用的。
在我們選定協(xié)議之后,服務(wù)間通信就告一段落了嗎?當(dāng)然不是!可以說微服務(wù)相關(guān)的技術(shù)棧都是圍繞服務(wù)間,后面還有很多需要解決的問題。
比如在單體應(yīng)用中,加入我們發(fā)現(xiàn)一個漏洞,修復(fù)的方法是讓獲取訂單詳情的函數(shù)增加驗證用戶的token。此時我們需要改動獲取訂單詳情的函數(shù)簽名以及它的內(nèi)部實現(xiàn),同時在各個調(diào)用處都加傳token參數(shù),然后通過編譯即可。但是在微服務(wù)中,由于系統(tǒng)間是隔離的,單個服務(wù)的改動別的服務(wù)無法感知,上線也不是同步的。這意味著如果我修改了接口簽名并重新上線后,所有依賴于我的服務(wù)將會立刻失??!因為根據(jù)之前的接口定義生成的client對數(shù)據(jù)的序列化,此時新的server端無法成功反序列化出來。
當(dāng)然,這個問題gRPC和Thrift也早已經(jīng)考慮到。它們的IDL讓你在定義接口時,不僅要給出參數(shù)名和類型,同時還需要編號。這個編號就用來保證序列化的兼容性。也就是說,只要你更新接口定義是通過在結(jié)構(gòu)體后面增加參數(shù)而不是刪除或者修改原參數(shù)類型,那么序列化和反序列化是兼容的。所以解決上面問題的方法也很簡單,只需要在原來定義的結(jié)構(gòu)體后面增加一個Token字段即可,服務(wù)端做兼容。傳了Token的就驗Token,沒傳Token的依然可以按照老邏輯運行,只是你需要統(tǒng)計哪些上游還沒有更新,然后去逐個通知他們。
到這里你也能發(fā)現(xiàn)微服務(wù)架構(gòu)面臨的一個比較嚴(yán)峻的問題,想要全量升級某個服務(wù)是非常困難的,想要整個系統(tǒng)同時升級某個服務(wù)是幾乎不可能的。
gRPC和Thrift都是非常常用的RPC框架,它們的優(yōu)劣其實并不太明顯,如果一個比另一個在各方面都強的話,就不需要拿來比了…Thrift由于時間更長,支持的語言更多功能更齊全;而gRPC更年輕,支持的語言更少,但是gRPC集成了Google出品的一貫作風(fēng),配套設(shè)施和文檔、教程非常齊全。當(dāng)然它們還有很多性能上的差異,但是這些差異大多是由對應(yīng)語言的geneator造成的,并不是協(xié)議本身。所以實際上你可以隨意選擇一個,只要整個公司統(tǒng)一就行,我個人更建議gRPC。
我們上面的討論也講了,我們在升級服務(wù)接口時需要統(tǒng)計哪些上游還在用過時的協(xié)議,方便我們推動對方升級。由于不同接口定義都不一樣,差異化很大,以現(xiàn)有的架構(gòu)幾乎無法實現(xiàn)旁路追蹤,只能在服務(wù)端進行埋點,在反序列化之后服務(wù)端自己來判斷,從而統(tǒng)計出需要的信息。有沒有更好的辦法呢?我們后面再聊。
Tracing
我們上面說了很多和微服務(wù)息息相關(guān)的點,比如限流,比如熔斷,比如服務(wù)發(fā)現(xiàn),比如RPC通信。但如果僅僅是這些,你會覺得整個系統(tǒng)還是很模糊,很零散,你不知道一個請求通過API Gateway之后都調(diào)用了哪些服務(wù)——因為你缺少一個全局的視圖。
對于單體應(yīng)用來說,最簡單的全局視圖就是backtrace調(diào)用棧。通過在某個函數(shù)中輸出調(diào)用棧,可以在運行時打印出從程序入口運行到此的層層調(diào)用關(guān)系。哪個模塊被誰調(diào)用,哪個模塊調(diào)用了誰,都一目了然(其實backtrace的輸出一般也不太好看…)。更強大一點的,比如說JAVA編寫的程序,通過在eclipse中安裝插件CallGraph,就能靜態(tài)分析出各個對象和方法的調(diào)用關(guān)系,并以圖像來展示,非常直觀。但是對于微服務(wù)來說,下游服務(wù)無法打印出它上游服務(wù)的backtrace,也沒有任何編譯器能把所有服務(wù)的代碼合并起來做靜態(tài)分析。因此對于微服務(wù)來說,要得到調(diào)用關(guān)系的視圖并不容易。
Google在一篇名為Dapper的論文中,提出了一種方法用于在微服務(wù)系統(tǒng)中“繪制”調(diào)用關(guān)系視圖。不過拋開具體的論文,我們自己其實也能很容易地把tracing劃分出三個比較獨立的部門:
- 業(yè)務(wù)埋點
- 埋點日志存儲
- Search+可視化UI
但是事實上調(diào)用鏈路追蹤是個很復(fù)雜的系統(tǒng),而不單單是某個微服務(wù)中的一個模塊,它是重量級的。不像之前說的限流、熔斷等可以通過引入一個開源庫就能實現(xiàn),它的復(fù)雜性體現(xiàn)在:
- 業(yè)務(wù)埋點是個藝術(shù)活,怎么樣才能是埋點負(fù)擔(dān)最小同時埋點足夠準(zhǔn)確。另一方面,就像之前提到服務(wù)升級的話題,在微服務(wù)中一旦代碼上線后,想再全量升級是非常困難的。埋點收集的數(shù)據(jù)要足夠豐富,但是太豐富又會給業(yè)務(wù)帶來負(fù)擔(dān),必須提前規(guī)劃好哪些是必要的,這很難
- 一旦系統(tǒng)規(guī)模做大,RPC調(diào)用是非常多的,埋點收集數(shù)據(jù)將非常多,需要一個穩(wěn)定的存儲服務(wù)。這個存儲不僅要能承載海量數(shù)據(jù),同時需要支持快速檢索(一般來說就是ES)
- 需要單獨的界面能夠讓用戶根據(jù)某些條件檢索調(diào)用鏈路,并進行非常直觀的圖形化展示
Dapper最重要的其實就是它提出了一種日志規(guī)范,如果每個業(yè)務(wù)埋點都按此標(biāo)準(zhǔn)來打日志,那么就可以以一種統(tǒng)一的方式通過分析日志還原出調(diào)用關(guān)系。一般來說,Tracing有以下幾個核心概念:
- Trace: 用戶觸發(fā)一個請求,直到這個請求處理結(jié)束,整個鏈路中所有的RPC調(diào)用都屬于同一個Trace
- Span: 可以認(rèn)為一個RPC請求就是一個Span,Span中需要附帶一些上下文信息支持后續(xù)的聚合分析
- Tag: Tag是Span附帶的信息,用于后續(xù)的檢索。它一般用來把Span分類,比如db.type="sql"表示這個RPC是一個sql請求。后續(xù)檢索時就可以很容易把進行過sql查詢的請求給篩出來
這里只是簡單列舉了Tracing系統(tǒng)最重要的三個概念。如果一條日志包含了 traceID spanID Tag,相信你也能很容易地利用它們繪制出請求調(diào)用鏈路圖。當(dāng)然,這其實也不用你自己來實現(xiàn),業(yè)界已經(jīng)有比較成熟的開源方案了,比如twitter開源的zipkin和Uber開源Go的jaeger(jaeger已經(jīng)進入CNCF進行孵化了,進入CNCF意味著它通??梢宰鳛榉植际健⒃朴嬎愕阮I(lǐng)域的首選方案)。但是它們和之前所說的各種限流或者熔斷組件不一樣,它們并不是一個庫,而是一個整體的解決方案,需要你部署存儲和Dashbord,也提供給你SDK進行埋點。但是由于Docker的存在,實際上部署也非常簡單(Docker我們后面會細(xì)聊)。
然而,jaeger和zipkin也各有各的不足,比如它們薄弱的UI。因此還有很多類似的項目正在被開發(fā)??紤]到通用性,所以業(yè)界一開始就先出了一個OpenTracing項目(也進入了CNCF),它可其實是一個interface定義。它致力于統(tǒng)一業(yè)務(wù)埋點收集數(shù)據(jù)的API和數(shù)據(jù)格式,這樣使得大家可以把中心放到展示層等其他方面。由于有了一致的數(shù)據(jù),用戶也能隨意切換到別的系統(tǒng)。jaeger和zipkin都實現(xiàn)了OpenTracing規(guī)范。
不過總的來說,服務(wù)鏈路跟蹤是一個很龐大的工作,有很多需要優(yōu)化和訂制的地方。如何快速響應(yīng)用戶的查詢,這依賴于高性能的存儲引擎。隨著數(shù)據(jù)量的增加,存儲的容量也會成問題。當(dāng)然,展示是否直觀,是否能從Tag或者Log里挖出更多信息,也是非常重要的。一般來說,這都需要一個團隊深入去做。Tracing實際上是一個比較深的領(lǐng)域,要做好不容易,這里也就不深入下去了,感興趣可以從Dapper開始看起。
這篇文章已經(jīng)很長了,但實際上微服務(wù)中還有非常多的topic沒講。即使我們講過的topic,大多也是泛泛而談,比如服務(wù)發(fā)現(xiàn)系統(tǒng)其實就是一個非常復(fù)雜的系統(tǒng)。每一個點都值得我們程序員去學(xué)習(xí)鉆研。
在后續(xù)的文章中我會接著講監(jiān)控、日志等在微服務(wù)中應(yīng)用。微服務(wù)體系有這么多需要解決的問題,但實際上更重要的問題是,如何交付系統(tǒng),這涉及到持續(xù)集成和持續(xù)部署相關(guān)話題。在現(xiàn)有的架構(gòu)體系中,持續(xù)集成和部署并不是一件容易的事情,很多時候它們可能會讓運維同學(xué)疲于奔命,因此我們會講到Docker到底是如何解決這些問題,以及簡單聊一聊Docker的原理。Docker的出現(xiàn)給微服務(wù)架構(gòu)插上了翅膀,使得微服務(wù)以更快的速度普及。但是所有團隊都會面臨微服務(wù)帶來的新問題,而這些問題實際上并沒有被系統(tǒng)的解決。Docker使得一個個的微服務(wù)就像一個函數(shù)一樣簡單,但是正如單體應(yīng)用是由一系列函數(shù)按一定邏輯組合而成,我們的系統(tǒng)也是由一系列微服務(wù)構(gòu)建而成。這種組合函數(shù)的工作并不會消失,只是從單體應(yīng)用中的Controller遷移到了容器編排,我們會看到Swarm和Kubernates是如何解決這些問題的。Kubernates是一個革命性的軟件,它的抽象使得我們前面聊的Topic可以有更先進更純粹的解決方案,比如服務(wù)網(wǎng)格ServiceMesh……還有好多好多,我會在下一章細(xì)細(xì)道來