为Bluesky DataPlane选择Elixir:我们未曾预料的抉择
摘要
这篇博客文章详细说明了为什么团队选择Elixir而不是Go、Rust或Node来构建高性能的Bluesky DataPlane,利用BEAM的并发性处理热路径,并将计算密集型操作卸载到Rust NIF上。
<p><a href="https://lobste.rs/s/2ljllm/elixir_for_bluesky_dataplane_choice_we">评论</a></p>
查看缓存全文
缓存时间: 2026/06/10 13:47
# Elixir 用于 Bluesky DataPlane:出乎意料的选择 | bitcrowd 博客
来源:https://bitcrowd.dev/why-elixir-bluesky-dataplane/
Bluesky 的源码广泛开源,因此你可以运行自己的社交网络。但缺少什么呢?一个高性能的 DataPlane 实现。填补这个缺口将是迈向数字独立的重要一步。我们希望贡献自己的一份力量,并决定为 Bluesky 开发一个高性能的 DataPlane。项目开始时,我们预计它会是一个 Go、Rust 甚至是 Node 项目。结果,我们选择了 Elixir。本文将阐述我们做出这一决策的原因和过程。
**四种语言,一个 DataPlane:我们如何选择**
*我们着手评估 Go、Rust、Node 和 Elixir,用来从头实现 Bluesky AppView DataPlane,并完全预期其中一种常规候选语言会胜出。结果却让我们意外。以下是我们如何从工作负载出发进行推理的——以及我们如何最终落脚于一个开始时未曾预料到的地方。*
**TL;DR**
- DataPlane 的工作负载清晰地分为两部分:一个*热路径*(时间线读取,从内存中服务,受并发限制)和一个*冷路径*(记录、线程和个人资料,受 I/O 限制)。我们预期 Go、Rust 或 Node 会胜出;但 Elixir 最合适。它唯一真正的弱点——原始的单核计算能力——集中在关注者图集合运算上,我们将其卸载到一个小的 Rust NIF(本地实现函数)中。剩下的就是高并发服务和突发吸收的扇出队列,BEAM 让我们可以在*进程内*构建这些,而无需额外引入 Redis 或 Kafka。本文的其余部分将介绍我们的推理过程。
## 没人谈论的组件 (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#the-component-nobody-talks-about)
Bluesky 的基础设施大部分是开源的,这令人耳目一新。但有一个明显的例外:*DataPlane*,它是 AppView 的一部分。它只作为一个基于 Node 和 Postgres 的参考实现公开存在,而生产环境中的 Bluesky 网络运行在一个专用的、基于[ScyllaDB](https://www.scylladb.com/)的闭源 DataPlane 上(如 Jaz 的博客和[Pragmatic Engineer 对 Bluesky 架构的深度分析](https://newsletter.pragmaticengineer.com/i/114113498/5-scaling-the-database-layer)中所述)。
这个差距正是事情变得有趣的地方。参考实现告诉了你 DataPlane 是*做什么*的;但没有告诉你如何让它经受住真实流量的考验。如果你想运行自己的 DataPlane,你必须自己回答扩展问题——而第一步就是充分理解工作负载,不再将其视为单一整体。
Bluesky 架构概览,DataPlane 是 Bluesky 架构中 AppView 的核心组件。请阅读[上一篇文章](https://bitcrowd.dev/2026/03/30/building-a-performance-evaluation-toolkit-and-a-dataplane-poc-for-atproto)了解更多信息。ScyllaDB 是 Bluesky 运营上的选择,并非接口的一部分。DataPlane 的契约是一个 gRPC 服务,用于回答高容量、低复杂度的查询,并返回*骨架*——ID 列表、计数、布尔值——上层随后将其充实为完整视图。该契约背后的实现完全由你决定:语言和数据存储。因此,在选择之前,我们把时间花在了唯一真正限制选择的因素上:负载的特征。
## 两种工作负载的故事 (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#a-tale-of-two-workloads)
这是核心观察。网络的历史数据是巨大的——达到 TB 级别。但当用户打开应用查看时间线时,他们几乎从不会回溯到时间的起点。他们阅读几十条帖子,然后分心去参与一条帖子、查看个人资料或浏览一个线程。
> 关于具体数值的说明:据[观察](https://jazco.dev/2025/02/19/imperfection/),Bluesky 的时间线通常只提供最近一两天的内容,较深的光标位置往往填充的是非常新的帖子而非真实历史。除非你在自己的部署环境中实际测量过,否则请将具体窗口视为示例——无论具体数值如何,架构上的观点都成立。
这就造成了一种矛盾。大多数“历史”数据——即超过几天的数据——在*时间线*中永远不会再被查看。当旧数据变得重要时,几乎总是出现在不同的上下文中:比如有人查看个人资料,或跟进过去的某个线程。然而,为了编译时间线,参考实现必须对可能达到 TB 级别的表进行连接操作。这既没有性能也不具备可扩展性——而时间线请求正是 DataPlane 需要处理的大部分工作。
数据量和访问频率的反比关系:海量数据是旧的且几乎从不读取,而少量的最新帖子驱动了几乎所有时间线流量。结论不言而喻:**时间线生成应该与检索单个记录或线程的使用场景从根本上得到不同的对待。** 将它们混为一谈正是导致朴素实现效率低下的原因。一旦分开处理,你会发现它们不仅在程度上不同——它们拥有相反的资源需求,并且对底层运行时也有不同的要求。
**热路径**
- 时间线
**冷路径**
- 记录、线程、个人资料
**数据时效**
最近(一两天内)
历史(更早的数据)
**数据量**
很小一部分
TB 级别
**请求占比**
主要工作负载
相对较少
**资源瓶颈**
内存 + 计算
I/O
**存放位置**
内存
数据库,按需获取
**策略**
扇出 + 有限时间线长度
接口后可替换的数据存储
### 热路径:从内存服务的时间线 (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#the-hot-path-timelines-served-from-memory)
大多数社交平台使用混合策略解决时间线扇出问题,这是有充分理由的。来自拥有几千粉丝的账号的帖子会立即分发——*写入时扇出*,在发布时推送到粉丝的时间线中。而来自拥有数百万粉丝的账号的帖子则*不会*被扇出;只有当该粉丝实际请求时才会被添加到其时间线中——*读取时扇入*。前者保证了普通账号的写入放大可控;后者避免了名人帖子可能导致的惊群写入风暴。
在时间线长度限制中隐藏着另一个简化。由于时间线是有界的,关注了大量账号的用户无论如何也*无法*在其时间线中看到所有这些账号的帖子。因此,限制向任何单个时间线的分发是完全合理的。你没有义务将每条帖子都推送到每个粉丝;你的义务是提供一条好的、近期的、有界的时间线。
将这些结合起来,热路径就不再像一个数据库问题了:
- 最近发布的帖子——构成时间线的绝大部分——可以**存放在内存中并提供服务**。
- 扇出可以**延迟**:只要帖子在几分钟内落地到粉丝的时间线中,并且扇出作业的积压没有超出可用资源,没有人会注意到延迟。
- 当真正需要较旧的内容时,可以从数据库按需获取,并且是安全的。
这正是改变下游所有事情的关键一步。主要的请求——时间线读取——从*I/O 密集型*(等待巨大的连接操作)转变为*内存和计算密集型*(快速操作内存中的数据结构)。这一单一的转变重新开启了语言选择的问题,因为它改变了这里“快”的真正含义。
这也建议将具体数据库抽象在一个接口之后,这样冷路径的后端存储可以在不影响系统其他部分的情况下进行替换。热路径几乎不接触数据库;冷路径才是真正依赖它的地方。保持这个边界清晰可以让你保持选项的灵活性。
### 关注者图:在内存中,但不能太天真 (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#the-follower-graph-in-memory-but-not-naively)
有一个问题。扇出和时间线组装都需要回答关于关注者图的问题——谁关注了谁,以及这些集合的交集和并集。以天真的方式在内存中保存数亿条关注关系将是极其浪费的。
这是一个前人已经探索过的领域。Jaz 的 [“GraphD”系列文章](https://jazco.dev/2024/04/15/in-memory-graphs/)直接记录了这一过程:一个内存图存储,最初使用哈希映射和哈希集合来追踪每个用户的关注者和关注对象,支持工作负载所需的双向查找、交集和并集。突破性的进展是切换到 **Roaring Bitmaps**(一种[压缩位图结构](https://roaringbitmap.org/)),这正是为此类工作负载而构建的。结果令人瞩目:整个 Bluesky 关注图仅占用约 6.5 GB 内存,磁盘上约 1.6 GB,加载时间约 20 秒。
关键的是,Jaz 还[指出了两种成本模式](https://jazco.dev/2024/04/20/roaring-bitmaps/),它们精确地映射到我们的热/冷分离:*分页遍历*用户的所有关注者是昂贵的操作,以分页方式或作为扇出期间的异步作业执行;而按需的集合交集——“我关注的人中有哪些也关注了这个用户”——必须以交互速度运行。这种架构与其说是发明,不如说是对数据本身已经做出的区分的一种确认。
## 那么我们实际上需要从语言中得到什么? (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#so-what-do-we-actually-need-from-a-language)
在确定了工作负载之后,我们终于可以诚实地陈述需求了——它们朝着两个方向拉拽:
1. **快速的、计算密集的集合运算**,针对一个大型、长生命周期、内存中的图(Roaring Bitmap 的交集和并集)。这涉及到单核计算、字节级工作、缓存敏感性。
2. **高并发、内存驻留的请求服务**——时间线读取——具有有界且可预测的响应大小和严格的尾部延迟要求。
3. **一个可延迟、能吸收突发流量的扇出队列**,能在几分钟内投递,应用背压,并在流量高峰时优雅降级而非崩溃。
4. **一个清晰的数据存储边界**用于冷路径:单个记录、线程、个人资料——一个相对普通的 I/O 密集型工作负载。
前三个是热路径;第四个是冷路径。没有一种语言在所有四个方面都是明显的赢家——所以这是我们对每个候选语言的评分卡。
## 四个候选语言 (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#the-four-candidates)
在 [bitcrowd](https://bitcrowd.net/en),我们日常使用 Elixir、Go 和 Rust,偶尔也做一些 Node 项目。所以这并非在一种偏爱语言和一群陌生语言之间的竞争——我们对于每一方的比较都有实际经验可以权衡。
### Go (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#go)
Go 是自然的既定选择——Bluesky 自己的生产 DataPlane 就是用 Go 编写的,而且契合度确实很高。Goroutines 和通道可以清晰地映射到“并行执行工作、收集结果、响应”的模式。内存和位图操作在 Go 中非常自然(GraphD 本身就是用 Go 写的)。gRPC 支持也是一流的,部署是一个单一的静态二进制文件,运维工具成熟。在进程内实现扇出非常可行:使用工作池从缓冲通道中取数据。
成本体现在边缘。在极高的请求速率下,Go 的垃圾收集器在高分配负载下可能开始窃取 CPU,其网络后端在处理大量套接字时可能会因系统调用而成为瓶颈——两者都可以通过运行时级别的调优来解决,但在极限情况下需要实质性的工作。而且你构建的进程内扇出缺乏开箱即用的监督和隔离层:一个崩溃的工作器可能导致整个进程挂掉,并且需要你自己处理背压和生命周期管理。Go 的进程内能力比人们通常认为的更接近 BEAM;差距在于框架提供的安全性,而非原始能力。
### Rust (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#rust)
如果你想要最高的性能和最严格的控制,Rust 就是答案。没有垃圾收集器意味着没有 GC 窃取 CPU 的行为,也没有尾部延迟暂停。Tokio 处理大量并发,每个任务开销低;tonic 提供了可靠的 gRPC 支持;Roaring Bitmap 和数据存储库的质量都很高。对于工作负载中计算密集型的那一部分,没有语言能比得上它。
代价是开发速度和给自己设限的可能性。对于这样一个*薄*业务逻辑的服务,你会在每一行代码上都付出 Rust 的代价——借用检查器、异步 Rust 的尖锐边缘、更长的迭代周期——同时只捕获了相对较小的安全性优势,因为需要保护的逻辑很少。进程内扇出可以通过 Tokio 任务和通道实现,并且性能出色,但你需要完全自己构建生命周期、背压和监督。它是最强大同时也是最“你得靠自己”的选项。
### Node / TypeScript (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#node--typescript)
Node 值得认真考虑,因为它是参考 DataPlane *以及* atproto 栈其余部分(PDS、AppView 前端、词表)的语言。代码库统一使用一种语言,从词表定义共享类型,可招聘的人才库最大,迭代速度最快。对于参考实现和中等规模的自托管来说,它是一个合理的默认选择,对于冷路径中普通的 I/O 密集型记录获取来说也完全够用。
但是,一旦主要工作负载变成内存和并发密集型,Node 就成了异类。每个进程只有一个事件循环,意味着内存中的队列和请求服务争夺同一个循环,并且任何 CPU 密集型工作都会阻塞它。使用多核心意味着运行多个进程,没有共享内存——这正好重新引入了进程内设计原本要消除的跨进程协调问题,并且使得大型共享内存图变得笨拙。对于参考实现来说它是合适的工具,但对于面向吞吐量的生产服务器来说则力不从心。
### Elixir (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#elixir)
Elixir 运行在 BEAM 之上,这是一个专门为处理大量廉价、隔离、抢占式调度的进程以处理并发工作而构建的运行时。每个进程的垃圾收集意味着没有全局的 stop-the-world 暂停,因此尾部延迟在负载下保持一致。监督树提供了近乎免费的故障隔离和自我修复。对于高并发的请求服务以及可延迟、有背压、能吸收突发流量的扇出队列来说,它可以说是四种运行时中最自然匹配的。
它有一个众所周知的弱点,我们必须坦诚:**原始的单核计算能力**。BEAM 针对并发性和一致性进行了优化,而不是单线程的数字运算,字节级工作——比如大规模关注者图的集合运算——恰好是它最慢的地方。从表面上看,对于包含繁重位图操作的工作负载而言,这是对 Elixir 的严重不利因素。
## 每个候选都有缺陷——哪些可以修复? (https://bitcrowd.dev/why-elixir-bluesky-dataplane/#every-candidate-has-a-flaw---which-ones-can-you-fix)
此时我们有四个合理的选项,每个选项都有一件事情阻碍它成为完美匹配。因此,我们没有在纸上争论,而是针对每个候选语言的缺陷开始着手构建补救方案——深入到足以看出它能否被*工程解决*或者
相似文章
Elixir 应用优化之旅
一位开发者分享了优化 Elixir 应用的经验与教训,重点介绍了针对 Postgres 连接池工具 Ultravisor 的性能改进。文章涵盖了使用火焰图、调用追踪等性能分析技术,以及 eFlambè 和 tprof 等工具。
遥测驱动开发
Smart Rent 的 Noah 为 Elixir 提出「遥测驱动开发」:先用 OpenTelemetry 埋点,再上线,用 84.8 万台 Nerves 网关的真实数据取代拍脑袋。
我们如何(及为何)将生产环境的C++前端基础设施重写为Rust
NearlyFreeSpeech.NET 将其生产环境的C++前端基础设施(nfsncore)重写为Rust,该系统负责所有传入请求的路由、缓存和访问控制。迁移的动机是Rust的安全性保证、性能、生态系统优势以及老化的C++代码库的局限性。
12万行Rust代码:深入Nosdesk后端
深入技术解析Nosdesk的Rust后端,涵盖架构决策如流式管道、Postgres同步以及贯穿12万行代码的类型安全设计模式。
我(至今)在使用 Elixir 和 Swift 构建在线小游戏中学到的东西
一位开发者反思了使用 Elixir 与 Phoenix 以及 Swift 与 SpriteKit 构建在线小游戏应用 Migo Games 的经历,强调了 AI 编码辅助的作用以及 Elixir 进程模型的可扩展性优势。