使用新型索引策略对关键消息总线进行扩展与基准测试

Hacker News Top 新闻

摘要

Jane Street 分享了其内部消息框架 Aria 的扩展实践:通过为 topic partition 引入索引与树拆分策略优化 tip recovery 过滤流程,在生产负载上实现 30% 的 CPU 使用率下降。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/10/09 06:40

# 使用新的索引策略扩展和基准测试关键消息总线 原文:https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/ *以下内容是关于2026年夏季实习项目的系列文章之一——更多内容请参阅"What the interns have wrought, special jumbo 2026 edition"(https://blog.janestreet.com/wrought-2026/)* Aria(https://signalsandthreads.com/state-machine-replication-and-why-you-should-care/)是我们内部的消息传输框架和托管系统,每天处理数TB的数据。客户端可以订阅Aria以获取实时消息流。随着Aria在公司内的使用量快速增长,我们不得不寻找更多优化和重新架构系统的机会,以应对数据量和吞吐量的增长。与Aria团队合作的实习生Theodor Totev今年夏天专注于使用索引和树分裂来改进一个特定的用例:如何让客户端以更低的成本只读取消息的一个子集?他的优化在生产工作负载上实现了**CPU使用率降低30%**,同时保持了这样一个关键系统所需的极高正确性标准。 ## 消息过滤正在压垮我们的服务器 当Aria通过TCP向客户端传递消息时,它会将最近的消息流——我们称之为**流末端(stream tip)**——保存在内存中的环形缓冲区里。如果客户端落后了,它可以请求这个环形缓冲区中的近期消息来追上进度。我们把这个过程称为**末端恢复(tip recovery)**。 末端恢复面临的一个挑战是,Aria存储了整个消息流,而客户端通常只关心其中很小的一个子集。Aria将消息流划分为**主题(topics)**,形成类似文件系统的层次命名空间。客户端可以订阅单个主题或一个主题子树——主题子树包含某个主题下的所有主题,类似于 [globstar](https://www.linuxjournal.com/content/globstar-new-bash-globbing-option)。Aria会将整个消息流过滤为仅包含已订阅主题的消息,然后将这些消息传递给客户端。 最初,我们用简单的线性遍历来完成这种过滤,虽然算法上效率不高,但遍历消息流对CPU缓存友好,因此速度还算不错。然而,随着进行末端恢复的客户端数量增加,我们发现服务器在负载增加下开始吃力。一些服务器达到了100%的CPU利用率,导致客户端"掉出流末端"而无法追上。我们暂时通过增加更多服务器来应对,但显然迫切需要重新思考末端恢复的代码。 [](https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/image2.png) Theodor决定通过添加索引数据结构来解决这个问题,让Aria能够高效地从消息流中过滤消息。但应该索引什么呢?一种朴素的做法是为每个主题建立索引。然而,Aria实例可能拥有近百万个主题,这种方法不可行。相反,Theodor为每个**主题分区(topic partition)**创建了索引。一个主题分区包含所有具有相同两段前缀的主题,其中段是指由斜杠分隔的主题名称的单个部分。例如,`app/codestore/commits`和`app/codestore/features`都属于`app/codestore`主题分区。 每个主题分区都有一个索引,记录其消息在Aria流中的位置: [](https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/image3.png) 当客户端请求消息时,Aria使用最小堆对各个索引进行n路归并。这使得Aria能够高效地按顺序重构出仅包含这些特定主题分区的消息流: [](https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/image4.png) ### 索引的原型设计和基准测试 与任何性能工作一样,我们需要进行基准测试来验证我们确实改进了系统。我们还希望进行大量测试,因为消息发布逻辑处于Aria的关键路径上。 Theodor首先编写了一个工具来对各种恢复场景进行性能分析。这样,我们可以测试不同的配置,例如主题分区的数量、交错程度、读取者的数量等,并观察这些变量的变化如何影响系统性能。 有了基准测试之后,他实现了主题分区索引的初始版本。当我们对这个实现进行性能分析时,发现最小堆是代码中最大的瓶颈。在AI出现之前,这大概就是我们选择一个看起来更好的实现的地方。但如今,做实验的成本很低:Theodor让一个AI代理在一夜之间搭建了五种不同的堆实现并进行性能分析。第二天,我们就有了答案:`fast_heap_unboxed`为我们的索引恢复代码带来了**2倍**的性能提升。 Theodor用大量的[expect tests](https://blog.janestreet.com/the-joy-of-expect-tests/)测试了新索引,在[Antithesis](https://blog.janestreet.com/getting-from-tested-to-battle-tested/)中对新逻辑进行了检验,最后让一群AI代理对代码进行了严格的审查。 ### 使用块池存储索引 设计好索引数据结构后,我们需要找到一种高效的表示方式。我们不想为每个索引都创建环形缓冲区,因为环形缓冲区实际上不支持动态调整大小。因此,它们必须按照最坏情况来设定大小。具体来说,索引大小与消息大小成反比(消息越小,能放入流末端存储的消息就越多,索引也就越大)。一条Aria消息可以小到32字节。如果整个流末端存储都是这么小的消息,对应的索引将达到2GB。为每个主题分区都保留一个2GB的索引会浪费太多内存。 相反,我们希望索引能够随其主题分区中消息数量的变化动态地收缩和增长。 最终我们决定使用一个共享的块池,每个块包含1024个条目。索引指向某个块并插入值。当一个块中的所有消息都已离开环形缓冲区后,这些块就可以从索引中弹出并被其他索引复用: [](https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/image5.png) ## 读取消息耗时太长 Theodor在索引方面的工作解决了我们末端恢复的困境,但我们还有另一个消息传递问题:那些需要读取流末端*之前*的旧消息的客户端等待时间过长。 当客户端重启时,它通常会请求其订阅主题自本周开始以来的所有消息。我们把这个过程称为**初始恢复(initial recovery)**。初始恢复可能涉及数百万甚至*数十亿*条消息。Aria必须尽快读取这些消息并传递给客户端,这一点至关重要。此外,客户端通常在同一时间集中重启,因此这个过程的良好扩展性尤其重要。 在一次特定的事故中,一个通常耗时不到2.5秒的初始恢复竟然花了超过**13分钟**。当我们深入排查原因时,发现了与流末端存储相同的问题:Aria实际读取和过滤的数据量是它真正需要发送的数据量的**10倍**。显然,我们需要重新思考消息在磁盘上的存储方式。 ### Aria通过子树存储持久化消息 Aria最初将消息按照时间顺序存储在每个主题分区的分段中。然后,一个独立的进程会取出这些分段,将它们拆分为多个**子树存储(subtree stores)**,每个子树存储包含一个主题子树的消息。这些子树存储旨在在两种方案之间取得平衡:写入更多文件可以实现高效的过滤,但重新组合为消息流需要更多工作;写入更少的文件更容易重新组合,但过滤效率较低。 Aria之前使用一个简单的启发式方法来进行这种拆分:通过读取主题名称的前三段来选择子树存储。因此,如果主题是`app/options/orders/created`和`app/options/orders/cancelled`,它们都会被放入`app/options/orders`的子树存储中。如果主题名称少于三段,它就会被放入自己的子树存储中;例如,`app/options`会拥有自己的存储。实际上,这种启发式方法为每个主题分区的每个直接子主题分配了一个独立的子树存储。 然而,当一个存储中某个主题的消息量远超其他主题时,这种启发式方法效果不佳。如果客户端只订阅了不太活跃的那个主题,Aria就必须过滤掉另一个主题的所有消息。与末端恢复一样,这种过滤产生了大量额外工作。 更广泛地说,我们不满意用户在设计主题结构时必须了解Aria内部设计的这些细节。我们希望用户按照他们自己的方式组织主题,并相信Aria会智能地进行拆分。 Theodor的任务是开发一种算法,通过根据每个主题的消息量智能地拆分主题树来避免这个问题。由于Aria在持久化消息流之后会进行分段处理,因此它能够计算每个主题的消息量。 ### 收集数据并测试不同的算法 与末端恢复的实现类似,Theodor在自适应拆分方面的工作也需要探索不同的方案。他首先从各个Aria会话中收集数据,用于测试不同的算法。然后他实现了几种不同的拆分算法,并在收集的数据上运行它们。Theodor从以下几个角度分析了这些结果: - 如果读取某个主题,我们需要从存储中忽略多少字节? - 所有主题的这种"浪费字节"的总和是多少? - 哪个主题如果被读取,会导致最多的浪费字节? - 哪个主题如果被读取,会导致总字节与有效字节的比率最差? 与之前一样,这个探索过程借助了大语言模型的辅助。Theodor迅速用vibe coding编写了一个Web UI,将不同的拆分算法在真实数据集上的效果进行了可视化展示。 ## 最终算法 最终,Theodor确定了一个将贪心聚类算法与二分搜索相结合的方案。这实现了我们所期望的特性:消息量大的主题拥有自己的子树存储,而较小的主题则合并到一个存储中。 以下是使用之前启发式方法的主题树示例: [](https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/image6.png) 注意那个2.72GB的主题与10MB和28.5MB的主题在同一个存储中。这意味着如果有人想读取10MB的主题,就必须过滤掉所有其他消息。与此同时,较小的主题被分散在三个存储中,尽管它们比大主题小了好几个数量级。 使用新的自适应拆分算法后,同一棵树看起来是这样的: [](https://blog.janestreet.com/scaling-and-benchmarking-a-critical-message-bus/image7.png) 现在2.72GB的主题拥有自己独立的存储,与28.5MB和10MB的主题分开,而较小的主题则全部合并到了一个共享存储中。 ### 对算法使用多种测试策略 由于自适应拆分直接影响Aria中消息的存储方式,我们希望以极高的确定性来确保没有bug。消息丢失或乱序将会造成严重后果。 我们首先使用expect tests来检查算法的各种属性,例如大主题获得自己的存储、较小的兄弟主题共享存储等等。 关于消息一致性,我们已经有一个基于属性的测试,它在随机选择的主题上插入一系列随机生成的消息,然后运行拆分算法。测试会读取主题树的随机片段,确认消息的顺序和内容正确无误。Theodor扩展了这个测试,使其随机化树的拆分方式,从而确认无论主题树如何拆分,消息内容都保持不变。 最后,我们在Antithesis中进行了多次运行,确认这些改动不会破坏Aria中的其他功能。 ## 优化效果如何? 流末端索引代码已经部署到生产环境。我们在预发布(staging)环境中观察到了初步结果:在一些真实场景中CPU利用率降低了30%。在这些时间段内,所有客户端的整体延迟显著下降。我们注意到,在未实现索引的服务器上出现了数秒级的尾部延迟,而运行新代码的服务器上没有出现这些问题。 自适应拆分已部署到预发布环境,即将投入生产运行。 Nicholas是Jane Street的软件工程师和技术撰稿人。他撰写过多篇关于辣油的博客文章。

相似文章

@jakevin7: https://x.com/jakevin7/status/2086031167040426488

X AI KOLs Timeline

Maka 是一个开源 Agent Harness,通过日志即运行时、上下文剪枝、thinking 回传等机制,将同一 DeepSeek 任务的成本降至 OpenCode 的 1/8,并在 Terminal-Bench 上以更低成本取得更高通过率。

同一任务,两大引擎:InnoDB 与 Iceberg 内部机制

Hacker News Top

作者对比了股票分析项目从基于 InnoDB 的 OLTP 架构迁移到基于 Iceberg 的 OLAP 湖仓栈的过程,深入讲解了 InnoDB 的行存储、索引 B-tree、undo log 与 MVCC 等内部机制,以及 Iceberg 如何帮助优化分析型负载。