切換語言為:簡體
聊聊 Discord 是如何儲存萬億級別的資料的

聊聊 Discord 是如何儲存萬億級別的資料的

  • 爱糖宝
  • 2024-07-11
  • 2076
  • 0
  • 0

在2017年,我們寫了一篇部落格文章,講述了我們如何儲存數十億條訊息的。我們分享了我們的歷程,最初我們使用MongoDB,但後來我們將資料遷移到Cassandra,因為我們當時正在尋找一種可擴充套件、容錯且相對低維護的資料庫。因為我們知道我們儲存的資料會持續增長,目前來看也確實如此。

我們希望資料庫的容量能自然的增長,我們也不用付出更多精力去進行維護。但不幸的是,我們的Cassandra叢集出現了嚴重的效能問題,我們需要投入更多的精力僅僅用在維護上九很多,而不是最佳化上。

將近六年之後,我們的業務發生了很大的變化,我們儲存訊息的方式也隨之改變。

使用Cassandra遇到的問題

我們將訊息儲存在一個稱為cassandra-messages的系統中。顧名思義,它是基於Cassandra,並用於儲存訊息的系統。在2017年,我們執行了12個Cassandra節點,儲存了數十億條訊息。

到了2022年初,該系統已擴充套件至177個節點,儲存的訊息數量達到了數萬億條。令我們難受的是,這是一個高運維負擔的系統——我們的值班團隊因資料庫問題而頻繁oncall,經常出現異常的資料延遲(訊息落庫和訊息查詢cost太長),我們不得不減少成本太高而無法執行的維護操作。(砍了部分功能)

是什麼導致了這些問題?首先,讓我們來看一條訊息。

CREATE TABLE messages (
   channel_id bigint,
   bucket int,
   message_id bigint,
   author_id bigint,
   content text,
   PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

上面的CQL語句是我們訊息的最小結構。我們使用的所有ID都是Snowflake(雪花演算法)生成的,這使得它們在時間上是有序的。我們按照message_id和bucket(bucket 是一個時間範圍,比如10天)對訊息進行分割槽。這種分割槽方式意味著,在Cassandra中,所有針對特定頻道和桶的訊息都將被一起儲存並跨三個節點(或者你設定的複製因子所決定的數量)進行復制。

在這種分割槽策略下隱藏著一個潛在的效能陷阱:一個只有使用者的伺服器傳送的訊息量通常會比有成千上萬使用者的大伺服器少幾個數量級。

在Cassandra中,讀取操作比寫入消耗更多。寫入操作被append到commit log中,並寫入到一個稱為memtable的記憶體結構中,該結構最終會被重新整理到磁碟上。然而,讀取操作可能需要查詢記憶體中的memtable以及多個SSTable(沒有命中memtable的場景)(SSTable是直接存在磁碟上檔案),這是一個更耗時的操作。隨著使用者與伺服器的互動,大量的併發讀取可能會使一個分割槽成為熱點,我們形象地稱這種情況為““hot partition”。因為我們萬億級別的資料量,以及特殊的查詢寫入方式,導致我們的叢集遇到了困難。

當我們遇到熱點分割槽時,它經常會影響到整個資料庫叢集的延遲。一個頻道和桶的組合會接收到大量的流量,隨著節點努力處理越來越多的流量而逐漸落後,節點的延遲也會增加。(這是因為discord的場景,越熱門的頻道,訪問的人越多,發言越多,可以類比成微博的熱門話題,訪問的人多,討論的也多,查詢和寫入的需求很大,導致某個話題切進去就白屏,發言要等一段時間才傳送成功)

由於節點無法跟上處理速度,對這個節點的其他查詢也會受到影響。由於我們使用Quorum(Quorum可以理解成寫入操作,大於一半的節點回復ok了)一致性級別 進行讀寫操作,所有服務於熱點分割槽的節點的查詢都會遭受延遲增加,導致更多使用者影響。

叢集維護任務也經常帶來麻煩。我們容易在合併操作上滯後,這是Cassandra爲了提高讀取效能而將磁碟上的SSTable進行壓縮的過程。不僅我們的讀取操作因此變得更加昂貴,當節點試圖進行壓縮時,我們還會看到延遲的連鎖反應。

我們經常執行一種我們稱之為“gossip dance”的操作,我們將一個節點從輪換中取出,讓它在不接收流量的情況下進行壓縮,然後將其帶回以從Cassandra的提示移交中獲取提示,然後重複直到壓縮積壓為空。我們還花費大量時間調整JVM的垃圾收集器和堆設定,因為GC暫停會導致顯著的延遲峰值。

改變我們的架構

我們的訊息叢集並不是我們唯一的Cassandra資料庫。我們還有其他幾個Cassandra叢集,每個叢集都表現出了類似的(儘管可能沒有那麼嚴重)故障。

在我們之前版本的這篇文章中,我們提到了對ScyllaDB感興趣,這是一個用C++編寫的與Cassandra相容的資料庫。它承諾提供更好的效能、更快的bugfix、透過其每個核心一個分片的架構實現更強的工作負載隔離,以及0 GC。

儘管ScyllaDB不是絕對沒有問題的,但它沒有GC,因為它是用C++而不是Java編寫的。從歷史上看,我們的團隊在Cassandra的GC遇到了許多問題,從GC暫停影響延遲,一直到連續超長的GC暫停,嚴重到需要運維人員手動重啟並觀察受影響的節點恢復。這些問題成爲了巨大oncall壓力,也是我們訊息叢集中許多穩定性問題的根源。

在試驗了ScyllaDB並觀察到測試中的改進後,我們做出了遷移所有資料庫的決定。雖然這個決策本身可以單獨寫成一篇文章,簡而言之,到2020年,除了一個之外,我們所有的資料庫都已經遷移到了ScyllaDB。

最後一個呢?就是我們的老朋友,cassandra-messages。(影響太大了,直接遷移怕出問題,不要嘗試一步到位做完某個事,不然出了問題就難排查了)

為什麼我們還沒有遷移它?首先,這是一個龐大的叢集。擁有數以萬億計的訊息和近200個節點,任何遷移都需要付出巨大的努力。此外,我們想確保在我們致力於最佳化其效能的同時,我們的新資料庫能夠儘可能地優秀。我們還想在生產環境中獲得更多使用ScyllaDB的經驗,將其置於實際壓力下執行,並學習它的不足之處。

我們還致力於為我們的用例提升ScyllaDB的效能。在我們的測試中,我們發現反向查詢的效能對於我們的需求來說是不夠的。當我們嘗試按照與表排序相反的順序進行資料庫掃描時,例如按升序掃描訊息,我們會執行反向查詢。ScyllaDB團隊優先考慮了改進並實現了高效的反向查詢,消除了我們遷移計劃中最後一個數據庫障礙。

我們懷疑僅僅在系統上套用一個新的資料庫並不能讓一切都神奇地變得更好。在ScyllaDB中,熱點分割槽仍然可能是個問題,所以我們還希望投資於改進資料庫上游的系統,以幫助遮蔽和促進更好的資料庫效能。

資料服務:提供資料

在使用Cassandra的過程中,我們遇到了熱點分割槽的問題。對特定分割槽的高流量導致了大量的併發,進而引發了連鎖延遲效應,即後續查詢的響應時間持續變長。如果我們能夠控制對熱點分割槽的併發流量,就能夠防止資料庫被過載。

爲了解決這一任務,我們開發了一種稱為data services的中間層——它位於我們的APIServer與資料庫叢集之間。在編寫data services時,我們選擇了一門在Discord內部越來越受到青睞的語言:Rust!我們之前已用Rust完成了一些專案,它確實如傳聞中那樣,提供了媲美C/C++的速度,同時不犧牲安全性。(Rust 確實牛的,效能和C++一樣,安全性更是超過C++,或許這就是Next C++)

Rust語言的一大優勢是所謂的“無畏併發”——它應該能輕鬆編寫安全且併發的程式碼。其庫也與我們想要達成的目標完美契合。TokioTokio - An asynchronous Rust runtime 為構建基於非同步I/O的系統提供了堅實的基石,並且該語言支援Cassandra和ScyllaDB的驅動。

此外,在編譯器的輔助下,清晰的錯誤資訊、語言結構以及對安全性的強調,使得使用Rust程式設計成為一種享受。一旦程式碼透過編譯,一般就能正常執行。然而,最重要的是,它讓我們可以說我們用Rust重寫了這部分程式碼(在網際網路文化中,這可是相當重要的)。

我們的資料服務位於API與ScyllaDB叢集之間。它們為每個資料庫查詢提供了一個gRPC的介面,並且不包含任何業務邏輯。資料服務提供的主要特性是請求合併。如果多個使用者同時請求同一行資料,我們只會向資料庫發起一次查詢。首次發出請求的使用者將觸發服務內的一個工作執行緒啟動。隨後的請求會檢查該執行緒的存在,並訂閱它。這個工作執行緒將從資料庫獲取資料,並將結果返回給所有訂閱者。(想同參數的併發查詢請求,只有一個會真正打到資料庫上,其他的併發請求會作為消費者等待打到資料庫上的那個請求的返回結果,簡而言之就是合併查詢)

這就是Rust發揮作用的力量:它讓編寫安全的併發程式碼變得容易。

聊聊 Discord 是如何儲存萬億級別的資料的

讓我們設想一下,在擁有非常多使用者的伺服器上釋出通知並@所有人的場景:超級多的使用者將會開啟應用並閱讀最近的訊息,這將向資料庫傳送大量相同的查詢(大家都在查詢從現在開始的latest的N條資料)。在過去,這種情況可能會導致熱點分割槽的出現,甚至可能需要值班人員介入來幫助系統恢復。但是有了我們的資料服務,我們能夠顯著減少針對資料庫的流量峰值。

這裏厲害的第二部分發生在資料服務的上游。我們實現了基於一致性雜湊的路由機制到我們的資料服務,以便實現更有效的請求合併。對於每一個到達資料服務的請求,我們都會提供一個路由鍵。例如,對於訊息請求,這個鍵是一個頻道ID,這意味著所有對同一個頻道的請求都將被導向服務的同一例項。這種路由方式進一步幫助減輕了資料庫的負載。

(這裏是提前對請求進行hash了,對相同的頻道查詢的會直接打到某個資料庫上,不用做二次hash)

簡而言之,當大型伺服器廣播重要通知並提及所有使用者時,使用者們會蜂擁而至,開啟應用閱讀訊息,從而向資料庫傳送大量的請求。過去,這樣的情況可能導致熱點分割槽現象,系統可能因此過載,甚至需要緊急聯絡值班工程師進行干預。但現在,藉助我們的資料服務,我們有能力大幅削減資料庫面臨的瞬時高流量壓力。

這裏的另一大妙處在於資料服務的前端。我們引入了一致性雜湊演算法為基礎的路由策略,以此增強請求合併的效果。對於每次資料服務的請求,我們都會為其指定一個路由鍵值。就訊息請求而言,這個鍵就是頻道ID,由此確保所有指向同一頻道的請求都能匯聚到服務的同一例項上。這種路由策略,有效地降低了資料庫的負擔,提升了系統的整體效能。

聊聊 Discord 是如何儲存萬億級別的資料的 這些改進確實幫助很大,但它們並不能解決我們所有的問題。我們仍然在Cassandra叢集上觀察到熱點分割槽和延遲增加的情況,只不過沒有以前那麼頻繁了。這為我們爭取到了一些時間,使我們能夠準備新的最佳化過的ScyllaDB叢集,並執行遷移計劃。

儘管上述的解決方案帶來了顯著的改善,我們依舊面臨熱點分割槽和延遲上升的挑戰,只是現在這些問題發生的頻次有所降低。這短暫的喘息時機對我們至關重要,它讓我們有機會去構建和最佳化新的ScyllaDB叢集,為即將到來的資料庫遷移做好充分準備。在這個過渡期間,我們得以細緻規劃,確保新叢集的效能和穩定性達到最優,從而平滑過渡到ScyllaDB,最終徹底解決熱點問題,提升使用者體驗。(別想著一步最佳化到位,這是不現實的,得有個plan逐步迭代)

一場宏大的遷移(萬億級別的資料庫遷移)

我們的遷移需求非常明確:我們需要在不停機的情況下遷移數萬億條訊息,並且需要快速完成,因為儘管Cassandra的情況有所改善,但我們仍然經常需要處理突發問題。

第一步很簡單:我們使用超級磁碟儲存拓撲來配置一個新的ScyllaDB叢集。透過使用本地SSD來提升速度,並利用RAID映象資料到持久化磁碟上,我們得到了本地磁碟的速度與持久化磁碟的耐用性。隨著叢集的搭建完成,我們可以開始向其中遷移資料了。

我們遷移計劃的第一個草稿旨在迅速獲得價值。我們將開始使用我們閃亮一新的ScyllaDB叢集來處理新資料,採用一個切換時間點,然後將歷史資料遷移到它後面。這增加了複雜性,但每個大型專案不都需要額外的複雜性嗎?

我們開始雙寫新資料到Cassandra和ScyllaDB,並同時開始配置ScyllaDB的Spark遷移器。它需要大量的調整,一旦我們設定完畢,我們得到一個預計完成時間:三個月。(已經滿頭大汗了,資料遷移要3個月)

這個時間框架並沒有讓我們感到溫暖和舒適,我們更傾向於更快地獲得價值。我們作為一個團隊坐下來,集思廣益尋找加速的方法,直到我們想起我們編寫了一個快速而高效的資料庫元件,可以潛在地擴充套件它。我們決定進行一些以梗為驅動的工程實踐,用Rust重寫了資料遷移器。

在一個下午的時間裏,我們擴充套件了我們的資料服務庫來執行大規模的資料遷移。它從資料庫讀取令牌範圍,透過SQLite本地檢查點它們,然後像消防水龍一樣將它們灌入ScyllaDB。我們連線上我們新的改進版遷移器並獲得了新的估計時間:九天!如果我們能如此快速地遷移資料,那麼我們就可以忘記複雜的基於時間的方法,而是直接一次性切換所有內容。

我們開啟它並讓它持續執行,以每秒高達320萬條訊息的速度遷移。幾天後,我們聚集在一起觀看它達到100%,然後我們意識到它卡在了99.9999%完成(真的)。我們的遷移器在讀取最後幾個令牌範圍的資料時超時了,因為它們包含著龐大的墓碑範圍,這些範圍在Cassandra中從未被壓縮掉。我們對那個令牌範圍進行了壓縮,幾秒鐘後,遷移就完成了!

我們透過向兩個資料庫傳送一小部分讀取請求並比較結果的方式進行了自動化資料驗證,一切看起來都很完美。叢集在滿負荷生產流量下表現良好,而Cassandra卻遭受越來越頻繁的延遲問題。我們在團隊現場集合,切換開關使ScyllaDB成為主要資料庫,並享用了慶祝蛋糕!

幾個月後

我們在2022年5月將訊息資料庫切換了過來,但從那以後它的表現如何呢?

它一直是一個安靜且行為良好的資料庫(這麼說沒問題,因為我這周並不值班)。我們不再經歷整個週末的緊急應對,也不再在叢集中來回挪動節點試圖保持正常執行時間。這是一個更加高效的資料庫——我們從執行177個Cassandra節點減少到了僅需72個ScyllaDB節點。每個ScyllaDB節點有9TB的磁碟空間,相比每個Cassandra節點平均4TB有了顯著提升。

我們的尾部延遲也得到了顯著改善。例如,在Cassandra上獲取歷史訊息的p99延遲在40-125毫秒之間,而在ScyllaDB上,p99延遲則愉快地保持在15毫秒。訊息插入效能也從Cassandra上的5-70毫秒p99延遲,穩定到了ScyllaDB上的5毫秒p99。得益於上述效能提升,我們現在對於訊息資料庫有了信心,解鎖了新的產品應用場景。

2022年末,全球各地的人們都在關注世界盃。我們很快發現的一個現象是進球會出現在我們的監控圖表中。這非常酷,因為不僅能看到現實世界的事件反映在你的系統中很有趣,這也給了我們團隊在會議期間看足球的理由。我們並不是“在會議期間看足球”,我們是在“主動監控系統的效能”。

由於效能的顯著提升以及ScyllaDB的穩定表現,我們能夠更加自信地探索和實現新的產品功能,同時也保證了使用者體驗的流暢性和響應性。這種實時監控能力不僅提升了我們對系統健康狀況的洞察,還讓工作變得稍微輕鬆一些,偶爾在緊張的工作之餘也能享受一些體育賽事帶來的樂趣。

聊聊 Discord 是如何儲存萬億級別的資料的

我們實際上可以透過我們的訊息傳送圖表來講述世界盃決賽的故事。這場比賽精彩絕倫。萊昂內爾·梅西正試圖完成職業生涯中的最後一項成就,鞏固他作為史上最偉大球員的地位,並帶領阿根廷隊奪冠,但橫在他面前的是天賦異稟的基利安·姆巴佩和法國隊。

圖表中的每一次尖峰都代表著比賽中的一次事件。

1、梅西罰進點球,阿根廷1-0領先。

2、 阿根廷再次得分,擴大比分至2-0。

3、上半場結束。使用者們在討論比賽,形成了一段持續十五分鐘的平臺期。

4、這裏的大尖峰是因為姆巴佩為法國隊得分,並且在90秒後再次得分,將比分扳平!

5、常規時間結束,這場激烈的比賽進入了加時賽。

6、加時賽的前半段沒有太多事情發生,但當我們進入中場休息時,使用者們又開始聊天。

7、梅西再次得分,阿根廷重新取得領先!

8、 姆巴佩反擊,再次扳平比分!

9、加時賽結束,比賽將進入點球大戰!

10緊張和興奮在整個點球大戰中不斷升級,直到法國隊失手,而阿根廷隊沒有!阿根廷贏了!

透過監控圖表中的資料波動,我們不僅能夠看到使用者活動與真實世界事件之間的直接關聯,還能感受到世界盃決賽那跌宕起伏、充滿戲劇性的氛圍。每一道尖峰背後都是球迷們的熱情與激動,是對比賽程序的實時反饋。

(我在想直播彈幕系統應該也會有類似的波動圖,彈幕和IM區別並不大)


聊聊 Discord 是如何儲存萬億級別的資料的 

全世界的人們都在緊張地觀看這場令人難以置信的比賽,但與此同時,Discord及其訊息資料庫卻輕鬆應對,毫不費力。我們的訊息傳送量激增,但我們處理得恰到好處。憑藉基於Rust的資料服務和ScyllaDB,我們能夠承擔起這股流量,為使用者提供了一個交流的平臺。

我們構建了一個能夠處理數萬億條訊息的系統,如果這項工作讓你感到興奮,不妨訪問我們的招聘頁面看看。我們正在招聘!

這段描述不僅展現了Discord在技術上的實力和穩定性,即使在面對全球大型賽事引發的高流量衝擊時也能保持優異的表現,同時也傳達了公司對人才的渴望,邀請那些對處理大規模資料和構建高效能系統感興趣的開發者加入團隊,共同創造更多可能。

來源 How Discord Stores Trillions of Messages

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.