
2016 年 7 月,Discord 宣布日消息量達到 4000 萬一天,12 月達到一億條
為了應(yīng)對如此巨量的數(shù)據(jù),技術(shù)團隊在 2015 年 11 月時達到 100 萬條消息時,一改年初的單副本 MongoDB 架構(gòu),最終遷移到 Cassandra 數(shù)據(jù)庫。
當(dāng)前的問題
采用單副本的 MongoDB 滿足 CAP 理論的 CA,即保證一致性和可用性,但無法容忍網(wǎng)絡(luò)分區(qū)。當(dāng)出現(xiàn)網(wǎng)絡(luò)分區(qū)問題時,服務(wù)將不可用。
隨著數(shù)據(jù)量增長,內(nèi)存不足以支撐對應(yīng)量級的索引和數(shù)據(jù),出現(xiàn)無法預(yù)測的延時。
業(yè)務(wù)情況
- Discord 隨機讀多,重度寫,讀寫比例接近 1:1。
- 不同頻道數(shù)據(jù)傾斜明顯,技術(shù)團隊構(gòu)建 MongoDB 索引時用的是 channel_id + create_at(渠道ID+創(chuàng)建時間)。
其中語音頻道每日消息個位數(shù),私有頻道每年消息在萬級別,而公有頻道則讀寫均很多,主要讀近期數(shù)據(jù),每年消息量超過百萬。
MongoDB 能否適應(yīng)這些問題
MongoDB 將活躍的數(shù)據(jù)和索引盡可能加載到內(nèi)存中,以提高讀寫性能。
當(dāng)訪問語音頻道或私人頻道,大概率數(shù)據(jù)不在緩存中,需要從磁盤獲取,產(chǎn)生磁盤 IO 延遲增大。
同時驅(qū)逐了其他公有頻道的緩存,造成其他頻道的訪問延遲也增大。
而大量寫入操作需要更新數(shù)據(jù)和索引,同樣要先讀數(shù)據(jù),因 MongoDB 強調(diào)一致性,還會碰到寫競爭問題,進一步拖慢性能。
不遷移架構(gòu),最簡單的是服務(wù)器升配,無限制增加單機磁盤和內(nèi)存是不可能的。
從單副本到分片是很自然的選擇,Discord 卻放棄了,為什么?
分片復(fù)雜且不夠穩(wěn)定
- 分片遷移復(fù)雜性
從單副本到多分片,涉及對數(shù)據(jù)的一致性、可用性以及性能的管理,可能會引發(fā)大量寫入導(dǎo)致服務(wù)不可用
- 全局一致性難以保證
MongoDB 默認為 CA 保證一致性,涉及多個用戶同時修改、查詢時,會有寫入競爭和鎖定。
- 再分片復(fù)雜性
不同數(shù)據(jù)類別(文字、語音)和不同公開程度(私有、公開)的頻道存在明顯的數(shù)據(jù)傾斜問題。
此時可能需要重新劃分分片,又會出現(xiàn)分片遷移類似的問題。
新數(shù)據(jù)庫滿足什么
從上面的分析看,數(shù)據(jù)庫需支持重度寫、同時易于分片(擴展)。
技術(shù)團隊還提出這些要求,最終 Cassandra 勝出了。
- 線性擴展性:不需要在數(shù)據(jù)增長時重新分片或手動管理節(jié)點。
- 自動故障切換:確保高可用性,降低運維成本。
- 低維護成本:一旦部署,只需隨著數(shù)據(jù)增長添加新節(jié)點。
- 性能可預(yù)測:能滿足重度寫和隨機讀。不需要額外引入緩存層。
- 開源:Discord 希望自己控制,不依賴第三方公司。
遷移-數(shù)據(jù)建模優(yōu)化
數(shù)據(jù)庫選型要考慮數(shù)據(jù)遷移和新數(shù)據(jù)的寫入。
Cassandra 的數(shù)據(jù)是自動分片,集群是線性可擴展的。這一切來自于 KKV(Key-Key-Value)的設(shè)計,比我們熟知的 K-V 多了一個 K,第一個 K 是分區(qū)鍵(Partition Key),定義數(shù)據(jù)的邏輯分區(qū),是實現(xiàn)水平擴展和負載均衡的關(guān)鍵。第二個鍵是找到對應(yīng)記錄的 Clustering Key,用于分區(qū)內(nèi)部對數(shù)據(jù)排序。
分區(qū)鍵需要唯一定位到節(jié)點,Clustering 鍵需要單調(diào)遞增。

創(chuàng)建時間不能作為分區(qū)鍵,因為無法唯一定位到節(jié)點,無效查詢多個節(jié)點。
Discord 選擇用 ChannelID - MessageID - Mesaage 作為 KKV,MessageID 為可排序的雪花算法生成的ID,單調(diào)遞增。
再分區(qū)-解決數(shù)據(jù)傾斜
但數(shù)據(jù)傾斜問題還是存在,大分區(qū)情況下,Cassandra 會在壓縮時引發(fā) GC。擒賊先擒王,技術(shù)團隊分析了最大的頻道的數(shù)據(jù)分布,發(fā)現(xiàn) 10 天作為切割可以將分區(qū)控制在 100M 以內(nèi)。
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)
于是新的 KKV 變成 ((channel_id, bucket), message_id),對于某個大頻道 Pin 的數(shù)據(jù),要回到當(dāng)時的聊天時間點,只需根據(jù)消息 ID 計算對應(yīng)的 Bucket 范圍,就可以直接定位到節(jié)點,無需盲掃。
對于大頻道,大概率只需要掃描最近的一個桶就可以滿足一次數(shù)據(jù)拉取的量。
缺點是對于不活躍的頻道、或語音頻道等,這類時間分布稀疏的,查看數(shù)據(jù)必須跨多個 Bucket 才可以收集足夠的數(shù)據(jù)范圍。
你可能會問,那要是頻道的數(shù)據(jù)停在幾年前,豈不是全表掃描了。
答案是一般以月或年為單位,限制一次請求最多可以跨的桶數(shù),因此這個性能是可預(yù)見的。
最終一致性
MongoDB 的一致性是 Read Before Write,寫之前必須查同時要加鎖。
而 Cassandra 是一個 AP 數(shù)據(jù)庫,是最終一致性,以最后一次寫入為準,不存在寫入競爭。在寫入性能上天然比強一致性的數(shù)據(jù)庫有優(yōu)勢。
編輯、刪除競爭問題
創(chuàng)建消息時,Discord 嚴格限制了必須有作者等額外信息字段,但卻出現(xiàn)了作者信息為空的數(shù)據(jù)!
Discord 允許同時多個用戶同時修改一條數(shù)據(jù),當(dāng)有用戶編輯數(shù)據(jù)時,另一個用戶可以刪除。
創(chuàng)建雖然要求必須有作者等額外的字段,但編輯卻沒有,如編輯請求晚于刪除,就會存在空字段。
兩個方案,直接以全字段上報編輯請求,另一個是當(dāng)檢測到字段不全時標(biāo)記為刪除,Discord 選擇了后者。
標(biāo)記刪除的墓碑問題
Cassandra 的刪除也是一種 upsert 操作,數(shù)據(jù)像墓碑一樣標(biāo)標(biāo)記出來,當(dāng)讀到該塊數(shù)據(jù)時,這些墓碑?dāng)?shù)據(jù)就會被跳過,過了一段時間才被數(shù)據(jù)庫壓縮刪除。
在遷移后的半年,Discord 碰到了一個詭異的情況:
訪問一個叫 PuzzleAndDragon 頻道,里面只有一條數(shù)據(jù),卻會觸發(fā) 10s 的 GC 且加載要花費長達 20s 的時間。
原來這個頻道的消息被大量刪除了,有別于不活躍頻道大部分時間是在讀空桶,這里的數(shù)據(jù)雖然被標(biāo)記刪除,但進入這個頻道依舊需要掃描上百萬的消息。
最終,技術(shù)團隊找到了兩個點:
- 降低刪除數(shù)據(jù)的生命周期,從 10 天改到 2 天
- 跟蹤
(channel, bucket)中無任何數(shù)據(jù)的 Bucket 分片,查詢時跳過這些分片,不讀取。
結(jié)論
數(shù)據(jù)存儲架構(gòu)的遷移不是對新技術(shù)的盲目追求,是否到了非改不可的地步,需要給出明確的原因。
遷移的選型離不開對業(yè)務(wù)的讀寫模式、數(shù)據(jù)分布做分析,同時對擴展性留有余地。
例如本文中涉及了對分區(qū)鍵的擴展修改,如果選擇 ClickHouse(截止到 2024)則又涉及到對數(shù)據(jù)表的重建。
如同 CAP 是無法達成的完美三角,選型也需要我們根據(jù)業(yè)務(wù)做出合理取舍,技術(shù)就是這樣。
參考文章
- How Discord Stores Billions of Messages (https://discord.com/blog/how-discord-stores-billions-of-messages)
- Cassandra Basics (https://cassandra.apache.org/_/cassandra-basics.html)