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 亿条,这时,原来预期的问题开始出现:内存中再也放不下全部索引及数据,延迟开始变得不可控,是时候迁移到一个更适合这个项目的数据库了。并发
在选择一个新的数据库以前,咱们必须了解当前的读/写模式,以及咱们目前的解决方案为何会出现问题。高并发
接下来咱们来定义一下需求:性能
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”的策略自动解决写入冲突,这个策略对咱们有何影响?请看下面动画。
在例子中,一个用户编辑消息时,另外一个用户删除相同的消息,当 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 不得不扫描百万条墓碑(产生垃圾的速度比虚拟机收集的速度更快)。
咱们经过以下措施解决:
咱们目前在运行着一个复制因子是 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,而且也取得了成功。
原文连接 本文为云栖社区原创内容,未经容许不得转载。