使用Rust和Elixir實現高效的下發好友列表

譽兒發表於2019-06-01

去年,Discord的後端基礎設施團隊努力提高核心實時通訊基礎設施的可擴充套件性和效能。

我們進行的一個大專案是改變我們更新公會成員列表的方式(螢幕右側的那些漂亮的頭像)。我們可以直接傳送會員列表中可見部分的更新(分頁),而不是為會員列表中的每個人都傳送更新。這樣做的好處很明顯,例如網路流量更少,CPU使用率更低,電池壽命更長等等。

然而,這給伺服器端造成了一個大問題:我們需要一個能夠容納數十萬個元素的資料結構,以一種可以處理大量更新的方式進行排序,並且可以上報會員的位置索引新增和刪​​除。

Elixir是一種函式式語言,它的資料結構是不可變的。這對推理程式碼並支撐大量併發性都非常好。不可變資料結構是把雙刃劍。現有的資料結構的更新是通過建立全新資料結構來實現的,該全新資料結構是將該操作應用於現有的資料結構的結果。

這意味著當有人加入伺服器(內部稱為公會)並擁有100,000名成員的成員列表時,我們必須構建一個包含100,001名成員的新列表。 BEAM VM非常快速,並且每天都在變得更快。Elixir試圖在可能的情況下利用persistent data structure。但是在我們的運營規模下,這樣的更新效率是無法被接受的。

將Elixir推至極限

兩位工程師接受了製作純Elixir資料結構的挑戰,該資料結構可以容納大型sorted sets並支援快速更新操作。這說起來容易做起來難。

Elixir有一個名為MapSet的set實現。 MapSet是構建在Map資料結構之上的通用資料結構。它對許多Set操作很有用,但它不能保證有序,但這是成員列表的關鍵要求。排除MapSet。

考慮一下List型別:對List做一層封裝,強制保證唯一性並在插入新元素後對列表進行排序。這種方法的壓測資料表明,對於小型列表(5,000個元素) ,插入時間在500μs和3,000μs之間。這太慢了,不可行。更糟糕的是,插入的效能與列表的大小和列表中的位置深度成正比。在250,000個元素的末尾新增一個新元素,大約170,000μs:基本上是恆定的。

圖片描述

接下來再看看。

Erlang有一個名為ordsets的模組。 Ordsets是有序sets,所以聽起來我們找到了解決問題的方法:讓我們壓測一下。當列表很小時,效能看起來相當不錯,範圍在0.008μs和288μs之間。遺憾的是,當測試的大小增加到250,000時,最壞情況下的效能提高到27,000μs,這比我們的自定義List的實現速度提高了五倍,但仍然不夠快。

嘗試了語言附帶的所有候選者,粗略地搜尋了開源lib,看看其他人是否已經解決了這個問題並開源。看了一些lib,但它們都沒有提供所需的屬性和效能。值得慶幸的是,電腦科學領域一直在優化用於儲存和分類資料的演算法和資料結構。

SkipList

ordset在小資料下表現非常出色。也許有一些方法可以將一堆非常小的ordsets連結在一起,並在訪問特定位置時快速訪問正確的ordset。這類似於一個skiplist

這個新資料結構的第一個版本非常簡單。 OrderedSet是一個Cell列表的封裝,每個Cell內部都是一個小的ordset:ordset的第一項,ordset的最後一項,以及count。這允許OrderedSet快速遍歷Cells列表以找到適當的Cell,然後執行非常快速的ordset操作。在250,000專案列表的末尾插入專案從27,000μs降至5,000μs,比原始ordsets快5倍,比原始List實現快34​​倍。

效能有所提升,但是在列表的頭部Cell建立250,000個元素,單個插入時間仍為19,000μs。

這是有道理的。當你在OrderedSet的前面插入一個專案時,它會在第一個Cell中結束,但是Cell已經滿了,所以它將最後一個專案驅逐到下一個Cell,但是Cell已經滿了,所以它將最後一個專案驅逐到下一個Cell,依此類推。這樣的情況,我們稱之為級聯。

OrderedSet

問題在於,當元素填滿時,操作會從Cell級聯到下一個Cell。如果我們允許Cell分裂,在列表中間動態插入新Cell呢?好處是:最壞的情況是Cell分裂,而不是級聯。

優化後的情況:

在小列表時,這個新的OrderedSet可以在列表中的任何點執行4μs和34μs之間的插入,很不錯。我們將大小調整到250,000。在列表的開頭插入,第一個插入為4μs,後面會逐慚變慢。最終在列表末尾插入一個專案需要640μs,看起來還行。

圖片描述

必須更快!

上面的解決方案適用於高達250,000名成員的公會,但我們想要更多!Discord一直在使用Rust來讓事情變得更快,我們可以使用Rust來加快速度嗎?

Rust不是一種函式式語言,可以使用可變資料結構。它也沒有執行時並提供“zero-cost abstractions”。如果我們用Rust,它可能會表現得更好。

我們的核心服務不是用Rust編寫的,它們是基於Elixir的。 Elixir非常適合呼叫Rust,幸運的是,BEAM VM還有另一個漂亮的技巧。 BEAM VM有三種型別的函式:

  1. 用Erlang或Elixir編寫的函式。這些是簡單的使用者空間函式。
  2. 內建於語言中的函式,充當使用者空間函式的構建塊。這些被稱為BIF或內建函式。
  3. NIF或native函式。這些是使用C或Rust構建並編譯到BEAM VM中的函式。呼叫這些函式就像呼叫BIF一樣,但是你可以控制它的功能。

有一個名為Rustler的Elixir專案。它為Elixir和Rust提供了很好的支援,可以建立一個表現良好的安全的NIF,並保證使用Rust不會VM崩潰或記憶體洩漏。

我們預留了一個星期,看看這是否值得付出努力。到本週末,我們給出一個非常有限的驗證資料。壓測資料看上去很有希望,與OrderedSet的4μs至640μs相比,向SortedSet新增元素的最佳情況是0.4μs,最差情況為2.85μs。這只是使用integer來測試,但它足以證明優於Elixir的實現。

有了資料支撐,我們決定繼續擴充套件程式支援更多的Elixir資料型別。最後我們的測試資料如下:
我們將數量一直增加到1,000,000。最後列印出結果:SortedSet最佳情況為0.61μs,最差情況為3.68μs。結果是基於多種大小的sets,從5,000到1,000,000。

我們使最壞的情況與先前的最佳情況一樣好!Rust支援的NIF提供了巨大的效能優勢,而無需犧牲易用性或記憶體。

圖片描述

喜訊

今天,Rust版的SortedSet為每一個Discord公會提供支援:從計劃到日本旅行的3人公會到享受最新、有趣的遊戲的20萬人公會。

自部署SortedSet以來,我們已經看到效能全面提升,不會對記憶體壓力產生影響。我們瞭解到Rust和Elixir可以並肩工作。我們仍然可以將我們的核心實時通訊邏輯保留在更高階別的Elixir中,它具有出色的保護和簡單的併發實現,同時在需要時可以使用Rust。

如果你需要一個高效更新的SortedSet,我們已經開源了SortedSet

相關文章