Discord CTO 談如何構建500W併發用戶的Elixir應用

從一開始,Discord就是Elixir的早期使用者。 Erlang VM是咱們打算構建的高併發、實時系統的完美候選者。咱們用Elixir開發了Discord的原型,這成爲咱們如今的基礎設施的基礎。 Elixir的願景很簡單:經過更加現代化和用戶友好的語言和工具集,使用Erlang VM的強大功能。html

兩年多的發展,咱們的系統有近500萬併發用戶和每秒數百萬個事件。雖然咱們對選擇的基礎設施沒有任何遺憾,但咱們須要作大量的研究和實驗才能達到這種程度。 Elixir是一個全新的生態系統,Erlang的生態系統缺少在生產環境中的使用信息(儘管erlang in anger很是棒)。咱們爲Discord工做的過程當中吸收了一系列的經驗教訓和創造了一系列的開源庫。node

消息發佈

雖然Discord功能豐富,但大多數功能都歸結爲發佈/訂閱。用戶鏈接WebSocket並啓動一個會話process(一個GenServer),而後會話process與包括公會process(內部稱爲「Discord Server」,也是一個GenServer)在內的遠程Erlang節點進行通訊。當公會中發佈任何內容時,它會被展現到每一個與其相關的會話中。react

當用戶上線時,他們會鏈接到公會,而且公會會向全部鏈接的會話發佈該用戶的在線狀態。公會在幕後有不少其餘邏輯,但這是一個簡化的例子:git

def handle_call({:publish, message}, _from, %{sessions: sessions}=state) do
  Enum.each(sessions, &send(&1.pid, message))
  {:reply, :ok, state}
end

最初,Discord只能建立少於25人的公會。當人們開始將Discord用於大型公會時,咱們很幸運可以出現「問題」。最終,用戶建立了許多像守望先鋒這樣的Discord公會服務器,最多能夠有30,000個併發用戶。在高峯時段,咱們開始看到這些process的消息消費沒法跟上消息產生的速度。在某個時刻,咱們必須手動干預並關閉產生消息的功能以應對高負載。在達到超負載以前,咱們必須弄清楚問題所在。github

首先,咱們在公會process中對熱門路徑進行基準測試,並迅速發現了一個明顯的問題。在Erlang process之間發送消息並不像咱們預期的那麼高效,而且reduction(用於進程調度的Erlang工做單元)的負載也很是高。咱們發現單次 send/2 調用的運行時間可能在30μs到70us之間。這意味着在高峯時段,從大型公會(3W人)發佈消息可能須要900毫秒到2.1秒! Erlang process其實是單線程的,並行工做的惟一方法是對它們進行分片。這原本是一項艱鉅的任務。web

咱們必須以某種方式均勻地分發發佈消息的工做。因爲Erlang中建立process很廉價,咱們的第一個猜想就是建立另外一個process來處理每次發佈。可是這個方案沒法應對如下兩種狀況:(1)每次發佈的消息的schedule(例如:發佈1個小時後的消息)不一樣,Discord客戶端依賴於事件的原子一致性(linearizability);(2)該解決方案也不能很好地擴展,由於公會服務自己的壓力並無減輕。編程

受到一篇博客文章《Boost message passing between Erlang nodes》的啓發,Manifold誕生了。 Manifold將消息的發送工做分配給的遠程分區節點(一系列PID),這保證了發送process調用send/2的次數最多等於遠程分區節點的數量。 Manifold首先對會話process PID進行分組,而後發送給每一個遠程分區節點的Manifold.Partitioner。而後Partitioner使用 erlang.phash2/2 對會話process PID進行一致性哈希,分紅N組,並將消息發送給子workers(process)。最後,這些子workers將消息發送到會話process。這能夠確保Partitioner不會過載,而且經過 send/2 保證原子一致性。這個解決方案其實是 send/2 的替代品:安全

Manifold.send([self(), self()], :hello)

Manifold的做用是不只能夠分散消息發佈的CPU成本,還能夠減小節點之間的網絡流量:
圖片描述服務器

高速訪問共享數據

Discord是經過一致性哈希實現的分佈式系統。使用此方法須要咱們建立可用於查找特定實體的節點的環數據結構。咱們但願環數據結構的性能很是高,因此咱們使用Erlang C port(負責與C代碼鏈接的process)並選擇了Chris Moos寫的lib。它對咱們頗有用,但隨着Discord的發展壯大,當咱們有大量用戶重連時,咱們開始發現性能問題。負責處理環數據結構的Erlang進程將開始變得繁忙以致於處理量跟不上請求量,而且整個系統將變得過載。解決方案彷佛很明顯:運行多個process處理環數據結構,以充分利用cpu的多核來響應請求。可是,咱們注意到這是一條熱門路徑,必須找到再好的解決方案。網絡

讓咱們分解這條熱門路徑的消耗:

  • 用戶能夠加入任意數量的公會,但普通用戶是5個。
  • 負責會話的Erlang VM最多能夠有500,000個實時會話。
  • 當會話鏈接時,必須爲它加入的每一個公會查找遠程節點。
  • 使用request/reply與另外一個Erlang進程通訊的成本約爲12μs。

若是會話服務器崩潰並從新啓動,則須要大約30秒(500000X5X12μs)的時間來查找環數據結構。這甚至沒有計算Erlang爲其餘process工做而取消環數據結構process調度的時間。咱們能夠取消這筆開銷嗎?

當他們想要加速數據訪問時,人們在Elixir中作的第一件事就是引入ETS。 ETS是一個用C實現的快速、可變的字典; 咱們不能立刻將環數據結構搬進ETS,由於咱們使用C port來控制環數據結構,因此咱們將代碼轉換爲純Elixir。 在Elixir實現中,咱們會有一個process,其工做是持有環數據結構並不斷將其copy到ETS中,以便其餘process能夠直接從ETS讀取。 這顯著改善了性能,ETS讀取時間約爲7μs(很快),但咱們仍然花費17.5秒來查找環中的值。 環數據結構數據量至關大,而且將其copy進和copy出ETS是很大開銷。 使人失望的是,在任何其餘編程語言中,咱們能夠輕鬆地擁有一個能夠安全讀的共享值。 在Erlang中必須造輪子!

在作了一些研究後,咱們找到了mochiglobal,一個利用Erlang VM功能的module:若是Erlang VM發現一個老是返回相同常量的函數,它會將該數據放入一個只讀的共享堆中,process能夠訪問而無需複製。 mochiglobal的實現原理是經過在運行時建立一個帶有一個函數的Erlang module並對其進行編譯。 因爲數據永遠不會被copy,查詢成本下降到0.3us,總時間縮短到750ms(0.3usX5X500000)! 天下沒有免費午飯,在運行時使用環數據結構(數據量大)構建module的時間可能須要一秒鐘。 好消息是咱們不多改變環數據結構,因此這是咱們願意接受的懲罰。

咱們決定將mochiglobal移植到Elixir並添加一些功能以免建立atoms。 咱們的版本名爲FastGlobal

極限併發

在解決了節點查找熱路徑的性能以後,咱們注意到負責處理公會節點上的guild_pid查找的process變慢了。 先前的節點查找很慢時,保護了這些process,新問題是近5,000,000個會話process試圖衝擊10個process(每一個公會節點上有一個process)。 使這條路徑的runtime跑更快並不能解決問題,潛在的問題是會話process對公會註冊表的request可能會超時並將請求留在公會註冊表的queue中。 而後request會在退避後重試,但會永久堆積request並最終進入不可恢復狀態。 會話將阻塞在這些request直到接收到來自其餘服務的消息時引起超時,最終致使會話撐爆消息隊列並OOM,最終整個Erlang VM級聯服務中斷

咱們須要使會話process更加智能。理想狀況下,若是調用失敗是不可避免的,他們甚至不會嘗試對公會註冊表進行調用。 咱們不想使用斷路器(circuit breaker),由於咱們不但願超時致使不可用狀態。 咱們知道如何用其餘編程語言解決這個問題,但咱們如何在Elixir中解決它?

在大多數其餘編程語言中,若是失敗數量太高,咱們可使用原子計數器來跟蹤未完成的請求並提早釋放,事實上就是實現信號量(semaphore)。 Erlang VM是圍繞協調process之間通訊而構建的,可是咱們不想負責進行協調的process超負載。 通過一些研究,咱們偶然發現這個函數:ets.update_counter/4,它的功能是對ETS的鍵值執行原子遞增操做。 其實咱們也能夠在write_concurrency模式下運行ETS,可是ets.update_counter/4 會返回更新後結果值,爲咱們建立 semaphore庫 提供了基礎。 它很是易於使用,而且在高吞吐量下表現很是出色:

semaphore_name = :my_sempahore
semaphore_max = 10
case Semaphore.call(semaphore_name, semaphore_max, fn -> :ok end) do
  :ok ->
    IO.puts "success"
  {:error, :max} ->
    IO.puts "too many callers"
end

事實證實,該庫有助於保護咱們的Elixir基礎設施。 與上述級聯服務中斷相似的狀況發生在上星期,但此次能夠自動恢復服務。 咱們的在線服務(管理長連的服務)因爲某些緣由而崩潰,但會話服務甚至沒有影響,而且在線服務可以在從新啓動後的幾分鐘內重建:

在線服務中的實時在線狀態:
在線服務中的實時在線狀態

session服務的cpu使用狀況:
session服務的cpu使用狀況

總結

選擇使用和熟悉Erlang和Elixir已被證實是一種很棒的體驗。 若是咱們不得不從新開始,咱們確定會作出相同的選擇。 咱們但願分享咱們的經驗和工具,而且能幫助其餘Elixir和Erlang開發人員。但願在咱們的旅程中繼續分享、解決問題並在此過程當中學習。

相關文章
相關標籤/搜索