原文来自 Discord官网 ,作者 CTO Stanislav Vishnevskiy Discord ,侵权删。
Discord 继续以超出咱们预期的速度添加,用户生成内容也是如此。用户越多,谈天信息就越多,每天的音讯现已远远超越了上亿条。咱们很早就决议永久保存全部的谈天记录,这样用户就能够随时回来,并在任何设备上获取他们的数据。这些海量数据在速度、规划上不断添加,并且有必要坚持可获取的状况。咱们怎么做呢?
Discord一直在做什么
Discord 的原始版别是在 2015 年初仅用不到两个月的时刻开发出来的。能够说,MongoDB 是最适合快速迭代的数据库之一。Discord 上的全部数据都存储在一个 MongoDB 副本会集,但咱们也计划将全部数据轻松迁移到一个新的数据库 (咱们不打算运用 MongoDB 分片,由于它运用起来很复杂,稳定性也不好)。这实际上是咱们公司文化的一部分: 快速构建以证明产品的功能,但始终有一条通往更稳健处理方案的路途。
音讯被存储在 MongoDB 会集,在 channel_id 和 created_at 上有一个单一的复合索引。大约在 2015 年 11 月,咱们存储的音讯到达了 1 亿条,这时咱们开端看到预期的问题呈现了:数据和索引不再和 RAM 匹配,推迟开端变得不行猜测。是时分迁移到更适合这项使命的数据库了。
挑选合适的数据库
在挑选一个新的数据库之前,咱们有必要了解咱们的读 / 写模式,以及为什么咱们当时的处理方案会呈现问题。
- 很快咱们发现,咱们的读取是非常随机的,读 / 写比率大约是 50/50。
- Discord 的语音谈天服务器简直不发送任何音讯。它们每隔几天会发送一到两条信息,在一年内,该服务器发送的音讯不太或许到达 1000 条。问题是,即便音讯很少,它也使向用户供给这些数据变得更加困难。只是为用户康复 50 条音讯就或许导致对磁盘的屡次随机查找,然后导致磁盘缓存的收回。
- Discord 的私信谈天服务器发送了适当数量的音讯,每年很简单到达 10 万到 100 万条。他们恳求的数据一般是最近的。问题是,由于这些服务器一般只有不到 100 个用户,因而恳求这些数据的频率很低,并且这些数据不太或许在磁盘缓存中。
- Discord 的大型公共服务器会发送很多音讯。他们有成千上万每天发送数以千计信息的成员,一年下来很简单就积累了数百万条信息。他们简直总是在恳求上一小时发送的信息,并且恳求频率很高。因而,数据一般在磁盘缓存中。
接下来界说一下咱们的需求:
- 线性可扩展性:咱们不期望没多久就重新考虑处理方案或手动重新分拣数据。
- 主动故障转移:咱们期望建立 Discord 的自修正才能。
- 低保护本钱:它应该在咱们设置好后就开端作业。咱们只需求跟着数据的添加添加更多节点。
- 经过验证的技术:咱们喜欢测验新技术,但不要太新。
- 可猜测的功能:当 API 呼应时刻的第 95 个百分位数超越 80ms 时,会发出警报。咱们也不想在 Redis 或 Memcached 中缓存音讯。
- 非 blob 存储:如果咱们有必要不断反序列化 blob 并向其添加内容,那么每秒写入数千条音讯的效果就不会很好。
- 开源:咱们想自己掌握命运,不想依赖于第三方公司。
Cassandra 是仅有满意咱们全部需求的数据库。咱们只需添加节点来扩展它,它能够容忍节点故障,而不会对应用程序形成任何影响。Netflix 和苹果等大公司拥有数千个 Cassandra 节点。相关的数据被接连地存储在磁盘上,这样削减了数据拜访寻址本钱,且数据易于在集群周围分布。它由 DataStax 支持,但仍然是开源和社区驱动的。
在做出挑选之后,咱们需求证明它是可行的。
数据建模
向新手描绘 Cassandra 最好的方法便是将它描绘为 KKV 存储,两个 K 构成了主键。第一个 K 是分区键,用于确认数据地点的节点以及在磁盘上的位置。分区中包括多行数据,行由第二个 K,也便是聚类键标识。聚类键既充当分区中的主键,又决议了数据行排序的方法。你能够将分区看作一个有序的字典。这些特点结合起来能够支持非常强壮的数据建模。
前面提到音讯索引在 MongoDB 运用 channel_id 和 created_at。由于全部查询都在一个频道上进行,Channel_id 成为了分区键,但是 created_at 并不是一个很好的聚类键,由于两条音讯或许具有相同的创建时刻。幸运的是,Discord 上的每个 ID 实际上都是 Snowflake(可按时刻排序),所以咱们能够用它们来替代。主键变成了 (channel_id, message_id),其间 message_id 是一个 Snowflake。这意味着,当加载一个频道时,咱们能够确切地告知 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);
Discord 的数据库发展进程
虽然 Cassandra 的模式与联系型数据库并不一样,但修改它们的本钱很低,并且不会对功能形成任何临时性影响。咱们充分利用了 blob 存储和联系型存储的长处。
当咱们开端将现有音讯导入 Cassandra 时,当即会在日志中看到正告,告知咱们分区的巨细超越 100MB。到底发生了什么事? !Cassandra 声称它能够支持 2GB 的分区! 显然,只是由于它能够做到,并不意味着它应该做到。在紧缩、集群扩展等进程中,大的分区会给 Cassandra 带来很大的 GC 压力。拥有一个大分区也意味着其间的数据不能分布在集群周围。很明显,咱们有必要以某种方法约束分区的巨细,由于一个 Discord 频道能够存在多年,并且其巨细会一直添加。
咱们决议按时刻对信息进行分类。咱们查看了 Discord 上最大的频道,并确认咱们是否能够将 10 天的音讯存储在 100MB 以下的贮存空间里。贮存空间有必要能够从 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 的贮存空间规模。然后,咱们依次查询分区,直到搜集到满足的音讯。这种方法的缺陷是,关于活动很少的 discord,咱们将不得不查询多个贮存空间来搜集满足的音讯。在实践中,这被证明是可行的,由于关于活跃的 discord 来说,一般在第一个分区中就能够发现满足的音讯。
将音讯导入 Cassandra 没有任何问题,咱们现已预备好在生产环境下进行测验。
摸黑发动
将一个新系统引进生产环境总是很可怕的,所以最好能够在不影响用户的情况下对其进行测验。咱们把咱们的代码设置成对 MongoDB 和 Cassandra 进行双重读 / 写。
在发动之后,咱们开端在 bug 跟踪器中收到报错,告知咱们 author_id 为空。它怎么或许是空的? 这是一个必填字段!
终究一致性
Cassandra 是一个 AP 数据库,这意味着它以强壮的一致性换取了可用性,这也正是咱们想要的东西。在 Cassandra 中,这是一种 “先读后写” 的反模式 (读取本钱更高),因而即便你只拜访某些列,其本质上也会变成更新刺进。你还能够写入任何节点,它将运用“last write wins” 主动处理抵触。那么,这对咱们有什么影响呢?
在一个用户修改音讯的一起,另一个用户删去相同音讯的场景中,由于 Cassandra 的写入都是更新刺进,咱们终究得到了一个短少除主键和文本之外的全部数据的行。处理这一问题有两种或许的处理办法:
- 在修改音讯时将整个音讯写回。这样就有或许康复已删去的音讯,并添加并发写入其他列的抵触机会。
- 确认音讯已损坏并将其从数据库中删去。
咱们运用了第二个选项,按要求挑选了一列 (在本例中是 author_id) 并在音讯为空时删去了该音讯。
在处理这个问题时,咱们发现咱们的写入效率很低。由于 Cassandra 被设计为终究一致性,它不能当即删去数据。它有必要将删去仿制到其他节点,即便其他节点暂时不行用,它也要这样做。Cassandra 经过把删去当作一种被称为 “tombstone” 的写入方式来做到这一点。在读取时,它只是越过它遇到的 tombstone。tombstone 的坚持时刻是可设置的(默以为 10 天),在逾期后,它会在紧缩进程中被永久删去。
删去列和向列写入 null 是完全相同的工作。它们都产生了一个 tombstone。由于在 Cassandra 中全部的写入都是更新刺进,这意味着即便在第一次写入 null 时也会生成一个 tombstone。实际上,咱们的整个音讯模式包括 16 列,但一般的音讯只设置了 4 个值。这导致大多数时分,咱们都在没原由地向 Cassandra 写入 12 个 tombstone。处理这个问题的方法很简单: 只向 Cassandra 写入非空值。
功能
众所周知,Cassandra 的写入速度比读取速度快。其写入速度低于一毫秒,读取速度低于 5 毫秒。无论拜访什么数据,咱们都能观察到这一点,且在一周的测验中,其功能坚持一致。咱们得到的正是咱们所期望的。
为了坚持快速、一致的读取功能,下面是一个在有数百万条音讯的频道中跳转到一年多曾经的音讯的示例:
出人意料的工作
全部都很顺畅,所以咱们将其作为咱们的主数据库推出,并在一周内淘汰了 MongoDB。它完美地作业了大约 6 个月,直到有一天 Cassandra 变得毫无反应。
咱们注意到 Cassandra 继续了 10 秒的 “stop-the-world”GC,但不知道为什么。咱们开端分析,并发现了一个需求 20 秒加载的 Discord 频道:Puzzles & Dragons Subreddit 的公共 Discord 服务器便是罪魁祸首。由于它是揭露的,咱们就参加进去看了看。令咱们惊讶的是,这个频道只有一条信息。很明显,就在那一刻,他们运用咱们的 API 删去了数百万条音讯,只留下一条音讯在频道中。
你或许还记得 Cassandra 是怎么运用 tombstone 处理删去的。当用户加载这个频道时,即便只有 1 条音讯,Cassandra 也有必要有效地扫描数以百万计的音讯 tombstone(生成废物的速度比 JVM 搜集的速度更快)。
咱们经过以下方法处理了这个问题:
- 咱们将 tombstone 的坚持周期从 10 天削减到 2 天,由于咱们每天晚上都在咱们的音讯集群上运转 Cassandra repair(一种反熵进程)。
- 咱们更改了查询代码,以跟踪空的贮存空间,并防止它们会在将来其他频道呈现。这意味着如果用户再次触发这个查询,那么最坏情况下 Cassandra 只会扫描最近的贮存空间。
未来**
咱们现在正在运转一个仿制系数是 3 的 12 节点的集群,并将根据需求继续添加新的 Cassandra 节点。咱们信任这将继续很长一段时刻,但跟着 Discord 的不断发展,在悠远的未来,咱们将每天存储数十亿条信息。Netflix 和苹果公司运转着数百个节点的集群,所以咱们知道咱们短时刻不需求对此考虑太多。但是,咱们期望咱们对未来能有一些想法。
近期
- 将咱们的音讯集群从 Cassandra 2 升级到 Cassandra 3。Cassandra 3 有一种新的存储格局,能够削减 50% 以上的存储巨细。
- 更新版别的 Cassandra 更擅长在单个节点上处理更多的数据。现在,咱们在每个节点上存储了将近 1TB 的紧缩数据。咱们信任咱们能够安全地削减集群中的节点数量,将其提升至 2TB。
长时刻
- 探索运用一个用 c++ 编写的与 Cassandra 兼容的数据库 Scylla。在正常运转期间,咱们的 Cassandra 节点实际上不会占用太多的 CPU,但是在非高峰时刻,当咱们运转修正 (一个反熵进程) 时,它们会变得适当受 CPU 约束,并且继续时刻会跟着自上次修正以来写入的数据量而添加。而 Scylla 声称将使修正时刻大大缩短。
- 构建一个系统,将未运用的频道备份到谷歌 Cloud Storage,并按需加载它们。咱们期望尽量防止这样做,并且不以为咱们非得这么做。
结论
间隔咱们转化现已很久了,虽然发生了 “巨大的意外”,但全部都很顺畅。咱们的音讯从每天超越 1 亿条添加到超越 1.2 亿条,但功能和稳定性一直坚持良好。
由于这个项目的成功,咱们现已把剩余的现场生产数据迁移到了 Cassandra 上,这也是一个成功。