基准测试第 2 轮:Rust、Go 与各 Rust 框架的性能剖析
Go 与七个 Rust 应用(axum、actix-web、Rocket、Loco、Cot,以及运行在 axum 和 actix 上的 Rustango)在相同规则下测试,每次只运行被测应用,并附上 CPU、系统调用和网络协议剖析,展示每个应用把时间花在哪里。
这是公平基准测试的第 2 轮。第 2 轮只涵盖编译型应用:Go 和七个 Rust 应用。它新增了三个 Rust 框架(Rocket、Loco、Cot),以及展示每个应用时间花在哪里的 CPU、系统调用和网络协议剖析。
测量于 2026 年 10 月 14 日,机器为 Apple M3 Pro(Docker 虚拟机,12 个 CPU、16 GB)。
- 441/s Go 在缓存首页上的垃圾回收次数
- 3 vs 9 每个详情页的 Postgres 往返次数:Go 对比带获取 ping 的 sqlx
- +45% Rustango 运行于 actix,首页,每个 worker 一个连接池分片
- 6.3–22.8 MiB 负载下内存,全部八个应用
- 0 144 次运行中的错误或非 200 响应
测试环境
与第 1 轮相同的博客:10 位作者、30 个标签、1 000 篇文章、2 600 条文章-标签关联和 10 000 条评论。六个路由:首页(最新 20 篇文章)、包含全部评论的文章详情页、标签页、从 Redis 页面缓存读取的首页和详情页,以及 /compute(计算小于 20 000 的素数之和,不访问数据库)。
八个应用:
- Go:标准库
net/http、pgx v5、go-redis - axum + sqlx 和 actix + sqlx:原生 sqlx,使用与 Go 相同的 SQL 文本,Tera 模板
- Rocket:
rocket_db_pools(sqlx)和rocket_dyn_templates(Tera) - Rustango,分别运行在 axum 和 actix-web 上:Rustango ORM 及其页面缓存
- Loco:SeaORM、Tera
- Cot:Cot 自带 ORM、askama 模板
规则:
- 应用获得 4 个 CPU(绑定到 0–3 号核心)和 2 GB 内存,使用 4 个 worker。PostgreSQL 16 和 Redis 7 运行在 4–7 号核心,负载生成器 oha 运行在 8–11 号核心。
- 只运行被测应用,其他应用均停止。切换应用之间,Postgres 连接数会回落到基线。
- 连接池:最多 16 个连接,最少 4 个。
- 施加负载之前,每个应用的每个页面都会与 Go 输出的 HTML 对比。只要不一致,测试即终止。
- 不启用压缩。先预热 5 s,然后每个端点在 50 个连接下运行三次、每次 20 s;取中位数。
测试装置
与第 1 轮相同的测试装置。第 2 轮只使用 PostgreSQL 和 Redis(不使用 MySQL)。
版本
- Go 1.25.14、pgx v5.7.2、go-redis v9.7.0
- rustc 1.99.0;axum 0.8.9、actix-web 4.16.0、tokio 1.53、sqlx 0.8.6、Tera 1.20.1
- Rocket 0.5.1(sqlx 0.7.4、hyper 0.14)
- Loco 1.2.0(SeaORM 2.0.4、sqlx 0.9.0)
- Cot 0.7.0(sqlx 0.9.0、askama 0.16.1)
- Rustango:无获取 ping,页面缓存存储原始字节,每个请求使用一个池连接(发现 5)
无法拉平的设置
| 设置 | 应用 | 原因 |
|---|---|---|
连接池每次借出连接都会 ping 一次 Postgres(sqlx test_before_acquire) | Rocket, Loco, Cot | 没有可关闭它的设置。axum + sqlx、actix + sqlx 和 Rustango 中已关闭。 |
| 连接池为 10 个连接而非 16 个 | Cot | Cot 没有连接池设置。 |
| 每个 worker 一个连接池分片(16 个连接中的 4 个),每个 worker 一个 Redis 连接 | actix + sqlx, Rustango · actix | 在 actix-web 上共享连接池更慢(见发现 6)。 |
| sqlx 0.7.4 和 hyper 0.14 | Rocket | 由 Rocket 0.5.1 锁定。 |
| 首页、详情页和标签页分别需要 5、5 和 7 次查询,而不是 3、3 和 4 次 | Cot | 它的查询 API 无法表达 Go 的查询(见发现 9)。 |
| 使用 askama 而非 Tera | Cot | Cot 的模板引擎。 |
结果
每秒请求数,越高越好。
未缓存页面
相同 HTML,50 个连接,每个请求都访问 PostgreSQL
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
50 个连接下三次 20 s 运行的中位数。测试时只运行被测应用。0 个非 200 响应,0 个错误。
缓存页面
从 Redis 页面缓存提供,不执行 SQL
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
50 个连接下三次 20 s 运行的中位数。测试时只运行被测应用。0 个非 200 响应,0 个错误。
计算
小于 20 000 的素数,不访问数据库
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
50 个连接下三次 20 s 运行的中位数。测试时只运行被测应用。0 个非 200 响应,0 个错误。
延迟,p50 / p99,单位 ms:
| 应用 | 首页 | 详情页 | 标签页 | 首页,缓存 | 详情页,缓存 | 计算 |
|---|---|---|---|---|---|---|
| Go | 5.50 / 9.96 | 1.91 / 5.02 | 7.61 / 11.72 | 0.35 / 2.73 | 0.24 / 2.05 | 2.37 / 10.75 |
| axum + sqlx | 3.49 / 4.71 | 2.14 / 3.60 | 7.42 / 13.89 | 0.24 / 0.53 | 0.22 / 0.51 | 3.35 / 6.07 |
| actix + sqlx | 3.32 / 6.38 | 2.21 / 4.30 | 7.23 / 13.23 | 0.20 / 0.50 | 0.19 / 0.45 | 3.41 / 4.97 |
| Rocket | 3.66 / 5.83 | 1.97 / 3.58 | 7.14 / 11.55 | 0.34 / 0.70 | 0.30 / 0.67 | 3.40 / 6.14 |
| Rustango · axum | 3.28 / 5.65 | 1.83 / 3.55 | 6.90 / 11.18 | 0.27 / 0.59 | 0.25 / 0.52 | 3.38 / 6.09 |
| Rustango · actix | 2.80 / 4.51 | 1.77 / 3.37 | 6.91 / 11.53 | 0.20 / 0.48 | 0.19 / 0.45 | 3.41 / 3.96 |
| Loco | 4.62 / 10.84 | 2.99 / 6.34 | 8.67 / 14.52 | 0.30 / 0.79 | 0.28 / 0.62 | 3.48 / 6.90 |
| Cot | 23.35 / 50.36 | 5.28 / 8.02 | 15.57 / 27.06 | 0.25 / 0.50 | 0.23 / 0.52 | 3.27 / 5.85 |
内存
负载下的内存
未缓存首页上应用容器的常驻内存
MiB · lower is better- Go
- Rust
- Rustango
- Loco
- Cot
docker stats,取首页第一次运行期间六个采样中 CPU 最高的那一个。
Go 和 Cot 用得最多。Cot 的首页每次请求都会加载全部 1 000 篇文章及其正文(发现 9)。
发现
每条发现都说明测量了什么、为什么会这样,以及如何证明。剖析数据来自同一测试装置上单次 25 s 的运行,每次一个应用,因此其 req/s 与上面的公平测试略有不同。凡是原因未经证实的,都会注明。
1. 计算:Go 快 8–17%
现象。Go 在 /compute 上达到 16 580 req/s;Rust 应用为 14 184–15 330。
原因。两个编译后的循环都是 8 条指令,含一条 64 位 sdiv 和一次除零检查。差别在于 LLVM 选择的指令顺序。究竟是 CPU 的哪个微架构细节让这种顺序更慢,尚未证实:Docker 虚拟机没有硬件计数器。
证明方法。单独运行这个循环,单核,不经过 HTTP:
素数循环,单次调用
单核,不经过 HTTP
微秒 · lower is better- Go
- Rust
采用与 Go 完全相同指令顺序的 Rust,运行时间与 Go 相同。每个请求的其余部分在 Rust 中更便宜:8 µs,而 Go 为 14 µs。
延迟。Go 的 p50 更低,但 p99 更高。每个应用的平均延迟都等于 50 / req/s,因此平均值跟随吞吐量;差别在于分布。Go 不按顺序处理请求:p10 0.56 ms,p99 10.75 ms。Rust 应用几乎按先进先出处理:axum + sqlx p10 1.85 ms,p99 6.07 ms。Go 的 p99 为何接近 10 ms,尚未证实。
2. 缓存页面:Go 的垃圾回收器
现象。在缓存首页上,Go 达到 91 839 req/s;axum + sqlx 为 198 669。
原因。Go 在这个路由上每秒执行 441 次垃圾回收,stop-the-world 停顿合计约占 10% 的时间。其余差距是每个请求的 CPU:Go 为 27.2 µs,axum + sqlx 为 14.6 µs。
证明方法。调高 GOGC 可消除大部分差距:
Go,缓存首页,调优 GC
之前:默认 GOGC=100。剖析运行。
剩余 CPU 的去向,按缓存首页上每个请求计:
| Go | axum + sqlx | |
|---|---|---|
| 应用 CPU | 27.2 µs | 14.6 µs |
| 内核写路径 | 10.6 µs | 6.9 µs |
| 系统调用 | 10.7(6.6 read,2.9 write) | 4.8(2.0 recvfrom,1.4 writev) |
| Redis CPU | 6.8 µs | 2.5 µs |
| Redis 流量 | go-redis:每个请求一次 GET | 一个连接,多个请求的 GET 合并在同一个 pipeline 中 |
Go:每个缓存请求的 CPU
总计 27.2 µs
- 39%内核写路径,10.6 µs
- 61%其余全部,16.6 µs
axum + sqlx:每个缓存请求的 CPU
总计 14.6 µs
- 47%内核写路径,6.9 µs
- 53%其余全部,7.7 µs
3. 详情页:Postgres 往返次数
现象。详情页:Go 22 607,axum + sqlx 22 842,Rocket 24 561,Rustango · axum 25 788 req/s。它们发送的都是同样的三条查询。
原因。与 Postgres 的往返次数不同。pgx 每条查询一次往返。sqlx 在从连接池取出连接时多一次(获取 ping),归还时再多一次。axum + sqlx 每条查询取一次连接,所以每条查询都要付出这笔开销;Rocket 和 Rustango 每个页面只取一次连接,所以只付一次。每多一次往返,约消耗 10 µs 的 Postgres CPU。
证明方法。在负载期间抓取 Postgres 协议流量,统计每个页面的 ReadyForQuery 消息数:
每个详情页的 Postgres 往返次数
协议抓包,同样的三条查询;ping = sqlx 的获取 ping
次往返 · lower is better- Go
- Rustango
- Rust
首页的次数与此相同。
axum + sqlx:关闭获取 ping
剖析运行
少三次往返,每个页面节省 29–32 µs 的 Postgres CPU:每次约 10 µs。
Go 的往返次数最少,但在这里并不是最快的:在这个路由上,它的垃圾回收器约有 9% 的时间让它停下。
4. 首页:GC 与模板渲染
现象。首页:Go 8 741 req/s,axum + sqlx 14 140。
原因。两个已测量的原因。一是 Go 的垃圾回收器(在这个路由上停顿合计约占 8% 的时间),二是模板渲染:Go 的 html/template 使用反射,每个请求耗费 167 µs;Rust 中的 serde + Tera 耗费 72 µs。
证明方法。
Go,未缓存首页,调优 GC
之前:默认 GOGC=100。剖析运行。
模板渲染,首页
每个请求的 CPU,来自 CPU 剖析
微秒 · lower is better- Go
- Rust
根据剖析,axum + sqlx 在首页上的 CPU 去向(各部分有重叠:内存分配发生在 sqlx 和 Tera 内部):
- sqlx:每个请求约 112 µs,其中约 25 µs 是连接归还连接池时的 ping
- glibc
malloc:约 56 µs - 内核
sendto:38 µs
5. Rustango:修复带来了什么变化
现象。修复之前对 Rustango 的剖析发现了四项开销。其中三项已修复:
| 修复前的开销 | CPU 占比 | 修复 |
|---|---|---|
| 每条查询都从连接池借一次连接 | 19–35% | 每个请求使用一个池连接:详情页 +26% |
| 每次借出连接都 ping Postgres | 8–16% | 默认关闭 ping |
CachePageLayer 以 JSON + base64 存储页面 | 每次缓存命中 ~12–20% | 页面以原始字节存储 |
| 每次请求都重新构建并编译 queryset | ~2% | 尚未修复 |
Rustango · axum,修复前 → 修复后
剖析运行,每次一个应用
在公平测试中,运行在 actix-web 和 axum 上的 Rustango 是全部三个未缓存页面上最快的两个应用。
待解决。在缓存首页上,Rustango · axum 为 174 501 req/s,而 axum + sqlx 为 198 669。这一差距尚未剖析。
6. actix-web:共享连接池很慢
现象。在第 1 轮中,actix-web 应用在未缓存页面上落后于 axum,且只用了 400% CPU 预算中的不到 300%。
原因。只有一个共享连接池时,每条查询都会唤醒另一个运行时的线程。给每个 actix worker 分配自己的连接池分片(16 个连接中的 4 个)和自己的 Redis 连接即可解决。其机制——套接字由打开它的运行时驱动——属于推断;效果则是实测的。
证明方法。相同应用,共享连接池对比每个 worker 一个分片:
共享连接池 → 每个 worker 一个连接池分片
剖析运行
| 每个请求的上下文切换次数 | 共享连接池 | 每个 worker 一个池 |
|---|---|---|
| actix + sqlx,首页 | 4.04 | 1.45 |
| actix + sqlx,详情页 | 3.57 | 1.64 |
| Rustango · actix,首页 | 2.70 | 0.81 |
| Rustango · actix,详情页 | 2.34 | 1.03 |
每个请求的上下文切换次数约为原来的 1/2.5。增加 worker 没有帮助:8 个 worker 配共享连接池,在 actix + sqlx 首页上达到 11 904 req/s(4 个 worker、共享池:11 094)。
7. Rocket:数据库页面表现强劲,缓存页面最慢
现象。Rocket:详情页 24 561 req/s,领先 Go 和原生 sqlx 应用;缓存首页 138 224,是 Rust 中最慢的结果。
原因。在数据库页面上,Rocket 每个页面只取一次连接,进行 5 次往返(发现 3)。在缓存页面上,它每个请求消耗 24.1 µs CPU,而 axum + sqlx 为 14.6 µs;每个请求 8.1 次系统调用,对方为 4.8 次。剖析显示多了一次 writev(每个请求 2.24 次对 1.36 次),且 malloc 存在争用;futex 调用为每个请求 2.88 次对 0.91 次。
证明方法。在缓存首页上进行 CPU 计量、系统调用计数和 CPU 剖析。
8. Loco
现象。Loco:首页 10 290,详情页 15 635,缓存首页 153 243 req/s。
原因。SeaORM 发送的查询数与 Go 相同。在缓存页面上,Loco 每个请求消耗 21.0 µs:比 axum + sqlx 在 malloc 中多 2.4 µs,在中间件中多 1.2 µs。在数据库页面上,获取 ping 无法关闭(发现 3 展示了它在 sqlx 中的开销)。
证明方法。在缓存首页上进行 CPU 计量和 CPU 剖析。Loco 的数据库页面没有剖析,因此 ping 能解释多少差距尚未证实。
9. Cot:查询 API 决定了上限
现象。Cot:首页 2 018,详情页 9 282,标签页 3 065 req/s,但缓存首页达到 190 245。
原因。Cot 的查询 API 只有 filter、limit 和 offset:没有 ORDER BY、JOIN、IN 或 COUNT。为了显示最新的 20 篇文章,首页要加载全部 1 000 篇文章及其正文。页面需要 5、5 和 7 次查询,而不是 3、3 和 4 次。负载下内存为 22.6 MiB。缓存页面不经过 ORM,速度很快:每个请求 16.4 µs CPU。
证明方法。查询次数来自代码,内存来自公平测试,CPU 计量来自缓存首页。
10. 内存
见上方的内存图表。六个 Rust 应用在负载下使用 6.3–12.0 MiB。Go 使用 22.8 MiB。Cot 使用 22.6 MiB;它的首页每次请求都加载所有文章(发现 9)。
11. 共同开销
有两项开销出现在每个 Rust 应用中,且不属于任何框架:
- Tera 渲染:在未缓存页面上约占 18–20% 的 CPU
- glibc
malloc:约 12%
换用更快的分配器(如 mimalloc 或 jemalloc)可能会降低第二项。这一点未经测试。
局限
- 只有一台机器:Apple M3 Pro。其他 CPU 上结果的排序可能不同,尤其是计算(发现 1)。
- 以读取为主的负载:没有写入,没有事务。
- 应用运行在 macOS 上的 Docker 虚拟机中,而不是裸机 Linux。虚拟机没有硬件性能计数器。
结论:为什么 actix-web 上的 Rustango 快了这么多
Rustango · actix,首页
三次运行:第 1 轮、一次剖析运行,以及本轮的公平测试
req/s · higher is better两处改动,分别在两个地方。
1. Rustango 的修复。这些修复对 axum 和 actix-web 都有帮助:
- 每一列按其类型只解码一次。之前解码占用 46% 的 CPU。
- 每次从连接池借出连接前不再 ping:每条查询少一次 Postgres 往返。
- 每个请求使用一个池连接:一个页面的所有查询共用一个连接,而不是每条查询各借一次、归还时各 ping 一次。
2. actix-web 的配置。axum 运行一个共享的多线程 Tokio 运行时。actix-web 为每个 worker 运行一个单线程运行时。当所有 worker 共享一个连接池时,连接的套接字始终由打开它的运行时驱动,因此当另一个 worker 使用该连接时,每次读写都会唤醒那个线程。于是应用的上下文切换多了 2.5×,4 个 CPU 只用了约 280%。每个 worker 一个连接池分片(16 个连接中的 4 个)和一个 Redis 连接,就消除了这些跨线程跳转:
- Rustango · actix:首页 12 052 → 17 454,详情页 23 032 → 28 693,缓存首页 190 889 → 237 962 req/s
- actix + sqlx:首页 11 094 → 15 108 req/s
- 8 个 worker 配共享连接池并无帮助:actix + sqlx 首页 11 904 req/s
提速和上下文切换的减少都是实测的。套接字归属是最好的解释,但并未直接证明。axum 没有这个问题,因为它的所有任务都运行在同一个共享运行时上。
实践建议:在 actix-web 上,应在每个 worker 内部创建数据库连接池,而不是为所有 worker 只创建一次。