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

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


内容结构概览

markdown 复制代码
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

rust 复制代码
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,输出会带上时间戳和日志级别:

ini 复制代码
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();

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

css 复制代码
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,输出类似:

ini 复制代码
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

ruby 复制代码
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

相关推荐
王立志_LEO7 小时前
Gunicorn 启动django服务
后端
fliter7 小时前
一个让我调试一周的 Rust match 陷阱
后端
一只大袋鼠7 小时前
SpringBoot 初学阶段知识点汇总(一)
spring boot·笔记·后端
Rust研习社7 小时前
Rust 官方拟定 LLM 政策,防止 LLM 污染开源社区?
开发语言·后端·ai·rust·开源
无风听海7 小时前
ASP.NET Core Minimal API 深度解析
后端·asp.net
IT_陈寒7 小时前
Java的finally块竟然不是你想的那个finally!
前端·人工智能·后端
zb200641207 小时前
Laravel4.x核心特性全解析
spring boot·后端·php·laravel
techdashen8 小时前
在 Async Rust 中实现请求合并(Request Coalescing)
开发语言·后端·rust
lzp07918 小时前
C#如何优雅处理引用类型的深拷贝(贰)
spring boot·后端·ui