《指數級增長背后,滴滴出行業務系統的架構升級》要點:
本文介紹了指數級增長背后,滴滴出行業務系統的架構升級,希望對您有用。如果有疑問,可以聯系我們。
成立四年,估值已超260億美元,公司指數級發展、業務爆炸式增長,在此背景下,滴滴出行業務系統的架構升級是怎樣進行的?本文根據滴滴出行平臺產品中心技術總監——杜歡在2016ArchSummit全球架構師(深圳)峰會上的演講整理而成.
老司機簡介
杜歡,滴滴平臺產品中心技術總監.2015年加入滴滴,負責公司公共業務、客戶端/前端架構和新業務孵化,致力于用技術手段解決業務痛點和提升研發效率,曾作為技術負責人主導公司技術架構升級以支撐公司業務快速迭代的需求.在加入滴滴前有長達五年的創業經歷,具有豐富的團隊管理經驗,熟悉移動互聯網應用的整個技術棧.
今天給大家主要介紹的是去年滴滴內部做的一次重大架構升級,滴滴快速發展的過程中,系統的迭代速度和其他方面的設計遇到了很多困難,這次升級就是為了解決這些困難.
去年我們做了一次非常大的重構.上面圖中是今天要講的大綱,我會從問題本身出發,回顧一下整個過程,包括如何發現問題、分析問題和解決方案.最后,我也會提出一些想法,如何規避重蹈這樣的覆轍.
挑戰在哪里?
首先,我們看一下挑戰在哪里.滴滴在出行領域是非常獨特的公司,它的獨特不在于業務模式多復雜,而在于它的發展非???滴滴的成立時間是2012年的6月,到現在為止才經過了四年的時間.
滴滴的成長速度十分驚人,到今天它的估值已經超過260億美元,融資輪次非常多.如果不是因為競爭非常惡劣,滴滴也不會一直用融資的方法為自己開路.在這樣的壓力之下,滴滴所有的動作可能都會走形,所有的想法可能因為現在一些短期利益不得不進行一些權衡.
同時,公司的業務也在爆炸式地增長.如果滴滴只做一個業務,原本可以做得非常深入.滴滴從2014年開始加入了專車業務,2015年業務數量增加到七條,2016年已經超過十條.業務急速發展之中大家會思考,到底怎么做才能使這些還不穩定或者還沒有想清楚的業務很好地迭代起來.
想到最簡單的方法是,如果新業務跟某個舊業務非常類似但又不完全一樣,我們就把舊業務的舊代碼復制并修改,這樣新業務就做出來了.之前,這種情況經常發生,就造成了很大的問題.
在2015年上半年,滴滴整個系統已經積累了很多問題,分布在乘客App、服務端、Web App之中.特別值得一提的是,服務端的問題并不是性能,而是在于巨大的耦合導致數據紊亂和迭代速度越來越慢.
滴滴的獨特性迫使我們獨立思考這些問題,所有的解法都要針對滴滴現狀,而不是看哪個大公司是怎么做的,然后直接復制過來.
現狀是什么?
在解決問題之前,我們需要了解現狀是怎樣的.如圖所示,在2015年下半年,滴滴的系統架構分為四層.最頂層是用戶應用,每一個用戶應用就是一個端,也就是用戶所能看到的入口.然后是接入層,這是非常傳統的結構,我們用了Nginx,還專門做了TCP接入層.
在業務層,Web是非常大的集群,有非常大的代碼量,我們只對業務做了分割,有策略引擎、司機調度.在數據層,有KV集群、MySQL集群、任務隊列、特征存儲.這是任何一個初創公司應該有的架構,我們對這個架構并沒有做特殊的策劃,僅僅在這個技術體系里面把業務邏輯實現出來.
上面這張圖可能會比較有趣.右邊這個紅色的球,代表的是重構之前App依賴的關系.當時我很想梳理一下App在模塊之間是如何進行依賴的,然后我就寫了一個腳本運行了一下,得到的結果讓我很驚訝.我用藍色的線表示正常的依賴,就是模塊A依賴于模塊B,A是B的上一層,B不會反過來依賴A,用紅色的線表示異常的依賴,即A依賴B、B通過各種手段反過來依賴A,最后發現基本上都是紅色的.
做任何模塊的拆分,發現不得不面臨這樣的問題:把任何一個模塊取出來就等于把所有模塊都取出來,實際上沒有做拆分.所以,關鍵是需要解耦模塊結果.這是iOS的情況.安卓的情況更糟糕.
對于Web App來講,最大的問題在于耦合性.以前滴滴只有出租車這個業務,最開始的Web App只有出租車,后來專車上線了,就在出租車里面加了專車入口,只是業務名不同界面會有小區別,后來加入了快車、代駕,都跟出租車差不多,沒遇到太大問題.
再后來有了順風車,順風車跟其他功能不一樣,整體界面是預約型的,有乘客和車主兩種模式.如果在老首頁里面開發順風車成本太大了,需要和出租車業務線的人一起開發業務模塊,如果未來做迭代,這種開發模式將非常痛苦.老首頁的模塊也沒有做拆分,代碼散落各地,只是通過打包工具拼接在一起,沒有做模塊化,所以整體情況也比較糟糕.
相比端,API稍好一點的是,API至少在業務維度上是分開的,出租車與專車、快車是分開的兩個系統,放在兩個倉庫里面.不過API也有一個很大的問題,業務代碼沒有做服務化拆分,沒有model 封裝,業務所有的API和后臺MIS都在一個倉庫里,這對系統來說是非常大的一個隱患.
該如何入手?
做到這一點之后,所有的業務迭代問題就迎刃而解了,因為業務間已經沒有依賴和耦合了.這一步完成之后做的就是重新梳理業務,讓業務根據自己模型特點進行一些重構.
最開始的時候,我們考慮的是怎么做代碼治理和模塊下沉.代碼治理本質上就是把各種模塊進行染色、再把它們歸類的過程.代碼治理最難的事情在于消除錯綜復雜的依賴.到底怎么做才對呢?
模塊下沉與代碼治理息息相關.如果只是要求把所有代碼拆分,而沒有合適的拆分方法,這件事情是無法推進下去的.對于程序員來說,他們內心總有一種沖動想做有意思的事情,比如封裝一個很有意思的模塊給更多程序員用.大家并非不想做封裝,只是如果封裝并共享出來的代價太大,就會影響大家的熱情.
模塊下沉是一種機制,一方面我們應該鼓勵,另一方面還應該讓大家發現這是一件不得不做的事情.如果僅僅對內公開模塊列表讓大家自由選擇,達不到模塊下沉的目的.因為人都很懶,不想思考太多,只想盡快把事情完成,大家往往傾向于復制粘貼,也不愿意額外花時間做下沉.
怎么辦呢?我們會給所有業務提供一個統一的SDK,里面包含所有能用的組件,大家必須使用它進行開發.如果業務模塊穩定了并且比較通用,我們有工具和相應的簡單機制把業務模塊下沉下來,變成SDK的一部分,長期下去SDK會越來越大,只要SDK里做好分類和規劃,上層就會越來越輕,我們可以真正專注于業務邏輯開發.
除了上面這些,最核心的一點在于,一定要把所有業務都做到“無狀態”和“異步化”.
“無狀態”這個概念在服務端比較容易理解.一般我們傾向于把各種業務做到無狀態,這樣容易做水平擴展.在客戶端也是一個道理,也要考慮橫向擴展性.一個簡單的框架往往提供一些最基礎的控件,比如按紐、列表,這些都不會耦合任何業務邏輯,所以很容易使用.
但是當業務做起來,大家習慣將一些狀態放到業務控件里面,這在一定程度上方便了,但是一旦需要將業務進行重構或者進行模塊化下沉的時候,就造成了非常大的困難.例如,一個模塊如果大量通過全局變量或單例跟上下游耦合,那么這個模塊就很難復用和重構,這些全局變量或單例就是狀態.
所以,我們在客戶端也提出使用“無狀態”的方式,把存儲的信息都放到外面.后面我會提到到底應該怎么樣去做.
“異步化”也是解耦的方式.服務端的RPC類似于函數調用,如果參數變了,實現和調用的雙方都要做改變,這很不透明,也不能夠漸進式上線.我們用訂閱/發布的模式對 RPC進行解耦,要求所有接口都要異步返回.
在客戶端也是這樣,比如做數據的緩存,想優化網絡,我們不能夠期待這個函數是一個同步函數,一定用回調的方式接受所有參數.所以做設計的時候,只要是有可能發生網絡請求或者訪問磁盤,在客戶端也盡量異步請求數據.
剛剛講的都是相對比較抽象的內容,接下來會說一下滴滴的業務形態本身.
滴滴是一個出行的平臺,涵蓋的是整個出行領域所有的出行需求.大家出行到底想要什么?就是到達自己想去的地方.實際上,我們的模型可以做得非常抽象和簡單.比如,我想要打快車去機場,我就是一個需求方,我的需求會發到很多服務者那里去,服務者會根據特征進行一些匹配.
最基本的特征是服務能力,如果服務者能夠開快車并通過了能力驗證,這個需求就有可能發給他.如果開出租車的也有能力開快車,但是他還沒有在平臺上驗證這個能力,就只能開出租車.一個人可以驗證很多服務,白天可以開快車,晚上可以做代駕,做不同的事.
服務和需求的匹配是通過計價模型和匹配策略來實現的.發送需求的時候需要選擇計價模型和車的類型.快車和專車服務過程大同小異,但是價格差別很明顯,專車價格會貴很多.通過匹配策略可以實現各種需求的匹配.
例如,選擇了拼車,這個需求會盡量匹配已經有拼友和順路的車.如果選擇專車,可以要求這輛車在指定時間來接人,這時候匹配策略會優化傾向這種方式.
滴滴所有的業務基本上都是以這種模式運轉的,所有功能都是核心主干或者旁路,只要把業務模型抽象出來,基本上就能夠滿足大部分的業務了.
基于這樣的想法,我們就思考如何設計真正高度抽象的工具.簡單起見,我們把滴滴出行的過程抽象成一個框架(見上圖),這并不是完整的框架.有顏色的地方表示出租車、快車、專車、代駕共同的流程,只要組合各種流程就可以實現整個業務形態的能力.在這個框架里可以定制所有業務形態的車標、提示語、匹配的模型、計價模型等功能.
當時梳理這個抽象的時候,我們感覺非常興奮,因為這意味著在這個基礎之上就可以簡易擴展出滴滴未來的業務形態.只要滴滴還是在做需求和服務的匹配,基本上就離不開這樣一種套路.
客戶端怎么拆?
首先就是客戶端,最重要的是需要將業務拆出來.以前所有業務放在同一個倉庫里,如果不小心提交了一段錯誤代碼就會帶來災難性的后果,所有業務工作可能都會受到影響.以前編譯速度也很糟糕,大家可以想象,每次下載代碼都會有幾個頭文件發生改變,由于循環依賴的緣故幾乎所有文件都要重編,二三十分鐘后才能重新調試,這個過程讓人極度崩潰.
對于iOS,我們用cocoapods把業務拆到不同的pod里面;對于安卓,我們把業務拆分打包并用Maven管理起來.我們拆分方法如下圖所示,其中虛線框部分展示的是公共框架,最開始沒有很細致分割,只是把它放在一個獨立倉庫里,保證依賴關系充分清楚,后面就可以隨時把代碼獨立出來,使其變成單獨的模塊.
同時,我們也在開發構建系統.原生的構建系統使用起來會有很多問題,它并不支持多人并行開發,如果要實現一個舒適的工作流就需要定制.我們還做了網絡和日志的封裝,將其放在下層.還有一個業務整合的基礎框架,包括滴滴出行的App界面框架、首頁導航欄,各種業務可以注冊自己的入口,并在導航欄里進行切換.
業務之間沒有任何代碼耦合,比如出租車和專車業務沒有關聯性,那么代碼也沒有任何相關的地方,這意味著開發出租車業務的時候,完全沒有必要實時更新專車代碼,集成的時候也不會因為專車代碼而造成問題.
最頂層的One Travel可以通過簡單的配置分業務包,比如可以輸出只有出租車業務的包,在這上面開發測試速度比較快,整體也會比較靈活.One Travel里面只有極少的代碼,未來會改成沒有代碼、通過腳本就可以生成的項目.
怎么做頁面的解耦?上圖中是一種類似數據庫緩存的設計.從客戶端角度來看,如果把服務器當做一個數據庫,最終狀態存儲在服務器,而客戶端里存著的是跟服務器同步過的最新狀態的緩存.客戶端不太可能做到精確的數據同步,一定是每隔一段時間同步一次,或者是在關鍵節點上靠服務器推送得到訂單狀態變化.
客戶端的業務代碼其實不關心究竟是如何同步狀態的,所以我們專門寫了一個緩存服務器狀態的Store層,它是熱數據.如果不需要最新狀態的數據,業務讀取Store時可以讀到上次同步的數據,假設此時Store從未同步過狀態就會自動讀取最新狀態;如果業務一定要最新狀態的數據,那么就顯示要求緩存失效,這樣Store就會再讀取一次獲取最新的信息.
Store還可以自動設置失效時間長度,這個機制跟跟做數據庫緩存是一樣的,為了性能的平衡,要保證讀出準確的數據,同時性能也要最優.同時,Store也有責任負責數據更新,當客戶端變化可能會讓服務器狀態變化時,Store可以自動讓相關狀態失效,這也是管理緩存的一般做法.
做了這樣一些解耦之后,令人驚喜的是,我們發現所有界面是可以隨意跳轉的,雖然沒有從發單直接跳到評價的必要性,但實際上只要有這個架構,就可以從界面A跳到界面B,不會有任何問題.
如果跳到另外一個界面,沒有發現必要的數據,就從服務器讀取,它自己也會報錯,整個邏輯非常清晰.如果需要在流程A和流程B之間再增加一個流程C,我們可以把流程C直接加進去,流程C沒有破壞A和B之間的依賴,因為原本A和B之間也沒有什么依賴.
我們也做一些App的組件化,把從服務端API到客戶端邏輯打包在一起,引用客戶端組件就可以實現完整功能.實際封裝方法略微有點復雜(注:可以閱讀另外一篇文章支撐滴滴高速發展的引擎:滴滴的組件化實踐與優化).
圖中所示是做平滑移動組件,地圖上有很多車在移動,這些車就是地圖上的額外信息,把這些車掛在地圖上.如果這個控件不存在,地圖上就沒有車,控件存在,地圖上就有車,只要在上面啟動控件就好了.
App集成也采用了異步和無障礙的做法,每個業務只需要在倉庫里面測試完之后直接打tag,之后就能自動生成整個所有業務的ipa/apk包.
Web App怎么拆?
接下來講Web App的拆解,這實際上是純工程的解耦.
首先,我們需要實現一個簡單的公共框架,這跟業務是無關的.我們使用scrat和webpack來實現工程化,將首頁拆分成了許多組件,所有的業務可以根據不同配置選擇使用哪些組件,同時也保證頁面風格的統一、功能的穩定.
如果網絡比較糟糕,我們會做一系列的降級,首先出來的會是一些統一的控件,比如上車地點、目的地、廣告等,之后會根據定位的結果得到當前開通的業務線列表,并加載業務代碼,然后默認選擇當前業務線的邏輯.
如果業務線代碼加載好了就開始渲染,如果業務加載出錯或代碼執行出錯,業務就會被隱藏.業務線之間也是完全解耦的,大家可以通過公共框架提供的事件機制來通信,但不允許業務之間直接通信.線上的Web App就是如上圖所看到的,每個業務線都有一段獨立js代碼,第一次加載相對較慢,會看到很多請求,如果業務線代碼沒有更新,下次打開就完全不走網絡請求.
我們也做了很多控件,這是內網發布的一些控件(見上圖),每個業務只要關注自己的業務邏輯即可,公共的功能都可以使用控件.特別是選擇地址的控件,它把前端界面交互和后端API都打包在一起,和客戶端一樣,只要引用它,就可以直接在Web App使用,無需任何服務端的開發.
服務器API怎么拆?
關于服務器API的拆分,我們最開始希望一次性實現理想方案,但是這個理想方案遇到一些問題.
我先來談談理想方案是什么.首先,滴滴業務一般都是基于訂單流轉推動各種業務動作.為什么會發生訂單流轉?是因為對乘客和司機做了一些操作,如果想象成一個客戶端系統,就有點類似于觸發各種用戶事件.客戶端動作根本上決定了信息該如何流轉,所有事情都應該在客戶端觸發,觸發之后來到了組件這一層,所有動作進行消費,然后進行下一步操作.
比如,用戶提出一個需求,發單對需求進行過濾,判斷是哪種需求,然后進行一些檢查.快車有拼車和不拼車兩種,發單的時候就可以知道是拼車還是不拼車,對于統一訂單系統來說這就是個標志.無論拼不拼,這個單對用戶都一樣,無非就是消耗多少人民幣、消耗幾個座位還是消耗整輛車的問題.
之后分單系統會進行訂單的匹配.一旦匹配成功,客戶端有很多動作,司機確認接單,乘客可以看到確認.如果直接做成消息,客戶端和服務端用一條總線連接,問題就解決了.
這里有一個很大的優點——可拼接,所有東西都組件化了.但是最大的問題在于抽象程度非常高.這是函數式的思想,要求所有的Worker都是純函數,純函數是非常高的要求,上下文狀態必須要通過參數才行.我們發現很難做到這一點,因為所有系統必須有狀態,一旦這樣這個純函數就不是純函數了,要依賴外部的變量.
與面向對象設計的思路差異非常大,做函數式設計時很容易陷入一些抉擇當中,如何定義輸入、輸出,如何劃分流程.有一些流程劃分成三段式,中間的流程異步調出去,又異步調回來繼續后續流程,這種設計讓人很糾結.
函數很依賴異步化,異步化會讓數據流變得復雜.我們思考數據流的流向,以及每次數據流在流轉的時候都需要設置的輸入、輸出.最終,這個方案并沒有實施,雖然我們開發了接近半年的時間.
2016年,我們又重新思考了這個問題,這次是比較簡單和現實的方法.首先我們進行了一些代碼的隔離,把代碼分開,之后對系統按照剛才講的模塊進行面向對象的抽象,比如發單就是單獨的系統,訂單也是一個單獨的系統,支付的收銀體系是一個系統,評價體系是一個系統.每一個系統變得很簡單,互相之間用RPC調用關聯起來.
這會有什么缺點呢?長期來講缺點還是比較明顯的,就是不容易擴展.現在我們設計的模型是來源于當前業務現狀,如果業務發生改變,比如多了一種車型,就會遇到該如何擴展的抉擇:應該提供更多API接口滿足新的業務功能,還是在原有API修改上提供更多參數.
兩種方法看起來都可以,但是本質上我認為無論用哪種方案都會使模塊本身變得越來越臃腫,其實都是把很多種東西融合在一起,并不是很理想.當一個服務臃腫到一定程度之后又會出現以前的問題,又要再次做拆分和重構,甚至整個RPC調用流程都會發生很大震動.
從項目整體實施效果上來講,這次重構最主要是解決了開發迭代的問題,能夠讓迭代速度更快.讓我們比較意外的情況是,重構前客戶端crash率非常高,重構中我們對代碼進行了非常多的修改,同時還在用戶體驗上做了很多優化,但最終crash率反而大幅下降,從以前1%降低到0.3%.
重構后各個業務團隊的開發模式發生了根本的變化,以前是各個業務各耦合在一起進行開發,現在各個業務都能獨立開發,互不干擾,同時平臺還會不斷產出更多的公共組件.
如何避免重蹈覆轍?
最后提一下如何重蹈覆轍.我認為,所有的設計應該是自上而下,先從產品層面上規劃核心業務的模式,然后考慮如何讓產品技術實現它.如果把業務模式描述成如圖所示的核心循環,會非常清楚.我們不僅要考慮現在,還要考慮未來.如果讓整個架構保持健康,就要考慮什么功能是真正緊密相關的.
比如在服務端,直覺上感覺各種不同的發單應該是在一起的,但實際上并不是這樣.不同車型的發單接口互相之間并沒有什么聯系,每一種發單都會有獨特的個性化定制,這些定制才是真正應該跟發單緊耦合的東西.
所以我們應該從產品角度上考慮,把一種發單所調用的所有相關API放在一起,服務端發生變化,調用的組件也會發生變化,做到發單閉環.剛剛提到的今年服務端的重構的方法,實際上并沒有讓各個子系統打通,這是一件很遺憾的事.未來如果開發一些新需求,肯定還會涉及多個模塊、團隊,避免不了一些溝通成本.
另外給大家介紹一下,我們專門做了一個組件平臺,叫做魔方組件庫,是客戶端到服務端的庫,我們會繼續沉淀更多的客戶端到服務端打通的組件,讓業務開發更快更輕松.
文章出處:InfoQ