PROD欧洲主权BaaS平台打开仪表板 →

性能 · 10 最小读取值

为什么垃圾收集器没有改变 p99 延迟

Affane Daylami · Fondateur · 2026年7月2日

返回博客

p99 测量一百个请求中最慢的一个——它打破了你的 SLA,而平均水平却保持完美。在高流量后端,这少数缓慢的请求通常只有一个原因:垃圾收集器,它会中断整个程序以释放内存。 Rust 没有垃圾收集器。这是机制,其中包含过时的第三方来源,而不是营销数据。

该英文文本是根据法文原文自动生成的,尚未经过审查。
该页面已自动翻译。英文版具有权威性。

本文仅依赖于第三方、过时的来源——从未依赖于发明的 Aurabase 密码。不包括特定于我们后端的 p99 延迟。我们用 Rust 编写应用程序核心,没有垃圾收集器:这一事实可以直接在代码、工作区 Cargo 和 axum 服务中验证。然而,我们尚未发布可重复的 p99 基准方法来以数字方式证明这一点。本文解释的是一种机制,而不是测量结果。

要点

  • p99 衡量的是一百个查询中最慢的查询——垃圾收集器 (GC) 暂停造成的伤害最大的地方,而不是平均值(Dean & Barroso,“The Tail at Scale”,Google,2013)。
  • GC 会中断整个程序(“停止世界”)以释放未使用的内存。 Rust 没有 GC:当值超出范围时,内存会被释放,并由编译器验证。
  • Discord 在 2020 年记录了一个缓存服务,其中 Go 至少每两分钟触发一次垃圾收集周期,每个周期都会导致延迟峰值(Discord 工程博客)。
  • 减少 GC 暂停需要多年的工程设计,即使在 Google 也是如此:2015 年至 2018 年间,GB 收集器从 300-400 毫秒缩短到 500 微秒,但从未达到零 (go.dev)。
  • Aurabase 的后端核心是用 Rust 编写的,没有垃圾收集器——已在代码中验证。迄今为止,尚未发布 p99 Aurabase 延迟数据:这仍然是一种机制,而不是测量结果。
#
尾部延迟

为什么p99不平均

平均值掩盖了本质。如果 100 个请求中有 99 个请求在 5 毫秒内响应,而只有一个请求需要 500 毫秒,则平均值仍然较低。但百分之一的用户经历的等待时间是原来的一百倍。 p99 准确测量了这一请求:最慢的百分之一,即违反 SLA 而平均延迟仪表板保持绿色的请求。

在 Google,Jeffrey Dean 和 Luiz André Barroso 在 “The Tail at Scale”(Communications of the ACM,第 56 卷,2013 年)中正式阐述了这个问题。他们的观察结果经常被引用:“在中等规模的系统中并不重要的临时高延迟事件可能会在大规模情况下主导整体服务性能”。简而言之:偶尔出现的延迟(在小范围内可以忽略不计)最终会主导分布式系统的感知性能。

每秒处理数千个请求的后端必然会在某个时刻发送一个在 GC 暂停期间失败的请求。从大范围来看,这种情况并不少见。这是统计上的确定性。

#
气相色谱力学

垃圾收集器的作用以及为什么它会暂停一切

垃圾收集器 (GC) 持续跟踪程序的活动对象(那些仍在某处引用的对象),并释放已变得无法访问的对象的内存。这种跟踪称为 tracing:GC 遍历参考图,标记仍在使用的内容,然后清除其余内容。

问题是:在程序继续创建新引用的同时遍历该图会产生不一致的结果。历史上的答案仍然被许多现代 GC 作为最后的手段,是 stop-the-world — 整个程序在标记和扫描时暂停。堆越大,暂停的时间往往越长:其持续时间取决于实时数据的大小,而不是当前的工作负载。

大多数现代 GC 使用分代策略:它们假设大多数对象在年轻时就消亡了。因此,最近的分配会在较小的内存区域中被频繁但快速地扫描。在几个周期中幸存下来的对象会迁移到更大的区域,不经常扫描 - 但当该区域需要清洁时,相关的暂停会随着其大小而增加。正是这种“主要”中断,而不是小的“次要”中断,主导了高流量、高分配服务的 p99。

信息

现代并发和分代 GC 通过与程序并行工作来减少这些中断的频率和持续时间。但几乎所有这些都针对边界情况保留了一种“停止世界”的后备机制,而减少这种机制需要多年的工程设计。第 04 节给出了一个量化的、有来源的例子。

#
真实案例

Discord,2020:GC 中断变成生产事件

2020 年 2 月,工程师 Jesse Howarth 发表了一篇文章,已成为业界参考:“Why Discord isswitching from Go to Rust”(Discord 工程博客)。相关服务“读取状态”管理数百万用户的消息读取状态(每个缓存有数千万个条目),每秒有数十万次更新。

诊断是直接的,正如文章中引用的那样:“Go 将强制至少每 2 分钟运行一次垃圾收集”。换句话说,Go 在此服务上至少每两分钟触发一次垃圾收集周期 - 每个周期都会产生团队图表中可见的延迟峰值。

该团队首先减小了缓存大小以消除峰值。妥协仍然是不利的:更少的 GC 暂停,但更多的缓存未命中请求落在数据库上 - 因此其他地方的整体 p99 降级。基本的修复是用 Rust 重写服务,没有垃圾收集器来监控。

该帖子引发了一场激烈的技术辩论:在其发布的同一天(2020 年 2 月 4 日),黑客新闻 上就有超过 1,580 个点和 642 条评论——这表明问题远远超出了 Discord 案件的范围。

#
围棋历史

Google 经过三年的工程设计,将暂停时间从 400 毫秒减少到 500 微秒

Go 垃圾收集器说明了控制 GC 暂停所需的工作量——即使使用 Google 专门团队的资源也是如此。 Go GC 技术负责人 Rick Hudson 在两篇 Go 官方博客文章中记录了这个故事。

2015年8月之前300-400毫秒重新设计之前的历史 Go 收集器
2015 年 8 月 · Go 1.530-40毫秒第一个竞争收集器,设置目标 < 10 ms
2016·围棋 1.6< 10 毫秒(SLO 保持)生产中达到的初步目标
2017 年 3 月 · Go 1.8毫秒以下删除停止世界堆栈扫描
2017 年 8 月 · Go 1.9100-200 µs(标记)团队提到的新的非正式基准
2018·SLO公布每个周期 500 µsRick Hudson 制定的服务目标

资料来源:“开始使用:Go 垃圾收集器之旅”,go.dev,2018 年 7 月 12 日;和 “Go GC:优先考虑低延迟和简单性”,go.dev,2015 年 8 月 31 日。

三年的专注工作使典型的休息时间减少了一千倍。但停顿从未消失:这是一个服务目标 (SLO),而不是零的绝对保证。通过构造,GC 跟踪必须不时地遍历活动对象图。唯一可调整的变量是这次旅程的频率和持续时间,而不是它的存在。

这种优先级的选择不是中立的。 Go 主要针对网络服务和 Web 后端,其中数百毫秒的暂停会直接破坏用户体验 - 因此在延迟而不是原始 GC 吞吐量上投入了大量精力。其他托管运行时继承了由其历史用例决定的不同权衡,然后再用自己的低暂停收集器弥补这一缺陷。共同点仍然是相同的:它们都是从 GC 跟踪开始,因此从暂停机制开始最小化——永远不会被构造消除。

#
锈蚀力学

为什么 Rust 的构造不会出现这个问题

Rust 不会减少 GC 暂停:它消除了导致它们的机制。编译器在编译时跟踪谁拥有每个内存值 - 这是ownership。当值的所有者超出范围时,Rust 会自动在二进制代码中的同一位置插入释放该内存的调用。这种机制称为 RAII(资源获取即初始化):释放是确定性的,不是由后台运行的垃圾收集器调度的。

Scala/Rust 生态系统中公认的技术博客作者 Alexandru Nedelcu 在最近的一篇文章中总结了这种权衡:“Rust 所做的权衡是易用性,优先考虑具有可预测延迟和安全性的性能”(alexn.org,2026 年 7 月 21 日)。 Rust 牺牲了一些编写的简单性来换取可预测的延迟。

同一篇文章总结了为什么现代 GC 并不总是足够的:“现代 GC 试图增量且并发地完成工作,而不影响程序。但它们的能力是有限的,会退回到停止世界的 GC 循环,从而冻结整个程序,从而影响延迟”。

以下是大约十行的机制——一个通用示例,而不是 Aurabase 代码的摘录:

example.rsrust
struct Connection {
    id: u32,
}

impl Drop for Connection {
    fn drop(&mut self) {
        println!("connexion {} fermée", self.id);
    }
}

fn handle_request() {
    let conn = Connection { id: 42 };
    // ...处理请求...
} // conn 超出范围: drop() 执行
   // 在准确的时刻,由编译器检查 -
   // 不通过垃圾收集周期。

重要的细微差别:并非一切都是免费的。引用计数类型(Rc、 Arc)为每个克隆和发布增加了少量成本。该成本仍然是局部的和确定性的。当整个程序通过内存堆时,绝不会出现冻结整个程序的暂停。

对异步后端的有用说明:Rust 异步运行时(tokio,由所有 Aurabase 服务使用)与垃圾收集器无关。它在线程池上调度协作任务,但从不迭代活动对象图来释放内存。在异步运行时和 GC 由同一虚拟机管理的生态系统中,混乱很常见。

#
建筑寓意

对于高流量后端来说这会发生什么变化

在服务数千个并发请求的后端上,GC 的缺失会从方程 p99 中删除一个变量。不再需要调整内存堆的大小、调整收集器的代数或监视可能在最差时间下降的周期。单个请求的延迟取决于其自身的工作,而不取决于程序中其他地方的某些不可预测的全局事件。

Aurabase 的后端核心应用了这一原则:所有服务(aura-gateway、 aura-auth、 aura-db、 aura-realtime、 aura-storage、 aura-functions、 aura-ai…)均用 Rust 编写,组织在单个 Cargo 工作区中。这可以直接在存储库中验证:

Cargo.tomltoml
[workspace]
resolver = "2"

members = [
    "services/aura-gateway",
    "services/aura-auth",
    "services/aura-db",
    # ...9 个其他服务 + 共享库
]

[workspace.dependencies]
axum = { version = "0.8", features = ["ws", "multipart", "macros"] }

来自 Cargo.toml的实际摘录,工作区版本 2021,解析器 v2 — 在 Aurabase 存储库中进行了验证。

在现阶段,这一事实并不能证明什么:为 Aurabase 测量的 p99 延迟数据。我们还没有为我们自己的后端发布可重复的基准方法——这是一项正在进行的工作,目前还没有结果。没有垃圾收集器是代码中验证的机制。仅此一点并不能证明测量到的 p99 潜伏期。面对有关该主题的任何营销争论(包括我们的争论)时,请记住这一区别 - 请参阅我们的 技术比较 Aurabase 与 Supabase 了解架构的详细信息。

正确测量 p99 需要它自己的规则:代表性负载条件、在足够宽的滑动窗口上计算的百分位数以及接近生产的测试环境。如果没有这种方法,发布数据就像发布营销数据一样。这正是我们在本文中拒绝做的事情。

#
细微差别

没有 GC 并不能解决什么问题

No-GC 不是一根魔杖

删除垃圾收集器只能消除尾部延迟的一个来源,而不是全部。由于网络等待、饱和的 Postgres 连接池、有争议的数据库锁、索引不良的 SQL 查询或缓慢的第三方 API 调用,Rust 后端仍然可能显示降级的 p99。本文描述的机制消除了结构性原因。它不提供针对他人的免疫力。

例如,在 Aurabase 中,每个服务通过连接池 (sqlx) 与 Postgres 通信,并通过 NATS JetStream与其他服务通信。池容量过小、消耗缓慢的 NATS 订阅或没有合适索引的 SQL 查询都会产生自己的延迟峰值 — 无论是否存在垃圾收集器。

实际结论:没有 GC 是为 p99 感知系统选择 Rust 后端的一个很好的架构理由。这本身并不能保证延迟——无论是在 Aurabase 还是其他地方。重要的方法保持不变:测量、发布方法,然后纠正测量结果。如果您要从带有 GC 的后端迁移,我们的 Supabase 到 Aurabase 迁移指南 详细介绍了哪些内容发生了变化以及哪些内容保持不变。

#
常见问题

常见问题解答:垃圾收集器和 p99 延迟

没有垃圾收集器就保证p99低吗?+
不会。它消除了不可预测的暂停的结构性来源,但其他因素(网络、连接池、数据库锁)也会影响尾部延迟。请参阅上面的“没有 GC 无法解决的问题”部分。
为什么 Discord 不对其 GC Go 进行不同的调整?+
该团队尝试了多项调整,包括减少缓存大小,这些调整已记录在 2020 年的帖子中。权衡仍然不利:更少的 GC 暂停与更多的缓存未命中。用 Rust 重写是消除了妥协,而不是移动它。
像 Go 这样的现代 GC 解决了这个问题吗?+
他们大幅减少了它——2015 年至 2018 年间,Go 的 GC 从 300-400 毫秒减少到了 500 微秒 (go.dev)——但并没有消除。 GC 跟踪通过构建为边缘情况保留了暂停机制。
Aurabase 是否发布了 p99 延迟基准?+
还没有。代码中验证的事实是缺少垃圾收集器(Rust、workspace Cargo、axum 服务)。测量的 p99 延迟数据及其可重复的方法仍有待发布——我们更喜欢没有数据,而不是无来源的数据。

准备好部署了吗?

五分钟内完成您的后端。

无需信用卡 · 500 MB 免费 · 50,000 MAU