在 Async Rust 中实现请求合并(Request Coalescing)

本文是对 Request coalescing in async Rust 的整理与翻译


内容结构概览

复制代码
1. 从一个阻塞式 Web 服务器出发
2. 为什么需要异步(async)
3. 进入 Tokio:async 运行时的本质
4. Tracing 生态:异步代码的可观测性
   4.1 tracing + tracing-subscriber
   4.2 tracing-tree:层级化日志
   4.3 JSON 结构化输出
   4.4 OpenTelemetry + Jaeger 分布式追踪
   4.5 tokio-console:异步运行时的 top(1)
5. 使用 axum 构建正式 HTTP 服务
6. 实现一个有实际意义的 HTTP 服务(调用外部 API)
7. 请求合并(Request Coalescing)的核心实现
   7.1 问题:并发请求造成的"惊群效应"
   7.2 方案一:简单缓存(naive cache)
   7.3 方案二:广播通道(broadcast channel)实现真正的请求合并
   7.4 最终完整实现

一、从一个阻塞式 Web 服务器出发

为了引入今天的主题,我们先从最简单的出发点开始:一个用标准库手写的阻塞 HTTP 服务器。

rust 复制代码
use std::{
    error::Error,
    io::{Read, Write},
    net::{SocketAddr, TcpListener},
};

fn main() -> Result<(), Box<dyn Error>> {
    let addr: SocketAddr = "0.0.0.0:3779".parse()?;
    let listener = TcpListener::bind(addr)?;
    println!("Listening on http://{}", addr);
    loop {
        let (mut stream, addr) = listener.accept()?;
        let mut incoming = vec![];
        loop {
            let mut buf = vec![0u8; 1024];
            let read = stream.read(&mut buf)?;
            incoming.extend_from_slice(&buf[..read]);
            if incoming.len() > 4 && &incoming[incoming.len() - 4..] == b"\r\n\r\n" {
                break;
            }
        }
        let incoming = std::str::from_utf8(&incoming)?;
        stream.write_all(b"HTTP/1.1 200 OK\r\n")?;
        stream.write_all(b"\r\n")?;
        stream.write_all(b"Hello from plaque!\n")?;
    }
}

这段代码可以跑起来,也能正常响应 curl 请求。但它有一个致命的问题:同一时刻只能处理一个连接


二、为什么需要异步(async)

上面的服务器用 rust-gdb 查看线程调用栈,会发现它永远阻塞在系统调用上:

  • 等待新连接时:阻塞在 accept4
  • 读取请求时:阻塞在 recv

用多线程可以缓解这个问题------为每个连接 spawn 一个线程:

rust 复制代码
loop {
    let (stream, addr) = listener.accept()?;
    std::thread::spawn(move || {
        handle_connection(stream, addr).unwrap();
    });
}

这样确实能处理多个连接,但线程数量是有代价的:每个线程都有自己的栈内存,当连接数激增时,内存消耗会线性增长。

更优雅的方案是用非阻塞 I/O :告诉内核"如果没有数据就直接返回,别挂住我",然后通过 epoll 这样的机制订阅事件通知,当 socket 就绪时才去处理。把这套繁琐的状态机管理封装好,就是异步运行时的职责。


三、进入 Tokio:async 运行时的本质

toml 复制代码
[dependencies]
tokio = { version = "1.17.0", features = ["full"] }

先不使用 #[tokio::main] 宏,手动构建运行时,揭开它的面纱:

rust 复制代码
fn main() -> Result<(), Box<dyn Error>> {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?;
    rt.block_on(async {
        let listener = TcpListener::bind("0.0.0.0:3779").await?;
        loop {
            let (stream, addr) = listener.accept().await?;
            drop(stream); // 先丢弃,只是演示
        }
    })
}

此时 GDB 显示,线程不再阻塞在 accept4,而是阻塞在 epoll_wait

复制代码
Thread 1 (Thread ... "plaque"):
#0  0x... in epoll_wait (epfd=3, ...)
#1  0x... in mio::sys::unix::selector::epoll::Selector::select ...
...
#22 0x... in plaque::main ()

从调用栈最底层往上看,依次经过 block_on、线程本地存储、epoll 驱动层......这就是整个 Tokio 运行时的内部结构。

通常我们用宏来简化:

rust 复制代码
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
    let listener = TcpListener::bind("0.0.0.0:3779").await?;
    loop {
        let (mut stream, _addr) = listener.accept().await?;
        // ...
        stream.write_all(b"HTTP/1.1 200 OK\r\n").await?;
        stream.write_all(b"\r\n").await?;
        stream.write_all(b"Hello from plaque!\n").await?;
    }
}

代码看上去和同步版本几乎一样,只是在可能阻塞的地方加了 .await

为什么用 write_all 而不是 write

因为 write 不保证写完整个 buffer------内核 TCP 缓冲区可能只有部分空间,需要等待排空后再继续写。write_all 内部会循环调用 write 直到全部写完。

支持并发连接,只需把处理逻辑放进 tokio::spawn

rust 复制代码
loop {
    let (stream, addr) = listener.accept().await?;
    tokio::spawn(async move {
        handle_connection(stream, addr).await.unwrap();
    });
}

四、Tracing 生态:异步代码的可观测性

异步代码有一个让人头疼的调试问题:GDB 能看到线程堆栈,但那只是运行时内部的 epoll_wait,完全看不出业务逻辑跑到哪里了。Go 通过 goroutine 堆栈和 pprof 解决了这个问题,Rust 目前还在完善中。作为替代方案,tracing 生态提供了非常完整的可观测性支持。

4.1 基础使用:tracing + tracing-subscriber

toml 复制代码
tracing = "0.1.31"
tracing-subscriber = { version = "0.3.9", features = ["env-filter"] }

println! 替换成结构化的 tracing 宏:

rust 复制代码
use tracing::{debug, info};

tracing_subscriber::fmt::init();

info!("Listening on http://{}", addr);
info!(%addr, "Accepted connection");
debug!(%incoming, "Got HTTP request");

运行时配合 RUST_LOG=debug,输出会带上时间戳和日志级别:

复制代码
2022-03-03T17:22:22.837683Z  INFO plaque: Listening on http://0.0.0.0:3779
2022-03-03T17:22:24.077415Z  INFO plaque: Accepted connection addr=127.0.0.1:34718
2022-03-03T17:22:24.077511Z DEBUG plaque: Got HTTP request incoming=GET / HTTP/1.1

除了事件(event) ,tracing 还支持跨度(span) :用 #[tracing::instrument] 标记异步函数,即可自动追踪其生命周期:

rust 复制代码
#[tracing::instrument]
async fn run_server() -> Result<(), Box<dyn Error>> { ... }

#[tracing::instrument(skip(stream))]
async fn handle_connection(mut stream: TcpStream, addr: SocketAddr) -> Result<(), Box<dyn Error>> { ... }

4.2 tracing-tree:层级化可视化

toml 复制代码
tracing-tree = "0.2.0"
rust 复制代码
Registry::default()
    .with(EnvFilter::from_default_env())
    .with(
        HierarchicalLayer::new(2)
            .with_targets(true)
            .with_bracketed_fields(true),
    )
    .init();

输出变成了带缩进的层级结构,能清晰看出执行流程和哪里"卡住了":

复制代码
plaque::run_server{}
  0ms  INFO plaque Listening on http://0.0.0.0:3779
  plaque::accept{}

  plaque::handle_connection{addr=127.0.0.1:34728}
    plaque::read_http_request{}
    0ms DEBUG plaque Got HTTP request, req=GET / HTTP/1.1...
    plaque::write_http_response{}

  plaque::accept{}

4.3 JSON 结构化输出

tracing 的各层可以叠加,同时输出到终端和 JSON 文件:

rust 复制代码
.with(
    tracing_subscriber::fmt::layer()
        .json()
        .with_writer(File::create("/tmp/log.json").unwrap()),
)

每条日志变成完整的 JSON 对象,包含 span 层级、字段值等,便于后续分析处理。

4.4 OpenTelemetry + Jaeger 分布式追踪

tracing 还可以对接 OpenTelemetry 标准,将 span 数据发送到 Jaeger 等分布式追踪系统:

toml 复制代码
opentelemetry = { version = "0.17.0", features = ["rt-tokio"] }
opentelemetry-jaeger = { version = "0.16.0", features = ["rt-tokio"] }
tracing-opentelemetry = "0.17.2"
rust 复制代码
let tracer = opentelemetry_jaeger::new_pipeline()
    .install_batch(opentelemetry::runtime::Tokio)?;
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);

Registry::default()
    .with(EnvFilter::from_default_env())
    .with(HierarchicalLayer::new(2)...)
    .with(telemetry)
    .init();

在本地跑一个 Jaeger 容器:

bash 复制代码
docker run -d -p6831:6831/udp -p16686:16686 jaegertracing/all-in-one:latest

打开 http://localhost:16686,就能看到每条请求的完整追踪数据,包括各 span 的耗时、属性标签等。

4.5 tokio-console:运行时的 top(1)

tokio-console 是一个 TUI 工具,提供对 Tokio 运行时的实时观测,被称作"异步运行时的 top 命令"。

bash 复制代码
cargo install tokio-console

它同样基于 tracing 机制,通过 console-subscriber 层收集数据:

rust 复制代码
let (console, server) = console_subscriber::ConsoleLayer::builder().build();
tokio::spawn(async move { server.serve().await.unwrap(); });
// 加入 Registry

启动后可以看到所有任务的运行状态、Poll 时间直方图、waker 统计等。目前(2022年初)功能还比较初步,但规划中有很多令人期待的特性,比如"为什么这个 task 没有进展?它依赖哪些 resource?"


五、使用 axum 构建正式 HTTP 服务

手写 HTTP 服务器的乐趣到此为止。现在引入 axum------一个基于 hyper(Rust HTTP 生态的基石)和 tower(中间件生态)构建的 Web 框架:

toml 复制代码
axum = "0.4.8"
tower = "0.4.12"
tower-http = { version = "0.2.3", features = ["trace"] }
rust 复制代码
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
    tracing_stuff::setup()?;
    run_server().await?;
    tracing_stuff::teardown();
    Ok(())
}

async fn run_server() -> Result<(), Box<dyn Error>> {
    let addr: SocketAddr = "0.0.0.0:3779".parse()?;
    info!("Listening on http://{}", addr);

    let app = Router::new()
        .route("/", get(root))
        .layer(
            ServiceBuilder::new()
                .layer(TraceLayer::new_for_http())
                .into_inner(),
        );
    Server::bind(&addr).serve(app.into_make_service()).await?;
    Ok(())
}

#[tracing::instrument]
async fn root() -> impl IntoResponse {
    "Hello from plaque!\n"
}

TraceLayer 会自动为每个请求生成 span,输出类似:

复制代码
tower_http::trace::make_span::request{method=GET, uri=/, version=HTTP/1.1}
  0ms DEBUG started processing request
  0ms DEBUG finished processing request, latency=0 ms, status=200

性能上,axum 版相比手写版延迟从约 1ms 降至 0.4ms,原因是 hyper 的实现质量远高于我们手写的简陋版本。


六、实现一个有实际意义的 HTTP 服务

现在要实现一个真正有意义的接口:返回 YouTube 频道最新视频的 ID

这个接口选择有两个原因:一是 YouTube 提供公开的 RSS/Atom Feed,无需 API Key;二是它涉及外部 HTTP 调用,是引入缓存和请求合并的完美场景。

先引入 HTTP 客户端和 XML 解析依赖:

toml 复制代码
reqwest = { version = "0.11.9", features = ["stream"] }
serde = { version = "1.0.136", features = ["derive"] }
roxmltree = "0.14.1"

构建请求 URL:

rust 复制代码
use url::Url;

const YT_CHANNEL_ID: &str = "UCs4fQRyl1TJvoeOdekW6lYA";

async fn fetch_video_id() -> Result<String, Box<dyn Error>> {
    let mut api_url = Url::parse("https://www.youtube.com/feeds/videos.xml")?;
    api_url.query_pairs_mut().append_pair("channel_id", YT_CHANNEL_ID);

    let response_body = reqwest::get(api_url).await?.text().await?;
    // 解析 XML,取第一个 <yt:videoId> 的值
    let doc = roxmltree::Document::parse(&response_body)?;
    let video_id = doc
        .descendants()
        .find(|n| n.has_tag_name(("http://www.youtube.com/xml/schemas/2015", "videoId")))
        .and_then(|n| n.text())
        .ok_or("video id not found")?;
    Ok(video_id.to_string())
}

同时引入 color-eyre 改善错误展示------它能在 panic 时同时打印 SPANTRACE (异步调用链)和 BACKTRACE

复制代码
The application panicked (crashed).
Message: video id not found
Location: src/youtube.rs:25

━━━━━━━━━━━━━━━━━━━ SPANTRACE ━━━━━━━━━━━━━━━━━━━
 0: plaque::youtube::fetch_video_id
    at src/youtube.rs:10
 1: plaque::root
    at src/main.rs:35
 2: tower_http::trace::make_span::request with method=GET uri=/
    at .../tower-http-.../src/trace/make_span.rs:116

这正是 tracing 生态的强大之处:即使在异步代码中,依然能清晰地看到完整的调用上下文。


七、请求合并(Request Coalescing)的核心实现

终于到了本文的主题。

7.1 问题:惊群效应(Thundering Herd)

假设我们的服务被高并发访问,比如同时有 100 个请求进来。每个请求都会独立地去调用 YouTube API。这会带来两个问题:

  1. 对 YouTube 造成大量重复请求,可能触发速率限制(rate limit)
  2. 100 次网络调用的延迟全部叠加,服务响应时间变差

这就是"惊群效应"的一种体现:明明请求的是同一份数据,却因为并发而产生了大量重复的后端调用。

7.2 方案一:简单缓存(Naive Cache)

最直觉的解法是加一层简单缓存:

rust 复制代码
use std::sync::Arc;
use tokio::sync::RwLock;

struct AppState {
    youtube_video_id: Arc<RwLock<Option<String>>>,
    http_client: reqwest::Client,
}

处理请求时,先查缓存,有缓存就直接返回,否则发起请求后写入缓存:

rust 复制代码
async fn root(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    {
        let cache = state.youtube_video_id.read().await;
        if let Some(video_id) = cache.as_ref() {
            return video_id.clone();
        }
    }
    // 缓存未命中,去 YouTube 取
    let video_id = fetch_video_id(&state.http_client).await.unwrap();
    {
        let mut cache = state.youtube_video_id.write().await;
        *cache = Some(video_id.clone());
    }
    video_id
}

这个方案能解决"数据一旦缓存后"的重复请求问题,但它有一个明显的漏洞:在缓存填充的那一刻(即第一次请求还没完成、但后续请求已经大量涌入时),所有并发请求都会"错过"缓存,同时发起后端调用。

这正是需要请求合并的场景。

7.3 方案二:广播通道(Broadcast Channel)实现真正的请求合并

请求合并的核心思想是:如果某个请求已经在飞行中,后来的请求不要再重复发起,而是"订阅"到第一个请求的结果上,等待其完成后直接共享结果。

Tokio 的 broadcast 通道天然适合这个模式:

rust 复制代码
use tokio::sync::broadcast;

我们把状态改造成这样:

rust 复制代码
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};

type VideoIdSender = broadcast::Sender<Result<String, String>>;

struct AppState {
    // 缓存已知的结果
    youtube_video_id: Arc<RwLock<Option<String>>>,
    // 正在进行中的请求的广播发送端
    youtube_fetch_in_progress: Arc<RwLock<Option<VideoIdSender>>>,
    http_client: reqwest::Client,
}

完整的请求合并逻辑如下:

rust 复制代码
async fn fetch_or_coalesce(state: Arc<AppState>) -> Result<String, String> {
    // 1. 先查缓存
    {
        let cache = state.youtube_video_id.read().await;
        if let Some(video_id) = cache.as_ref() {
            return Ok(video_id.clone());
        }
    }

    // 2. 查是否有进行中的请求,如果有,订阅它
    {
        let in_progress = state.youtube_fetch_in_progress.read().await;
        if let Some(sender) = in_progress.as_ref() {
            let mut receiver = sender.subscribe();
            drop(in_progress);
            // 等待第一个发起者的结果
            return receiver.recv().await.map_err(|e| e.to_string())?;
        }
    }

    // 3. 没有进行中的请求,自己来发起,并建立广播通道
    let (sender, _) = broadcast::channel(1);
    {
        let mut in_progress = state.youtube_fetch_in_progress.write().await;
        *in_progress = Some(sender.clone());
    }

    // 4. 发起真实请求
    let result = fetch_video_id(&state.http_client)
        .await
        .map_err(|e| e.to_string());

    // 5. 将结果写入缓存,并广播给所有等待者
    if let Ok(ref video_id) = result {
        let mut cache = state.youtube_video_id.write().await;
        *cache = Some(video_id.clone());
    }
    {
        let mut in_progress = state.youtube_fetch_in_progress.write().await;
        *in_progress = None;
    }
    let _ = sender.send(result.clone());

    result
}

这个方案的行为是:

  • 第一个请求:发现缓存没有、也没有进行中的请求,自己发起后端调用,并在 in_progress 中留下广播通道
  • 后续并发的请求:发现有进行中的请求,立即订阅广播通道,挂起等待
  • 第一个请求完成:写入缓存,通过广播通道把结果发给所有等待者,所有等待者同时得到结果
  • 再之后的请求:直接命中缓存,无需任何后端调用

这就是请求合并(Request Coalescing)、也叫请求去重 (Request Deduplication)或 Single-Flighting的精髓。

7.4 完整实现的整体结构

将上述逻辑与 axum 整合,最终的服务结构如下:

rust 复制代码
// src/main.rs
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
    color_eyre::install()?;
    tracing_stuff::setup()?;

    let state = Arc::new(AppState {
        youtube_video_id: Arc::new(RwLock::new(None)),
        youtube_fetch_in_progress: Arc::new(RwLock::new(None)),
        http_client: reqwest::Client::new(),
    });

    let app = Router::new()
        .route("/", get(root))
        .with_state(state)
        .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()).into_inner());

    Server::bind(&"0.0.0.0:3779".parse()?)
        .serve(app.into_make_service())
        .await?;

    tracing_stuff::teardown();
    Ok(())
}

#[tracing::instrument(skip(state))]
async fn root(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    match fetch_or_coalesce(state).await {
        Ok(video_id) => video_id,
        Err(e) => format!("Error: {}", e),
    }
}

八、总结与回顾

这篇文章走了一条从底层到高层的完整路径:

异步基础方面 ,我们从阻塞 I/O 出发,理解了多线程的局限性,进而引入了 Tokio 异步运行时。通过 GDB 调试揭示了 epoll_wait 是异步 I/O 的底层基础,#[tokio::main] 只是对运行时构建的语法糖。

可观测性方面 ,我们系统地了解了 Rust 的 tracing 生态:从基础的 tracing + tracing-subscriber,到 tracing-tree 的层级化展示,再到结构化 JSON 日志、OpenTelemetry/Jaeger 分布式追踪,以及 tokio-console 这个运行时调试利器。tracing 的分层设计(layer 模式)允许多个输出目标并存,极为灵活。

Web 框架方面,axum 展现了 Rust 生态的成熟:基于 hyper 的高性能底层,加上 tower 中间件体系,代码简洁且性能出色(P99 延迟约 0.5ms)。

核心主题------请求合并方面 ,我们清晰地演示了问题的由来(惊群效应),以及通过 tokio::sync::broadcast 实现的优雅解法:让所有并发的重复请求"订阅"同一个飞行中的请求,避免对后端的重复调用。这个模式在任何需要"昂贵操作去重"的场景下都适用,不仅限于 HTTP 缓存。


涉及的主要 crate
tokioaxumhypertowertower-httptracingtracing-subscribertracing-treetracing-chromeopentelemetryopentelemetry-jaegertracing-opentelemetrytokio-consoleconsole-subscriberreqwestroxmltreecolor-eyretracing-errorurl

相关推荐
RSTJ_16251 小时前
PYTHON+AI LLM DAY FIFITY-THREE
开发语言·人工智能·python
JAVA社区1 小时前
Java进阶全套教程(一)—— 数据框架Mybatis详解
java·开发语言·面试·职场和发展·mybatis
lzp07911 小时前
C#如何优雅处理引用类型的深拷贝(贰)
spring boot·后端·ui
UEBqbZvUB1 小时前
基于 Flask 框架开发的在线学习平台,集成人工智能技术,提供分类练习、随机练习、智能推荐等多种学习模式 HTTPS ECDHE 握手全解析
开发语言·flask·java-consul
qq_2518364571 小时前
基于java 安卓-RSS阅读系统毕业论文
android·java·开发语言
之歆1 小时前
Day15_JavaScript DOM 事件完全指南:从基础到实战(上)
开发语言·javascript·ecmascript
JAVA社区1 小时前
Java进阶全套教程(八)—— Docker超详细实战详解
java·运维·开发语言·docker·容器·面试·职场和发展
Mr.Java.1 小时前
Spring AI MCP Server分布式翻车现场:Streamable协议的甜蜜与危险,以及无状态救赎
java·后端·spring·ai·负载均衡
夕除1 小时前
spring boot 11
java·spring boot·后端