YEN HARVEY
cd ../notes

后端 / 性能

从 21 RPS 到 800 RPS:一次 Rust 列表接口优化

13 min read
夜色中伏案排查性能问题的开发者,屏幕上是接口压测数据。
夜色中伏案排查性能问题的开发者,屏幕上是接口压测数据。

这篇记录一次后端列表接口优化。

背景很简单:我在测社区帖子相关接口。详情接口在 release 下已经能跑到几百到一千多 RPS,/health 也能接近 3 万 RPS。结果列表接口一开始很难看:page_size=20、20 并发下只有 21 RPS,p95 直接到 1 秒

这就很尴尬。因为列表/feed 是内容社区最常见的入口。如果这里不行,详情接口再快也没什么意义。

先看数字

最开始的 debug 压测是这样:

接口并发avgp95
/v1/community/anli?page_size=1123.8ms52.5ms
/v1/community/anli?page_size=201194.9ms241.7ms
/v1/community/anli?page_size=12097.1ms135.8ms
/v1/community/anli?page_size=2020942.1ms1011.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=1page_size=20复杂度
改前变量查询9180O(9 × N)
改后变量查询88O(1)

含基础查询一起算,大概是:

版本page_size=1page_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_itemasync 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 模式:

版本RPSavgp95p99
优化前 debug21.1942.1ms1011.0ms1041.2ms
优化后 debug180.7108.2ms179.2ms226.8ms

release 模式:

场景RPSavgp95p99
page_size=20, c20, 300 请求653.929.7ms68.3ms73.8ms
page_size=20, c20, 10 秒稳定压测749.126.7ms36.5ms62.3ms
page_size=1, c201594.512.3ms23.3ms28.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 秒稳定压测:

版本RPSavgp95p99
try_join749.126.7ms36.5ms62.3ms
try_join813.724.6ms30.2ms40.0ms
变化+8.6%-7.9%-17.3%-35.8%

这刀有效,而且主要体现在尾延迟上。

吞吐只涨了 8.6%,不算夸张;但 p99 从 62.3ms 收到 40.0ms,这个就很明显了。它说明 try_join 不是又砍掉了一整类 N+1,而是把请求里本来串行等待的几段 I/O 压到一起了。

为什么一个“降延迟”的改动还能把吞吐也拉高?原因也不复杂:每个请求持有连接、等待查询返回的墙钟时间变短了,连接更快回到池子里,单位时间能服务的请求就更多。尾延迟收紧也是同一个逻辑:请求花在持有连接、等待连接、等待查询上的时间少了。

并发爬坡之后,天花板也出来了

接着我又做了并发爬坡。这个比单点 RPS 更有意思,因为它能看出系统是还没吃满,还是已经开始排队。

并发RPSavgp99成功率
20770.426.0ms63.0ms100%
50738.667.9ms129.6ms100%
100795.3126.6ms173.7ms100%
200719.6279.6ms415.5ms100%
400678.8612.6ms1046.2ms100%

这张表的重点不是 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_databuild_list_item 以后,哪些字段是列表真的需要的,哪些是历史遗留字段,就会非常清楚。

这也是我这次最大的感受之一:

性能优化不只是让代码更快,有时候也是重新看清楚边界。

这次优化真正说明了什么

我不太想把这个结果包装成“Rust 很快”。那样反而没什么意思。

更准确的结论是:框架轻链路和业务接口不是一回事。/health release 单实例接近 3 万 RPS,说明应用层基础开销不大;但真实列表接口一开始只有 21 RPS,说明瓶颈不在语言,而在查询结构。

优化之后,page_size=20 的业务列表能稳定到 700+ RPS,p99 大概 60ms。这个结果让我比较放心的是:只要把数据访问路径理顺,业务接口也可以有不错的余量。

但它也不是终点。真到生产环境,还要继续看这些东西:

  • 数据量变大后索引是否仍然命中;
  • 登录态 viewer 状态会不会把查询重新拖重;
  • 热门排序、推荐排序是不是会变成新的慢点;
  • DB 连接池、慢 SQL、缓存命中率在真实流量下是什么样子;
  • 多实例扩展时,瓶颈是不是从应用层转移到数据库。

所以这次更像是把一个明显的结构性问题清掉了。后面要做的是继续用数据验证,而不是凭感觉继续加层。

最后

这次优化最有价值的地方,不是某个 Rust 写法,也不是某条 SQL,而是判断顺序:

  1. 先看数据的形状。
  2. 再对照代码里的查询结构。
  3. 先修结构问题。
  4. 最后再谈缓存。

性能问题的形状,很多时候已经写在压测数据里了。page_size 翻 20 倍,延迟也跟着翻上去,这不是“该加 Redis”,而是“每条数据都在重复查”。

把查询提到循环外,让循环内只剩内存装配。就这么一刀,效果比我一开始预期的要明显得多。

作者头像
yen@harvey:~$ exit 0