據報導,Uber 僅在過去 4 年的時間裡,業務就激增了 38 倍。Uber 首席系統架構師Matt Ranney  在一個非常有趣和詳細的訪談《可擴展的 Uber 實時市場平台》中告訴我們 Uber 軟體是如何工作的。

本次訪談中沒有涉及你可能感興趣的峰時定價(Surge pricing,譯註:當 Uber 平台上的車輛無法滿足大量需求時,將提升費率來確保乘客的用車需求)。但我們了解到 Uber 的調度系統,他們如何實現地理空間索引、如何擴充系統、如何提高可用性和如何處理故障,例如在處理數據中心故障時,他們甚至會把司機電話作為一個外部分佈式存儲系統用於恢復系統。

訪談的總體印像是 Uber 成長得非常快速。很多他們選擇的的架構是快速成長的結果,同時也想讓組建不久的團隊可以盡可能快地移動。因為他們的主要目標是讓團隊的工程速度盡可能得快,所以在後台使用了大量的技術。

在經歷一個稍顯混亂但非常成功的開端后,Uber 似乎學習到很多:他們的業務和他們需要做什麼才能成功。他們早期的調度系統只是為了送人。由於 Uber 的使命成長為除了送人以外,還要處理箱子和雜物(編註:Uber 已涉及快遞業務。),他們的調度系統已經被抽象並構建在可靠的和智慧的架構基礎上。

雖然 Matt 認為他們的架構可能有點瘋狂,使用一致性哈希環(Consistent Hashing)和 gossip 協議的想法非常適合他們的使用場景。

很難不被 Matt 幹事業的熱情所迷住。當談到他們的調度系統——DISCO,他興奮地說就像學校裡的旅行推銷員問題(traveling salesman problem)。這是一個很酷的計算機科學問題。雖然解決方案不是最優的,但這是現實世界中一個規模很大,要求實時性,由容錯和可擴展的部件建立起來的問題。這是不是很酷?

讓我們看看 Uber 內部是如何工作的。下面是我對 Matt’s 談話的註釋:

  • 統計

·Uber 地理空間索引的目標是每秒一百萬次寫入,讀取速度比寫入速度快很多倍

·調度系統有數以千計的節點

  • 平台

·Node.js (譯者註:Node.js 是一個開源的、跨平台的、用於服務器和網絡應用的運行環境。Node.js 應用用 JavaScript 編寫)

·Python 語言

·Java 語言

·Go 語言

·iOS 和 Android 上的本機應用程序

·微服務

·Redis(譯者註:Redis 是一個開源、支持網絡、基於內存、鍵值對存儲的數據庫,使用 ANSI C 編寫。)

·Postgres(譯者註:PostgreSQL 標榜自己是世界上最先進的開源數據庫。)

·MySQL 數據庫

·Riak (譯者註:Riak是由技術公司basho開發的一個類 Dynamo 的分佈式 Key-Value 系統。以分佈式、水平擴展性、高容錯性等特點著稱。)

·Twitter 公司提供基於 Redis 的 Twemproxy (譯者註:一個快速和輕量的代理)

·谷歌的 S2 地理函數庫

·ringpop —— 一致哈西環

·TChannel ——網絡多路復用和 RPC 幀協議(譯者註:RPC,Remote Procedure Call,遠程過程調用)

·Thrift (譯者註:Thrift 是一個跨語言的服務部署框架)

  • 概述

Uber 是一個用來連接乘客和司機的運輸平台。

他們的挑戰是:實時匹配動態的需求和供給。在供給方面,司機可以自由地做他們想做到的任何事情。在需求方面,乘客可以隨時要求運輸服務。

而 Uber 的調度系統是一個實時的市場平台,通過移動電話來匹配司機和乘客。根據統計,新年前夕是 Uber 一年中最忙碌的時候。

  • 架構概述

驅動了所有這些的原因是乘客和司機在他們的手機上運行他們的 App。後台主要是服務移動電話的流量。客戶端通過移動數據和盡力而為的網路和後台溝通。10 年前你可以想像有個基於移動數據的業務嗎?而我們現在可以做這樣的事情,太棒了。沒有使用私有網絡,沒有花哨的 Q0S (服務質量),僅僅是開放的網路。

客戶端連接調度系統,它協調司機和乘客,供給和需求。調度系統幾乎都是用 node.js 編寫的,原來計劃把它移植到 io.js 上,不過後來 io.js 和 node.js 合併了。

你可以用 javascript 做一些有趣的分佈式系統的工作。不過記得決不要低估熱情帶來的生產力,而且節點開發者都相當有熱情。他們可以非常快速地完成很多事情。

整個 Uber 系統可能看上去相當簡單。為什麼你還需要這些子系統和這些人呢?只要它看上去是那樣,那就是成功的標誌。只要看上去他們很簡單地完成了他們的工作,就有很多事情需要去做。

地圖或 ETA(預期到達時間):為了讓調度做出更加智慧的選擇,必須要獲取地圖和路線信息。街道地圖和曾經的行駛時間可以用來預測當前的行駛時間。至於語言很大程度上取決於系統集成,所以這裡有 Python、C++ 和 Java。

服務:這裡有大量的業務邏輯服務。使用了一種微服務的方法;大部分用 Python 編寫。

數據庫:使用了很多不同的數據庫,最老的系統是用 Postgres 編寫的;Redis 也使用了很多,而有些是基於 Twemproxy;有些是基於一個客制化的集群系統。

此外也使用了 MySQL 數據庫;Uber 正在建立自己的分佈式列存儲,那是一堆精心策劃的 MySQL 實例。最後有些調度服務還停留在 Riak 上。

旅行後期的流水處理一個旅行結束後要處理很多事情,包括收集評分、發 email、更新數據庫、安排支付;用 Python 編寫。

金流:Uber 集成了很多支付系統。

  • 舊的調度系統

原有調度系統的局限性開始限制了公司的成長,因此 Uber 不得不改變它。

儘管  Joel Spolsky 聲稱幾乎整個系統都被重寫了。但大部分其它系統沒有被觸及,甚至有些調度系統的服務也被保留下來。

舊系統是為專用客車運輸所設計的,做了很多假設:

·每個車輛一個乘客,不適用  Uber Pool (拼車服務)。

·運送人的想法深深嵌入到數據模型和接口裡。這樣限制了擴展到新的市場和產品上,比如運送食物和箱子。

·最初的版本是按城市劃分的。這對於可擴展性而言是好的,因為每個城市可以獨自運營。但當越來越多的城市加入,這變得越來越難以管理。城市有大有小,負載也不一樣。

由於建造得很快,他們沒有單點故障,都是多點故障。

  • 新的調度系統

為了解決城市分片和支持更多產品,供給和需求的概念應該是廣義的,所以供給服務和需求服務被創建出來。

》供給服務跟踪所有供給的性能和狀態機:

有很多屬性模型可以跟踪車輛:座位數目、車輛類型、是否有兒童座椅、可以放進輪椅嗎,諸如此類。

配置需要被追踪。例如,一輛車可能有三個座位但是有兩個都被佔用了。

》需求服務跟踪需求、訂單和需求的方方面面:

如果一名乘客要求一個小車座位,庫存必須滿足需求。

如果一名乘客為了更便宜的價錢,不介意和別人分享一輛車,這也是要建模的。

如果需要移動一個箱子,或者遞送食物呢?

》匹配所有供給和需求的邏輯是一個被稱為 DISCO(調度優化)的服務:

舊系統只匹配當前可用的供給,這意味著當前路上等著工作的車輛。

DISCO 支持未來規劃和使用可用的信息。例如,在旅行過程中修改路線。

geo 供給:基於供給來自哪里和哪裡需要它,DISCO 需要一個地理空間索引做決策。

geo 需求需求也需要一個 geo 索引。

要使用所有這些信息需要有一個更好的路由引擎。

  • 調度

當車輛移動的位置更新被發送到 geo 供應商。為了匹配乘客和司機,或者僅是在地圖上顯示車輛,DISCO 發送一個請求給 geo 供應商。

接著 geo 供應商會先粗略過濾一遍,得到附近滿足需求的候選人。然後列表和需求發送給路線或 ETA(預計到達時間);用以計算它們距離遠近的 ETA,是基於道路系統而不是地理上的。

接著根據 ETA 排序然後把它返回給供應商,再派給司機。至於在機場,Uber 不得不模擬一個虛擬的出租車隊列。因為考慮到他們到達的順序,供應商必須排隊。

  • 地理空間索引

必須有相當的可擴展性。設計目標是每秒處理一百萬次寫入寫入的速度源自司機每 4 秒發送的移動更新。至於讀取速度的目標是要比寫入速度快很多,因為每個打開應用的人都在進行讀取操作。

通過一個簡化的假設——僅跟踪可調度的供給,舊地理空間索引可以很好地工作。大部分供給正在忙著做其它事情,所以支持可用供給的子集就很容易。在為數不多的進程中,有一個全局索引存儲在內存裡。很容易做簡單的匹配。

在新世界裡必須跟踪所有狀態下的供給。Uber 必須跟踪它們涉及的路線;這是相當多的數據。此外,新的服務運行在好幾百個進程上

而因為地球是一個球體,Uber 很難僅依靠經度和緯度做出總結和近似。所以 Uber 通過 Google S2 函數庫將地球分割成微小的單元,每個單元有一個唯一的 ID。

可以通過一個 64 位整數(int64)代表地球上的​​每一平方厘米。Uber 使用一個等級為 12 的單元,根據你所在的位置,面積從 3.31 到 6.38 平方公里。盒子根據它們在球體中的位置,改變它們的形狀和大小。

S2 可以給出一個形狀的覆蓋面積是多大。如果你想以倫敦為中心畫一個半徑 1 公里的圓,S2 可以告訴你填充這塊區域需要多少單元。由於每個單元都有一個 ID,這個 ID 可以作為一個分區鍵。當供給到達一個位置,這個位置的單元 ID 就知道了。可以用一個做為分區鍵的單元 ID 來更新供給位置。然後發送多個副本。

當 DISCO 需要找到附近位置的供給,會以乘客所在位置為中心計算一個圓的面積。借助單元 ID,讓所有在這個範圍內的分區都反饋供給數據。

所有這些都是可擴展的。儘管它不像你想像得那樣高效,但因為扇出相對便宜,寫入負載總是可以通過增加更多的節點來加以擴充。讀取負載可以通過使用複制來擴充。如果需要更大的讀取能力,可以增加複制因子。(譯者註:fanout,扇出,IC 概念,一個邏輯門在正常工作下,其輸出端可接的同族系 IC 門的數目,成為此門的扇出數。簡單的說,其所能推動同種類的次級門的數目就稱為扇出。)

一個限制條件是單元尺寸固定在等級 12 的大小。未來可能會支持動態的單元尺寸。但這需要權衡利弊,單元格越小,查詢的扇出就越多。

  • 路線

討論完地理空間,我們來討論路線的選擇必須分級。

有一些主要目的:

減少空載(extra driving):開車是人們的工作,他們希望可以更有效率。空載不會給他們帶來收入(譯者註:感覺此處有筆誤)。理想情況下,司機一直在行駛中。一堆賺錢的工作排隊等著他們。

減少等待乘客等待要盡可能的短。

整體 ETA 最少(整體預計到達時間)

舊系統讓需求查詢當前可用的供給,加以匹配並最終完成。這很容易實現和讓人理解。這在專車運輸下工作得相當好。

但僅看當前可用的,並不能做出好的選擇:其想法是一個正在運送乘客的司機可能更適合這位叫車的客戶,因為目前空閒的司機距離比較遠。挑選正在途中的司機減少了客戶的等待時間,也讓遠程司機的空載時間降到最小。

在可預見的未來,這個模型可以更好地處理動態條件:

例如,一名客戶附近剛好有一名司機上線,但是這個客戶之前已經分派給另一位距離位置遠一點的司機,這種情況下就不應該改變調度決策。

另一個例子是客戶希望可以分享一輛車。通過在非常複雜的情況下嘗試預測未來,可以進行更多的優化。

當考慮到運送箱子或者食物,所有這些決策會更加有趣。在這些情況下,人們通常會做其它事情,就需要有其他不同的考量。

  • 可擴展的調度

調度使用 node.js 構建;他們構建了一個有狀態的服務,所以無狀態的擴展方法不能工作。

Node 運行在一個單獨進程上,所以必須想一些辦法讓 Node 可以運行在同一台機器的多個 CPU 上和多台機器上。而用 Javascript 重新實現所有 Erlang 的實現是個笑話。

擴展 Node 的一個解決方案是 ringpop,它是一個基於 gossip 協議的一致哈希環,實現了一種可擴展的和容錯的應用層分區。在 CAP 術語中,ringpop 是一個 AP 系統,權衡一致性和可用性。一些不一致性要比無法服務更好解釋。最好是可以一直可用只是偶爾出錯。

ringpop 是一個可以包含在每一 Node 進程的嵌入式模塊。

Node 基於一個成員集合實現 gossip 。一旦所有節點相互認可,它們可以獨立和高效地進行查詢和轉發的決策。這是真正得可擴展:增加更多的進程可以完成更多的工作。這可以被用來切分數據,或作為一個分佈的閉鎖系統、或協調一個發布或者訂閱的會合點、或者一個長時間輪詢的 socket。

Gossip 協議一種基於可擴充可傳導的弱一致性進程組成員協議(SWIM,Scalable Weakly-consistent Infection-style Process Group Membership Protocol);為了提升收斂時間已經做了一些改善。

一系列在線的成員都在「傳播流言」 (gossip around 譯註:雙關用語)。當更多的節點加入,它就是可擴充的。SWIM 中的“ S ”代表可擴展的,並且的確可以工作;這可以擴展到數千個節點的程度。(SWIM 結合了健康檢查和成員變更,並把它們作為協議的一部分。)

在一個 ringpop 系統中,所有 Node 進程都包含 ringpop 模塊。它們在當前成員中「傳播流言」。

從外面看,如果 DISCO 想要使用地理空間,每個節點都是相等的。可以選擇任意一個健康的節點。通過檢查哈希環,接受請求的節點會負責把這個請求轉發給正確的節點。如下圖所示:

讓這些躍點和對端可以相互溝通聽上去很瘋狂,但可以得到一些很好的特性,比如在任意機器上增加實例就可以擴充服務。

ringpop 的構建基於 Uber 自己的遠程過程調用(RPC,Remote Procedure Call)機制,被稱為 TChannel。它是什麼?

這是一個雙向的請求和響應協議,它的靈感來自 Twitter 的 Finale。

一個重要的目標是控制跨不同語言的性能;特別是在 Node 和 Python 中,很多現有的 RPC 機制不能很好地工作,因此需要 redis 級別的性能。而 TChannel 已經比 HTTP 快 20 倍。

需要一個高性能的轉發路徑,這樣中間層不需要知道整個負載,就可以很容易做出轉發的決策。

需要適合的流水線,這樣就不會有排頭擁塞的問題,任何時候任何方向都可以發送請求和響應,每個客戶端也是一個服務器。

需要嵌入負載檢驗、跟踪和一流的功能。在系統內處理中,每個請求都應該是可被跟踪的。

需要一個乾淨的脫離 HTTP 的方法。HTTP 可以非常自然地被封裝到 TChannel 裡。

Uber 正在遠離 HTTP 和 Json 業務。都在遷往基於 TChannel 的 Thrift。

ringpop 基於持久連接的 TChannel 實現 gossip 協議。同樣這些持久連接被用來擴展或者轉發應用流量。TChannel 也被用來進行服務間的通信。

  • 調度可用性

可用性很重要:Uber 有競爭對手而且切換成本非常低。如果 Uber 只是短暫掛掉,這些錢就會被其他人賺走。其他產品的粘性更強,客戶也願意再次嘗試它們。Uber 不一定如此。

讓每件事情都可以重試:如果有些事情不能工作,那它就要可以重試。這就是如何繞過錯誤。這要求所有的請求是冪等的。例如一次調度的重試,不能調度兩次或者刷兩次某人的信用卡。(譯者註:一個冪等操作的特點是其任意多次執行所產生的影響均與一次執行的影響相同)

讓每件事情都可以終止:失敗是一個常見的情況。任意終止進程不應該造成損害。

只有崩潰:沒有優雅的關閉;優雅的關閉不需要練習。需要練習的是當不遇期的事情發生了(要怎麼辦)。

小塊:要把事情失敗的成本降到最低就是把它們分成小塊。可以在一個實例中處理全部流量,但如果它掛掉了怎麼辦?如果有兩個,就算一個掛了,只是性能減半。所以服務要可以被拆分。這聽上去像一個技術問題,但更像一個文化問題。很容易就擁有一對數據庫。這是一件很自然的事情,但配對就不好。如果你能夠自動發起一個和重新啟動新的備用,隨機終止它們是相當危險的。

終止一切就算終止所有數據庫來確保可以從失敗中恢復過來。這需要改變數據庫的使用策略。他們選擇 Riak 而不是 MySQL。這也意味著使用 ringpop 而不是 redis。因為 redis 實例通常相當大和昂貴,終止一個 redis 實例是一個很昂貴的操作。

把它分成小塊:談到文化轉變。通常服務 A 通過一個負載均衡器和服務 B 溝通。如果均衡器掛掉會怎樣?你要如何處理這種情況?如果你沒有練習過你永遠都不知道。你應該終止負載均衡器。你如何繞過負載均衡器?負載均衡的邏輯已經在服務裡面。客戶端需要有一些信息知道如何繞過問題。這和 Finagle 的工作方式類似。

一個集群的 ringpop 節點創建了服務發現和路由系統,讓整個系統有可擴展性和應對後台的壓力。

  • 整個數據中心的故障

雖然不會經常發生,但還是會出現一個意想不到的級聯故障或者一個上游網絡提供商的故障。Uber 維護了一個備份的數據中心,通過適當的開關可以把所有事情都切換到備份的數據中心。

問題是在途的旅行數據可能不在備份的數據中心。他們會把司機手機當作旅行數據的源頭而不是數據的副本。

結果調度系統會周期發送一個加密的狀態摘要給司機的手機。現在假設有一個數據中心發生故障轉移。司機手機下一次發送位置更新給調度系統,調度系統將會檢測到它不知道這個旅行,它會問(手機)要狀態摘要。然後調度系統根據狀態摘要進行更新,這個旅行會繼續就像什麼事情都沒有發生過。

  • 不足之處

Uber 解決可擴展性和可用性問題的不足之處,可能在於 Node 處理轉發請求和發送信息給大量扇出所帶來的高延遲。在一個扇出系統中,微小的波動和故障都會有驚人的影響,系統的扇出越高出現高延遲請求的機會就越大。

一個好的解決方案是可以跨服務器取消備份的請求。這個一流的功能已經內嵌到 TChannel 中。一個請求的信息同時發送給服務 B1 和 B2;發送給服務 B2 的請求會有些延遲,當 B1 完成這個請求,它會在 B2 上取消這個請求。由於這個延遲通常情況下 B2 不會工作,但如果 B1 出了問題,B2 就可以處理這個請求,這樣會比 B1 先嘗試超時後 B2 再嘗試情況下的反饋要快一些。

(本文轉載自合作夥伴《伯樂在線》;未經授權,不得轉載;圖片來源:bfishadow,CC Licensed)