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

譽兒發表於2019-06-14

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

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

訊息釋出

雖然Discord功能豐富,但大多數功能都歸結為釋出/訂閱。使用者連線WebSocket並啟動一個會話process(一個GenServer),然後會話process與包含公會process(內部稱為“Discord Server”,也是一個GenServer)的遠端Erlang節點進行通訊。當公會中釋出任何內容時,它會被展示到每個與其相關的會話中。

當使用者上線時,他們會連線到公會,並且公會會向所有連線的會話釋出線上狀態。公會在幕後有很多其他邏輯,但這是一個簡化的例子:

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的訊息消費無法跟上訊息生成的速度。在某個時刻,我們必須手動干預並關閉生成訊息的功能以應對高負載。在達到超負載之前,我們必須弄清楚問題所在。

我們首先在公會process中對熱門路徑進行基準測試,並迅速發現了一個明顯的問題。在Erlang process之間傳送訊息並不像我們預期的那麼高效,並且reduction(用於程式排程的Erlang工作單元)也非常高。我們發現單次 send/2 呼叫的執行時間可能在30μs到70us之間。這意味著在高峰時段,從大型公會(3W人)釋出活動可能需要900毫秒到2.1秒! Erlang process實際上是單執行緒的,並行工作的唯一方法是對它們進行分片。這本來是一項艱鉅的任務。

我們必須以某種方式分發釋出訊息的工作。由於Erlang中建立process很廉價,我們的第一個猜測就是建立另一個process來處理每次釋出。但是,每次釋出的時間安排不同,Discord客戶端依賴於事件的原子一致性(linearizability)。該解決方案也不能很好地擴充套件,因為公會服務本身的壓力並沒有減輕。

受到一篇《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,一個利用VM功能的module:如果Erlang發現一個總是返回相同常量的函式,它會將該資料放入一個只讀的共享堆,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)。 使這條路徑跑得更快並不能解決問題,潛在的問題是會話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開發人員。希望在我們的旅程中繼續分享、解決問題並在此過程中學習。

相關文章