万亿条消息,72 个节点:Discord 如何走出 Cassandra、重建在 ScyllaDB 上

2 minute read

Published:

English version

先看一个让基础设施工程师停下来想想的数字:2017 年到 2022 年初,Discord 的消息数据库从 12 个节点长到 177 个节点——同一时期,它变得更不可预测,而不是更稳。集群里存着万亿条消息,on-call 团队频繁被呼,运维甚至要把节点轮流摘出流量,才能让后台维护任务追上进度。规模放大的是 toil,不是效率。

这是 🚀 Extreme Scale 系列的第一篇,选 Discord 的消息存储开场,是因为它的教训并不舒服:在极端规模下,问题很少只出在数据库本身——出在访问模式上。

背景:为单一访问模式而生的数据模型

Discord 的消息 schema 刻意做得很简单,每个设计决定都服务于那个主导查询:”把这个频道最近的消息给我,新的在前。”

  • 分区键:频道 + bucket。 消息按所在频道分区,外加一个静态时间窗口 bucket。同一频道、同一 bucket 的消息在磁盘上存放在一起。
  • Snowflake ID。 所有 ID 都是 Snowflake——按时间天然可排序。按 ID 排序就是按时间排序,分区内一次范围扫描即可按序返回消息,不需要额外排序步骤。
  • 副本因子 3、quorum 一致性。 每个分区复制到 3 个节点,读写走 quorum,单节点故障不影响可用性与正确性。

这是教科书式的宽列设计,多年来运转良好。问题出在分区键对负载分布的影响:一个只有几个朋友聊天的小服务器,消息量比一个几十万人的大社区低几个数量级。负载跟着社区规模走,不跟节点数走——而少数频道的体量比其他频道大出天文数字。

故障模式:热分区

在 Cassandra 里,写很便宜:追加 commit log、进 memtable 缓冲、之后再刷盘。贵的是读——一次读可能要同时查 memtable 和多个磁盘上的 SSTable。而恰恰是读这个贵操作会高度集中。

当一个超大服务器迎来高光时刻——一条 @全体成员 的公告、一粒进球、一次产品发布——成千上万用户在同一时间打开同一个频道。所有这些读都落到拥有该频道分区的少数几个节点上。这就是热分区(hot partition),而它的爆炸半径比看上去大:

  1. 持有分区的节点处理不过来,该分区的延迟开始爬升。
  2. 因为读写走 quorum,凡是碰到这些节点的查询都继承这份延迟——热分区的问题变成所有与它同居的分区的共同问题。
  3. 与此同时,集群维护任务在抢同一批资源。Compaction 落后,读就要碰更多 SSTable,读更慢,compaction 落得更远。

再叠加上 JVM 垃圾回收停顿:轻则延迟尖刺,重则节点卡死到需要人工介入重启。Discord 团队有一个手工例行操作,内部戏称”八卦舞”(gossip dance):把一个节点摘掉流量、让它安心做 compaction,再放回来通过 hinted handoff 补齐漏掉的写,如此反复直到积压清空。当一个数据库需要靠编排好的舞蹈才能完成日常维护时,架构本身已经在发信号了。

修复一:换库之前,先给数据库加盾

Discord 的第一步不是迁移,而是一个承认:对热分区的无界并发,本质上是上游问题。他们在 API 单体和数据库之间加了一层 data services(数据服务),用 Rust 写成。

设计刻意无聊——大致一个数据库查询对应一个 gRPC 接口,不放任何业务逻辑。价值在两个机制上:

请求合并(request coalescing)。 一千个用户在同一时刻请求同一行,数据库应该只看到一次查询。在数据服务里,第一个请求会启动一个 worker task 去执行查询;之后所有对同一行的并发请求都订阅这个 task、共享它的结果。重复读在到达存储之前就被折叠掉了。

一致性哈希路由。 只有重复请求落到同一个服务实例上,合并才有效。所以请求按频道 ID 的一致性哈希路由:同一频道的所有流量去同一个数据服务实例——正是能把它的重复请求合并起来的地方。

两者合起来,一次 @全体 的洪峰从”上千次相同的 quorum 读砸向 3 个节点”变成”一次读、N 个订阅者”。之后 Cassandra 上仍有热分区,但发作频率明显降低——这为团队买到了迁移最需要的东西:时间。

修复二:ScyllaDB,以及它为什么对症

ScyllaDB 是一个用 C++ 写的、与 Cassandra 兼容的数据库。对着 Discord 的痛点清单,有三个属性最关键:

  • 没有垃圾回收器。 用 C++ 而非 Java,GC 停顿这一整类故障直接消失——而这正是历史上延迟尖刺和 on-call 痛苦的最大来源之一。
  • Shard-per-core。 每个 CPU 核心独占一份数据分片、独立提供服务,一个热分片的负载被隔离,而不是和节点上的其他一切互相争抢。
  • 与 Cassandra 兼容。 同样的数据模型、同族的查询语言、现成的驱动——迁移换的是引擎,不是应用契约。

当时有一个拦路虎:反向查询——按与存储顺序相反的方向扫描分区,Discord 读消息升序时就会用到——在 ScyllaDB 上性能不够。Discord 与 ScyllaDB 团队协作,对方实现了高性能的反向查询,最后一个技术障碍就此清除。值得注意的是,到 2020 年 Discord 已经把其他所有数据库迁到了 ScyllaDB;消息集群排最后,纯粹因为它体量最大。

新集群还用了 Discord 称之为 super-disk 的存储拓扑:本地 SSD 求速度,再通过 RAID 镜像到持久盘——本地盘的延迟,持久盘的可靠性。

graph TD A[Discord 客户端] --> B[API 单体] B --> C{一致性哈希路由
key: channel_id} C --> D1[Rust 数据服务
实例 A] C --> D2[Rust 数据服务
实例 B] D1 -->|请求合并:每个热行
一个 worker task,
N 个订阅者共享一次查询| E[ScyllaDB 集群
72 节点 · shard-per-core · RF 3 · quorum] D2 -->|请求合并| E E --> F[每节点 Super-disk:
本地 SSD 经 RAID 镜像
到持久盘 · 每节点 9 TB] G[Cassandra 集群
177 节点 · 旧系统] -.->|Rust 迁移工具:token range、
SQLite 检查点,峰值
每秒 320 万条消息| E E -.->|验证期双读采样| G

迁移:一个下午的工作,打败了三个月的估算

要求说起来简单:搬完万亿条消息,零停机,而且要快——因为 Cassandra 集群那边还在持续救火。

第一版方案走的是标准剧本:新数据双写两边,历史数据用 ScyllaDB 的 Spark 迁移工具回填。调优之后,Spark 迁移工具给出的估算约为三个月。然后团队想起自己手里已经有一套快的数据库库——就是 Rust 数据服务里的那套——用一个下午把它扩展成了迁移工具。新估算:九天。它从 Cassandra 读取 token range,用本地 SQLite 记录检查点,再把数据灌入 ScyllaDB,峰值速度达到每秒 320 万条消息。

然后迁移做了迁移都会做的事:卡在 99.9999%。最后几个 token range 一直读超时,因为里面密布着 tombstone——从未被 Cassandra compaction 清掉的删除标记。对这些 range 手工做一次 compaction 之后,拷贝在数秒内完成。验证用的是双读采样:一小部分生产读同时打到两个数据库、比对结果。2022 年 5 月,团队正好聚在 onsite,Discord 把 ScyllaDB 切为主库。

结果

指标CassandraScyllaDB
节点数17772
每节点平均磁盘约 4 TB9 TB
历史消息读取 p9940–125 ms15 ms
消息插入 p995–70 ms(波动)5 ms(稳定)

节点数减少约 60%,单节点密度翻倍还多。但更有信息量的是延迟数字的形状:赢的不只是 p99 降了,而是区间收窄了。p99 在 40 到 125 ms 之间漂移,是运维问题;p99 钉在 15 ms,是产品能力——Discord 提到,新的余量解锁了此前不敢做的产品用例,因为团队终于对消息存储有了信心。

承压证明出现在 2022 年底:世界杯决赛期间,Discord 的消息发送曲线上出现了九个与比赛进程对应的尖峰——进球、中场、加时赛——集群全部平稳吸收,没有出任何事故。

可以拿走的教训

  1. 热分区是访问模式问题,不是容量问题。 当所有重复读都打向同一个分区时,加副本没有用。先折叠重复读——请求合并把无界的扇入变成了有界的。
  2. 按你的缓存/合并键路由,而不是按均衡路由。 按频道 ID 做一致性哈希,刻意制造了服务实例之间不均衡的负载,因为均衡从来不是目标——重复请求的同址才是。
  3. 修复有顺序:先加盾,再换库。 数据服务在迁移之前上线、之后保留。它先把 Cassandra 的痛苦降到可以从容迁移的程度,今天仍在保护 ScyllaDB——因为任何分区存储都会有热分区。
  4. 基于你已信任的库自研迁移工具,可能比通用工具快 10 倍。 三个月变九天,是因为团队复用了性能特征已被自己摸透的代码。但要给长尾留预算:最后的 0.0001% 卡在 tombstone 上,不在吞吐上。
  5. 评估迁移动作,看方差,不看均值。 头条不是”15 ms 对 40–125 ms”,而是尾巴不再漂移。on-call 团队和产品团队真正消费的,是可预测性。

来源

  • Bo Ingram,《How Discord Stores Trillions of Messages》,Discord 工程博客,2023 年 3 月 6 日。https://discord.com/blog/how-discord-stores-trillions-of-messages ——本文所有数字、时间线与机制描述均出自该文。
  • 该文引用的前作:《How Discord Stores Billions of Messages》(2017),记录最初 12 节点 Cassandra 部署。