1. 程式人生 > >我們如何用Cassandra每天儲存上億條線上資料

我們如何用Cassandra每天儲存上億條線上資料

Discord

譯者注: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),聚類鍵充當分割槽內的主鍵,以及決定了資料行如何排序。可以將分割槽視為有序字典。這些屬性相結合,可以支援非常強大的資料建模。

前面提到過,訊息在 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 掃描資料的範圍。

下面是我們的訊息表的簡化模式。

Cassandra

assandra 的 schema 與關係資料庫模式有很大區別,調整 schema 非常方便,不會帶來任何臨時性的效能影響。因此我們獲得了最好的二進位制儲存和關係型儲存。

當我們開始向 Cassandra 資料庫匯入現有的訊息時,馬上看見出現在日誌上的警告,提示分割槽的大小超過 100MB。發生了什麼?!Cassandra 可是宣稱單個分割槽可以支援 2GB!顯然,支援那麼大並不意味著它應該設成那麼大。

大的分割槽在進行壓縮、叢集擴容等操作時會對 Cassandra 帶來較大的 GC 壓力。大分割槽也意味著它的資料不能分佈在叢集中。很明顯,我們必須限制分割槽的大小,因為一個單一的 channel 可以存在多年,且大小不斷增長。

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

bucket

Cassandra 資料庫的分割槽鍵可以複合,所以我們新的主鍵成為 (( channel_id, bucket), message_id)。

為了方便查詢最近的訊息,我們生成了一個從當前時間到 channel_id(也是 Snowflake 發號器生成,要比第一個訊息舊)的 bucket。然後我們依次查詢分割槽直到收集到足夠的訊息。這種方法的缺點是,不活躍的 channel 需要遍歷多個 bucket 從而收集到足夠返回的訊息。在實踐中,這已被證明還行得通,因為對於活躍的 channel,查詢第一個 bucket 就可以返回足夠多的資料。

將訊息匯入到 Cassandra 資料庫十分順利,我們準備嘗試遷移到生產環境。

冒煙啟動

在生產環境引入新系統總是可怕的,因此最好在不影響使用者的前提下先進行測試。我們將程式碼設定成雙讀/寫到 MongoDB 和 Cassandra。

一啟動系統我們就收到 bug 追蹤器發來的錯誤資訊,錯誤提示 author_id 為 null。怎麼會是 null ?這是一個必需的欄位!在解釋這個問題之前,先介紹一下問題的背景。

最終一致性

Cassandra 是一個 AP 資料庫,這意味著它犧牲了強一致性(C)來換取可用性(A),這也正是我們所需要的。在 Cassandra 中讀寫是一個反模式(讀比寫的代價更昂貴),即使你只訪問某些列,本質上也會變成一個更新插入操作(upsert)。

你也可以寫入任何節點,在 column 的範圍,它將使用“last write wins”的策略自動解決寫入衝突,這個策略對我們有何影響?

編輯/刪除 race condition 的例子

在例子中,一個使用者編輯訊息時,另一個使用者刪除相同的訊息,當 Cassandra 執行 upsert 之後,我們只留下了主鍵和另外一個正在更新文字的列。

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

  1. 編輯訊息時,將整個訊息寫回。這有可能找回被刪除的訊息,但是也增加了更多資料列衝突的可能。
  2. 能夠判斷訊息已經損壞時,將其從資料庫中刪除。

我們選擇第二個選項,我們按要求選擇一列(在這種情況下, author_id),如果訊息是空的就刪除。

在解決這個問題時,我們也注意到我們的寫入效率很低。由於 Cassandra 被設計為最終一致性,因此執行刪除操作時不會立即刪除資料,它必須複製刪除到其他節點,即使其他節點暫時不可用,它也照做。

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

刪除列以及將 null 寫入列是完全相同的事情。他們都產生墓碑。因為所有在 Cassandra 資料庫中的寫入都是更新插入(upsert),這意味著哪怕第一次插入 null 都會生成一個墓碑。

實際上,我們整個訊息資料包含 16 個列,但平均訊息長度可能只有了 4 個值。這導致新插入一行資料沒緣由地將 12 個新的墓碑寫入至 Cassandra 中。

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

效能

Cassandra 以寫入速度比讀取速度要快著稱,我們觀察的結果也確實如此。寫入速度通常低於 1 毫秒而讀取低於 5 毫秒。

我們觀察了資料訪問的情況,效能在測試的一週內保持了良好的穩定性。沒什麼意外,我們得到了我們所期望的資料庫。

效能

通過 Datadog 監控讀/寫延遲

說到快速、一致的讀取效能,這裡有一個例子,跳轉到某個上百萬條訊息的 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,並且也取得了成功。

文章來自微信公眾號:高可用架構