Appearance
Discord如何存储数十亿条消息
Discord的增长速度持续超出我们的预期,UGC(用户生成内容)同样迅猛扩张。用户基数扩大带动聊天消息激增,7月份时我们宣布每日处理4000万条消息,12月已达1亿条,截至本文发稿时已远超1.2亿条。我们早期就确立永久存储所有聊天记录的策略,确保用户随时可跨设备访问历史数据。面对海量持续增长且必须保持高可用的数据,我们如何应对?答案是Cassandra!
原有技术架构
2015年初,Discord初版仅耗时不到两个月完成。可以说MongoDB是实现快速迭代的理想数据库。所有数据最初都存于单个MongoDB副本集 —— 这是有意为之(由于MongoDB分片机制复杂且稳定性欠佳),但同时也规划了无缝迁移到新数据库的路径。这实际体现了我们的核心文化:通过敏捷开发验证产品功能,同时预设向健壮方案演进的通道。
消息存储采用单一复合索引(基于channel_id和created_at字段)。2015年11月前后,存储消息量突破1亿条,预期问题开始显现:内存无法容纳完整数据和索引,延迟波动剧烈。此时必须迁移至更适配的数据库。
数据库选型策略
选型前需深入分析读写特性及既有方案痛点:
- 明确识別出读取操作呈高度随机性,读写比例接近5:5;
- 语音聊天主导的服务器消息量极少,几天仅发一两条消息,全年通常不足千条。但用户仅获取50条消息也会触发大量磁盘随机寻址,导致磁盘缓存(disk cache)失效;
- 私密文字聊天主导的服务器每年产生10-100万条消息,用户通常仅查询近期记录。因成员数常低于百人,数据请求频率低,难驻磁盘缓存;
- 大型公共服务器消息量惊人:数千成员日发消息超万条,年积累轻松破百万。用户高频查询最近一小时消息,故数据通常常驻磁盘缓存;
- 预判来年将新增多种随机读场景:查看30天内的被提及消息并跳转定位、浏览置顶消息跳转、全文搜索等 —— 这些都将引发更多随机读操作!
基于此制定核心需求:
- 线性可扩展 — 杜绝后期重构或手动分片
- 自动故障转移 — 保障系统夜间自愈能力,运维团队安枕无忧
- 低维护成本 — 部署后自动运行,仅需随数据增长扩容节点
- 久经实战检验 — 接纳新技术但拒绝实验性方案
- 性能可预测 — API响应时间95百分位超过80ms自动告警,且无需引入Redis/Memcached消息缓存
- 非blob存储 — 若需持续反序列化blob并追加数据,每秒数千条消息的写入将难以实现
- 开源可控 — 掌握技术自主权,规避第三方依赖风险
Cassandra是唯一全项达标的数据库。通过添加节点即可实现水平扩展,节点故障时业务零感知。Netflix、Apple等企业已部署数千节点实践验证。其数据连续存储机制实现磁盘最小化寻址,并优化集群数据分布。该系统获得DataStax技术支持,同时保持开源和社区驱动特性。
方案确定后,进入实际验证阶段。
数据建模
向新手描述 Cassandra 的最佳方式是将其称为 KKV 存储。两个 K 共同构成主键。第一个 K 是分区键(partition key),用于确定数据存储在哪个节点以及磁盘位置。分区内部包含多行数据,每行由第二个 K(即聚类键 clustering key)标识。聚类键在分区内同时充当主键和行排序依据。你可以将分区理解为一个有序字典。这些特性组合成就了强大的数据建模能力。
还记得消息在 MongoDB 中通过 channel_id 和 created_at 建立索引吗?由于所有查询都针对频道进行,channel_id 自然成为分区键。但 created_at 不适合作为聚类键——两条消息可能有相同创建时间。幸运的是,Discord 所有 ID 本质上都是雪片 ID(可按时间排序),因此我们改用 message_id。主键变为 (channel_id, message_id),其中 message_id 就是雪片 ID。这意味着加载频道时,我们可以精确告知 Cassandra 需要扫描哪些消息范围。
以下是消息表的简化模式(省略约 10 列):
sql
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 的模式类似关系数据库,但其修改成本低且不会引起临时性能波动。这样我们同时获得了二进制存储和关系型存储的优势。
开始将历史消息导入 Cassandra 时,日志立即出现警告:发现超过 100MB 的分区。怎么回事?Cassandra 官方文档说支持 2GB 分区啊! 显然"能做到"不代表"应该做"。大分区会在压缩、集群扩展等操作时给 Cassandra 带来巨大 GC(垃圾回收)压力。大分区还意味着数据无法分散到整个集群。问题很明显:单个 Discord 频道可能存续数年并持续增长,我们必须限制分区大小。
最终采用按时间分桶(bucket)方案。分析 Discord 上最大频道后发现:每个桶存储约 10 天消息,就能稳定控制在 100MB 以内。分桶必须能通过 message_id 或时间戳推导:
python
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:
# 雪片ID高位22位存储的是自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):
sql
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 也是雪片ID且早于第一条消息)生成桶范围,然后顺序查询分区直到收集足够消息。这种方案的缺点在于:低活跃度频道需要查询多个桶才能获取足够历史消息。但实践中影响不大——活跃频道通常在首个分区就能找到足够消息,而这类频道才是主体。
消息导入 Cassandra 过程顺利,我们做好了生产环境部署准备。
暗启动(Dark Launch)
新系统上线总是令人提心吊胆,因此最好在不影响用户的前提下测试。我们实现了 MongoDB 和 Cassandra 的双重读写机制。
启动后立即在错误追踪系统发现 author_id 为空的报错。这怎么可能?这可是必填字段!
最终一致性(Eventual Consistency)
Cassandra 作为 AP 数据库,用强一致性换取高可用性——这正是我们需要的。Cassandra 中应避免"先读后写"(读操作成本更高),因此所有操作本质上都是更新插入(upsert),即使只更新部分字段。数据可写入任意节点,Cassandra 会按字段以"末次写入优先"原则自动解决冲突。那么问题如何产生的?
当用户编辑消息和另一用户删除该消息同时发生时,由于 Cassandra 所有写入都是更新插入,最终导致行数据只剩主键和文本内容(其他字段丢失)。我们考虑过两种方案:
- 编辑消息时完整回写全部内容——但可能导致已删除消息复活,并增加其他字段的写入冲突风险
- 检测数据损坏后主动删除消息
最终选择方案二:检查必填字段(如 author_id),发现为空时删除该消息。
解决该问题时,我们注意到写入效率低下。Cassandra 的最终一致性意味着删除操作不会立即生效——它需将删除指令(称为逻辑删除标记 tombstone)同步到所有节点(即使节点暂时离线)。读取时会自动跳过这些标记,标记默认存活 10 天后在数据压缩过程中永久清除。
需要注意:删除字段与写入空值效果相同——两者都会生成逻辑删除标记。由于 Cassandra 所有写入都是更新插入,首次写入空值也会生成标记。实践中消息模式包含 16 个字段,但平均每条消息只设置 4 个值,这意味着多数情况下我们竟为 12 个空值生成了逻辑删除标记!解决方案很简单:仅向 Cassandra 写入非空值。
性能表现
Cassandra 以写入快于读取著称,实际观测完全印证该特性。写入操作持续稳定在亚毫秒级,读取操作始终低于5毫秒。无论访问何种数据,该性能特征都保持一致,整个测试周期内性能曲线平稳。所有结果均在预期范围内,未出现异常情况。
重大意外
由于运行稳定,我们将其升级为主数据库,仅用一周便完成MongoDB的汰换。系统完美运转约半年后,Cassandra在某个关键日突然失去响应。
当时观察到Cassandra持续触发10秒级"全局停顿GC",却苦于不明原因。深入追踪发现Puzzles & Dragons主题的Reddit公共Discord服务器中,某个频道加载耗时达20秒。问题症结在于:用户通过API删除了该频道数百万条历史消息,致使频道仅存单条消息。
若您关注前文技术细节,应记得Cassandra通过墓碑标记实现删除操作(详见最终一致性章节)。用户加载该频道时,尽管有效消息仅1条,系统仍需遍历数百万条消息的墓碑标记——垃圾数据生成速度远超JVM回收能力。
最终采用双重解决方案:
- 将墓碑标记存续期从10天压缩至2天(消息集群每日执行Cassandra修复反熵进程)
- 改造查询逻辑:标记空数据桶并建立跳过机制。确保未来同频道的查询至多扫描最近数据桶
未来规划
当前12节点集群(副本系数3)运行稳定,后续将持续动态扩展节点。我们确信该架构具备长期稳定性,但鉴于Discord的增长态势,未来可能面临日均百亿级消息的存储挑战——Netflix与苹果运维的数百节点集群案例,让我们得以暂缓该问题的深度规划,但仍需储备技术预案。
近期举措
- 消息集群从Cassandra 2升级至3:新版存储格式可降低50%以上空间占用
- 提升单节点负载:当前节点存储1TB压缩数据,扩容至2TB后可安全缩减集群规模
长期战略
- 评估Scylla(C++编写的Cassandra兼容数据库):常规负载时Cassandra节点CPU压力有限,但非高峰反熵修复会随数据增量出现CPU瓶颈。Scylla宣称显著优化修复耗时
- 构建冷热分层:将闲置频道数据归档至Google云存储的平面文件,支持按需加载。该预案作为终极手段,我们力争规避实施
结语
迁移至今已逾一年,虽经历重大技术挑战,整体仍平稳运行。平台日均消息处理量从1亿条跃升至1.2亿条,性能与稳定性始终如一。
基于此成功实践,我们已将核心生产数据全面迁移至Cassandra,同样取得显著成效。