土法炼钢兴趣小组的算法知识备份

【系统架构设计】Discord 架构:从 Go 到 Rust 的性能进化

文章导航

分类入口
architecture
标签入口
#Discord#Elixir#Rust#Go#ScyllaDB#WebRTC#SFU#WebSocket#Cassandra#actor-model

目录

Discord 同时维护数百万 WebSocket 长连接、每秒百万级消息写入、全球 2.5M+ 并发语音用户(2018 年公开数据),以及万亿级历史消息的检索与搜索。没有任何单一语言或数据库能覆盖这条链路:Gateway 与 Guilds 用 Elixir 的 Actor 模型扛连接扇出;Read States 与 message data services 在 Go 碰壁后迁到 Rust;持久层从 MongoDB 经 Cassandra 落到 ScyllaDB;语音媒体面是 C++/Rust SFU,信令仍走 Elixir。

这不是「全栈 Rust」的故事,而是按热路径选运行时的工程样本。Go 在 Read States 上暴露了 GC 与巨型 LRU 的结构性冲突;Rust 用所有权模型换可预测的尾延迟;Elixir 用进程隔离换实时系统的可运维性——直到 supervisor 邮箱成为 2026 年 voice outage 的瓶颈。

本文依据 Discord Engineering Blog 的一手复盘,串联架构全景、存储演进、语音与边缘迁移、事故教训与语言决策矩阵。与 Slack 实时协作案例 对照阅读,可看到两家 IM 在 WebSocket 网关与消息存储上的不同取舍;事件驱动架构线程模型 则分别解释 Gateway 扇出与 Rust async/Tokio 选型的背景。


一、Discord 架构全景

1.1 产品负载特征

Discord 的负载有几条硬约束,直接塑造了后端形态:

负载类型 访问模式 架构后果
Gateway WebSocket 每用户长连接;连接、发消息、读消息均触发下游 需要百万级连接管理与低延迟事件推送
Read States 每次连接、每条消息发送/已读都访问 极端热路径;内存内 LRU + 持久化双写
频道消息 按 channel 分区;大服与小服流量差 orders of magnitude 易出现 Cassandra/Scylla hot partition
语音/视频 全双工 UDP;单通话选单一 SFU 宿主 区域选型、SFU 容量、边缘 PoP 与对等互联
搜索 懒索引;guild/DM 分片 Elasticsearch cell 架构与 BFG 特例

Discord 早期在 2015 年用 MongoDB 单副本集快速迭代,但 2015 年 11 月前后已出现索引无法常驻 RAM、尾延迟不可预测的问题,随后迁移到 Cassandra(2017 年博客)。到 2022 年初,仅 cassandra-messages 集群就有 177 个节点、数万亿条消息(Bo Ingram, 2023)。

1.2 多语言栈总览

# Discord 后端语言分工(据官方博客归纳,非完整服务清单)
realtime_signaling:
  language: Elixir (BEAM)
  services: Gateway, Guilds, Sessions, Voice 信令, Voice Syncers
  rationale: Actor 模型、热代码升级、进程级隔离

hot_path_services:
  language: Rust
  examples: Read States, Authentication, Message Data Services, 迁移工具
  rationale: 无 GC、可控尾延迟、fearless concurrency

legacy_and_api:
  language: Go, Python, 等
  note: Read States 曾用 Go;Discord 明确不建议「因 Rust 流行而全面重写」

media_plane:
  language: C++ / Rust
  role: SFU 选择性转发;WebRTC 原生栈定制

data_stores:
  primary: ScyllaDB (Cassandra 兼容)
  search: Elasticsearch on Kubernetes (ECK)
  discovery: etcd → Valkey (语音主机发现演进)

1.3 高层架构图

graph TB
    subgraph 客户端
        C1[Desktop / Mobile / Web]
    end

    subgraph 实时层_Elixir
        GW[Gateway<br/>WebSocket 入口]
        SESS[Sessions<br/>每设备一会话进程]
        GUILDS[Guilds<br/>频道/语音状态]
        VSYNC[Voice Syncers<br/>SFU 路由]
    end

    subgraph 热路径_Rust
        RS[Read States]
        AUTH[Authentication]
        DS[Message Data Services<br/>请求合并]
    end

    subgraph 媒体层
        SFU[SFU 集群<br/>UDP/WebRTC]
    end

    subgraph 数据层
        SCYLLA[(ScyllaDB 集群)]
        ES[(Elasticsearch Cells)]
    end

    C1 -->|Gateway WS| GW
    GW --> SESS
    SESS --> GUILDS
    GUILDS --> VSYNC
    VSYNC -->|HTTPS RPC| SFU
    C1 -->|Voice WS + UDP| SFU

    GW --> AUTH
    GUILDS --> RS
    GUILDS --> DS
    DS --> SCYLLA
    RS --> SCYLLA
    AUTH --> SCYLLA

    GUILDS --> ES

1.4 学术与工业谱系(简)

实时信令层可追溯到 Actor 模型(Hewitt et al., 1973;Erlang/OTP 是生产级实现)。Discord 的 Gateway/Guilds 不是「微服务拼盘」,而是百万 GenServer 进程 + 进程监控(:DOWN 驱动的分布式 Actor 系统——2026 年 voice outage 正是这一模型副作用的教科书案例(见第七节)。

消息存储路径则来自 LSM-tree 家族:Cassandra/Scylla 的分区有序写入与读放大权衡,与 O’Neil 等人 1996 年 LSM 定义及后续 leveled/tiered compaction 争论同构。Discord 的 (channel_id, bucket) 分区 + Snowflake 聚簇键,是在 AP 系统上为按频道顺序扫描做的经典宽表建模(2017 billions 博文)。

语音面采用 SFU(Selective Forwarding Unit) 而非 MCU:每个客户端只向服务器上传一路流,由 SFU 转发给其他参与者——这与 WebRTC 标准栈(W3C/IETF)一致,Discord 在原生客户端上裁剪 SDP/ICE,在服务端用自研 SFU 做桥接(2018 WebRTC 博文)。


二、Gateway 与 Elixir 实时层

2.1 Gateway WebSocket 的角色

用户在线时,客户端与 Discord Gateway 维持一条 WebSocket(Gateway 连接)。Guild 列表、频道、消息、presence、typing 等事件经此连接下发。2018 年语音架构博文明确:Gateway、Guilds、Voice 信令服务均用 Elixir 编写,以复用代码与 OTP 监督树。

连接建立后,客户端通过 Gateway 更新 voice state;加入语音频道时,由 Discord Guilds 为 guild 分配 Discord Voice 服务器(详见第五节流程图)。

2.2 Sessions:一切推送的中枢

2026 年 voice outage 复盘揭示:Sessions 服务为每个设备维护一个 session 进程——同一用户在 Web 与 Desktop 各有一个 session;所有经 WebSocket 推送到客户端的聊天、presence 等,都经过对应 session(Discord Engineering, 2026/04)。

Sessions 与 Gateway 的关系:

1. 客户端连接 Gateway
2. Gateway 向 Sessions 服务创建 session
3. Gateway 持有 socket,向客户端 dispatch 事件
4. Session 退出时,监控该 session 的 Guild 等进程收到 {:DOWN, ...}
5. Gateway 指示客户端重连;优先同 zone resume,否则新建 session

这一设计在单 session 断开时表现稳健;但当 17% 的 session 同时非优雅退出(2026/03/25),:DOWN 消息风暴成为级联故障起点。

2.3 为何 Elixir 适合 Gateway

维度 Elixir/BEAM 典型 thread-per-request
并发单元 轻量进程(KB 级) OS 线程(MB 栈)
状态隔离 进程邮箱串行处理 需锁或无共享可变状态
故障域 Supervisor 重启子树 常波及整进程
运维 :sys.get_state、Recon、热补丁 依赖外部 APM

Discord 在 Rust 博客中亦提到:团队曾早期采用 Elixir、React、Scylla 等「有前景但当时尚未完全稳」的技术栈,并以相对精简的工程规模支撑大规模实时产品。Elixir 的价值在于连接扇出 + 有状态进程;代价是必须严格管控单 GenServer 邮箱深度——Holster.Pool 的 DynamicSupervisor 在 2026 事故中成为反例。

2.4 与线程模型的关系

Gateway 的 BEAM 调度器将大量 I/O bound 连接映射到少量 OS 线程,语义上接近 Reactor + 协程 路线,但错误隔离粒度是进程而非 goroutine/task。Rust Read States 则走 Tokio 异步 I/O(Jesse Howarth, 2020),在语言层用 async/await 表达并发,用所有权替代 GC。

2.5 Gateway 事件扇出(概念模型)

Discord 未公开 Gateway 源码,但 2018 语音博文与 2026 Sessions 复盘可拼出事件路径:

用户 A 在 guild G 的 text channel 发消息
    → API 持久化消息(经 Data Services → ScyllaDB)
    → Guild G 的进程收到新消息事件
    → 查找 G 内在线成员各自的 session 进程
    → 各 session 经 Gateway 连接 push MESSAGE_CREATE 给客户端

这与 Slack 的 Redis Pub/Sub 扇出 不同:Discord 更依赖 进程监控图(谁 monitor 谁)而非独立消息总线。优势是状态局部性;劣势是 fan-out 与 monitor 图 在 mass disconnect 时成为放大器——2026 年 17% session 同时退出即为例证。

2.6 Kubernetes 迁移与有状态 Elixir

Realtime Infrastructure 团队将 Elixir 服务迁到 K8s「blessed path」(Discord Engineering, 2026/04)。有状态服务与普通 stateless Deployment 的关键差异:

操作 Stateless 服务 Discord Sessions/Guilds
缩容 直接减少 replica 必须等 entity count = 0(进程 handoff 完成)
滚动升级 替换 pod 监控每 pod 上 GenServer 是否已迁移
故障域 单 pod 无状态 单 pod 承载数千 session/guild 进程

2026/03/25 的 bug 正在于:缩容触发 pod 终止,但 handoff 安全等待 与 K8s terminationGracePeriod 竞态,导致 17% session 被硬杀。事后 validating admission webhook 拒绝未完成 drain 的缩容——这是控制平面Actor 运行时语义 的补偿。


三、Read States:Go 的 GC 困境与 Rust 迁移

3.1 服务职责与规模

Read States 唯一职责:记录用户在每个频道「读到哪里」、 计数等。访问发生在:

Discord 称该服务处于 hot path,必须「super snappy」。

数据规模(Jesse Howarth, 2020):

3.2 Go 实现:每约 2 分钟的延迟尖刺

Go 版「多数时间很快」,但每隔几分钟出现巨大 latency spike,伤害体验。团队归因于 Go 内存模型与 GC

关键机制:

  1. 驱逐不立即释放内存:对象无引用后仍驻留,等 GC 判定可回收。
  2. Go 至少每 2 分钟强制 GC 一次(即使堆增长不快)——源码行为,与分配速率无关。
  3. Read States 代码已「非常高效、极少分配」,尖刺并非垃圾量大,而是 GC 需 扫描整个 LRU 以确定指针可达性。
  4. 缩小 LRU → GC 扫描变快、尖刺变小 → p99 上升(缓存命中率下降 → 更多 Cassandra 读)。

团队曾动态调节 GOGC、拆分多分区 LRU,找到「还算可以」的配置后运行相当长时间,但仍不满意。

脚注(同文 [1]):图表来自 Go 1.9.2;曾试 1.8、1.9、1.10 无改善;Go→Rust 初版移植完成于 2019 年 5 月;博文发布于 2020 年 2 月。

Go Read States 延迟尖刺因果链(据 Discord 博客)

LRU 驱逐 → 对象变为不可达但未释放
    → 每 ~2min 强制 GC
    → GC 扫描数千万条目 LRU
    → STW/标记工作导致 CPU 与延迟尖刺
    → 缩小 LRU 缓解尖刺但损害 p99

3.3 Rust:驱逐即释放与 800 万缓存

Rust 无 GC;Read State 从 LRU 驱逐时 立即释放(所有权结束),无运行时扫描整个缓存。

2019 年移植时使用 nightly Rust async(稳定版 async 故事尚不完善);Discord 集体决定跑 nightly 直至 stable 支持 async——后证明值得(Jesse Howarth, 2020)。

负载测试结果:

Raising the cache capacity:无 GC 后,团队提高 LRU 上限至 800 万 Read States;图表显示平均耗时降至 微秒级,max @mention毫秒级(同文 Launch 图表)。

3.4 机制对比表

维度 Go 1.9.2 Read States Rust Read States
内存回收 tracing GC,驱逐延迟释放 所有权,驱逐即时 free
尖刺 ~每 2 分钟 负载测试中无同类尖刺
LRU 规模 大缓存加剧 GC 扫描 提至 800 万条
并发模型 goroutine + 手动跨 goroutine 保护 编译期线程安全
持久层 Cassandra Cassandra(同)

3.5 不应过度泛化的结论

Discord 明确写:不应因 Rust 流行而重写一切;Read States 是小而自洽的服务,适合证明 Rust 作为服务语言。Go 在大量 IO 型 API 中仍合适;此处失败模式是 巨型长寿命堆 + 低频强制 full heap scan 的交集,而非 Go 「慢」。

3.6 Read State 数据结构与持久化策略

每条 Read State 含多个需 原子更新 的计数器(如 @mention 数),并常被重置为 0。热路径更新发生在 LRU 内;驱逐与定时器触发 Cassandra 写入(Jesse Howarth, 2020)。

// 概念结构(非 Discord 源码,仅说明博客描述的行为)
struct ReadState {
    user_id: Snowflake,
    channel_id: Snowflake,
    mention_count: u32,
    // ... 其他计数器
}

// LRU 驱逐路径(Rust):Drop 立即回收
// Go 路径:驱逐 → 等待 GC 扫描 LRU 图

持久化双路径:

  1. 驱逐时 commit — 保证不在缓存中的状态已落盘
  2. 更新后 30 秒 scheduled commit — amortize 写入,降低丢更新风险

这一模式与 事件驱动 中「写后异步刷盘」类似,但 Read States 在 产品语义上接近强一致(用户期望未读数即时正确),故 30 秒窗口是工程折中而非最终一致故意设计。

3.7 Rust 侧优化与 Tokio 生态

Discord 列出的 Rust 优化(Jesse Howarth, 2020):

  1. BTreeMap — 在 LRU 中换 HashMap,降低内存占用(有序遍历对 GC 无意义,但对 Rust 内存布局有意义)
  2. Metrics 库 — 换用更现代的并发 metrics 实现
  3. 减少 memcpy — 热路径拷贝 elimination

移植后 tokio 0.2 升级 带来「免费」CPU 下降(同文 Evolving ecosystem 图表,约 16 日前后 CPU 曲线下移)。这说明 运行时升级语言迁移 收益需分开评估。

3.8 Go GC 与 Java/Cassandra GC 的类比

Read States 的 Go 问题与 Cassandra messages 集群的 JVM GC(Bo Ingram, 2023)形态不同但同属 managed heap + 大型常驻结构 类:

系统 堆上 giant structure 周期/workload 症状
Go Read States LRU 数千万 entry 2min 最小 GC + 全堆扫描 API 热路径尖刺
JVM Cassandra row cache + memtable compaction + GC 调参 全集群尾延迟、gossip dance

Discord 在存储层用 Scylla(C++,无 JVM GC) 解决后者;在 Read States 用 Rust 解决前者——不是统一换语言,而是消除对应层的 GC


四、消息存储:MongoDB → Cassandra → ScyllaDB

4.1 2017:120 万消息/日与 120 节点 Cassandra

2017 年 1 月博文(Stanislav Vishnevskiy)要点:

读写特征:读写约 50/50;读「极度随机」——小服一年不足 1000 条消息却也要随机 seek;大服则集中在最近一小时。

4.2 2022 初:177 节点与运维噩梦

2023 年 follow-up(Bo Ingram)描述 cassandra-messages2022 年初:177 节点、数万亿消息

2017 博文已预见 ScyllaDB(C++、shard-per-core、无 JVM GC);2020 年前 Discord 已将除 messages 外全部 Cassandra 迁到 ScyllaDB——messages 集群最大,迁移风险最高。

4.3 Rust Data Services:请求合并(Request Coalescing)

在换库之前,团队先在 API 与数据库间插入 Rust data services(Bo Ingram, 2023):

Tokio + Cassandra/Scylla driver;Discord 称「一旦编译通过通常就能跑」。这对 @everyone 大公告等流量尖刺尤为关键——否则 hot partition 直接打穿数据库。

4.4 万亿消息迁移与 ScyllaDB 收益

迁移要点(同文):

数月后指标(仅引用该文数字)

指标 Cassandra p99 ScyllaDB p99
拉取历史消息 40–125 ms 15 ms
消息插入 5–70 ms 稳定 5 ms

节点:177 台 Cassandra → 72 台 ScyllaDB;每节点磁盘 4 TB → 9 TB。2022 世界杯决赛期间消息发送尖刺与进球事件在监控曲线上可见——系统未崩溃。

4.5 消息读写路径(Mermaid)

sequenceDiagram
    participant Client
    participant API as API Monolith
    participant DS as Rust Data Service
    participant SC as ScyllaDB

    Client->>API: GET channel messages
    API->>DS: gRPC (channel_id routing key)
    Note over DS: 同 channel 并发读合并为单次查询
    DS->>SC: quorum read (partition: channel+bucket)
    SC-->>DS: rows
    DS-->>API: coalesced result
    API-->>Client: messages

    Client->>API: POST message
    API->>DS: insert
    DS->>SC: write (append to memtable)
    Note over SC: 大服 hot partition 仍可能发生<br/>合并层减轻但未消除

4.6 Hot partition:未完全解决的问题

Scylla 消除了 JVM GC 类问题,但 hot partition 仍可存在。Data services bought time;根本张力在于:按 channel 分区超大型 guild 流量的几何不匹配。这与 Cassandra 论文假设(相对均匀分区)到生产 skew 的「工程间隙」一致——Discord 用合并与迁移工程化应对,而非重新发明存储引擎。

4.7 2017 Cassandra 建模与最终一致陷阱

Stanislav Vishnevskiy (2017) 还记录了 Cassandra AP 语义下的生产坑:

Snowflake ID 保证 (channel_id, message_id) 有序,使「加载频道最近 N 条」成为 range scan on clustering key,而非全分区扫描。Bucket 将单分区大小 bound 在 ~100MB 内,避免 2017 日志中 >100MB partition 警告与 compaction 压力。

-- 简化 CQL(2017 博文)
CREATE TABLE messages (
    channel_id bigint,
    bucket bigint,
    message_id bigint,
    author_id bigint,
    content text,
    PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);

4.8 迁移工程:Spark vs Rust migrator

Bo Ingram (2023) 对比两条迁移路径:

方案 估计耗时 特点
Scylla Spark migrator ~3 个月 需大量 tuning
Rust 扩展 data service ~9 天 token range + SQLite checkpoint + firehose

迁移卡住 99.9999%:Cassandra 末段 token range 含 巨型 tombstone 未 compaction → 读超时 → compact 后秒级完成。这说明 历史技术债(Cassandra 运维已吃紧)会拖慢最后一英里。

4.9 super-disk 与认证集群(交叉引用)

Mark Smith (2023) 描述的 hybrid RAID super-disk(Local SSD + 持久盘镜像)与 messages/Scylla 拓扑同族:

认证 outage 与 messages 迁移共享存储哲学,但 集群规模与 cache 行为 不同——认证集群更小、row cache 命中率对重启更敏感。架构上不能把「messages 集群能扛 zone 下线」的经验直接套到 全局 gate 的 auth 集群。


五、语音架构:Guilds、SFU 与 WebRTC

5.1 设计原则(2018)

Jozsef Vass (2018) 总结:

5.2 客户端:统一 WebRTC 栈

5.3 后端三件套

服务 语言 职责
Gateway Elixir WebSocket;voice state 更新
Guilds Elixir 为 guild 选择 Voice 服务器;维护 voice state
Voice Elixir 信令 + C++ SFU 信令 + 媒体转发

Voice 服务器分配(2018):

  1. Voice 服务器周期上报健康与负载 → etcd 服务发现
  2. Guilds 选择区域内 负载最低 的 Voice 服务器
  3. 同一 guild 所有语音频道共用一个 Voice 服务器
  4. 客户端显示 “Awaiting Endpoint” = Guilds 正在选型;“Voice Connected” = UDP 与 SFU 互通

Voice 服务器含 信令组件 + SFU(Selective Forwarding Unit):SFU 转发音视频,桥接浏览器与原生传输/加密差异,处理 RTCP 带宽反馈。

2018 规模数据:850+ 语音服务器、13 区域、30+ 数据中心;260 万+ 并发语音用户;出口 220 Gbps120 Mpps

5.4 语音加入流程(Mermaid)

sequenceDiagram
    participant Client
    participant GW as Gateway
    participant Guilds
    participant etcd
    participant Voice as Voice Server
    participant SFU

    Client->>GW: 更新 voice state(加入频道)
    GW->>Guilds: 转发 voice state
    alt guild 尚无 Voice 服务器
        Guilds->>etcd: 查询区域负载
        etcd-->>Guilds: 候选 Voice 节点
        Guilds->>Guilds: 选最低负载
    end
    Guilds->>Voice: 推送全部 voice state
    Guilds-->>Client: Voice endpoint(地址/端口/密钥)
    Client->>Voice: Voice WebSocket(信令)
    Client->>SFU: UDP 媒体(经 Voice 协调)
    Note over SFU: 单通话单 SFU 实例<br/>全员连接同一宿主

5.5 故障转移

Voice 服务器宕机:从 etcd 移除 → 客户端经 Gateway 请求 ping → Guilds 分配新 Voice 服务器 → 推送 voice state → 客户端重连。DDoS 与 更换 voice region 走类似流程。SFU 崩溃:信令监控并重启 SFU,状态由信令重建,客户端无感(少量丢包)。

5.6 Voice Syncers 与 SFU 控制面(2026 视角)

2018 博文描述 Guilds 直接协调 Voice 服务器;2026 outage 博文细化 voice syncers 层:

Call owning service (discord_guilds / discord_calls)
    → voice state 更新 → voice syncers
    → HTTPS RPC → 全球 25,000+ SFU 实例(Cloudflare + 传统主机)
    → SFU 转发 E2E 加密媒体

Syncer 职责:

这一层是 Elixir 进程Rust/C++ SFU 的边界:信令/路由在 BEAM;媒体在 SFU。2026 事故证明 syncer → SFU 的 HTTP 连接池 必须与 etcd 服务发现 解耦,否则 BEAM 邮箱背压会「假装」整个 A/V 控制面宕机(Awaiting Endpoint)。

5.7 WebRTC 简化交换(字节量对比)

Jozsef Vass (2018) 给出数量级:

路径 协商数据量 协议
标准 WebRTC P2P ~10 KB RT SDP
Discord 原生 ↔︎ SFU ~1000 B 自定义(地址/端口/密钥/codec/stream id)
Discord 浏览器 ~1200 B RT 客户端合成 SDP

省 ICE 是因为 客户端只连 Discord relay;省 SDP 体积降低 join latency。代价是 供应商锁定在 Discord SFU 语义,与标准 WebRTC 互操作需 SFU 内 browser/native 桥接

5.8 SFU vs MCU(理论注记)

MCU(Multipoint Control Unit) 混流后再下发,客户端只收一路——CPU 随参与者平方增长。SFU 只转发,客户端收 N-1 路,上行仍为一路。Discord 选择 SFU 支撑 千人级频道轮流发言Go Live 等场景;MCU 更适合终端能力弱、带宽不对称的会议系统。WebRTC 生态默认 SFU;学术上 multiparty 拓扑仍是开放设计空间(见 IETF RFC 8825 与 WebRTC 架构文档)。


六、语音上边缘:Cloudflare 与 recv starvation

2026 年 David Chen 博文描述 Discord 将 80%+ 语音/视频流量迁到 Cloudflare 300+ PoP(对比传统 hyperscaler 约 30–40 区域)。70%+ 已迁移区域 YoY 质量改善;法兰克福 ping 降 34%、丢包 降 42%

6.1 冰岛与鹿特丹:地理不等于最优宿主

教训:最近 PoP ≠ 最佳 call host;混合区域需 smarter call placement(仍在 roadmap)。

6.2 主机发现:etcd → Valkey

Cloudflare 上主机 自发注册(非预先 provision):Voice host 启动 → 注册 Valkey(10 分钟 TTL)。约 25,000 Cloudflare voice hosts 使旧 etcd 发现延迟上升;迁移期 双写 后退役 etcd(同文)。

6.3 容器密度与 NIC 队列

Cloudflare 容器最初 4 workers/host → US East 丢包 1.5–2%(基线 <0.5%)→ 根因 单 NIC 队列上四 worker 争用 → 暂降为 2 workers → UDP buffer 修复后 2025 年 9 月 默认 8 workers

6.4 recv starvation 与 CPU affinity(欧洲 ping 尖刺)

2025 末 Bucharest/Stockholm 迁到 Cloudflare 上 Rust SFU 后,峰值 client ping >500ms

Cloudflare eBPF 探针定位:问题在 VM 内应用。根因 1 — 写饥饿

根因 2 — softirq noisy neighbor:单 virtio-net 队列 → Receive softirq 与 worker 同 vCPU → taskset 将 worker pin 离 IRQ vCPU,配合 RPS。

修复后欧洲流量回迁 Cloudflare。这与 线程模型 中「Reactor 饥饿」同类:事件循环中永远 ready 的读端点 starve 写端——实时媒体比 HTTP 更苛刻。

6.5 25 秒「假 outage」

LA PoP 实例 25s 无响应无日志:Cloudflare 侧 page cache 写满后 数 GB 一次性 flush → block device 上其他 writer stall。属平台层问题,Discord telemetry 协助定位。

6.6 Cloudflare 主机生命周期与 Discord Deployer

边缘与 hyperscaler 假设不同(David Chen, 2026):

假设 传统云 Cloudflare
主机身份 稳定 hostname 调度器随时 reclaim
部署 rolling restart recreate 容器
维护 自选窗口 至少 每月 reboot

Discord 构建 deployer:poll 直到旧镜像 host 被 recreate;PoP 级协调 recreate 时 supervisor 延迟 5 分钟 exit,避免实例数瞬归零。这与 Cloudflare 案例 中「边缘机器可抢占、可重建」哲学一致——但有状态 UDP 通话要求 更复杂的 drain 语义

6.7 recv 饥饿与 Tokio select!(机制细读)

Rust SFU worker 结构(同文):

// 概念化:Discord 边缘 SFU 事件循环困境
loop {
    tokio::select! {
        Some(pkt) = socket.recv() => { /* 处理入站 */ }
        _ = flush_timer.tick() => { socket.send(flush_batch)?; }
    }
}

当 socket recv buffer 非空,recv() future 立即 Ready → select 重复 poll recv → flush_timer 永不就绪。Discord 修复:距上次 flush ≥9ms 则本轮从 select 移除 recv——本质是 人工 write bias,补偿 Tokio cooperative scheduling 在 always-ready 读 场景下的不足。Cloudflare eBPF 探针测得 futex stall 最高 860ms、单线程 100% CPU 与 socket recv buffer 增长同步——与上述机制一致。


七、事故复盘:2026/03/25 Voice Outage

Discord 在 authentication outage(2023/11/06)voice outage(2026/03/25) 中暴露了不同架构层脆弱性。前者是 Scylla 认证集群容量 + 区域升级 + degraded NVMe + retry storm(Mark Smith, 2023);后者是 Elixir 进程邮箱 + HTTP 连接池 supervisor + etcd 服务发现耦合。本文以 voice outage 为主——因其直接关联 Gateway/Guilds/Voice Syncers 架构,且用户指定关注 supervisor 与 etcd。

7.1 时间线与影响

7.2 级联:Gateway → Voice Syncers

  1. 17% session 同时 :DOWN → 各监控实体每 session 一条消息
  2. Gateway(us-east1-b)重连风暴;session 速率限制未随 K8s 迁移重调 → 内存 spike → 该 zone Gateway 重启 → 更多 failover 到 c/d
  3. Voice Syncers:session 退出 → disconnecting voice state → 大量 SFU RPC;重连 → 重建 call → HTTPS 出站连接洪水

7.3 Holster.Pool 与 supervisor 邮箱

Voice Syncers 经 Holster 池化 gun Erlang HTTP 客户端。新建连接路径经过两个瓶颈 Supervisor GenServer

  1. Holster.PoolDynamicSupervisor
  2. gun 连接 supervisor

spawn 子进程时用 selective receive 等子进程 ACK。Postmortem 测试:~100k 邮箱深度时 selective receive 增加 ~1ms/spawn;邮箱 1M100 spawn/s 时无法追上。

后果:

Recon 快照:Holster.Pool DynamicSupervisor 邮箱 #1

7.4 恢复与改进

事后(Discord Engineering, 2026/04):

7.5 架构教训

教训 说明
隐藏共享瓶颈 通用 HTTP 池 supervisor 与 etcd 共用 → 故障域扩大
Actor 串行化代价 单 GenServer 处理 spawn ACK + 全池流量 → 邮箱 O(n) selective receive
容量经济学 大流量尖刺要么 增供给(扩容 syncers)要么 减需求(rate limit)
与存储事故对比 2023 auth outage:RAID rebuild ~45 min、双 zone 脆弱窗口;2026 voice:进程级而非 Scylla

Authentication outage 补充一点(Mark Smith, 2023):scylla-authentication underprovisioned;一 zone 离线 + 另一 zone degraded NVMe(RAID rebuild 慢)→ quorum 饱和 → HTTP ~25% 可用;关 typing/read 事件后约 50% 恢复。Discord 双倍扩容认证集群并改进磁盘退化检测——说明 小集群 critical path 与 mega messages 集群运维逻辑不同。

7.6 时间线详表(PDT)

时刻 事件
12:13 Sessions K8s 缩容;17% session 非优雅终止
12:13+ Gateway us-east1-b 重连风暴、OOM 重启
12:13+ Voice syncers 邮箱增长;SFU RPC 下降
12:43 重启 voice syncers 实例 2-13
12:47 重启 2-1 上 Holster.Pool DynamicSupervisor
13:05 全集群 voice syncers 重启(失败)
13:09 再次全员 mailbox 积压
13:43 上游 rate limit 生效后再次全集群重启
14:03 实例 2-3 重启成功(低负载 + 限流)
14:15 5 台中 4 台恢复;15 台新实例上线
14:26 最后一台恢复;集群双倍容量健康
15:30 用户侧影响基本结束(博文窗口)

7.7 与 2023 Authentication Outage 对照

维度 2023/11/06 Auth 2026/03/25 Voice
触发 Scylla 区域 OS 升级 Sessions K8s 缩容
瓶颈层 Scylla + Rust Auth 读路径 Elixir HTTP pool supervisor
可用性 HTTP ~25% → ~50%( shed load 后) 语音 Awaiting Endpoint
存储 RAID rebuild ~45 min Scylla「表现良好」(博文原话)
架构回应 2× 集群、磁盘检测、control plane PartitionSupervisor、drain webhook

两起事故共同主题:计划内维护窗口 + hidden degradation + retry/重连风暴 → 超过设计冗余。Discord 2026 博文称已建 API circuit breaking 减载能力;本次瓶颈在 voice 而非 DB——说明 multi-hop 架构 中瓶颈会迁移。


八、搜索索引:Elasticsearch Cell 架构

Vicki Niu (2025) 描述从 billions(2017 Elasticsearch 两篇集群)到 trillions 索引的演进。

8.1 旧系统痛点

8.2 新架构要点

8.3 公开性能数字(同文)

搜索路径与消息主库 解耦:Scylla 负责权威存储与频道内顺序读;ES 负责全文与跨 DM——符合 事件驱动 中「读模型异步构建」模式,但 Discord 强调 lazy indexing(非全员用搜索则不索引)。

8.4 旧 bulk 失败概率(博文推导)

Vicki Niu (2025) 给出估算:100 节点 ES 集群,bulk 50 条随机落盘,单节点失败时 至少一条命中该节点 的概率约 40%。这解释了 Redis 队列积压 + 节点故障 → 连锁 re-enqueue 的脆弱性。

8.5 Cell 拓扑与 zone 容错

每个小型 ES 集群(cell 内):

master_eligible: 3  # 每 zone 1
ingest_nodes: ">=3" # 每 zone 至少 1,可弹性伸缩
data_nodes:
  shard_allocation: zone-aware + forced awareness
  primary/replica: 跨 zone

ingestdata 分 pool 调度到不同 machine type——避免 indexing spike 与 query 争用 heap。与 messages Scylla 的 72 节点 规模对比,搜索选择 更多更小 集群,因 ES cluster state 不随节点数线性扩展(master OOM 是 2025 年前旧系统痛点)。

8.6 BFG reindex 状态机

BFG 流程(同文)可表化为:

stateDiagram-v2
    [*] --> IndexA: 正常服务 index-a
    IndexA --> DualWrite: 创建 new-bfg-index
    DualWrite --> DualWrite: 历史 job 回填
    DualWrite --> QuerySwitch: 回填完成
    QuerySwitch --> Cleanup: 验证 new-bfg-index
    Cleanup --> [*]: 停止写 index-a,删除旧数据

Dual-write 期间 查询仍走 index-a,避免用户感知 reindex;切换后 cleanup 删旧索引——与 messages Scylla 迁移的 双读校验 类似,都是 零停机换引擎 范式。

8.7 搜索查询路径(简图)

flowchart LR
    Client --> API
    API --> Router{索引路由}
    Router -->|guild 消息| GM[guild-messages cell]
    Router -->|跨 DM| UDM[user-dm-messages cell]
    Router -->|BFG| BFGcell[BFG dedicated cell]
    GM --> ES1[(ES 小集群)]
    UDM --> ES2[(ES 小集群)]
    BFGcell --> ES3[(多 shard 索引)]

九、Elixir / Go / Rust 决策矩阵

Discord 官方叙述可归纳为「按问题选语言,而非按 fashion」:

选型维度 Elixir Go Rust
典型场景 Gateway、Guilds、Sessions、Voice 信令 曾用于 Read States;一般 backend Read States、Auth、Data Services、迁移器、SFU(新)
并发模型 进程 + 邮箱 goroutine + GC async/Tokio + 所有权
尾延迟 依赖单进程邮箱深度 大堆 + 强制 GC 周期 无 GC;需防 async 饥饿(voice SFU 案例)
运维/debug Recon、热补丁、:sys pprof、成熟生态 编译期保证;nightly 历史包袱已缓解
风险 Supervisor 单点、selective receive 巨型 in-memory 结构 学习曲线、async 生态演进
Discord 态度 实时有状态核心 合适处仍用;Read States 不适用 新组件优先考虑;非全面重写

Go → Rust 触发条件(Read States)

  1. 服务在 product hot path
  2. 数百万级 in-memory 对象 + LRU 驱逐
  3. GC 扫描成本与 缓存大小 强绑定
  4. 团队已有 Rust 基础设施(SDK、NIF、其他服务)

Elixir 保留条件:百万级 有状态 连接/会话;需要 监督树 与进程隔离;可接受严格 mailbox 与 rate limit 纪律。

Rust 扩展条件:数据库前 合并/限流CPU/内存敏感且无 GC 容忍;C++ SFU 旁路新模块(边缘 Rust SFU)。

9.1 决策流程(工程判断)

Discord 未发布正式 RFC,但从多篇博客可反推 新组件 checklist

1. 是否在 user-visible hot path?(是 → 优先考虑 Rust)
2. 是否百万级有状态连接/进程?(是 → Elixir/OTP)
3. 是否巨型 in-memory working set + 严格 p99?(警惕 Go/JVM GC)
4. 是否可用 data service 层合并/限流 shield DB?(Rust + Tokio)
5. 是否计划内维护会去掉整 zone?(验证 quorum 余量 + cache 命中率)
6. 共享连接池/supervisor 是否服务多条关键路径?(禁止 — 2026 教训)

9.2 Rust 在 Discord 的其他落点(博客提及)

Jesse Howarth (2020) 与 Bo Ingram (2023) 还提到 Rust 用于:

这表明 Rust 角色是 「C++ 的安全替代 + Go 的热路径替代」,而非替换 Elixir 实时层。

9.3 与 Slack 技术栈对照

层面 Slack(见 case-slack) Discord
实时网关 Go WebSocket Elixir Gateway
主存储 Vitess/MySQL 分片 Scylla/Cassandra 宽表
边缘缓存 Flannel 无直接等价;data services 合并
搜索 自研/Solr→ES ES cell + Rust router
语言迁移 PHP→Hack Go→Rust(点状)

两家都证明:IM 核心矛盾是 fan-out + 历史消息随机读 + 搜索,但 Discord 更激进地采用 Scylla + Elixir + Rust 三角,Slack 则深耕 Vitess + Go 网关


十、开放问题与未决张力

10.1 Call placement vs 边缘覆盖

Cloudflare 300+ PoP 与 Discord 单 SFU 宿主整通话 模型冲突。冰岛实验表明:地理覆盖最优宿主需联合优化。开放问题:如何在 低信令开销 下实现 region-aware SFU 选择(含混合国籍通话)?工业界仍在 SFU 拓扑(单宿主 vs 级联 vs 混合 mesh)之间权衡,无 peer-reviewed「最优解」。

10.2 Hot partition 与超级 guild

Scylla + data services 后 World Cup 流量通过,但 partition key = channel 的根本 skew 仍在。开放问题:是否在 API 层引入 按 guild 等级的分层存储或缓存(类似 Slack Flannel 但针对消息历史)?Cassandra 社区长期争论 materialized view / 二级索引应用层反规范化 的边界。

10.3 Actor 监督树 vs 连接池共享

2026 事故证明:跨-cutting 基础设施(HTTP 池、etcd) 不应与 高 churn spawn 路径 共用单 GenServer。开放问题:OTP 生态是否需 PartitionSupervisor 类模式 成为标准库一等公民,以避免每个团队重复踩 selective receive 坑?

10.4 小集群 critical path

Auth Scylla 集群(远小于 messages)在 zone 维护窗口 下仍可能全局不可用。开放问题:更高 replication factor vs 跨 region hot-standby 对 session 一致性的影响——Discord 2023 博文称尚未定论。

10.5 搜索与存储双写规模

BFG dual-index 与 DM 双 recipient 索引使 索引字节量 > 消息字节量。开放问题:在 数万亿 规模下,是否 eventual 搜索索引仍够用,还是需 增量 log + 独立 query engine(类似 OLAP 分离)——Discord 已用 cell 架构缓解,但 Lucene MAX_DOC 仍会逼出 online reindex 运维。

10.6 E2E 加密与 SFU 可见性

Discord 2026 产品博文宣布语音/视频 E2E 加密(DAVE 等) 全平台推进。SFU 传统上需解密或参与密钥协商才能做 moderation/server mute(2018 博文:mute 时 drop packets)。开放问题:E2E + SFU 路由 + 合规 moderation 三目标能否同时满足?工业界在 MLS、Insertable Streams 等标准上仍在演进;Discord 工程博客对 SFU 在加密模式下 mute 语义着墨较少,属 产品-架构边界 未完全公开领域。

10.7 研究入口


十一、小结

Discord 架构不是单一技术栈的胜利,而是 分层语言分工 的累积:

  1. Elixir 承载 WebSocket 与有状态实时逻辑,直到进程邮箱 discipline 被违反。
  2. Go 在 Read States 上输给 强制 GC + 巨型 LRU 的组合;Rust 用所有权换可预测延迟,并把 LRU 提到 800 万
  3. Mongo → Cassandra → Scylla 追踪消息量从 数十亿到数万亿Rust data services 用合并与路由 shield 数据库。
  4. SFU + WebRTC 支撑语音;Cloudflare 边缘 改善 RTT,但引入 recv starvation、NIC 队列、call placement 新问题。
  5. 2026 voice outage 揭示 Holster supervisor + etcd 耦合;PartitionSupervisor 与 K8s drain webhook 是架构级回应。
  6. 搜索 迁到 ECK + cell + PubSub + Rust MessageRouter,索引 数万亿 消息。

读到这里再打开 Cloudflare 边缘案例,可以把 Discord 语音迁移当作 「边缘同构栈」哲学的压力测试——同一套 Cloudflare PoP 对静态 CDN 成立,对有状态 UDP SFU 则必须重写发现、密度与 event loop 纪律。


参考资料

Discord Engineering Blog(A 级)

  1. Jesse Howarth, “Why Discord is Switching from Go to Rust,” Discord Blog, 2020-02-04. https://discord.com/blog/why-discord-is-switching-from-go-to-rust (Go 1.9.2 图表;2019-05 初版移植;800 万 Read States)
  2. Stanislav Vishnevskiy, “How Discord Stores Billions of Messages,” Discord Blog, 2017-01-13. https://discord.com/blog/how-discord-stores-billions-of-messages (MongoDB→Cassandra;12 节点)
  3. Bo Ingram, “How Discord Stores Trillions of Messages,” Discord Blog, 2023-03-06. https://discord.com/blog/how-discord-stores-trillions-of-messages (177 节点;gossip dance;Rust data services;72 Scylla 节点;p99 数字)
  4. Jozsef Vass, “How Discord Handles Two and Half Million Concurrent Voice Users using WebRTC,” Discord Blog, 2018-09-10. https://discord.com/blog/how-discord-handles-two-and-half-million-concurrent-voice-users-using-webrtc
  5. David Chen, “How We Moved Discord Voice to the Edge,” Discord Blog, 2026-06-09. https://discord.com/blog/how-we-moved-discord-voice-to-the-edge
  6. Discord Engineering, “You’ve Got (Too Much) Mail: Behind the Scenes of the 3/25/26 Voice Outage,” Discord Blog, 2026-04-29. https://discord.com/blog/behind-the-scenes-of-the-3-25-26-voice-outage
  7. Mark Smith, “25% or 6 to 4: The 11/6/23 Authentication Outage,” Discord Blog, 2023-11-15. https://discord.com/blog/authentication-outage
  8. Vicki Niu, “How Discord Indexes Trillions of Messages,” Discord Blog, 2025-04-24. https://discord.com/blog/how-discord-indexes-trillions-of-messages

规范与经典文献

  1. Hewitt, Bishop & Steiger, “A Universal Modular ACTOR Formalism for Artificial Intelligence,” IJCAI, 1973. (Actor 模型起源)
  2. O’Neil, Cheng, Gawlick & O’Neil, “The Log-Structured Merge-Tree (LSM-Tree),” Acta Informatica, 1996. (LSM / Cassandra 存储谱系)
  3. W3C WebRTC 1.0 Specification; IETF RFC 8825 (WebRTC Overview). (客户端媒体栈标准语境)

工具与运维

  1. Fred Hebert, Erlang in Anger(Recon 工具链;Discord 2026 postmortem 引用)
  2. Discord 相关:Tokio、ScyllaDB shard-per-core 架构文档(辅助理解 data services 选型)

上一篇:Shopify 架构
下一篇:Cloudflare 架构

相关阅读:Slack 架构 · 事件驱动架构 · 线程模型

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计】Slack 架构:实时协作的工程挑战

Slack 每天为超过一千万活跃用户提供实时消息服务,峰值时段同时维持数百万条 WebSocket(全双工通信协议)长连接。一条消息从发送到被同一频道所有成员看到,端到端延迟通常控制在 200 毫秒以内。这套系统并非一蹴而就:它从一个 PHP 单体应用起步,历经数次关键重构,逐步演变为以 Hack、Go、Java 为核…

2026-04-13 · architecture

【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT

推送系统的核心难度不在协议选型,而在连接管理、心跳检测、断线重连、消息可靠投递这些工程细节。本文从 WebSocket 帧格式、SSE 重连机制、MQTT QoS 三级语义讲起,拆解百万长连接的 epoll 单机架构,深入分析心跳探活、指数退避重连、离线消息队列的设计取舍,结合即时通讯和物联网两个工程案例,讨论推送系统从单机到集群的水平扩展路径。

2026-04-22 · architecture / fintech

【金融科技工程】行情分发:MBP/MBO、快照+增量、组播/TCP、FIX/ITCH

从 L1/L2/L3 分级、FIX FAST/ITCH/SBE 协议到 UDP 组播+重传通道、内核旁路与微波传输,系统梳理交易所行情系统的生产、分发与消费;给出 snapshot+incremental 恢复机制、Conflation 与 Full Tick 权衡、kdb+/ClickHouse/QuestDB tick 存储、事件时间回放与 K 线合成实现,并附 Binance WebSocket Diff Depth 维护本地订单簿的 Go/Python 示例。


By .