Discord的增長速度及用戶生成的內(nèi)容超出了我們的預(yù)期顽素。有了更多的用戶,更多的聊天信息就會出現(xiàn)徒蟆。7月胁出,我們計(jì)劃每天有4000萬條消息,12月我們每天達(dá)到1億條消息段审,截至本博客帖子全蝶,我們每天數(shù)據(jù)量已經(jīng)超過了1.2億條。我們很早就決定永遠(yuǎn)保存所有聊天記錄寺枉,這樣用戶就可以隨時回來抑淫,在任何設(shè)備上都可以使用他們的數(shù)據(jù)。這大量的數(shù)據(jù)姥闪,其速度始苇、大小都在不斷增加,我們必須保證他們的可用性筐喳。我們該怎么做呢催式?答案就是Cassandra!
我們在做什么
Discord的原始版本是在2015年初的不到兩個月內(nèi)構(gòu)建的避归∪僭拢可以說,最快的迭代數(shù)據(jù)庫之一是MongoDB梳毙。 Discord上的所有內(nèi)容都存儲在一個MongoDB副本集中哺窄,這是有意的,但我們還計(jì)劃了一切顿天,以便輕松遷移到新數(shù)據(jù)庫(我們知道我們不會使用MongoDB分片堂氯,因?yàn)樗褂闷饋砗軓?fù)雜而且不知道其穩(wěn)定性)。這實(shí)際上是我們公司文化的一部分:快速實(shí)現(xiàn)產(chǎn)品功能去對產(chǎn)品進(jìn)行試錯牌废,但始終通向更強(qiáng)大的解決方案咽白。
這些消息存儲在MongoDB集合中,在channel_id和created_at字段上具有單個復(fù)合索引鸟缕。 2015年11月左右晶框,我們存儲了1億條消息,此時我們開始看到預(yù)期出現(xiàn)的問題:數(shù)據(jù)和索引不再適合RAM懂从,延遲開始變得不可預(yù)測授段。是時候遷移到更適合該任務(wù)的數(shù)據(jù)庫了。
選擇正確的合適數(shù)據(jù)庫
在選擇新數(shù)據(jù)庫之前番甩,我們必須了解讀/寫模式以及我們當(dāng)前的解決方案會導(dǎo)致出現(xiàn)問題的原因侵贵。
- 很快就發(fā)現(xiàn)我們的讀取非常隨機(jī),我們的讀/寫比率約為50/50缘薛。
- Discord 的語音聊天服務(wù)幾乎不發(fā)送任何消息窍育。這意味著他們每隔幾天發(fā)一兩條信息卡睦。在一年之內(nèi),這種服務(wù)器消息量在1000條以內(nèi)漱抓。問題是表锻,盡管這是一小部分消息,但它使向用戶提供這些數(shù)據(jù)變得更加困難乞娄。比如只向用戶返回50條消息會導(dǎo)致在磁盤上進(jìn)行許多隨機(jī)查找瞬逊,從而導(dǎo)致磁盤緩存收回。
- Discord的私有文本聊天服務(wù)發(fā)送了相當(dāng)多的消息仪或,很容易達(dá)到每年10萬到100萬條消息确镊。他們請求的數(shù)據(jù)通常只是最近產(chǎn)生的。問題是范删,由于這些服務(wù)器的成員通常不到100個骚腥,因此請求此數(shù)據(jù)的速度很低,不太可能位于磁盤緩存中瓶逃。
- 大型公共Discord服務(wù)器發(fā)送大量消息束铭。他們有成千上萬的會員每天發(fā)送數(shù)千封郵件,每年輕松收集數(shù)百萬封郵件厢绝。他們幾乎總是在獲取過去一小時內(nèi)發(fā)送的消息契沫,并且他們經(jīng)常獲取這些消息。因此昔汉,這些數(shù)據(jù)通常位于磁盤緩存中懈万。
- 我們知道,在接下來的一年里靶病,我們將為用戶提供更多的隨機(jī)閱讀方式:查看過去30天內(nèi)您提到的內(nèi)容会通,然后跳到歷史上的那個點(diǎn),查看并跳到固定郵件娄周,以及全文搜索涕侈。所有這些拼寫都更隨意!煤辨!
接著下來是我們系統(tǒng)的要求
- 線性的可擴(kuò)展性 - 我們不希望稍后重新考慮解決方案或手動重新分片數(shù)據(jù)裳涛。
- 故障自動轉(zhuǎn)移 - 我們喜歡在晚上睡覺,并盡可能地建立Discord自我修復(fù)功能众辨。
- 維護(hù)成本低 - 一旦我們設(shè)置它就應(yīng)該工作端三。我們只需要在數(shù)據(jù)增長時添加更多節(jié)點(diǎn)。
- 事實(shí)證明(Proven to work) - 我們喜歡嘗試新技術(shù)鹃彻,但不是太新郊闯。
-可預(yù)測的性能 - 當(dāng)我們的API響應(yīng)時間第95百分位數(shù)超過80毫秒時,我們會發(fā)出警報(bào)。我們也不想在Redis或Memcached中緩存消息团赁。 - 存儲方式不是blob(Not a blob store?) - 如果我們必須不斷地反序列化blob并將其附加到Blob存儲區(qū)中旋奢,那么每秒寫入數(shù)千條消息將不會很好。
- 開源 - 我們相信控制自己的命運(yùn)然痊,不想依賴第三方公司。
Cassandra是唯一滿足我們所有要求的數(shù)據(jù)庫屉符。我們可以添加節(jié)點(diǎn)來擴(kuò)展節(jié)點(diǎn)剧浸,它可以容忍節(jié)點(diǎn)丟失而不會對應(yīng)用程序產(chǎn)生任何影響。 Netflix和Apple等大公司擁有數(shù)千個Cassandra節(jié)點(diǎn)矗钟。相關(guān)數(shù)據(jù)連續(xù)存儲在磁盤上唆香,提供最小的搜索并在群集周圍輕松分發(fā)。它得到了DataStax的支持吨艇,但仍然是開源和社區(qū)驅(qū)動的躬它。
做出選擇后,我們需要證明我們選擇的確是對的东涡。
數(shù)據(jù)模型
將Cassandra為新手描述的最佳方式是它是KKV存儲冯吓。兩個K包括主鍵。第一個K是分區(qū)鍵疮跑,用于確定數(shù)據(jù)所在的節(jié)點(diǎn)以及磁盤上的位置组贺。分區(qū)中包含多個行,分區(qū)中的行由第二個K標(biāo)識祖娘,第二個K是集群key失尖。群集key既充當(dāng)分區(qū)中的主鍵,又充當(dāng)行的排序方式渐苏。您可以將分區(qū)視為有序字典掀潮。結(jié)合這些屬性可實(shí)現(xiàn)非常強(qiáng)大的數(shù)據(jù)建模。
你還記得我們之前使用channel_id和created_at在MongoDB中索引消息? channel_id成為分區(qū)鍵琼富,因?yàn)樗胁樵兌荚谝粋€通道上運(yùn)行仪吧,但created_at沒有成為一個很好的集群鍵,因?yàn)閮蓚€消息可以具有相同的創(chuàng)建時間鞠眉。幸運(yùn)的是邑商,Discord上的每個ID實(shí)際上都是Snowflake(按時間順序排序)人断,所以我們可以使用它們恶迈。主鍵變?yōu)椋╟hannel_id,message_id)步做,其中message_id是Snowflake全度。這意味著在加載channel 時将鸵,我們可以準(zhǔn)確地告訴Cassandra掃描消息的范圍顶掉。
這是我們的消息表的簡化模式(這省略了大約10列)痒筒。
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的schemas 與關(guān)系數(shù)據(jù)庫不同簿透,它們的改變成本很低廉萎战,并且不會對臨時性能產(chǎn)生任何影響蚂维。我們得到了最好的blob存儲和關(guān)系存儲虫啥。
當(dāng)我們開始將現(xiàn)有消息導(dǎo)入Cassandra時涂籽,我們立即在日志中看到警告评雌,告訴我們分區(qū)的大小超過100MB景东。發(fā)生什么事了斤吐?和措! Cassandra宣稱它可以支持2GB分區(qū)派阱!顯然贫母,僅僅因?yàn)樗梢酝瓿砂涠溃⒉灰馕吨鼞?yīng)該誓酒。在壓縮靠柑,集群擴(kuò)展等過程中歼冰,大型分區(qū)會對Cassandra造成很大的GC壓力隔嫡。擁有大分區(qū)也意味著其中的數(shù)據(jù)無法在群集中分布腮恩。很明顯秸滴,我們必須以某種方式限制分區(qū)的大小荡含,因?yàn)閱蝹€Discord通道可以存在多年并且不斷增大释液。
我們決定按時間播放我們的消息。我們查看了Discord上最大的channel(頻道)找前,并確定我們是否在一個存儲桶中存儲了大約10天的消息躺盛,我們可以輕松地保持在100MB以下槽惫。存儲桶必須可以從message_id或時間戳中導(dǎo)出界斜。
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分區(qū)鍵可以組合项贺,因此我們的新主鍵變?yōu)椋ǎ╟hannel_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);
為了查詢頻道中的最近消息坏怪,我們生成從當(dāng)前時間到channel_id的桶范圍(它也是Snowflake并且必須比第一條消息更舊)。然后我們依次查詢分區(qū)鹏秋,直到收集到足夠的消息侣夷。這種方法的缺點(diǎn)是很少有活動的Discords必須查詢多個桶以隨時間收集足夠的消息琴锭。在實(shí)踐中决帖,這被證明是好的,因?yàn)閷τ诨顒拥腄iscords刻像,通常在第一個分區(qū)中找到足夠的消息细睡,并且它們占大多數(shù)。
將消息導(dǎo)入Cassandra順利進(jìn)行萌京,我們已準(zhǔn)備好投入正式使用靠瞎。
Dark Launch(注: 是Fackbook使用的一種測試產(chǎn)品新功能的測試方法)
將新系統(tǒng)引入生產(chǎn)總是很可怕佳窑,所以嘗試在不影響用戶的情況下進(jìn)行測試是個好主意神凑。我們設(shè)置代碼以對MongoDB和Cassandra進(jìn)行雙重 讀/寫操作。
啟動后我們立即開始在錯誤跟蹤器中收到錯誤瓣喊,告訴我們author_id為空洪橘。怎么會是null熄求?這是一個必填字段抡四!
最終一致性
Cassandra是一個AP數(shù)據(jù)庫,這意味著它可以提供強(qiáng)大的可用性一致性,這是我們想要的勉耀。它是Cassandra中的read-before-write(讀取更昂貴)的反模式便斥,因此即使你只提供某些列黎棠,Cassandra所做的一切本質(zhì)上都是一個upsert木西。您還可以寫入任何節(jié)點(diǎn),它將使用每列的“l(fā)ast write wins”語義來自動解決沖突恋捆。那我們會遇到坑么?
在用戶同時編輯消息而另一個用戶刪除相同消息的情況下,我們最終得到的行缺少除主鍵和文本之外的所有數(shù)據(jù)室奏,因?yàn)樗蠧assandra寫入都是upserts占业。處理此問題有兩種可能的解決方案:
1.編輯消息時寫回整個消息。這有可能恢復(fù)被刪除的消息,并為并發(fā)寫入其他列的沖突增加更多機(jī)會。
2.弄清楚該消息是否是臟數(shù)據(jù)并從數(shù)據(jù)庫中刪除它瞳氓。
我們選擇了第二個選項(xiàng)策彤,我們選擇了一個必需的列(在本例中為author_id)并刪除了該消息(如果它為null)。
在解決這個問題時顿膨,我們注意到我們的寫入效率非常低锅锨。由于Cassandra最終是一致的叽赊,它不能立即刪除數(shù)據(jù)恋沃。它必須將刪除復(fù)制到其他節(jié)點(diǎn),即使其他節(jié)點(diǎn)暫時不可用也要執(zhí)行此操作必指。 Cassandra通過將刪除視為一種稱為“墓碑”的寫入形式來實(shí)現(xiàn)這一點(diǎn)囊咏。在閱讀時,它只是跳過它遇到的墓碑塔橡。'墓碑' 在配置的時間內(nèi)(默認(rèn)為10天)存在梅割,并且在該時間到期時會在執(zhí)行壓縮操作后被永久刪除。
刪除列或?qū)ull寫入列里他們效果完全相同葛家,他們都會生成墓碑户辞。由于Cassandra中的所有寫入都是upserts,這意味著即使在第一次寫入null時癞谒,您也會生成一個邏輯刪除底燎。實(shí)際上刃榨,我們的整個消息模式包含16列,但平均消息只設(shè)置了4個值双仍。這樣我們大部分時間都無緣無故地向Cassandra寫了12個墓碑枢希。解決方案很簡單:只向Cassandra寫入非空值。
性能
眾所周知朱沃,Cassandra的寫入速度比讀取速度快苞轿,我們確實(shí)觀察到了這一點(diǎn)。寫入是亞毫秒逗物,讀取時間不到5毫秒搬卒。我們觀察到這一點(diǎn),無論訪問什么數(shù)據(jù)翎卓,并且在一周的測試期間性能保持一致秀睛。沒有什么是令人驚訝的,和我們預(yù)期效果差不多莲祸。
In line with fast蹂安,一致的讀性能,這里是一個一年前在包含數(shù)百萬條消息的頻道中跳轉(zhuǎn)到消息的示例:
一個大驚喜
一切順利锐帜,所以我們將其作為主要數(shù)據(jù)庫推出田盈,并在一周內(nèi)逐步淘汰MongoDB。它繼續(xù)完美地工作......大約6個月缴阎,直到有一天Cassandra反應(yīng)變得很遲鈍允瞧。
我們注意到Cassandra經(jīng)常運(yùn)行10秒“stop-the-world”GC,但我們不知道為什么蛮拔。我們開始研究并找到一個耗時20秒的Discord頻道述暂。由于它是公開的,我們加入它看看情況建炫。令我們驚訝的是畦韭,該頻道只有一條消息。就在那一刻肛跌,很明顯他們使用我們的API刪除了數(shù)百萬條消息艺配,在頻道中只留下了一條消息。
如果您一直在關(guān)注衍慎,您可能還記得Cassandra如何使用墓碑處理刪除(在最終一致性中提到)转唉。當(dāng)用戶加載此頻道時,即使只有一條消息稳捆,Cassandra也必須有效地掃描數(shù)百萬條消息(注:cassandra讀取到消息到再和墓碑里的delete消息進(jìn)行合并操作赠法,如果墓碑里的delete操作有成千上萬那么他合并操作就會有點(diǎn)費(fèi)時)邏輯刪除(生成垃圾的速度比JVM可以收集的速度快)。
我們通過以下方式解決了這個問題:
- 我們將墓碑的壽命從10天縮短到2天乔夯,因?yàn)槲覀兠刻焱砩显谖覀兊南⒓荷线M(jìn)行Cassandra修復(fù)(repair操作砖织,他會進(jìn)行壓縮操作)原朝。
- 我們更改了查詢代碼以跟蹤空桶,并在將來避免使用它們作為通道镶苞。這意味著如果用戶再次引發(fā)此查詢喳坠,那么在最糟糕的情況下,Cassandra將僅在最近的桶中進(jìn)行掃描茂蚓。
結(jié)論
自從我們進(jìn)行轉(zhuǎn)換以來已經(jīng)過去了一年多壕鹉,盡管“出人意料”,但它一帆風(fēng)順聋涨。我們從處理總量為1億條消息到每天超過1.2億條消息晾浴,性能和穩(wěn)定性保持很好。
由于該項(xiàng)目的成功牍白,我們已將線上的數(shù)據(jù)移至Cassandra脊凰,這也取得了成功。
在本文的后續(xù)內(nèi)容中茂腥,我們將探討如何使數(shù)十億條消息可搜索狸涌。
我們還沒有專門的DevOps工程師(只有4個后端工程師),因此擁有一個我們不必?fù)?dān)心的系統(tǒng)非常棒最岗。我們正在招聘帕胆,所以如果這種類型的東西刺激你的想法加入我們。
原文:https://blog.discordapp.com/how-discord-stores-billions-of-messages-7fa6ec7ee4c7