最近幾年“微服務(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我都盡量細化城丧,讓你知道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)常聽到的名詞——負載均衡。通常情況下权逗,我們希望請求能均勻的分散到所有機器上美尸,這樣不至于使得某臺機器負載過大而另一臺機器壓力過小。我們就需要盡可能公平的使用這些IP斟薇,因此需要引入一些算法來幫助我們選擇:
- 輪詢(加權(quán)輪詢)
- 隨機(加權(quán)隨機)
為什么會有加權(quán)輪詢师坎、加權(quán)隨機?這很可能是因為我們實際的物理機配置不一樣堪滨,雖然都在一個集群胯陋,有些是8核CPU有些是4核,內(nèi)存也有差異袱箱,加權(quán)算法使得我們可以人為配置哪些機器接受請求多一些哪些少一些遏乔。
還有一些特殊場景,我們希望相同特征的請求盡量落到同一臺服務(wù)器发笔,比如同一個用戶的請求我們可能希望它落到固定的某臺機器(雖然這么做不太合理盟萨,這里僅舉例)髓霞,我們也可以在負載均衡算法上做文章逛万,使得我們的目的達成。
另一個問題是敦腔,依賴的服務(wù)可能會宕機,如果我們的負載均衡算法剛好選中了該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并在本地通過負載均衡算法選取其一。其實還有另一種方案错维,是服務(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è)的負載均衡算法直接把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ù)層面的東西案怯,實際上還有很多細節(jié)內(nèi)容,這些細節(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達到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)負載過高钞瀑,或者遇到了其它麻煩肃晚。而評價信息對于商品詳情來說并不是必須的,因此為了減少評價系統(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)失敗率達到可以熔斷的閾值時砸捏,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)計
- 需要提供方便的接口供用戶表達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)闻丑,因此我們需要解決它漩怎。但是仔細想一下,為什么實施了微服務(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é)我們會詳細討論這種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貢獻給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ù)活,怎么樣才能是埋點負擔(dān)最小同時埋點足夠準(zhǔn)確祈匙。另一方面忽刽,就像之前提到服務(wù)升級的話題,在微服務(wù)中一旦代碼上線后夺欲,想再全量升級是非常困難的跪帝。埋點收集的數(shù)據(jù)要足夠豐富,但是太豐富又會給業(yè)務(wù)帶來負擔(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意味著它通常可以作為分布式铲汪、云計算等領(lǐng)域的首選方案)熊尉。但是它們和之前所說的各種限流或者熔斷組件不一樣,它們并不是一個庫掌腰,而是一個整體的解決方案狰住,需要你部署存儲和Dashbord,也提供給你SDK進行埋點齿梁。但是由于Docker的存在催植,實際上部署也非常簡單(Docker我們后面會細聊)。
然而勺择,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……還有好多好多纫雁,我會在下一章細細道來