再見微服務(wù)
來源:http://www.sohu.com/a/321866133_669829
本文翻譯自Alexandra Noonan 的 Goodbye Microservices: From 100s of problem children to 1 superstar。內(nèi)容是描述 Segment 的架構(gòu)如何從 「單體應(yīng)用」 -> 「微服務(wù)」 -> 「140+ 微服務(wù)」 -> 「單體應(yīng)用」 的一個歷程。翻譯比較粗糙,如有疏漏,請不吝指教。
注:下文說的目的地就是對應(yīng)的不同的數(shù)據(jù)平臺(例如Google Analytics, Optimizely)
除非你生活在石器時代,不然你一定知道「微服務(wù)」是當(dāng)世最流行的架構(gòu)。我們Segment早在2015年就開始實踐這一架構(gòu)。這讓我們在一些方面上吃了不少甜頭,但很快我們發(fā)現(xiàn):在其他場景,他時不時讓我們吃了苦頭。
簡而言之,微服務(wù)的主要宣傳點在于:模塊化優(yōu)化,減少測試負擔(dān),更好的功能組成,環(huán)境獨立,而且開發(fā)團隊是自治的(因為每一個服務(wù)的內(nèi)部邏輯是自洽且獨立的)。而另一頭的單體應(yīng)用:「巨大無比且難以測試,而且服務(wù)只能作為一個整理來伸縮(如果你要提高某一個服務(wù)的性能,只能把服務(wù)器整體提高)」
2017 早期,我們陷入了僵局,復(fù)雜的微服務(wù)樹讓我們的開發(fā)效率驟減,并且每一個開發(fā)小組都發(fā)現(xiàn)自己每次實現(xiàn)都會陷入巨大的復(fù)雜之中,此時,我們的缺陷率也迅速上升。
最終,我們不得不用三個全職工程師來維護每一個微服務(wù)系統(tǒng)的正常運行。這次我們意識到改變必須發(fā)生了,本文會講述我們?nèi)绾魏笸艘徊?,讓團隊需要和產(chǎn)品需求完全一致的方法。
為什么微服務(wù)曾經(jīng)可行?
Segment 的客戶數(shù)據(jù)基礎(chǔ)設(shè)施吸收每秒成百上千個事件,將每一個伙伴服務(wù)的API 請求結(jié)果一個個返回給對應(yīng)的服務(wù)端的「目的地」。而「目的地」有上百種類別,例如Google Analytics, Optimizely,或者是一些自定義的webhook。
幾年前,當(dāng)產(chǎn)品初步發(fā)布,當(dāng)時架構(gòu)很簡單。僅僅是一個接收事件并且轉(zhuǎn)發(fā)的消息隊列。在這個情況下,事件是由Web或移動應(yīng)用程序生成的JSON對象,例子如下:
{
"type": "identify",
"traits": {
"name": "Alex Noonan",
"email": "[email protected]",
"company": "Segment",
"title": "Software Engineer"
},
"userId": "97980cfea0067"
}事件是從隊列中消耗的,客戶的設(shè)置會決定這個事件將會發(fā)送到哪個目的地。這個事件被紛紛發(fā)送
到每個目的地的API,這很有用,開發(fā)人員只需要將他們的事件發(fā)送到一個特定的目的地——也就是
Segment 的API,而不是你自己實現(xiàn)幾十個項目集成。
如果一個請求失敗了,有時候我們會稍后重試這個事件。一些失敗的重試是安全的,但有些則不??芍卦嚨腻e誤可能會對事件目的地不造成改變,例如:50x錯誤,速率限制,請求超時等。不可重試的錯誤一般是這個請求我們確定永遠都不會被目的地接受的。例如:請求包含無效的認證亦或是缺少必要的字段。

此時,一個簡單的隊列包含了新的事件請求以及若干個重試請求,彼此之間事件的目的地縱橫交錯,會導(dǎo)致的結(jié)果顯而易見:隊頭阻塞。意味著在這個特定的場景下,如果一個目的地變慢了或者掛掉了,重試請求將會充斥這個隊列,從而整個請求隊列會被拖慢。
想象下我們有一個 目的地 X 遇到一個臨時問題導(dǎo)致每一個請求都會超時。這不僅會產(chǎn)生大量尚未到達目的地 X的請求,而且每一個失敗的事件將會被送往重試的隊列。即便我們的系統(tǒng)會根據(jù)負載進行彈性伸縮,但是請求隊列深度突然間的增長會超過我們伸縮的能力,結(jié)果就是新的時間推送會延遲。發(fā)送時間到每一個目的地的時間將會增加因為目的地X 有一個短暫的停止服務(wù)(因為臨時問題)??蛻粢蕾囉谖覀兊膶崟r性,所以我們無法承受任何程度上的緩慢。

為了解決這個隊頭阻塞問題,我們團隊給每一個目的地都分開實現(xiàn)了一個隊列,這種新架構(gòu)由一個額外的路由器進程組成,該進程接收入站事件并將事件的副本分發(fā)給每個選定的目標(biāo)?,F(xiàn)在如果一個目的地有超時問題,那么也僅僅是這個隊列會進入阻塞而不會影響整體。這種「微服務(wù)風(fēng)格」的架構(gòu)分離把目的地彼此分開,當(dāng)一個目的地老出問題,這種設(shè)計就顯得很關(guān)鍵了。

個人Repo 的例子
每一個目的地的API 的請求格式都不同,需要自定義的代碼去轉(zhuǎn)換事件來匹配格式。一個簡單的例子:還是目的地X,有一個更新生日的接口,作為請求內(nèi)容的格式字段為 dob ,API 會對你要求字段為 birthday,那么轉(zhuǎn)換代碼就會如下:
const traits = {}
traits.dob = segmentEvent.birthday許多現(xiàn)代的目的地終點都用了Segment 的請求格式,所以轉(zhuǎn)換會很簡單。但是,這些轉(zhuǎn)換也可能會
十分復(fù)雜,取決于目的地API 的結(jié)構(gòu)。
起初,目的地分成幾個拆分的服務(wù)的時候,所有的代碼都會在一個repo 里。一個巨大的挫折點就是一個測試的失敗常常會導(dǎo)致整個項目測試無法跑通。我們可能會為此付出大量的時間只是為了讓他像之前一樣正常運行通過測試。為了解決這個問題,我們把每一個服務(wù)都拆分成一個單獨的repo,所有的目的地的測試錯誤都只會影響自己,這個過渡十分自然。
拆分出來的repo 來隔離開每一個目的地會讓測試的實現(xiàn)變得更容易,這種隔離允許開發(fā)團隊快速開發(fā)以及維護每一個目的地。
伸縮微服務(wù)和Repo 們
隨著時間的偏移,我們加了50多個新的目的地,這意味著有50個新的repo。為了減輕開發(fā)和維護這些codebase 的負擔(dān),我們創(chuàng)建一個共享的代碼庫來做實現(xiàn)一些通用的轉(zhuǎn)換和功能,例如HTTP 請求的處理,不同目的地之間代碼實現(xiàn)更具有一致性。
例如:如果我們要一個事件中用戶的名字,event.name() 可以是任何一個目的地里頭的調(diào)用。共享的類庫會去嘗試判斷event 里的 name 或者 Name 屬性,如果沒有,他會去查 first name,那么就回去查找first_name 和 FirstName,往下推:last name 也會做這樣的事情。然后吧first name 和last name 組合成full name.
Identify.prototype.name = function() {
var name = this.proxy('traits.name');
if (typeof name === 'string') {
return trim(name)
}
var firstName = this.firstName();
var lastName = this.lastName();
if (firstName && lastName) {
return trim(firstName + ' ' + lastName)
}
}共享的代碼庫讓我們能快速完成新的目的地的實現(xiàn),他們之間的相似性帶給我們一致性的實現(xiàn)而且維護上也讓我們減少了不少頭疼的地方。
盡管如此,一個新的問題開始發(fā)生并蔓延。共享庫代碼改變后的測試和部署會影響所有的目的地。這開始讓我們需要大量時間精力來維護它。修改或者優(yōu)化代碼庫,我們得先測試和部署幾十個服務(wù),這其中會帶來巨大的風(fēng)險。時間緊迫的時候,工程師只會在某個特定的目的地去更新特定版本的共享庫代碼。
緊接著,這些共享庫的版本開始在不同的目標(biāo)代碼庫中發(fā)生分歧。微服務(wù)起初帶給我們的種種好處,在我們給每一個目的地都做了定制實現(xiàn)后開始反轉(zhuǎn)。最終,所有的微服務(wù)都在使用不同版本的共享庫——我們本可以用自動化地發(fā)布最新的修改。但在此時,不僅僅是開發(fā)團隊在開發(fā)中受阻,我們還在其他方面遇到了微服務(wù)的弊端。
這額外的問題就是每一個服務(wù)都有一個明確的負載模式。一些服務(wù)每天僅處理寥寥幾個請求,但有的服務(wù)每秒就要處理上千個請求。對于處理事件較少的目的地,當(dāng)負載出現(xiàn)意外峰值時,運維必須手動伸縮服務(wù)以滿足需求。(編者注,肯定有解決方案,但原作者突出的還是復(fù)雜度和成本。)
當(dāng)我們實現(xiàn)了自動伸縮的實現(xiàn),每個服務(wù)都具有所需CPU和內(nèi)存資源的明顯混合,這讓我們的自動伸縮配置與其說是科學(xué)的,不如說更具有藝術(shù)性(其實就是蒙的)。
目的地的數(shù)量極速增長,團隊以每個月三個(目的地)的速度增長著,這意味著更多的repo,更多的隊列,更多的服務(wù)。我們的微服務(wù)架構(gòu)的運維成本也是線性地增長著。因此,我們決定退后一步,重新考慮整個流程。
深挖微服務(wù)以及隊列
這時列表上第一件事就是如何鞏固當(dāng)前超過140個服務(wù)到一個服務(wù)中,管理所有服務(wù)的帶來的各種成本成了團隊巨大的技術(shù)債務(wù)。運維工程師幾乎無眠,因為隨時出現(xiàn)的流量峰值必須讓工程師隨時上線處理。
盡管如此,當(dāng)時把項目變成單一服務(wù)的架構(gòu)是一個巨大的挑戰(zhàn)。要讓每一個目的地擁有一個分離的隊列,每一個 worker進程需要檢查檢查每一隊列是否運行,這種給目的地服務(wù)增加一層復(fù)雜的實現(xiàn)讓我們感到了不適。這是我們「離心機」的主要靈感來源,「離心機」將替換我們所有的個體隊列,并負責(zé)將事件發(fā)送到一個單體服務(wù)。
譯者注:「離心機」其實就是Segment 制作的一個事件分發(fā)系統(tǒng)。 相關(guān)地址
搬到一個單體Repo
所以我們開始把所有的目的地代碼合并到了一個repo,這意味著所有的依賴和測試都在一個單一的repo 里頭了,我們知道我們要面對的,會是一團糟。
120個依賴,我們都提交了一個特定的版本讓每一個目的地都兼容。當(dāng)我們搬完了目的地,我們開始檢查每一個對應(yīng)的代碼是否都是用的最新的依賴。我們保證每一個目的地在最新的依賴版本下,都能正確運行。
這些改變中,我們再也不用跟蹤依賴的版本了。所有目的地都使用同一版本,這顯著地減小了codebase 的代碼復(fù)雜度。維護目的地變得快捷而且風(fēng)險也變小了。
另一方面我們也需要測試能簡單快速地運行起來,之前我們得出的結(jié)論之一就是:「不去修改共享庫文件主要的阻礙就是得把測試都跑一次?!?/span>
幸運的是,目的地測試都有著相似的架構(gòu)。他們都有基礎(chǔ)的單元測試來驗證我們的自定義轉(zhuǎn)換邏輯是否正確,而且也能驗證HTTP 的返回是否符合我們的期望值。
回想起我們的出新是分離每一個目的地的codebase 到各自的repo 并且分離各自測試的問題。盡管如此,現(xiàn)在看來這個想法是一個虛假的優(yōu)勢。HTTP 請求的發(fā)送仍然以某種頻率失敗著。因為目的地分離到各自的repo,所以大家也沒有動力去處理這類失敗的請求。這也讓我們走進了某種令人沮喪的惡性循環(huán)。本應(yīng)只需幾個小時的小改動常常要花上我們幾天甚至一周的時間。
構(gòu)建一個彈性測試套件
給目的地發(fā)送的HTTP 請求失敗是我們主要的失敗測試原因,過期憑證等無關(guān)的問題不應(yīng)該使測試失敗。我們從中也發(fā)現(xiàn)一些目的地的請求會比其他目的地慢不少。一些目的地的測試得花上5 分鐘才能跑完,我們的測試套件要花上一小時時間才能全部跑完。
為了解決這個問題,我們制作了一個「Traffic Recorder」,「Traffic Recorder」是一個基于yakbak 實現(xiàn)的工具,用于記錄并且保存一些請求。無論何時一個測試在他第一次跑的時候,對應(yīng)的請求都會被保存到一個文件里。后來的測試跑的時候,就會復(fù)用里頭的返回結(jié)果。同時這個請求結(jié)果也會進入repo,以便在測試中也是一致的。這樣一來,我們的測試就不再依賴于網(wǎng)絡(luò)HTTP請求,為了接下來的單一repo 鋪好了路。
記得第一次整合「Traffic Recorder」后,我們嘗試跑一個整體的測試,完成 140+ 目的地的項目整體測試只需幾毫秒。這在過去,一個目的地的測試就得花上幾分鐘,這快得像魔術(shù)一般。
為何單體應(yīng)用可行
只要每個目的地都被整合到一個repo,那么他就能作為一個單一的服務(wù)運行。所有目的地都在一個服務(wù)中,開發(fā)團隊的效率顯著提高。我們不因為修改了共享庫而部署140+ 個服務(wù),一個工程師可以一分鐘內(nèi)重新完成部署。
速度是肉眼可見地被提升了,在我們的微服務(wù)架構(gòu)時期,我們做了32個共享庫的優(yōu)化。再變成單體之后我們做了46個,過去6個月的優(yōu)化甚至多過2016年整年。
這個改變也讓我們的運維工程師大為受益,每一個目的地都在一個服務(wù)中,我們可以很好進行服務(wù)的伸縮。巨大的進程池也能輕松地吸收峰值流量,所以我們也不用為小的服務(wù)突然出現(xiàn)的流量擔(dān)驚受怕了。
壞處
盡管改變成單體應(yīng)用給我們帶來巨大的好處,盡管如此,以下是壞處:
1. 故障隔離很難,所有東西都在一個單體應(yīng)用運行的時候,如果一個目的地的bug 導(dǎo)致了服務(wù)的崩潰,那么這個目的地會讓所有的其他的目的地一起崩潰(因為是一個服務(wù))。我們有全面的自動化測試,但是測試只能幫你一部分。我們現(xiàn)在在研究一種更加魯棒的方法,來讓一個服務(wù)的崩潰不會影響整個單體應(yīng)用。
2. 內(nèi)存緩存的效果變低效了。之前一個服務(wù)對應(yīng)一個目的地,我們的低流量目的地只有少量的進程,這意味著他的內(nèi)存緩存可以讓很多的數(shù)據(jù)都在熱緩存中?,F(xiàn)在緩存都分散給了3000+個進程所以緩存命中率大大降低。最后,我們也只能在運維優(yōu)化的前提下接受了這一結(jié)果。
3. 更新共享庫代碼的版本可能會讓幾個目的地崩潰。當(dāng)把項目整合的到一起的時候,我們解決過之前的依賴問題,這意味著每個目的地都能用最新版本的共享庫代碼。但是接下來的共享庫代碼更新意味著我們可能還需要修改一些目的地的代碼。在我們看來這個還是值得的,因為自動化測試環(huán)節(jié)的優(yōu)化,我們可以更快的發(fā)現(xiàn)新的依賴版本的問題。
結(jié)論
我們起初的微服務(wù)架構(gòu)是符合當(dāng)時的情況的,也解決了當(dāng)時的性能問題還有目的地之間孤立實現(xiàn)。盡管如此,我們沒有準(zhǔn)備好服務(wù)激增的改變準(zhǔn)備。當(dāng)需要批量更新時,我們?nèi)狈m當(dāng)?shù)墓ぞ邅頊y試和部署微服務(wù)。結(jié)果就是,我們的研發(fā)效率因此出現(xiàn)了滑坡。
轉(zhuǎn)向單體結(jié)構(gòu)使我們能夠擺脫運維問題,同時顯著提高開發(fā)人員的工作效率。我們并沒有輕易地進行這種轉(zhuǎn)變,直到確信它能夠發(fā)揮作用。
1. 我們需要靠譜的測試套件來讓所有東西都放到一個repo。沒有它,我們可能最終還是又把它拆分出去。頻繁的失敗測試在過去損害了我們的生產(chǎn)力,我們不希望再次發(fā)生這種情況。
2. 我們接受一些單體架構(gòu)的固有的壞處而且確保我們能最后得到一個好的結(jié)果。我們對這個犧牲是感到滿意的。
在單體應(yīng)用和微服務(wù)之間做決定的時候,有些不同的因素是我們考慮的。在我們基礎(chǔ)設(shè)施的某些部分,微服務(wù)運行得很好。但我們的服務(wù)器端,這種架構(gòu)也是真實地傷害了生產(chǎn)力和性能的完美示例。但到頭來,我們最終的解決方案是單體應(yīng)用。
END



