大家好,我是杜歡,很榮幸能代表滴滴來做分享。我來滴滴的第一件事情就是幫助公司統(tǒng)一技術棧,在服務端我們要把以前拿 PHP 和 Java 做的服務統(tǒng)一起來,經(jīng)過很多思考和選擇之后我們決定用 Go 來重構大部分業(yè)務服務?,F(xiàn)在,滴滴內(nèi)部已經(jīng)有非常多的用 Go 實現(xiàn)的服務和大量 Go 開發(fā)者。
《?型微服務框架設計實踐》是一個很大的話題,這個題目其實分為三個方面,“微服務框架”、“大型”和“設計實踐”。我們?nèi)粘?吹降母鞣N開源微服務框架,在我看來都不算“大型”,解決的問題比較單純。大型微服務框架究竟是什么,又應該怎么去一步步落地實踐,我會從問題出發(fā),分別從以下幾個方面來探討這個話題。
? 發(fā)現(xiàn)問題:服務開發(fā)過程中的痛點
? 以史鑒今:從服務框架的演進歷程中找到規(guī)律
? ?道?簡:大型微服務框架的設計要點
? 精雕細琢:框架關鍵實現(xiàn)細節(jié)
********▍************發(fā)現(xiàn)問題:服務開發(fā)過程中的痛點****
▍****復雜業(yè)務開發(fā)過程中的痛點
我們在進行復雜業(yè)務開發(fā)的過程中,有以下幾個常見的痛點:
? 時間緊、任務多、團隊?、業(yè)務增?快,如何還能保證架構穩(wěn)定可靠?
? 研發(fā)?平參差不?、項?壓??顧不暇,如何保證質量基線不被突破?
? 公司有各種?具平臺、SDK、最佳實踐,如何盡可能的在業(yè)務中使??
互聯(lián)網(wǎng)業(yè)務研發(fā)的特點是“快”、“糙”、“猛”:開發(fā)節(jié)奏快、質量較粗糙、增長迅猛。我們能否做到“快”、“猛”而“不糙”呢?這就需要有一些技術架構來守住質量基線,在業(yè)務快速堆砌代碼的時候也能保持技術架構的健康。
在大型項目中,我們也經(jīng)常會短時間聚集一批人參與開發(fā),很顯然我們沒有辦法保證這些人的能力和風格是完全拉齊的,我們需要盡可能減少“人”在項目質量中的影響。
公司內(nèi)有大量優(yōu)秀的技術平臺和工具,業(yè)務中肯定是希望盡可能都用上的,但又不想付出太多的使用成本,必定需要有一些技術手段讓業(yè)務與公司基礎設施無縫集成起來。
很自然我們會想到,有沒有一種“框架”可以解決這個問題,帶著這個問題我們探索了所有的可能性并找到一些答案。
********▍************以史鑒今:從服務框架的演進歷程中找到規(guī)律****
▍****服務框架進化史
服務框架的歷史可以追溯到 1995 年,PHP 在那一年誕生。PHP 是一個服務框架,這個語言首先是一個模板,其次才是一種語言,默認情況下所有的 PHP 文件內(nèi)容都被直接發(fā)送到客戶端,只有使用了 <?php ?> 標簽的部分才是代碼。在這段時間里,我們也稱作 Web 1.0 時代里,瀏覽器功能還不算強,很多的設計理念來源于 C/S 架構的想法。這時候的服務框架的巔峰是 2002 年推出的 ASP.net,當年真的是非常驚艷,我們可以在 Visual Studio 里面通過拖動界面、雙擊按鈕寫代碼來完成一個網(wǎng)頁的開發(fā),非常具有顛覆性。當然,由于當時技術所限,這樣做出來的網(wǎng)頁體驗并不行,最終沒有成為主流。
接著,Web 2.0 時代來臨了,大家越來越覺得傳統(tǒng)軟件中經(jīng)常使用的 MVC 模式特別適合于服務端開發(fā)。Django 發(fā)布于 2003 年,這是一款非常經(jīng)典的 MVC 框架,包含了所有 MVC 框架必有的設計要素。MVC 框架的巔峰當屬 Ruby on Rails,它給我們帶來了非常多先進的設計理念,例如“約定大于配置”、Active Record、非常好用的工具鏈等。
2005 年后,各種 MVC 架構的服務框架開始井噴式出現(xiàn),這里我就不做一一介紹。
▍****標志性服務框架

隨著互聯(lián)網(wǎng)業(yè)務越來越復雜,前端邏輯越來越重,我們發(fā)現(xiàn)業(yè)務服務開始慢慢分化:頁面渲染的工作回到了前端;Model 層逐步下沉成獨立服務,并且催生了 RPC 協(xié)議的流行;業(yè)務接入層只需要提供 API。于是,MVC 中的 V 和 M 逐步消失,演變成了路由框架和 RPC 框架兩種形態(tài),分別滿足不同的需求。2007 年,Sinatra 發(fā)布了,它是一個非常極致的純路由框架,大量使用 middleware 設計來擴展框架能力,業(yè)務代碼可以實現(xiàn)的非常簡潔優(yōu)雅。這個框架相對小眾(Github Stars 10k,實際也算很有名了),其設計思想影響了很多后續(xù)框架,包括 Express.js、Go martini 等。同年,Thrift 開源,這是 Facebook 內(nèi)部使用 RPC 框架,現(xiàn)在被廣泛用于各種微服務之中。Google 其實更早就在內(nèi)部使用 Protobuf,不過直到 2008 年才首次開源。
再往后,我們的基礎設施開始發(fā)生重大變革,微服務概念興起,虛擬化、docker 開始越來越流行,服務框架與業(yè)務越發(fā)解耦,甚至可以做到業(yè)務幾乎無感知。2018 年剛開源的 Istio 就是其中的典型,它專注于解決網(wǎng)絡觸達問題,包括服務治理、負載均衡、動態(tài)擴縮容等。
▍****服務框架的演進趨勢
通過回顧服務框架的發(fā)展史,我們發(fā)現(xiàn)服務框架變得越來越像一種新的“操作系統(tǒng)”,越來越多的框架讓我們忘記了 Web 開發(fā)有多么復雜,讓我們能專注于業(yè)務本身。就像操作系統(tǒng)一樣,我們在業(yè)務代碼中以為直接操作了內(nèi)存,但其實并不然,操作系統(tǒng)為我們屏蔽了總線尋址、虛地址空間、缺頁中斷等一系列細節(jié),這樣我們才能將注意力放在怎么使用內(nèi)存上,而不是這些跟業(yè)務無關的細節(jié)。
隨著框架對底層的抽象越來越高,框架的入門門檻在變低,以前我們需要逐步學習框架的各種概念之后才能開始寫業(yè)務代碼,到現(xiàn)在,很多框架都提供了非常簡潔好用的工具鏈,使用者很快就能專注輸出業(yè)務代碼。不過這也使得使用者更難以懂得框架背后發(fā)生的事情,想要做一些更深層次定制和優(yōu)化時變得相對困難很多,這使得框架的學習曲線越發(fā)趨近于“階躍式”。
隨著技術進步,框架也從代碼框架變成一種運行環(huán)境,框架代碼與業(yè)務代碼也不斷解耦。這時候就體現(xiàn)出 Go 的一些優(yōu)越性了,在容器生態(tài)里面,Go 占據(jù)著先發(fā)優(yōu)勢,同時 Go 的 interface 也非常適合于實現(xiàn) duck-typing 模式,避免業(yè)務代碼顯式的與框架耦合,同時 Go 的語法相對簡單,也比較容易用一些編譯器技巧來透明的增強業(yè)務代碼。
******▍********?道?簡:?型微服務框架的設計要點**
▍****站在全局視角觀察微服務架構
服務框架的演進過程是有歷史必然性的。
傳統(tǒng) Web 網(wǎng)站最開始只是在簡單的呈現(xiàn)內(nèi)容和完成一些單純的業(yè)務流程,傳統(tǒng)的“三層結構”(網(wǎng)站、中間件、存儲)就可以非常好的滿足需求。
Web 2.0 時代,隨著網(wǎng)絡帶寬和瀏覽器技術升級,更多的網(wǎng)站開始使用前端渲染,服務端則更多的退化成 API Gateway,前后端有了明顯的分層。同時,由于互聯(lián)網(wǎng)業(yè)務越來越復雜,存儲變得越來越多,不同業(yè)務模塊之間的存儲隔離勢在必行,這種場景催生了微服務架構,并且讓微服務框架、服務發(fā)現(xiàn)、全鏈路跟蹤、容器化等技術日漸興盛,成為現(xiàn)在討論的熱點話題,并且也出現(xiàn)了大量成熟可用的技術方案。
再往后呢?我們在滴滴的實踐中發(fā)現(xiàn),當一個公司的組織結構成長為多事業(yè)群架構,每個事業(yè)群里面又有很多事業(yè)部,下面還有各種獨立的部門,在這種場景下,微服務之間也需要進行隔離和分層,一個部門往往會需要提供一個 API 或 broker 服務來屏蔽公司內(nèi)其他服務對這個部門服務的調用,在邏輯上就形成了由多個獨立微服務構成的“大型微服務”。
在大型微服務架構中,技術挑戰(zhàn)會發(fā)生什么變化?
據(jù)我所知,國內(nèi)某一線互聯(lián)網(wǎng)公司的一個事業(yè)群里部署了超過 10,000 個微服務。大家可以思考一下,假如一個項目里面有 10,000 個 class 并且互相會有各種調用關系,要設計好這樣的項目并且讓它容易擴展和維護是不是很困難?這是一定的。如果我們把一個微服務類比成一個 class,為了能夠讓這么復雜的體系可以正常運轉,我們必須給 class 進行更進一步的分類,形成各種 class 之上的設計模式,比如 MVC。以我們開發(fā)軟件的經(jīng)驗來看,當開發(fā)單個 class 不再成為一件難事的時候,如何架構這些 class 會變成我們設計的焦點。
我們看到前面是框架,更多解決是日?;A的東西,但是對于人與人之間如何高效合作、非常復雜的軟件架構如何設計與維護,這些方面并沒有解決太好。
大型微服務的挑戰(zhàn)恰好就在于此。當我們解決了最基本的微服務框架所面臨的挑戰(zhàn)之后,如何進一步方便架構師像操作 class 一樣來重構微服務架構,這成了大型微服務框架應該解決的問題。這對于互聯(lián)網(wǎng)公司來說是一個問題,比如我所負責的業(yè)務整個代碼量幾百萬行,看起來聽多了,但跟傳統(tǒng)軟件比就沒那么嚇人。以前 Windows 7 操作系統(tǒng),整體代碼量一億行,其中最大的單體應用是 IE 有幾百萬行代碼,里面的 class 也有上萬個了。對于這樣規(guī)模的軟件要注意什么呢?是各種重構工具,要能一鍵生成或合并或拆分 class,要讓軟件的組織形式足夠靈活。這里面的解決方法可以借鑒傳統(tǒng)軟件的開發(fā)思路。
▍****大型微服務框架的設計目標

結合上面這些分析,我們意識到大型微服務框架實際上是開發(fā)人員的“效率產(chǎn)品”,我們不但要讓一線研發(fā)專注于業(yè)務開發(fā),也要讓大家?guī)缀鯚o感知的使用公司各種基礎設計,還要讓架構師能夠非常輕易的調整微服務整體架構,方便像重構代碼一樣重構微服務整體架構,從而提升架構的可維護性。
公司現(xiàn)有架構就是業(yè)務軟件的操作系統(tǒng),不管公司現(xiàn)有架構是什么,所有業(yè)務架構必須基于公司現(xiàn)有基礎進行構建,沒有哪個部門會在做業(yè)務的時候分精力去做運維系統(tǒng)?,F(xiàn)在所有的開源微服務框架都不知道大家底層實際在用什么,只解決一些通用性問題,要想真的落地使用還需要做很多改造以適應公司現(xiàn)有架構,典型的例子就是 dubbo 和阿里內(nèi)部的 HSF。為什么內(nèi)部不直接使用 dubbo?因為 HSF 做了很多跟內(nèi)部系統(tǒng)綁定的事情,這樣可以讓開發(fā)人員用的更爽,但也就跟開源的系統(tǒng)漸行漸遠了。
大型微服務框架是微服務框架之上的東西,它是在一個或多個微服務框架之上,進一步解決效率問題的框架。提升效率的核心是讓所有業(yè)務方真正專注于業(yè)務本身,而不是想很多很重復的問題。如果 10,000 個服務花 5,000 人維護,每個人都思考怎么接公司系統(tǒng)和怎么做好穩(wěn)定性,就算每次開發(fā)過程中花 10% 的時間思考這些,也浪費了 5,000 人的 10% 時間,想想都很多,省下來可以做很多業(yè)務。
▍****Rule of least power
要想設計好大型微服務框,我們必須遵循“Rule of least power”(夠用就好)的原則。
這個原則是由 WWW 發(fā)明者 Tim Berners-Lee 提出的,它被廣泛用于指導各種 W3C 標準制定。Tim BL 說,最好的設計不是解決所有問題,而是恰好解決當下問題。就是因為我們面對的需求實際上是多變的,我們也不確定別人會怎么用,所以我們要盡可能只設計最本質的東西,減少復雜性,這樣做反而讓框架具有更多可能性。
Rule of least power 其實跟我們通常的設計思想相左,一般在設計框架的時候,架構師會比較傾向于“大而全”,由于我們一般都很難預測框架的使用者會如何使用,于是自然而然的會提供想象中“可能會被用到”的各種功能,導致設計越來越可擴展的同時也越來越復雜。各種軟件框架的演進歷史告訴我們,“大而全”的框架最終都會被使用者拋棄,而且拋棄它的理由往往都是“太重了”,非常具有諷刺意味。
框架要想設計的“好”,就需要抓住需求的本質,只有真正不變的東西才能進入框架,還沒想清楚的部分不要輕易納入框架,這種思想就是 Rule of least power 的一種應用方式。
▍****大型微服務框架的設計要點
結合 Rule of least power 設計思想,我們在這里列舉了大型微服務框架的設計要點。
最基本的,我們需要實現(xiàn)各種微服務框架必有的功能,例如服務治理、水平擴容等。需要注意的是,在這里我們并不會再次重復造輪子,而是大量使用公司內(nèi)外已有的技術積累,框架所做的事情是統(tǒng)一并抽象相關接口,讓業(yè)務代碼與具體實現(xiàn)解耦。
從工具鏈層面來說,我們讓業(yè)務無需操心開發(fā)調試之外的事情,這也要求與公司各種進行無縫集成,降低使用難度。
從設計風格上來說,我們提供非常有限度的擴展度,僅在必要的地方提供 interceptor 模式的擴展接口,所有框架組件都是以“組合”(composite)而不是“繼承”(inherit)方式提供給開發(fā)者??蚣軙峁┮蕾囎⑷氲哪芰Γ@種依賴注入與傳統(tǒng)意義上 IoC 有一點區(qū)別,我們并不追求框架所有東西都可以 IoC,只在我們覺得必要的地方有限度的開放這種能力,用來方便框架兼容一些開源的框架或者庫,而不是讓業(yè)務代碼輕易的改變框架行為。
大型微服務框架最有特色的部分是提供了非常多的“可靠性”設計。我們刻意讓 RPC 調用的使用體驗跟普通的函數(shù)調用保持一致,使用者只用關系返回值,永遠不需要思考崩潰處理、重試、服務異常處理等細節(jié)。訪問基礎服務時,開發(fā)者可以像訪問本地文件一樣的訪問分布式存儲,也是不需要關心任何可用性問題,正常的處理各種返回值即可。在服務拆分和合并過程中,我們的框架可以讓拆分變得非常簡單,真的就跟類重構類似,只需要將一個普通的 struct methods 進行拆分即可,剩下的所有事情自然而然會由框架做好。
******▍********精雕細琢:框架關鍵實現(xiàn)細節(jié)**
▍****業(yè)務實踐
接下來,我們聊聊這個框架在具體項目中的表現(xiàn),以及我們在打磨細節(jié)的過程中積累的一些經(jīng)驗。
我們落地的場景是一個非常大型的業(yè)務系統(tǒng),2017 年底開始設計并開發(fā)。這個業(yè)務已經(jīng)出現(xiàn)了五年,各個巨頭已經(jīng)投入上千名研發(fā)持續(xù)開發(fā),非常復雜,我們不可能在上線之初就完善所有功能,要這么做起碼得幾百人做一年,我們等不起。實際落地過程中,我們投入上百人從一個最小系統(tǒng)慢慢迭代出來,最初版本只開發(fā)了四個多月。
最開始做技術選型時,我們也在思考應該用什么技術,甚至什么語言。由于滴滴從 2015 年以來已經(jīng)積累了 1,500+ Go 代碼模塊、上線了 2,000+ 服務、儲備了 1000+ Go 開發(fā)者,這使得我們非常自然的就選擇 Go 作為最核心的開發(fā)語言。
在這個業(yè)務中我們實現(xiàn)了非常多的核心能力,基本實現(xiàn)了前面所說大型微服務框架的各種核心功能,并達成預期目標。
同時,也因為滴滴擁有相對完善的基礎設施,我們在開發(fā)框架的時候也并沒有花費太多時間重復造一些業(yè)務無關的輪子,這讓我們在開發(fā)框架的時候也能專注于實現(xiàn)最具有特色的部分,客觀上幫助我們快速落地了整體架構思想。
上圖只是簡單列了一些我們業(yè)務中常用的基礎設施,其實還有大量基礎設施也在公司中被廣泛使用,沒有提及。
▍****整體架構
上圖是我們框架的整體架構。綠色部分是業(yè)務代碼,黃色部分是我們的框架,其他部分是各種基礎設施和第三方框架。
可以看到,綠色的業(yè)務代碼被框架整個包起來,屏蔽了業(yè)務代碼與底層的所有聯(lián)系。其實我們的框架只做了一點微小的工作:將業(yè)務與所有的 I/O 隔離。未來底層發(fā)生任何變化,即使換了下面的服務,我們能夠通過黃色的兼容層解決掉,業(yè)務一行代碼不用,底層 driver 做了任何升級業(yè)務也完全不受影響。
結合微服務開發(fā)的經(jīng)驗,我們發(fā)現(xiàn)微服務開發(fā)與傳統(tǒng)軟件開發(fā)唯一的區(qū)別就是在于 I/O 的可靠程度不同,以前我們花費了大量的時間在各種不同的業(yè)務中處理“穩(wěn)定性”問題,其實歸根結底都是類似的問題,本質上就是 I/O 不夠可靠。我們并不是要真的讓 I/O 變得跟讀取本地文件一樣可靠,而是由框架統(tǒng)一所有的 I/O 操作并針對各種不可靠場景進行各種兜底,包括重試、節(jié)點摘除、鏈路超時控制等,讓業(yè)務得到一個確定的返回值——要么成功,要么就徹底失敗,無需再掙扎。
實際業(yè)務中,我們使用 I/O 的種類其實很少,也就不過十幾種,我們這個框架封裝了所有可能用到的 I/O 接口,把它們?nèi)孔兂?Go interface 提供給業(yè)務。
▍****實現(xiàn)要點
前面說了很多思路和概念,接下來我來聊聊具體的細節(jié)。
我們的框架跟很多框架都不一樣,為了實現(xiàn)框架與業(yè)務正交,這個框架干脆連最基本的框架特征都沒有,MVC、middleware、AOP 等各種耳熟能詳?shù)目蚣芤卦谶@里都不存在,我們只是設計了一個執(zhí)行環(huán)境,業(yè)務只需要提供一個入口 type,它實現(xiàn)了所有業(yè)務需要對外暴露的公開方法,框架就會自動讓業(yè)務運轉起來。
我們同時使用兩種技術來實現(xiàn)這一點。一方面,我們提供了工具鏈,對于 IDL-based 的服務框架,我們可以直接分析 IDL 和生成的 Go interface 代碼的 AST,根據(jù)這些信息透明的生成框架代碼,在每個接口調用前后插入必要的 stub 方便框架擴展各種能力。另一方面,我們在程序啟動的時候,通過反射拿到業(yè)務 type 的信息,動態(tài)生成業(yè)務路由。
做到了這些事情之后業(yè)務開發(fā)就完全無需關注框架細節(jié)了,甚至我們可以做到業(yè)務像調試本地程序一樣調試微服務。同時,我們用這種方式避免業(yè)務思考“版本”這個問題,我們看到,很多服務框架都因為版本分裂造成了很大的維護成本,當我們這個框架成為一個開發(fā)環(huán)境之后,框架升級就變得完全透明,實際中我們會要求業(yè)務始終使用最新的框架代碼,從來不會使用 semver 標記版本號或者兼容性,這樣讓框架的維護成本也大大降低。“更大的權力意味著更大的責任”,我們也為框架寫了大量的單元測試用例保證框架質量,并且規(guī)定框架無限向前兼容,這種責任讓我們非常謹慎的開發(fā)上線功能,非常收斂的提供接口,從而保持業(yè)務對框架的信任。

大家也許聽說過,Go 官方的 database/sql 的 Stmt 很好用但是有可能會出現(xiàn)連接泄漏的問題,當這個問題剛被發(fā)現(xiàn)的時候,公司很多業(yè)務線都不得不修改了代碼,在業(yè)務中避免使用 Stmt,而我們的業(yè)務代碼完全不需要做任何修改,框架用很巧妙的方法直接修復了這個問題。
下圖是框架的啟動邏輯,可以看到,這個邏輯非常簡單:首先創(chuàng)建一個 Server 實例 s,傳入必要的配置參數(shù);然后新建一個業(yè)務類型實例 handler,這個業(yè)務類型只是個簡單的 type,并沒有任何約束;最后將接口 IDL interface 和 handler 傳入 s,啟動服務即可。
我們在 handler 和 IDL interface 之間加一個夾層并做了很多事情,這相當于在業(yè)務代碼的執(zhí)行開始和結束前后插入了代碼,做了參數(shù)預處理、日志、崩潰恢復和清理工作。
我們還需要設計一個接口層來隔絕業(yè)務和底層之間的聯(lián)系。接口層本身沒什么特別技術含量,只是需要認真思考如何保證底層接口非常非常穩(wěn)定,并且如何避免穿透接口直接調用底層能力,要做好這一點需要非常多的心力。
這個接口層的收益是比較容易理解的,可以很好的幫助業(yè)務減少無謂的代碼修改。開源框架就不能保證這一點,說不定什么時候作者心情好了改了一個框架細節(jié),無法向前兼容,那么業(yè)務就必須跟著做修改。公司內(nèi)部框架則一般不太敢改接口,生怕造成不兼容被業(yè)務投訴,但有些接口一開始設計的并不好,只好不斷打補丁,讓框架越來越亂。
要是真能做到接口層設計出來就不再變更,那就太好了。
那我們真的能做到么?是的,我們做到了,其中的訣竅就是始終思考最本質最不變的東西是什么,只抽象這些不變的部分。
上圖就是一個經(jīng)典案例,展示一下我們是怎么設計 Redis 接口的。
左邊是 github.com/go-redis/redis 代碼(簡稱 go-redis),這是一個非常著名的 Redis driver;右邊是我們的 Redis 接口設計。
Go-redis 非常優(yōu)秀,設計了一些很不錯的機制,比如 Cmder,巧妙的解決了 Pipeline 讀取結果的問題,每個接口的返回值都是一個 Cmder 實例。但這種設計并不本質,包括函數(shù)的參數(shù)與返回值類型都出現(xiàn)多次修改,包括我自己都曾經(jīng)提過 Pull Request 修正它的一個參數(shù)錯誤問題,這種修改對于業(yè)務來說是非常頭疼的。
而我們的接口設計相比 go-redis 則更加貼近本質,我閱讀了 Redis 官方所有命令的協(xié)議設計和相關設計思路文檔,Redis 里面最本質不變的東西是什么呢?當然是 Redis 協(xié)議本身。Redis 在設計各種命令時非常嚴謹,做到了極為嚴格的向前兼容,無論 Redis 從 1.0 到 3.x 如何變化,各個命令字的協(xié)議從未發(fā)生過不兼容的變化。因此,我嚴格參照 Redis 命令字協(xié)議設計了我們的 Redis 接口,連接口的參數(shù)名都盡量與 Redis 官方保持一致,并嚴格規(guī)定各種參數(shù)的類型。
我們小心的進行接口封裝之后,還有一些其他收獲。
還是以 Redis 為例,最開始我們底層的 Redis driver 使用的是公司廣泛采用的 github.com/gomodule/redigo,但后來發(fā)現(xiàn)不能很好的適配公司自研的 Redis 集群一些功能,所以考慮切換成 go-redis。由于我們有這樣一層 Redis 接口封裝,這使得切換完全透明。

我們?yōu)榱四軌蜃寴I(yè)務研發(fā)不要關心很多的傳輸方面細節(jié),我們實現(xiàn)了協(xié)議劫持。HTTP 很好劫持,這里不再贅述,我主要說一下如何劫持 thrift。
劫持協(xié)議的目的是控制業(yè)務參數(shù)收到或發(fā)送的協(xié)議細節(jié),可以方便我們根據(jù)傳輸內(nèi)容輸出必要的日志或打點,還可以自動處理各種輸入或輸出參數(shù),把必要參數(shù)帶上,免得業(yè)務忘記。
劫持思路非常簡單,我們做了一個有限狀態(tài)機(FSM),在旁路監(jiān)聽協(xié)議的 read/write 過程并還原整個數(shù)據(jù)結構全貌。比如 Thrift Protocol,我們利用 Thrift 內(nèi)置的責任鏈設計,自己實現(xiàn)了一個 protocol factory 來包裝底層的 protocol,在實際 protocol 之上做了一個 proxy 層攔截所有的 ReadXXX/WriteXXX 方法,就像是在外部的觀察者,記錄現(xiàn)在 read/write 到哪一個層級、讀寫了什么結構。當我們發(fā)現(xiàn)現(xiàn)在正在 read/write 我們感興趣的內(nèi)容,則開始劫持過程:對于 read,如果要“欺騙”應用層提供一些額外的框架數(shù)據(jù)或者屏蔽框架才關心的數(shù)據(jù),我們就會篡改各種 ReadXXX 返回值來讓應用層誤以為讀到了真實數(shù)據(jù);對于 write,如果要偷偷注入框架才關心的內(nèi)容,我們會在調用 WriteXXX 時主動調用底層 protocol 的相關 write 函數(shù)來提前寫入內(nèi)容。
協(xié)議可以劫持之后,很多東西的處理就很簡單了。比如 context,我們只要求業(yè)務在各個接口里帶上 context,RPC 過程中則無需關心這個細節(jié),框架會自動將 context 通過協(xié)議傳遞到下游。
我們實現(xiàn)了協(xié)議劫持之后,要想實現(xiàn)跨服務邊界的 context 就變得很簡單了。
我們根據(jù) context interface 和設計規(guī)范實現(xiàn)了自己的 context 類型,用來做一些序列化與反序列化的事情,當上下游調用發(fā)生時,我們會從 context 里提取框架關心的內(nèi)容并注入到協(xié)議里面,在下游再透明解析出來重新放入 context。
使用 context 時候還有個小坑:context.WithDeadline 或者 context.WithTimeout 很容易被不小心忽略返回的 cancel 函數(shù),導致 timer 資源泄露。我們?yōu)榱吮苊獬霈F(xiàn)這種情況設計了一個低精度 timer 來盡可能避免創(chuàng)建真正的 time.Time 實例。
我們發(fā)現(xiàn),業(yè)務中根本不需要那么高精度的 timer,我們說的各種超時一般精度都只到 ms,于是一個精度達 0.5ms 的 timer 就能滿足所有業(yè)務需求。同時,在業(yè)務中也不是特別需要使用 Context interface 的 Done() 方法,更多的只是判斷一下是否已經(jīng)超時即可。為了避免大量創(chuàng)建 timer 和 channel,也為了避免讓業(yè)務使用 cancel 函數(shù),我們實現(xiàn)了一個低精度 timer pool。這是一個 timer 的循環(huán)數(shù)組,將 1s 分割成若干個時間間隔,設置 timer 的時候其實就是在這個數(shù)組上找到對應的時刻。默認情況下,done channel 都不需要初始化,直到真正有業(yè)務方需要 done channel 的時候才會 make 出來。在框架里我們非常注意的避免使用任何 done channel,從而避免消耗資源且極大的提高了性能。
業(yè)務壓力大的時候,我們比較容易在代碼層面上犯錯,不小心就放大單點故障造成雪崩,我們借用前面所有的技術,讓調用超時約束從上游傳遞到下游,如果單點崩潰了,框架會自動摘除故障節(jié)點并自動 fail-fast 避免壓力進一步上升,從而實現(xiàn)防雪崩。
防雪崩的具體實現(xiàn)原理很簡單:上游調用時會設置一個超時時間,這個時間通過跨邊界 context 傳遞到下游,每個下游節(jié)點在收到請求時開始記錄自己消耗的時間,如果自己耗時已經(jīng)超出上游規(guī)定的超時時間就會主動停止一切 I/O 調用,快速返回錯誤。
比如上游 A 調用下游 B 前設置 500ms 超時,B 收到請求后就知道只有 500ms 可用,從收到請求那一刻開始計時,每次在調用其他下游服務前,比如訪問 B 的下游 C 本身需要 200ms,但當前 B 已經(jīng)消耗了 400ms,只剩 100ms 了,那么框架會自動將 C 的超時收斂到 100ms,這樣 C 就知道給自己的時間不多了,一旦 C 沒能在 100ms 內(nèi)返回就會主動 fail-fast,避免無謂的消耗系統(tǒng)資源,幫助 C 和 B 快速向上游報告錯誤。
▍****業(yè)務收益
我們實現(xiàn)的這個框架切實的給業(yè)務帶來了顯著的收益。
我們總共用超過 100 名 Go 語言開發(fā)者,在非常大的壓力下開發(fā)了好幾個月便完成一個完整可運營的系統(tǒng),實現(xiàn)了大量功能,開發(fā)效率相當?shù)母?。我們后來代碼量和服務數(shù)量也不斷增加,并且由于業(yè)務發(fā)展我們還支持了國際化,實現(xiàn)了多機房部署,這個過程是比較順暢的。
我覺得非常自豪的是,我們剛上線一個月就做了全鏈路壓測,框架層稍作修改就搞定了,顯著提升了整體系統(tǒng)穩(wěn)定性和抗壓能力,而這個過程對業(yè)務是完全透明的,對業(yè)務未來的迭代也是完全透明的。我們在線上也沒有出現(xiàn)過任何單點故障造成的雪崩,各種監(jiān)控和關鍵日志也是自動的透明的做好,服務注冊發(fā)現(xiàn)、底層 driver 升級、一些框架 bug 修復等對業(yè)務都十分透明,業(yè)務只用每次升級到最新版就好了,十分省心。
▍****版本管理
最后提一個細節(jié):管理框架的各個庫版本。
我相信很多開發(fā)者都有一種煩惱,就是管理各種分裂的代碼版本。一方面由于框架會不斷升級,需要不斷用 semver 規(guī)則升級版本,另一方面業(yè)務方又沒有動力及時升級到最新版,導致框架各個庫的版本事實上出現(xiàn)了分裂。這個事情其實是不應該發(fā)生的,就像我們用操作系統(tǒng),比如大家開發(fā)業(yè)務需要跑在線上 linux 服務器上,我們會關心 linux kernel 版本么?或者用 Go 開發(fā),我們會總是關心用什么 Go 版本么?一般都不會關心的,這跟開發(fā)業(yè)務沒什么關系。我們關心的是系統(tǒng)提供了哪些跟業(yè)務開發(fā)相關的接口,只要接口不變且穩(wěn)定,業(yè)務代碼就能正常的工作。
這是為什么我們在設計框架的時候會花費很多心力保證接口穩(wěn)定的原因,我們就是希望框架即操作系統(tǒng),只有做到這一點,業(yè)務才能放心大膽的用框架做業(yè)務,真正把業(yè)務做到快而不糙。也正因為這一點,我們甚至于不會給框架的各個庫打 tag,每次上線都必須全部將框架升級到最新版,徹底的解決了版本分裂的問題。
▍****未來方向
未來我們還是有很多工作值得去做,比如完善工具鏈、接入更多的一些公司基礎設施等。
我們不確定是否能夠開源,大概率是不會開源,因為這個框架并不重要,它與滴滴各種基礎設施綁定,服務于滴滴研發(fā),重要的是設計理念和思路,大家可以用類似方法因地制宜的在自己的公司里實踐這種設計思想。
今天這個活動就是一個很好的場所,我希望通過這個機會跟大家分享這樣的想法,如果大家有興趣也歡迎跟我交流,我可以幫助大家在公司里實現(xiàn)類似的設計。
▍****Q&A
提問:我也一直在寫 Go 服務,你們每一個服務啟動是單進程還是多進程,每個進程怎么限制核數(shù)?
杜歡:對于 Go 來講這個問題不是問題,一般都用單進程模式,然后通過 GOMAXPROCS 設置需要占用的核數(shù),默認會占滿機器所有的核。
提問:我看到有 70+ 個微服務,微服務之間的接口和依賴關系怎么維護?接口變更或者兼容性怎么解決?
杜歡:微服務業(yè)務層的接口變更這個事情無法避免,我們是通過 IDL 進行依賴管理,不是框架層保證,業(yè)務需要保證這個 IDL 是向前兼容的??蚣苣軒臀覀冏鍪裁茨兀克梢詭臀覀冏鰳I(yè)務代碼遷移,根據(jù)我們的設計,只要把一個名為 service 的目錄進行拆分合并即可,這里面只有一個簡單的類型 type Service struct {},以及很多 Service 類型的方法,每個文件都實現(xiàn)了這個類型的一個或多個方法,我們可以方便的整合或者拆分這個目錄里面的代碼,從而就能更改微服務的接口實現(xiàn)。
你剛剛問題是很業(yè)務的問題,怎么管理之間依賴變化,這個沒有什么好辦法,我們做重構的時候,還是通知上下游,這個確實不是我們真正在框架層能夠解決的問題,我們只能讓重構的過程變得簡單一些。
提問:上下游傳輸 context 時設置超時時間,每一個接口超時時間是怎么設計的?
杜歡:我們設的超時時間就是通常意義上的這次請求從發(fā)起到收到應答的總時間。
提問:超時時間怎么定?各個模塊超時時間不一樣么?
杜歡:現(xiàn)在做得比較粗糙,還沒有做到統(tǒng)一管理所有的超時時間,依然是業(yè)務方自己根據(jù)預期,在調用下游前自己在代碼里面寫的,希望未來這個可以做到統(tǒng)一管理。
提問:開發(fā)者怎么知道下游經(jīng)過了怎樣的處理流程,能多長時間返回呢?
杜歡:這個東西一般開發(fā)者都是知道的,因為所有業(yè)務服務接口都會有 SLA,所有服務對上游承諾 SLA 是多少預先會定好。比如一個服務接口承諾 SLA 是 90 分位 50ms,上游就會在這個基礎上打一些 buffer,將調用超時設置成 70ms,比 SLA 大一點。實際中我們會結合這個服務接口在壓測和線上實際表現(xiàn)來設置超時。我們其實很希望把 SLA 線上化管理,不過現(xiàn)在沒有完全做到這一點。
提問:咱們這邊有沒有出現(xiàn)類似的超時情況?在測試期間或者線上?
杜歡:服務的時間超時情況非常常見,但業(yè)務影響很小,框架會自動重試。
提問:一般什么情況下會出現(xiàn)呢?
杜歡:最多的情況是調用外部的服務,比如我們會調用 Google Map 一些接口,他們就相對比較不穩(wěn)定,調用一次可能會超過 2s 才返回結果,導致這條鏈路上的所有接口都會超時。
提問:超時的情況可以避免么?
杜歡:不可能完全避免。一個服務接口不可能 100% 承諾自己的處理時間,就算 SLA 是 99 分位小于 50ms,那依然有 1% 可能性會超過這個值。