Discord 公司如何使用 Cassandra 存儲上億條線上數據

Discord 是一款國外的相似 YY 的語音聊天軟件。Discord 語音聊天軟件及咱們的 UGC 內容的增加速度比想象中要快得多。隨着愈來愈多用戶的加入,帶來了更多聊天消息。2016 年 7 月,天天大約有 4 千萬條消息;2016 年 12 月,天天超過億條。當寫這篇文章時(2017 年 1 月),天天已經超過 1.2 億條了。數據庫

咱們早期決定永久保存全部用戶的聊天曆史記錄,這樣用戶能夠隨時在任何設備查找他們的數據。這是一個持續增加的高併發訪問的海量數據,並且須要保持高可用。如何才能搞定這一切?咱們的經驗是選擇 Cassandra 做爲數據庫!緩存

咱們在作什麼

Discord 語音聊天軟件的最第一版本在 2015 年只用了兩個月就開發出來。在那個階段,MongoDB 是支持快速迭代最好的數據庫之一。全部 Discord 數據都保存在同一個 MongoDB 集羣中,但在設計上咱們也支持將全部數據很容易地遷移到一種新的數據庫(咱們不打算使用 MongoDB 數據庫的分片,由於它使用起來複雜以及穩定性很差)。安全

實際上這是咱們企業文化的一部分:快速搭建來驗證產品的特性,但也預留方法來支持將它升級到一個更強大的版本。服務器

消息保存在 MongoDB 中,使用 channel_id 和 created_at 的單一複合索引。到 2015 年 11 月,存儲的消息達到了 1 億條,這時,原來預期的問題開始出現:內存中再也放不下全部索引及數據,延遲開始變得不可控,是時候遷移到一個更適合這個項目的數據庫了。併發

選擇正確的數據庫

在選擇一個新的數據庫以前,咱們必須瞭解當前的讀/寫模式,以及咱們目前的解決方案爲何會出現問題。高併發

  • 很顯然,咱們的讀取是很是隨機的,咱們的讀/寫比爲 50 / 50。
  • 語音聊天服務器:它只處理不多的消息,每隔幾天才發幾條信息。在一年內,這種服務器不太可能達到 1000 條消息。它面臨的問題是,即便請求量很小,它也很難高效,單返回 50 條消息給一個用戶,就會致使磁盤中的許屢次隨機查找,並致使磁盤緩存淘汰。
  • 私信聊天服務器:發送至關數量的消息,一年下來很容易達到 10 萬到 100 萬條消息。他們請求的數據一般只是最近的。它們的問題是,數據因爲訪問得很少且分散,所以不太可能被緩存在磁盤中。
  • 大型公共聊天服務器:發送大量的消息。他們天天有成千上萬的成員發送數以千計的消息,每一年能夠輕鬆地發送數以百萬計的消息。他們幾乎老是在頻繁請求最近一小時的消息,所以數據能夠很容易地被磁盤緩存命中。
  • 咱們預計在將來的一年,將會給用戶提供更多隨機讀取數據的功能:查看 30 天內別人說起到你的消息,而後點擊到某條歷史記錄消息,查閱標記(pinned)的消息以及全文搜索等功能。這一切致使更多的隨機讀取!!

接下來咱們來定義一下需求:性能

  • 線性可擴展性  -  咱們不想等幾個月又要從新考慮新的擴展方案,或者是從新拆分數據。
  • 自動故障轉移 (failover) -  咱們不但願晚上的休息被打擾,當系統出現問題咱們但願它儘量的能自動修復。
  • 低維護成本  -  一配置完它就能開始工做,隨着數據的增加時,咱們要須要簡單增長機器就能解決。
  • 已經被驗證過的技術  -  咱們喜歡嘗試新的技術,但不要太新。
  • 可預測的性能  -  當 API 的響應時間 95% 超過 80ms 時也無需警示。咱們也不想重複在 Redis 或 Memcached 增長緩存機制。
  • 非二進制存儲  - 因爲數據量大,咱們不太但願寫數據以前作一些讀出二進制並反序列化的工做。
  • 開源  -  咱們但願能掌控本身的命運,不想依靠第三方公司。

Cassandra 是惟一能知足咱們上述全部需求的數據庫。咱們能夠添加節點來擴展它,添加過程不會對應用程序產生任何影響,也能夠容忍節點的故障。一些大公司如 Netflix 和蘋果,已經部署有數千個 Cassandra 節點。數據連續存儲在磁盤上,這樣減小了數據訪問尋址成本,且數據能夠很方便地分佈在集羣上。它依賴 DataStax,但依舊是開源和社區驅動的。測試

作出選擇後,咱們須要證實它其實是可行的。動畫

數據模型

向一個新手描述 Cassandra 數據庫最好的辦法,是將它描述爲 KKV 存儲,兩個 K 構成了主鍵。第一個 K 是分區鍵(partition key),用於肯定數據存儲在哪一個節點上,以及在磁盤上的位置。一個分區包含不少行數據,行的位置由第二個 K 肯定,這是聚類鍵(clustering key),聚類鍵充當分區內的主鍵,以及決定了數據行如何排序。能夠將分區視爲有序字典。這些屬性相結合,能夠支持很是強大的數據建模。spa

前面提到過,消息在 MongoDB 中的索引用的是 channel_id 和 created_at,因爲常常查詢一個 channel 中的消息,所以 channel_id 被設計成爲分區鍵,但 created_at 不做爲一個大的聚類鍵,緣由是系統內多個消息可能具備相同的建立時間。

幸運的是,Discord 系統的 ID 使用了相似 Twitter Snowflake [1] 的發號器(按時間粗略有序),所以咱們可使用這個 ID。主鍵就變成( channel_id, message_id), message_id 是 Snowflake 發號器產生。當加載一個 channel 時,咱們能夠準確地告訴 Cassandra 掃描數據的範圍。

下面是咱們的消息表的簡化模式。

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

Cassandra 的 schema 與關係數據庫模式有很大區別,調整 schema 很是方便,不會帶來任何臨時性的性能影響。所以咱們得到了最好的二進制存儲和關係型存儲。

當咱們開始向 Cassandra 數據庫導入現有的消息時,立刻看見出如今日誌上的警告,提示分區的大小超過 100MB。發生了什麼?!Cassandra 但是宣稱單個分區能夠支持 2GB!顯然,支持那麼大並不意味着它應該設成那麼大。

大的分區在進行壓縮、集羣擴容等操做時會對 Cassandra 帶來較大的 GC 壓力。大分區也意味着它的數據不能分佈在集羣中。很明顯,咱們必須限制分區的大小,由於一個單一的 channel 能夠存在多年,且大小不斷增加。

咱們決定按時間來歸併咱們的消息並放在一個 bucket 中。經過分析最大的 channel,咱們來肯定 10 天的消息放在一個 bucket 中是否會超過 100mb。Bucket 必須從 message_id 或時間戳來歸併。

DISCORD_EPOCH = 1420070400000
BUCKET_SIZE = 1000 * 60 * 60 * 24 * 10


def make_bucket(snowflake):
   if snowflake is None:
       timestamp = int(time.time() * 1000) - DISCORD_EPOCH
   else:
       # When a Snowflake is created it contains the number of
       # seconds since the DISCORD_EPOCH.
       timestamp = snowflake_id >> 22
   return int(timestamp / BUCKET_SIZE)
  
  
def make_buckets(start_id, end_id=None):
   return range(make_bucket(start_id), make_bucket(end_id) + 1)

Cassandra 數據庫的分區鍵能夠複合,因此咱們新的主鍵成爲 (( channel_id, bucket), message_id)。

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);

爲了方便查詢最近的消息,咱們生成了一個從當前時間到 channel_id(也是 Snowflake 發號器生成,要比第一個消息舊)的 bucket。而後咱們依次查詢分區直到收集到足夠的消息。這種方法的缺點是,不活躍的 channel 須要遍歷多個 bucket 從而收集到足夠返回的消息。在實踐中,這已被證實還行得通,由於對於活躍的 channel,查詢第一個 bucket 就能夠返回足夠多的數據。

將消息導入到 Cassandra 數據庫十分順利,咱們準備嘗試遷移到生產環境。

冒煙啓動

在生產環境引入新系統老是可怕的,所以最好在不影響用戶的前提下先進行測試。咱們將代碼設置成雙讀/寫到 MongoDB 和 Cassandra。

一啓動系統咱們就收到 bug 追蹤器發來的錯誤信息,錯誤提示 author_id 爲 null。怎麼會是 null ?這是一個必需的字段!在解釋這個問題以前,先介紹一下問題的背景。

最終一致性

Cassandra 是一個 AP 數據庫,這意味着它犧牲了強一致性(C)來換取可用性(A),這也正是咱們所須要的。在 Cassandra 中讀寫是一個反模式(讀比寫的代價更昂貴)。你也能夠寫入任何節點,在 column 的範圍,它將使用「last write wins」的策略自動解決寫入衝突,這個策略對咱們有何影響?請看下面動畫。

1_t7VkLRKZVeHb_c6Tl1heew

在例子中,一個用戶編輯消息時,另外一個用戶刪除相同的消息,當 Cassandra 執行 upsert 以後,咱們只留下了主鍵和另一個正在更新文本的列。

有兩個可能的解決方案來處理這個問題:

  • 編輯消息時,將整個消息寫回。這有可能找回被刪除的消息,可是也增長了更多數據列衝突的可能。
  • 可以判斷消息已經損壞時,將其從數據庫中刪除。

咱們選擇第二個選項,咱們按要求選擇一列(在這種狀況下, author_id),若是消息是空的就刪除。

在解決這個問題時,咱們也注意到咱們的寫入效率很低。因爲 Cassandra 被設計爲最終一致性,所以執行刪除操做時不會當即刪除數據,它必須複製刪除到其餘節點,即便其餘節點暫時不可用,它也照作。

Cassandra 爲了方便處理,將刪除處理成一種叫「墓碑」的寫入形式。在處理過程當中,它只是簡單跳過它遇到的墓碑。墓碑經過一個可配置的時間而存在(默認 10 天),在逾期後,會在壓縮過程當中被永久刪除。

刪除列以及將 null 寫入列是徹底相同的事情。他們都產生墓碑。由於全部在 Cassandra 數據庫中的寫入都是更新插入(upsert),這意味着哪怕第一次插入 null 都會生成一個墓碑。

實際上,咱們整個消息數據包含 16 個列,但平均消息長度可能只有了 4 個值。這致使新插入一行數據沒原因地將 12 個新的墓碑寫入至 Cassandra 中。

解決這個問題的方法很簡單:只給 Cassandra 數據庫寫入非空值。

性能

Cassandra 以寫入速度比讀取速度要快著稱,咱們觀察的結果也確實如此。寫入速度一般低於 1 毫秒而讀取低於 5 毫秒。咱們觀察了數據訪問的狀況,性能在測試的一週內保持了良好的穩定性。沒什麼意外,咱們獲得了咱們所指望的數據庫。

說到快速、一致的讀取性能,這裏有一個例子,跳轉到某個上百萬條消息的 channel 的一年前的某條消息,請看動畫

跳轉到一年前的聊天記錄的性能

巨大的意外

一切都很順利,所以咱們將它切換成咱們的主數據庫,而後在一週內淘汰掉 MongoDB。Cassandra 工做一切正常,直到 6 個月後有一天,Cassandra 忽然變得反應遲鈍。咱們注意到 Cassandra 開始出現 10 秒鐘的 GC 全停頓(Stop-the-world) ,可是咱們不知道緣由。

咱們開始定位分析,發現加載某個 channel 須要 20 秒。一個叫 「Puzzles & Dragons Subreddit」 的公共 channel 是罪魁禍首。由於它是一個開放的 channel,所以咱們也跑進去探個究竟。

令咱們驚訝的是,channel 裏只有 1 條消息。咱們也瞭解到他們用咱們的 API 刪除了數百萬條消息,只在 channel 中留下了 1 條消息。

上文提到 Cassandra 是如何用墓碑(在最終一致性中說起過)來處理刪除動做的。當一個用戶載入這個 channel,雖然只有 1 條的消息,Cassandra 不得不掃描百萬條墓碑(產生垃圾的速度比虛擬機收集的速度更快)。

咱們經過以下措施解決:

  • 由於咱們每晚都會運行 Cassandra 數據庫修復(一個反熵進程),咱們將墓碑的生命週期從 10 天下降至 2 天。
  • 咱們修改了查詢代碼,用來跟蹤空的 buckets,並避免他們在將來的 channel 中加載。這意味着,若是一個用戶再次觸發這個查詢,最壞的狀況,Cassandra 數據庫只在最近的 bucket 中進行掃描。

將來

咱們目前在運行着一個複製因子是 3 的 12 節點集羣,並根據業務須要持續增長新的節點,我相信這種模式能夠支撐很長一段時間。但隨着 Discord 軟件的發展,相信有一天咱們可能須要天天存儲數十億條消息。

Netflix 和蘋果都維護了運行着數千個節點的集羣,因此咱們知道目前這個階段不太須要顧慮太多。固然咱們也但願有一些點子能夠未雨綢繆。

近期工做

將咱們的消息集羣從 Cassandra 2 升級到 Cassandra 3。Cassandra 3 有一個新的存儲格式,能夠將存儲大小減小 50% 以上。新版 Cassandra 單節點能夠處理更多數據。目前,咱們在每一個節點存儲了將近 1TB 的壓縮數據。咱們相信咱們能夠安全地擴展到 2TB,以減小集羣中節點的數量。

長期工做

嘗試下 Scylla [4],它是一款用 C++ 編寫與 Cassandra 兼容的數據庫。在正常操做期間,咱們 Cassandra 節點其實是沒有佔用太多的 CPU,然而在非高峯時間,當咱們運行修復(一個反熵進程)變得至關佔用 CPU,同時,繼上次修復後,修復持續時間和寫入的數據量也增大了許多。 Scylla 宣稱有着極短的修復時間。

將沒使用的 Channel 備份成谷歌雲存儲上的文件,而且在有須要時能夠加載回來。咱們其實也不太想作這件事,因此這個計劃未必會執行。

結論

切換以後剛剛過去一年,儘管經歷過「巨大的意外」,一切仍是一路順風。從天天 1 億條消息到目前超過 1.2 億條,一直保持着良好的性能和穩定性。因爲這個項目的成功,所以咱們將生產環境的其餘數據也遷移到 Cassandra,而且也取得了成功。


原文連接 本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索