Discord是如何存儲數(shù)十億消息的

作者:Discord CTO & 聯(lián)合創(chuàng)始人 Stanislav Vishnevskiy
2017.01.13

Discord 的增長速度繼續(xù)快于我們的預期,我們的用戶生成內(nèi)容也是如此。用戶越多,聊天消息就越多。7 月,我們宣布每天 4000 萬條消息,12 月我們宣布 1 億條消息,截至本博文,我們早已超過 1.2 億條。我們很早就決定永久存儲所有聊天記錄,以便用戶可以隨時返回并在任何設備上提供他們的數(shù)據(jù)。這是大量數(shù)據(jù),其速度和大小不斷增加,并且必須保持高可用性。我們是怎么做的?Cassandra!
?
我們做了什么
Discord 的原始版本在 2015 年初不到兩個月的時間里就做好了。毫無疑問,快速迭代的最佳數(shù)據(jù)庫之一是MongoDB。Discord 上的所有內(nèi)容都存儲在單個 MongoDB 副本集 (replica set) 中,這是有意為之,但我們也計劃了一切,以便輕松遷移到新數(shù)據(jù)庫(我們知道我們不會使用 MongoDB 分片,因為它使用起來很復雜并且不以穩(wěn)定性而聞名)。這實際上是我們公司文化的一部分:快速構(gòu)建以證明產(chǎn)品功能,但始終提供通往更強大解決方案的途徑。
?
這些消息存儲在MongoDB集合 (Collection) 中,只有一個建立在channel_id和created_at上的復合索引。大約在 2015 年 11 月,我們累計存儲了 1 億條消息,此時我們開始看到預期的問題出現(xiàn):數(shù)據(jù)和索引無法完全容納在 RAM 中,延遲開始變得不可預測。是時候遷移到更適合該任務的數(shù)據(jù)庫了。
?
選擇正確的數(shù)據(jù)庫
在選擇新數(shù)據(jù)庫之前,我們必須了解我們的讀/寫模式以及當前方案出現(xiàn)問題的原因。
我們很快就發(fā)現(xiàn),我們的讀取非常隨機,讀/寫比率約為50/50。
語音聊天繁重的 Discord 服務器幾乎不發(fā)送任何消息。這意味著他們每隔幾天發(fā)送一兩條消息。在一年內(nèi),這種服務器不太可能達到 1000 條消息。問題是,即使這是少量的消息,它也使得向用戶提供這些數(shù)據(jù)變得更加困難。僅向用戶返回 50 條消息就可能導致磁盤上的許多隨機查找,從而導致磁盤緩存逐出。
文本聊天繁重的 Discord 服務器發(fā)送大量消息,每年輕松達到 10 萬到 100 萬條消息。他們請求的數(shù)據(jù)通常只是最近的。問題是,由于這些服務器通常具有少于 100 個成員,因此請求此數(shù)據(jù)的頻率較低,并且不太可能在磁盤緩存中。
大型公共 Discord 服務器發(fā)送大量消息。他們有成千上萬的成員每天發(fā)送數(shù)千條消息,每年輕松收集數(shù)百萬條消息。他們幾乎總是請求在最后一小時內(nèi)發(fā)送的消息,并且經(jīng)常請求它們。因此,數(shù)據(jù)通常位于磁盤緩存中。
我們知道,在未來一年,我們將為用戶添加更多隨機讀取的方式:能夠查看過去 30 天的提及,然后跳轉(zhuǎn)到歷史記錄中的該點,查看和跳轉(zhuǎn)到固定消息,以及全文搜索。所有這些都意味著更多的隨機讀?。?!
接下來,我們定義了我們的需求:
線性可擴展性 — 我們不希望以后重新考慮解決方案或手動重新分片數(shù)據(jù)。
自動故障轉(zhuǎn)移 — 我們喜歡在晚上睡覺,并構(gòu)建 Discord 以盡可能多地自我修復。
低維護 — 一旦我們設置好它,它應該就可以工作了。隨著數(shù)據(jù)的增長,我們只需要添加更多的節(jié)點。
被證明是有效的 — 我們喜歡嘗試新技術(shù),但不是太新。
可預測的性能 — 當我們有超過5%的 API 響應時間超過 80 ms時,會發(fā)出警報。我們也不希望在 Redis 或 Memcached 中緩存消息。
不要 blob 存儲 — 如果我們必須不斷反序列化 blob 同時追加消息,那么每秒寫入數(shù)千條消息的效果并不好。
開源 — 我們希望控制自己的命運,不想依賴第三方公司。
Cassandra是唯一滿足我們所有要求的數(shù)據(jù)庫。我們只需添加節(jié)點來擴展它,它可以容忍節(jié)點丟失而不會對應用程序產(chǎn)生任何影響。像Netflix和Apple這樣的大公司擁有數(shù)千個Cassandra節(jié)點。相關數(shù)據(jù)連續(xù)存儲在磁盤上,提供最少的尋道和圍繞集群的輕松分布。它由DataStax支持,但仍然是開源和社區(qū)驅(qū)動的。
?
做出選擇后,我們需要證明它確實有效。
?
數(shù)據(jù)建模
向新人描述Cassandra的最好方法是它是一個KKV存儲數(shù)據(jù)庫。兩個 K 構(gòu)成主鍵。第一個 K 是分區(qū)鍵,用于確定數(shù)據(jù)位于哪個節(jié)點上以及數(shù)據(jù)在磁盤上的位置。分區(qū)中包含多行,分區(qū)中的一行由第二個 K(即集群鍵)標識。集群鍵既充當分區(qū)中的主鍵,也充當行的排序標識。您可以將一個分區(qū)視為有序字典。這些屬性相結(jié)合,可以實現(xiàn)非常強大的數(shù)據(jù)建模。
?
還記得消息在MongoDB中使用channel_id和created_at建立索引嗎?channel_id可以作為分區(qū)鍵,因為所有查詢都在一個通道上運行,但created_at并不能作為一個很好的集群鍵,因為兩條消息可以具有相同的創(chuàng)建時間。幸運的是,Discord 上的每個 ID 實際上都是一片雪花(按時間順序排序),所以我們能夠使用它們來代替。主鍵變?yōu)?(channel_id, message_id),其中message_id是雪花 (Snowflake) 。這意味著在加載Discord頻道時,我們可以準確地告訴 Cassandra 在哪里掃描消息。
?
下面是消息表的簡化架構(gòu)(這省略了大約 10 列)。
?
雖然 Cassandra 的模式與關系數(shù)據(jù)庫沒有什么不同,但它們的更改成本很低,并且不會對性能造成任何臨時影響。我們充分利用了 blob 存儲和關系型存儲。
?
當我們開始將現(xiàn)有消息導入 Cassandra 時,我們立即開始在日志中看到警告,告訴我們檢測到分區(qū)大小超過 100MB。什么?!Cassandra宣傳它可以支持2GB分區(qū)!顯然,僅僅因為它可以做到,并不意味著它應該做到。大型分區(qū)在壓縮、集群擴展等過程中給 Cassandra 帶來了很大的垃圾回收壓力。擁有較大的分區(qū)也意味著其中的數(shù)據(jù)不能分布在集群周圍。很明顯,我們必須以某種方式限制分區(qū)的大小,因為單個 Discord 頻道可以存在多年并且大小不斷增長。
?
我們決定按時間存儲我們的消息。我們查看了 Discord 上最大的頻道,并確定我們是否可以在一個存儲桶中存儲大約 10 天的消息并舒適地保持在 100MB 以下。存儲桶必須可從message_id或時間戳派生。
?
Cassandra 分區(qū)鍵可以復合,所以我們的新主鍵變成了((channel_id,存儲桶id),message_id)
?
?
為了查詢頻道中最近的消息,我們生成一個從當前時間到channel_id的存儲桶范圍(它也是雪花,必須比第一條消息早)。然后,我們按順序查詢分區(qū),直到收集到足夠的消息。這種方法的缺點是,很少活躍的 Discords 將不得不查詢多個存儲桶才能隨著時間的推移收集足夠的消息。在實踐中,這被證明影響不大,因為對于活躍的 Discord頻道,通常在第一個分區(qū)中就能找到足夠的消息,并且這些頻道占絕大多數(shù)。
?
將消息導入 Cassandra 很順利,我們已經(jīng)準備好嘗試在生產(chǎn)環(huán)境中部署。
?
暗發(fā)布
將新系統(tǒng)引入生產(chǎn)環(huán)境總是很可怕,因此嘗試在不影響用戶的情況下對其進行測試是個好主意。我們將代碼設置為對MongoDB和Cassandra進行雙重讀/寫。
?
啟動后,我們立即開始在錯誤跟蹤器中收到錯誤,告訴我們author_id為null。怎么可能為null???這是必填字段!
?
最終一致性
Cassandra 是一個 AP 數(shù)據(jù)庫,這意味著它以強一致性換取可用性,這正是我們想要的。 這是 Cassandra 中先讀后寫的反模式(讀取成本更高),因此即使您只提供某些列,Cassandra 所做的一切本質(zhì)上都是更新插入 (upsert)。 您還可以寫入任何節(jié)點,它將在每列的基礎上使用“最后寫入獲勝”語義自動解決沖突。 那么它是如何反咬我們的呢?
?

在一個用戶編輯消息的同時另一個用戶刪除同一條消息的情況下,我們最終會得到一行缺少除主鍵和文本之外的所有數(shù)據(jù)的情況,因為所有 Cassandra 寫入都是更新插入。 處理這個問題有兩種可能的解決方案:
編輯消息時寫回整個消息。 這有可能復活已刪除的消息,并增加對其他列的并發(fā)寫入發(fā)生沖突的機會。
確定消息已損壞并將其從數(shù)據(jù)庫中刪除。
我們選擇了第二個選項,方法是選擇必需的列(在本例中為 author_id)并在它為 null 時將整條消息刪除。
?
在解決這個問題時,我們注意到我們的寫入效率非常低。 由于 Cassandra 最終是一致的,它不能立即刪除數(shù)據(jù)。 即使其他節(jié)點暫時不可用,它也必須將刪除操作復制到其他節(jié)點并執(zhí)行此操作。 Cassandra 通過將刪除視為一種稱為“墓碑”的寫入形式來實現(xiàn)此目的。 在閱讀時,它只是跳過它遇到的墓碑。 墓碑的生存時間可配置(默認為 10 天),并在該時間到期時在壓縮過程中永久刪除。
刪除列和向列寫入 null 是完全相同的事情。 他們都會生成一個墓碑。 由于 Cassandra 中的所有寫入都是更新插入,這意味著即使第一次寫入 null 也會生成墓碑。 實際上,我們的整個消息模式 (schema) 包含 16 列,但通常一個消息只有 4 個列有值。 大多數(shù)時候我們無緣無故地將 12 個墓碑寫入 Cassandra。 解決方案很簡單:只將非空值寫入 Cassandra。
?
性能
眾所周知,Cassandra 的寫入速度比讀取速度快,我們也確實觀察到了這一點。 寫入時間低于1毫秒,讀取時間低于 5 毫秒。 無論訪問什么數(shù)據(jù),我們都觀察到這一點,并且在一周的測試期間性能保持一致。 沒有什么令人驚訝的,我們得到了我們所期望的效果。
?

為了實現(xiàn)快速、一致的讀取性能,以下是在包含數(shù)百萬條消息的通道中跳轉(zhuǎn)到一年多前的消息的示例:

?
大驚喜
一切都很順利,因此我們將其作為我們的主要數(shù)據(jù)庫上線,并在一周內(nèi)逐步淘汰了 MongoDB。 它繼續(xù)完美地工作......大約 6 個月,直到有一天 Cassandra 變得無響應。
?
我們注意到 Cassandra 不斷運行 10 秒的“stop-the-world” GC,但我們不知道為什么。 我們開始探究,發(fā)現(xiàn)一個 Discord 頻道需要 20 秒才能加載。 罪魁禍首是“智龍迷城Subreddit ?(Puzzles & Dragons Subreddit) ”公共 Discord 服務器。 既然它是公開的,我們就加入進去看看。 令我們驚訝的是,該頻道中只有 1 條消息。 就在那一刻,很明顯他們使用我們的 API 刪除了數(shù)百萬條消息,頻道中只留下了 1 條消息。
?
如果您一直在關注,您可能還記得 Cassandra 如何使用邏輯刪除來處理刪除(在最終一致性中提到)。 當用戶加載此通道時,即使只有 1 條消息,Cassandra 也必須有效掃描數(shù)百萬條消息墓碑(生成垃圾的速度比 JVM 收集垃圾的速度快)。
?
我們通過執(zhí)行以下操作解決了這個問題:
我們將墓碑的壽命從 10 天降低到 2 天,因為我們每晚都會在消息集群上運行 Cassandra 修復(一個反熵過程)。
我們更改了查詢代碼以跟蹤空存儲桶并在將來的頻道中避免使用它們。 這意味著,如果用戶再次引發(fā)此查詢,那么在最壞的情況下,Cassandra 將僅掃描最近的存儲桶。
?
未來
我們目前正在運行一個副本因子為 3 的 12 節(jié)點集群,并將根據(jù)需要繼續(xù)添加新的 Cassandra 節(jié)點。 我們相信這將持續(xù)很長一段時間,但隨著 Discord 的不斷增長,在遙遠的未來我們每天會存儲數(shù)十億條消息。 Netflix 和 Apple 運行著由數(shù)百個節(jié)點組成的集群,因此我們知道我們可以在一段時間內(nèi)對此進行更多思考。 然而,我們也希望對未來有一些想法。
?
短期
將我們的消息集群從 Cassandra 2 升級到 Cassandra 3。Cassandra 3 具有新的存儲格式,可以將存儲大小減少 50% 以上。
新版本的 Cassandra 更擅長在單個節(jié)點上處理更多數(shù)據(jù)。 目前我們在每個節(jié)點上存儲了近 1TB 的壓縮數(shù)據(jù)。 我們相信,通過將其增加到 2TB,我們可以安全地減少集群中的節(jié)點數(shù)量。
長期
探索使用 Scylla,這是一個用 C++ 編寫的兼容 Cassandra 的數(shù)據(jù)庫。 在正常操作期間,我們的 Cassandra 節(jié)點實際上并沒有使用太多的 CPU,但是在非高峰時段,當我們運行修復(反熵過程)時,它們會相當受 CPU 限制,并且持續(xù)時間隨著自上次修復以來寫入的數(shù)據(jù)量而增加。 Scylla 宣稱修復時間顯著縮短。
構(gòu)建一個系統(tǒng),將未使用的頻道存檔到 Google Cloud Storage 上的平面文件(flat file)中,并按需加載它們。 我們想避免這樣做,也不認為我們必須這樣做。
?
結(jié)語
自從我們做出轉(zhuǎn)變以來已經(jīng)過去了一年多的時間,盡管有“巨大的驚喜”,但一切進展順利。 我們每天的消息總數(shù)從超過 1 億條增加到超過 1.2 億條,并且性能和穩(wěn)定性保持一致。
?
由于該項目的成功,我們已將其余實時生產(chǎn)數(shù)據(jù)轉(zhuǎn)移到 Cassandra,這也取得了成功。
?
在這篇文章的后續(xù)內(nèi)容中(未來),我們將探討如何使數(shù)十億條消息變得可搜索。
?
我們還沒有專門的 DevOps 工程師(只有 4 名后端工程師),因此擁有一個我們不必擔心的系統(tǒng)真是太棒了。 我們正在招聘,所以如果您喜歡這種類型的東西,請加入我們。