后端 / 性能
从 21 RPS 到 800 RPS:一次 Rust 列表接口优化
这篇记录一次后端列表接口优化。
背景很简单:我在测社区帖子相关接口。详情接口在 release 下已经能跑到几百到一千多 RPS,/health 也能接近 3 万 RPS。结果列表接口一开始很难看:page_size=20、20 并发下只有 21 RPS,p95 直接到 1 秒。
这就很尴尬。因为列表/feed 是内容社区最常见的入口。如果这里不行,详情接口再快也没什么意义。
先看数字
最开始的 debug 压测是这样:
| 接口 | 并发 | avg | p95 |
|---|---|---|---|
/v1/community/anli?page_size=1 | 1 | 23.8ms | 52.5ms |
/v1/community/anli?page_size=20 | 1 | 194.9ms | 241.7ms |
/v1/community/anli?page_size=1 | 20 | 97.1ms | 135.8ms |
/v1/community/anli?page_size=20 | 20 | 942.1ms | 1011.0ms |
关键不是它慢,而是慢的形状。
同样是单并发,page_size 从 1 到 20,延迟从 23.8ms 涨到 194.9ms,差不多 8 倍。同样是 20 并发,延迟从 97.1ms 涨到 942.1ms,差不多 9.7 倍。
这说明每多返回一条帖子,都在多付一份固定成本。也就是说,单条数据的成本没有被摊薄。
这个形状基本就是 N+1 的指纹。
问题出在复用
顺着列表 handler 往 service 里看,主分页查询本身没什么大问题。一条 SQL 拿到这一页帖子。
真正的问题在拿到帖子之后:
for post in posts {
let item = self.post_to_list_item_response(post, ...).await?;
items.push(item);
} 这段看起来很自然。甚至很干净。
问题是 post_to_list_item_response 内部复用了详情页装配逻辑。详情页一次只看一个帖子,逐个查作者、标签、媒体、封面、统计,问题不大。但列表页一次返回 20 个帖子,就变成了每个帖子都重新跑一遍详情装配。
一条帖子大概会打这些查询:
| 查询 | 说明 |
|---|---|
| 作者 | 查用户基础信息 |
| 作者 stats | 列表其实只用 level |
| tag 关系 | 帖子挂了哪些 tag |
| active tags | 过滤被封禁 tag |
| tag 展示名 | 内部还有 submitted/canonical 两类名称解析 |
| 封面图 | cover media |
| 媒体列表 | 预览图 |
| 属地偏好 | 前端已经移除展示,DTO 和测试也跟着删了,但查询路径漏删 |
约 9 次串行查询 / 帖。
page_size=20 就是差不多 180 次往返。20 个并发请求一起进来,连接池被占住,排队又把尾延迟继续放大。
这就是那种很常见的坑:单条正确,不等于批量正确。
改前和改后
修法不复杂,核心就一句话:
把循环里的查询提到循环外,整页一次查齐;循环内只做内存组装。
改前查询数大概是:
总查询数 ≈ 基础查询 + 9 × N 改后变成:
总查询数 ≈ 基础查询 + 8 也就是从随 page_size 线性增长,变成常数级。
| 版本 | page_size=1 | page_size=20 | 复杂度 |
|---|---|---|---|
| 改前变量查询 | 9 | 180 | O(9 × N) |
| 改后变量查询 | 8 | 8 | O(1) |
含基础查询一起算,大概是:
| 版本 | page_size=1 | page_size=20 |
|---|---|---|
| 改前 | ~14 | ~185 |
| 改后 | ~13 | ~13 |
代码长什么样
下面是脱敏版。项目里的表名和业务名都换掉了,但结构基本就是这次优化的形状。
// 改前:列表逐条复用详情装配
impl FeedService {
async fn hydrate_items(&self, posts: Vec<posts::Model>) -> Result<Vec<FeedItem>> {
let mut out = Vec::with_capacity(posts.len());
for post in posts {
// 每条帖子都会进一串串行查询
let item = self.post_to_list_item(post).await?;
out.push(item);
}
Ok(out)
}
async fn post_to_list_item(&self, post: posts::Model) -> Result<FeedItem> {
let author = users::Entity::find_by_id(post.user_id)
.one(&self.db)
.await?;
let stats = user_stats::Entity::find_by_id(post.user_id)
.one(&self.db)
.await?;
let rels = post_tags::Entity::find()
.filter(post_tags::Column::PostId.eq(post.id))
.all(&self.db)
.await?;
let active = tags::Entity::find()
.filter(tags::Column::Id.is_in(rels.iter().map(|r| r.tag_id)))
.filter(tags::Column::Status.ne(TagStatus::Banned as i16))
.all(&self.db)
.await?;
let names = resolve_tag_names(&self.db, &rels).await?;
let cover = match post.cover_media_id {
Some(id) => media::Entity::find_by_id(id).one(&self.db).await?,
None => None,
};
let media = post.find_related(media::Entity).all(&self.db).await?;
// 列表根本不展示这个字段,但旧路径会因为复用详情而查一次
let _flags = show_location_flags(&self.db, &[post.user_id]).await?;
Ok(FeedItem { /* ... */ })
}
} // 改后:整页批量预取,循环内纯内存组装
impl FeedService {
async fn hydrate_items(&self, posts: Vec<posts::Model>) -> Result<Vec<FeedItem>> {
let pf = self.batch_list_item_data(&posts).await?;
let mut out = Vec::with_capacity(posts.len());
for post in posts {
// 这里已经不需要 await
out.push(self.build_list_item(post, &pf)?);
}
Ok(out)
}
async fn batch_list_item_data(&self, posts: &[posts::Model]) -> Result<ListItemPrefetch> {
let user_ids: Vec<i64> = dedup(posts.iter().map(|p| p.user_id));
let post_ids: Vec<i64> = posts.iter().map(|p| p.id).collect();
let authors = users::Entity::find()
.filter(users::Column::Id.is_in(user_ids.clone()))
.all(&self.db)
.await?
.into_iter()
.map(|u| (u.id, u))
.collect::<HashMap<_, _>>();
let levels = user_stats::Entity::find()
.select_only()
.column(user_stats::Column::UserId)
.column(user_stats::Column::Level)
.filter(user_stats::Column::UserId.is_in(user_ids))
.into_model::<LevelRow>()
.all(&self.db)
.await?
.into_iter()
.map(|r| (r.user_id, r.level))
.collect();
let tags_by_post = self.batch_tags_by_post(&post_ids).await?;
let rels = post_media::Entity::find()
.filter(post_media::Column::PostId.is_in(post_ids.clone()))
.order_by_asc(post_media::Column::MediaOrder)
.all(&self.db)
.await?;
let mut media_ids_by_post: HashMap<i64, Vec<i64>> = HashMap::new();
let mut all_media_ids = Vec::new();
for rel in rels {
media_ids_by_post.entry(rel.post_id).or_default().push(rel.media_id);
all_media_ids.push(rel.media_id);
}
all_media_ids.extend(posts.iter().filter_map(|p| p.cover_media_id));
let media_models = media::Entity::find()
.filter(media::Column::Id.is_in(dedup(all_media_ids)))
.all(&self.db)
.await?
.into_iter()
.map(|m| (m.id, m))
.collect();
Ok(ListItemPrefetch {
authors,
levels,
tags_by_post,
media_models,
media_ids_by_post,
})
}
fn build_list_item(&self, post: posts::Model, pf: &ListItemPrefetch) -> Result<FeedItem> {
let author = pf.authors.get(&post.user_id).ok_or(NotFound)?;
let level = pf.levels.get(&post.user_id).copied().unwrap_or(0);
let tags = pf.tags_by_post.get(&post.id).cloned().unwrap_or_default();
let cover = post.cover_media_id.and_then(|id| pf.media_models.get(&id));
let media = pf.media_ids_by_post
.get(&post.id)
.map(|ids| ids.iter().filter_map(|id| pf.media_models.get(id)).collect())
.unwrap_or_default();
Ok(FeedItem { /* ... */ })
}
}这里我觉得有个小细节挺好:build_list_item 从 async fn 变成了普通 fn。这不是为了形式好看,而是一个很强的自证信号:这一步已经不会再碰数据库了。
时序对比
用图看会更直观。改前是每条帖子都在循环里打 9 次库;改后是整页先做固定次数的集合查询,再纯内存组装。
sequenceDiagram
autonumber
participant App as APP
participant DB as DB
App->>DB: 主分页查询
DB-->>App: 返回一页 N 条帖子
loop 每条帖子重复一次,共 N 次
App->>DB: q1 作者
DB-->>App: author
App->>DB: q2 作者等级
DB-->>App: level
App->>DB: q3 tag 关系
DB-->>App: tag relations
App->>DB: q4 active tags
DB-->>App: active tags
App->>DB: q5-q6 tag 展示名
DB-->>App: tag names
App->>DB: q7 封面图
DB-->>App: cover media
App->>DB: q8 媒体列表
DB-->>App: media list
App->>DB: q9 属地偏好(前端已移除展示,查询漏删)
DB-->>App: location flags
end
Note over App,DB: 总往返 ≈ 基础查询 + 9N sequenceDiagram
autonumber
participant App as APP
participant DB as DB
App->>DB: 主分页查询
DB-->>App: 返回一页 N 条帖子
App->>DB: 作者 IN (...)
DB-->>App: authors map
App->>DB: 等级 IN (...)
DB-->>App: levels map
App->>DB: tag 关系 IN (...)
DB-->>App: tags by post
App->>DB: active tags
DB-->>App: active tag ids
App->>DB: tag 展示名批量解析
DB-->>App: tag names
App->>DB: 媒体关系 IN (...)
DB-->>App: media ids by post
App->>DB: 媒体行 IN (...)
DB-->>App: media map
loop 每条帖子纯内存组装
App->>App: build_list_item(post, prefetch)
end
Note over App,DB: 总往返 ≈ 基础查询 + 8,与 page_size 解耦 N+1 的本质不是 SQL 写得丑,而是往返次数。本地 Postgres 查一条主键可能很快,但 180 次串行往返叠起来,就是会慢。
修完之后
优化后我又用同一套 oha 参数测了一遍。
debug 模式:
| 版本 | RPS | avg | p95 | p99 |
|---|---|---|---|---|
| 优化前 debug | 21.1 | 942.1ms | 1011.0ms | 1041.2ms |
| 优化后 debug | 180.7 | 108.2ms | 179.2ms | 226.8ms |
release 模式:
| 场景 | RPS | avg | p95 | p99 |
|---|---|---|---|---|
page_size=20, c20, 300 请求 | 653.9 | 29.7ms | 68.3ms | 73.8ms |
page_size=20, c20, 10 秒稳定压测 | 749.1 | 26.7ms | 36.5ms | 62.3ms |
page_size=1, c20 | 1594.5 | 12.3ms | 23.3ms | 28.6ms |
从 21 RPS 到 750 RPS,当然不全是代码优化。debug 和 release 本来就不是一个量级。但关键是,同样 debug 模式下也从 21 RPS 到 180 RPS,这个才是这次改动本身的收益。
release 之后,page_size=20 的业务列表接口稳定在 700+ RPS,p99 大概 60ms。对一个真实业务读接口来说,这个结果已经不是“玩具接口”的水平了。
再补一刀:try_join
批量预取以后,查询数量已经从 O(N) 打平到常数。剩下的问题是:这些常数查询还要不要串行等?
列表项需要的作者、统计、标签、媒体、封面这些数据,彼此之间没有强依赖。既然最后都是按 post_id 归并,那就没必要一条查完再查下一条。我又把这几组批量查询用 try_join 并起来,让它们在同一个请求里重叠执行。
一开始我跑了 300 请求短测,结果看起来变化不明显。但这个短测其实不太可信:c20 下 300 个请求不到半秒就跑完了,里面混着 warmup、调度抖动和连接池状态,持平很正常。
真正有信号的是 10 秒稳定压测:
| 版本 | RPS | avg | p95 | p99 |
|---|---|---|---|---|
try_join 前 | 749.1 | 26.7ms | 36.5ms | 62.3ms |
try_join 后 | 813.7 | 24.6ms | 30.2ms | 40.0ms |
| 变化 | +8.6% | -7.9% | -17.3% | -35.8% |
这刀有效,而且主要体现在尾延迟上。
吞吐只涨了 8.6%,不算夸张;但 p99 从 62.3ms 收到 40.0ms,这个就很明显了。它说明 try_join 不是又砍掉了一整类 N+1,而是把请求里本来串行等待的几段 I/O 压到一起了。
为什么一个“降延迟”的改动还能把吞吐也拉高?原因也不复杂:每个请求持有连接、等待查询返回的墙钟时间变短了,连接更快回到池子里,单位时间能服务的请求就更多。尾延迟收紧也是同一个逻辑:请求花在持有连接、等待连接、等待查询上的时间少了。
并发爬坡之后,天花板也出来了
接着我又做了并发爬坡。这个比单点 RPS 更有意思,因为它能看出系统是还没吃满,还是已经开始排队。
| 并发 | RPS | avg | p99 | 成功率 |
|---|---|---|---|---|
| 20 | 770.4 | 26.0ms | 63.0ms | 100% |
| 50 | 738.6 | 67.9ms | 129.6ms | 100% |
| 100 | 795.3 | 126.6ms | 173.7ms | 100% |
| 200 | 719.6 | 279.6ms | 415.5ms | 100% |
| 400 | 678.8 | 612.6ms | 1046.2ms | 100% |
这张表的重点不是 c100 比 c50 高一点,或者 c400 掉了一点。重点是:RPS 从 c20 到 c400 基本都卡在 680 到 795 之间,而延迟随着并发上去一路涨。
这就是很典型的饱和形状。系统的固定容量大概就在这里,再加并发不会换来更多吞吐,只会换来排队。
用 Little’s Law 粗算也能对上:
c20: 20 / 0.026 ≈ 769 RPS
c400: 400 / 0.613 ≈ 653 RPS 和实测的 770 RPS、679 RPS 基本在一个量级。也就是说,压测数字不是孤立的,它们互相能解释得通。
我又单独在 c100 下看了一次 CPU:吞吐大概还是 795 RPS,avg 126.7ms,p95 164.2ms,p99 196.3ms。进程 CPU 在 310% 到 453% 之间,RSS 从 97MB 到 101MB 左右。
macOS 这里的 400% CPU 大概就是吃了 4 个核心。内存基本没动,成功率还是 100%。所以这轮看起来不是内存问题,也不是请求直接打崩,而是 CPU、数据库、连接池或同步等待这些固定容量资源已经吃满了。
所以我现在会把这个接口的“舒适区”放在 c20 到 c50 左右:吞吐已经接近上限,延迟还比较好看。c100 也能扛,但尾延迟明显上来了。c200、c400 还能 100% 成功,说明退化是优雅的,没有雪崩;但这已经不是适合长期运行的延迟区间了。
这一步之后,结论反而更清楚了:老的瓶颈是 N+1,已经被拍掉;现在的瓶颈更像是本机和本地数据库这套环境的固定容量。继续在这段 handler 里抠小优化,收益会越来越小。下一阶段如果要再往上走,应该看数据库执行计划、连接池配置、运行机器、缓存策略和多实例,而不是再幻想改一行 Rust 就翻倍。
为什么我没先上 Redis
看到列表慢,很容易第一反应就是:加缓存。
但这次我没有先上 Redis。原因也很简单:问题不是单条查询慢,而是往返次数太多。
如果一页有 20 条帖子,每条帖子都查 9 次,那 Redis 只能把「数据库往返」换成「Redis 往返」。它会快一些,但结构问题还在。
而且行级缓存会引入一致性问题:
- 用户改昵称、头像,缓存怎么失效?
- 用户等级变化,列表展示多久更新?
- tag 被封禁,缓存里的展示名怎么处理?
- webhook 同步资料时,要不要主动清理所有相关缓存?
为了省几毫秒,把这些一致性复杂度引进来,我觉得不划算。
正确顺序应该是:
先消除 N+1,让查询结构正确
再看压测结果
如果还有热点或排序问题,再考虑缓存 真要用 Redis,我更倾向于缓存列表结果这一层,而不是缓存单个作者行、tag 行。
比如匿名公开 feed 可以短 TTL 缓存「排好序的 post_id 列表 + total」,命中后再对当前 viewer 做批量水合。这样跳过的是 count、排序和候选集合,而不是给 N+1 打补丁。
顺手发现的另一个问题
这次还有一个额外收获:列表路径以前会查属地偏好。这个不是接口设计一开始就错了,而是一次前端决策之后留下的尾巴。
后来前端不再展示 ip location,后端 DTO 和测试也已经跟着删掉了,但查询路径里的属地偏好没有一起删。结果就是:响应里已经不用这个字段了,数据库还在为它付查询成本。
这种东西平时很难注意到,因为它藏在一个“复用详情装配”的抽象里。等你把装配拆成 batch_list_item_data 和 build_list_item 以后,哪些字段是列表真的需要的,哪些是历史遗留字段,就会非常清楚。
这也是我这次最大的感受之一:
性能优化不只是让代码更快,有时候也是重新看清楚边界。
这次优化真正说明了什么
我不太想把这个结果包装成“Rust 很快”。那样反而没什么意思。
更准确的结论是:框架轻链路和业务接口不是一回事。/health release 单实例接近 3 万 RPS,说明应用层基础开销不大;但真实列表接口一开始只有 21 RPS,说明瓶颈不在语言,而在查询结构。
优化之后,page_size=20 的业务列表能稳定到 700+ RPS,p99 大概 60ms。这个结果让我比较放心的是:只要把数据访问路径理顺,业务接口也可以有不错的余量。
但它也不是终点。真到生产环境,还要继续看这些东西:
- 数据量变大后索引是否仍然命中;
- 登录态 viewer 状态会不会把查询重新拖重;
- 热门排序、推荐排序是不是会变成新的慢点;
- DB 连接池、慢 SQL、缓存命中率在真实流量下是什么样子;
- 多实例扩展时,瓶颈是不是从应用层转移到数据库。
所以这次更像是把一个明显的结构性问题清掉了。后面要做的是继续用数据验证,而不是凭感觉继续加层。
最后
这次优化最有价值的地方,不是某个 Rust 写法,也不是某条 SQL,而是判断顺序:
- 先看数据的形状。
- 再对照代码里的查询结构。
- 先修结构问题。
- 最后再谈缓存。
性能问题的形状,很多时候已经写在压测数据里了。page_size 翻 20 倍,延迟也跟着翻上去,这不是“该加 Redis”,而是“每条数据都在重复查”。
把查询提到循环外,让循环内只剩内存装配。就这么一刀,效果比我一开始预期的要明显得多。