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

我的專欄地址:我的segmentfault,歡迎瀏覽


去年,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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章