除非你一直生活在石器時代,不然你可能已經知道微服務是當前流行的架構。隨着這一趨勢的發展,Segment 在早期就將其做爲一種最佳實踐,在某些狀況下,這對咱們頗有幫助,但你很快就會了解到,在其餘狀況下效果並很差。web
簡單來講,微服務是一種面向服務的軟件架構,在這種架構中,服務器端應用程序是經過組合許多單用途、低空間佔用的網絡服務來構建的。人們極力宣揚的好處是改進模塊化、減小測試負擔、更好的功能組合、環境隔離和開發團隊自治。與之相反的是單體架構,大量的功能存在於一個服務中,該服務做爲一個單元進行測試、部署和擴展。緩存
2017 年初,Segment 的一個核心 產品 達到了一個臨界點。這就像咱們從微服務的樹上掉下來,並在下落的過程當中砸到每根樹枝同樣。小團隊沒有讓咱們更快地前進,相反,咱們發現本身陷入了複雜性爆炸的泥潭。這種架構的基本好處變成了負擔。咱們的速度急劇降低,咱們的缺陷率卻呈現爆炸式增加。安全
團隊最終發現,他們沒法取得進展,3 名全職工程師爲了維持系統的運行花費了大部分的時間。有些事情必須改變。這篇文章講述的是咱們如何後退一步,採用一種能夠很好地知足咱們的產品需求和團隊需求的方法。服務器
爲何微服務曾經有效?網絡
Segment 的客戶數據基礎設施每秒接收數十萬個事件,並將它們轉發給合做夥伴 API,咱們稱之爲服務器端目標。這些目標有 100 多種類型,好比谷歌 Analytics、Optimizely 或自定義 webhook。架構
幾年前,當產品最初發布時,架構很簡單。有一個 API 接收事件並將其轉發到分佈式消息隊列。在本例中,事件是 Web 或移動應用程序生成的 JSON 對象,其中包含關於用戶及其操做的信息。下面是一個有效載荷示例:app
{ "type": "identify", "traits": { "name": "Alex Noonan", "email": "anoonan@segment.com", "company": "Segment", "title": "Software Engineer" }, "userId": "97980cfea0067"}
當從隊列中消費事件時,將檢查客戶管理設置,以肯定哪些目標應該接收事件。而後將事件一個接一個地發送到每一個目標 API,這很是有用,由於開發人員只須要將事件發送到單個端點,即 Segment 的 API,而不須要構建幾十個可能的集成。Segment 向每一個目標端點發出請求。運維
若是對一個目標的某個請求失敗,有時咱們將嘗試稍後再次發送該事件。有些失敗能夠安全地重試,而有些則不能。可重試錯誤是指目標可能在不作任何更改的狀況下接受的錯誤。例如,HTTP 500、速率限制和超時。不可重試錯誤是咱們能夠肯定目標永遠不會接受的請求。例如,具備無效憑據或缺乏必需字段的請求。分佈式
此時,一個隊列既包含了最新的事件,也包含全部目標的那些可能已經屢次重試的事件,這些事件致使了 隊頭阻塞。在這種特殊狀況下,若是一個目標變慢或宕機,重試將淹沒隊列,致使全部目標延遲。ide
假設目標 X 遇到一個臨時問題,每一個請求都有一個超時錯誤。如今,這不只建立了還沒有到達目標 X 的大量請求的積壓列表,並且還將每一個失敗事件放回隊列中重試。雖然咱們的系統會根據負載的增長自動向上擴展,可是隊列深度的忽然增長會超過咱們的擴展能力,從而致使最新事件的延遲。全部目標的交付時間都將由於目標 X 發生了短暫停機而增長。客戶依賴於該交付的及時性,所以,咱們沒法承受在管道中的任何地方增長等待時間。
爲了解決隊頭阻塞問題,團隊爲每一個目標建立了單獨的服務和隊列。這個新的架構包括一個額外的路由器進程,它接收入站事件並將事件的副本分發到每一個選定的目標。如今,若是一個目標遇到問題,只有它的隊列會積滯,其餘目標不會受到影響。這種微服務風格的架構將目標彼此隔離,當有目標常常遇到問題時,這一點相當重要。
單代碼庫的狀況
每一個目標 API 使用不一樣的請求格式,須要自定義代碼轉換事件以匹配這種格式。一個基本的例子是目標 X 須要在有效載荷中發送生日 traits.dob,而咱們的 API 以 traits.birthday 接收。目標 X 中的轉換代碼應該是這樣的:
const traits = {}traits.dob = segmentEvent.birthday
許多現代化的目標端點都採用了 Segment 的請求格式,這使得一些轉換相對簡單。可是,根據目標 API 的結構,這些轉換可能很是複雜。例如,對於一些較老且分佈最廣的目標,咱們得本身將值硬塞進手工編寫的 XML 有效負載中。
最初,當目標被劃分爲單獨的服務時,全部代碼都存在於一個庫中。一個很是使人沮喪的地方是,一個失敗的測試致使全部目標的測試失敗。當咱們想要部署一個變動時,咱們必須花費時間來修復受損的測試,即便變動與最初的變動沒有任何關係。針對這個問題,咱們決定將每一個目標的代碼分解爲各自的庫。全部的目標都已經被劃分爲各自的服務,這種轉換很天然。
分割庫使咱們可以輕鬆地隔離目標測試套件。這種隔離容許開發團隊在維護目標時快速前進。
擴展微服務和代碼庫
隨着時間的推移,咱們增長了 50 多個新目標,這意味着 50 個新的庫。爲了減輕開發和維護這些代碼庫的負擔,咱們建立了共享庫,使跨目標的通用轉換和功能(如 HTTP 請求處理)更容易、更統一。
例如,若是咱們但願從事件中得到用戶名,則能夠在任何目標的代碼中調用 event.name()。共享庫檢查事件的屬性鍵 name。若是不存在,它將檢查名字,檢查屬性 firstName、first_name 和 firstName。它對姓氏執行相同的操做,檢查大小寫並將二者組合起來造成全名。
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) }
共享庫使構建新目標變得更快。一組統一的共享功能帶來的熟悉度使維護變得不那麼麻煩。
然而,一個新的問題開始出現。測試和部署這些共享庫的變動影響了咱們全部的目標。它開始須要至關多的時間和精力來維護。咱們知道,經過變動來改進咱們的庫須要測試和部署幾十個服務,這是一個冒險的提議。若是時間緊迫,工程師們將只在單個目標的代碼庫中包含這些庫的更新後版本。
隨着時間的推移,這些共享庫的版本開始在不一樣的目標代碼庫之間產生差別。曾經,減小目標代碼庫之間的定製讓咱們得到了巨大的好處,如今狀況開始反轉。最終,它們都使用了這些共享庫的不一樣版本。咱們本能夠構建一些工具來自動化滾動變動過程,但在這一點上,不只開發人員的生產效率受到影響,並且咱們開始遇到微服務架構的其餘問題。
另一個問題是,每一個服務都有不一樣的負載模式。一些服務天天處理少許事件,而另外一些服務每秒處理數千個事件。對於處理少許事件的目標,當出現意外的負載高峯時,運維人員必須手動擴展服務以知足需求。
雖然咱們實現了自動伸縮,可是每一個服務都有不一樣的 CPU 和內存資源,這使得自動伸縮配置調優更像是藝術而不是科學。
目標的數量繼續快速增加,團隊平均每個月增長三個目標,這意味着更多的代碼庫、更多的隊列和更多的服務。在咱們的微服務架構中,咱們的運維開銷隨着目標的增長而線性增長。所以,咱們決定後退一步,從新考慮整個管道。
拋棄微服務和隊列
清單上的第一項是將現有的 140 多個服務合併爲一個服務。管理全部這些服務的開銷對咱們的團隊來講是一個巨大的負擔。咱們幾乎爲此失眠,由於咱們這些隨叫隨到的工程師要常常處理負載峯值。
然而,當時的架構使遷移到單個服務變得頗具挑戰性。因爲每一個目標都有一個單獨的隊列,每一個工做進程必須檢查每一個隊列是否有工做,這將給目標服務增長一層複雜性,而咱們對這種複雜性感到不適。這是 Centrifuge 的主要靈感來源。Centrifuge 將替換全部單獨的隊列,並負責將事件發送到一個單體服務。
遷移到單一代碼庫
假設只有一個服務,那麼將全部目標的代碼移動到一個代碼庫中很容易理解,這意味着將全部不一樣的依賴關係和測試合併到一個代碼庫中。咱們知道這會很亂。
對於 120 個獨一無二的依賴項中的每個,咱們承諾爲全部目標提供一個版本。當咱們遷移目標時,咱們會檢查它使用的依賴項,並將它們更新到最新版本。咱們修復了與新版本發生衝突的全部目標。
經過此次遷移,咱們再也不須要跟蹤依賴項版本之間的差別。咱們全部的目標都使用相同的版本,這大大下降了整個代碼庫的複雜性。如今,目標維護變得更省時、風險更小。
咱們還須要一個測試套件,它容許咱們快速、輕鬆地運行全部的目標測試。在更新咱們前面討論的共享庫時,運行全部測試是主要的障礙之一。
幸運的是,目標測試都具備相似的結構。它們有基本的單元測試來驗證咱們的自定義轉換邏輯是否正確,並將執行到合做夥伴端點的 HTTP 請求來驗證事件是否如預期的那樣出如今目標中。
回想一下,將每一個目標代碼庫劃分到它本身的代碼庫中,最初的動機是爲了隔離測試失敗。然而,事實證實這是一個不成立的優點。發出 HTTP 請求的測試仍然以必定的頻率失敗。因爲目標被劃分到它們本身的代碼庫中,因此咱們幾乎沒有動力去清理失敗的測試。這種不良習慣致使了源源不斷的技術債務。一般,一個本來只須要一兩個小時就能完成的小變動,最終可能須要幾天到一週的時間才能完成。
構建一個有彈性的測試套件
測試運行期間對目標端點的出站 HTTP 請求是測試失敗的主要緣由。不相關的問題,好比憑證過時,不該該致使測試失敗。根據經驗,咱們還知道,有些目標端點比其餘端點慢不少。有些目標運行測試的時間長達 5 分鐘。咱們有超過 140 個目標,咱們的測試套件可能須要一個小時來運行。
爲了解決這兩個問題,咱們建立了 Traffic Recorder。它基於 yakbak 構建,負責記錄和保存目標的測試流量。每當測試第一次運行時,任何請求及其相應的響應都會被記錄到一個文件中。隨後的測試將回放文件中的請求和響應,而不是向目標端點發送請求。這些文件被檢入代碼庫中,以保證每次變動時測試都是一致的。如今,測試套件再也不依賴於互聯網上的這些 HTTP 請求,咱們的測試變得更有彈性,這是遷移到單個代碼庫的必要條件。
我還記得,在咱們集成了 Traffic Recorder 以後,第一次針對每一個目標運行測試。完成針對全部 140 多個目標的測試須要幾毫秒的時間。在過去,一個目標可能就須要幾分鐘才能完成。感受就像魔法同樣。
爲何單體架構有效?
一旦全部目標的代碼都存在於一個代碼庫中,就能夠將它們合併到一個服務中。因爲每一個目標都存在於一個服務中,咱們的開發人員的工做效率獲得了顯著提高。咱們再也不須要由於變動一個共享庫而部署 140 多個服務。一個工程師能夠在幾分鐘內完成服務部署。
證據就在於改進的速度。2016 年,當咱們還在使用微服務架構時,咱們對共享庫進行了 32 次變動。而今年,咱們已經作了 46 項改進。過去 6 個月,咱們對庫的改進比 2016 年整年都要多。
這種變化也使咱們的運營從中受益。因爲每一個目標都存在於一個服務中,咱們很好地組合了 CPU 密集型和內存密集型目標,這使得擴展服務以知足需求變得很是容易。大型工做池能夠承受負載峯值,所以,咱們再也不爲處理少許負載的目標分頁。
妥 協
從微服務架構到總體的單體架構是一個巨大的改進,可是,也有一些妥協:
故障隔離很困難。因爲全部內容都在一個總體中運行,若是在一個目標引入了致使服務崩潰的 Bug,那麼全部目標服務都會崩潰。咱們有全面的自動化測試,但測試有其侷限性。咱們目前正在研究一種更加健壯的方法,以防止一個目標使整個服務宕掉,同時又保持全部目標都在一個單體中。
內存緩存的效率較低。之前,每一個目標一個服務,咱們的低流量目標只有少數幾個進程,這意味着它們的控制平面數據的內存緩存將保持熱狀態。如今,緩存被分散到 3000 多個進程中,因此它命中的可能性要小得多。咱們能夠用像 Redis 這樣的東西來解決整個問題,但這是另外一個咱們須要考慮的擴展點。最後,咱們接受了這種效率的損失,由於它帶來了巨大的運營效益。
更新依賴項的版本可能會破壞多個目標。雖然將全部內容都遷移到一個代碼庫中解決了以前的依賴關係混亂問題,但這意味着若是咱們想要使用庫的最新版本,咱們可能必須更新其餘目標。然而,在咱們看來,這種方法的簡單性是值得作出這種妥協的。經過全面的自動化測試套件,咱們能夠很快地看到更新的依賴項版本帶來了什麼破壞。
小 結
咱們最初的微服務架構在一段時間內是有效的,經過將目標彼此隔離來解決管道中的即時性能問題。然而,咱們並無作好擴展準備。當須要大量更新時,咱們缺少測試和部署微服務的適當工具。結果,咱們的開發人員的生產效率迅速降低。
遷移到一個單體架構中,能夠在顯著提升開發人員生產力的同時,消除運維問題。不過,咱們並無輕率地實施此次遷移。咱們知道,若是要成功,有些事情是必須考慮的。
咱們須要一個健壯的測試套件,把全部的東西都放在一個代碼庫中。若是沒有這個,咱們就會和當初決定把它們分開時同樣。在過去,不斷失敗的測試損害了咱們的生產力,咱們不但願這種狀況再次發生。
咱們接受了單體架構中須要作出的妥協,並確保每一個方面都有一個好的故事。咱們必須適應這種變化帶來的一些犧牲。
在決定採用微服務仍是單體服務時,須要考慮不一樣的因素。在咱們的基礎設施的某些部分,微服務工做得很好,可是咱們的服務器端目標是這種流行趨勢如何實際損害生產力和性能的一個完美示例。原來,咱們的解決方案是一個單體架構。
Stephen Mathieson、Rick Branson、Achille Roussel、Tom Holmes 等人促成了向單體架構的轉變。