本文是对 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。这会带来两个问题:
- 对 YouTube 造成大量重复请求,可能触发速率限制(rate limit)
- 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
tokio、axum、hyper、tower、tower-http、tracing、tracing-subscriber、tracing-tree、tracing-chrome、opentelemetry、opentelemetry-jaeger、tracing-opentelemetry、tokio-console、console-subscriber、reqwest、roxmltree、color-eyre、tracing-error、url