哪里可以用
主线位置:主线的反面,什么时候不该用它。
stream!的惰性是运行时一段的性质:块只在消费者 poll 时执行,消费者丢掉流,执行就停。下面三处候选都要先过这一关:这件事该不该跟着消费者的生死走。
flowchart TB
q1{"有人在实时看逐项结果吗"}
q1 -->|没有| no["不用:一次返回报告或写库就够"]
q1 -->|有| q2{"消费者走了,活还要不要干完"}
q2 -->|要| ch["spawn 干活,通道或 watch 推进度"]
q2 -->|不要| st["try_stream! 产出,交给 Sse"]
候选一:搜索增量重建的进度
reindex_incremental 收集文件和数据库两类文档,和上一版 manifest 做差分,写入索引,最后只返回一个 IncrementalReport { upserts, deletes, elapsed_ms }。它有两个调用方:
main.rs第 324–338 行:启动时spawn一个定时任务,每 600 秒调一次,结果写日志。search_reindexserver 函数:管理员手动触发,等它跑完后回一个报告(第 136 行)。
可以把它改成 try_stream!,每处理完一批 yield 一条进度,交给 axum 的 Sse 推给后台页面。下面这个独立的小程序把这件事做了一遍,用睡眠模拟分批重建,验证这条路在 async-stream 0.3.6 + axum 0.8.9 上实际是什么样子:
async-stream = "=0.3.6"
axum = "=0.8.9"
futures = "0.3"
tokio = { version = "=1.54.1", features = ["full"] }
use std::time::Duration;
use async_stream::try_stream;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::{routing::get, Router};
use futures::Stream;
// 假装在分批重建索引:每批 50ms,第 `fail_at` 批失败
async fn index_batch(batch: u32, fail_at: Option<u32>) -> Result<usize, String> {
tokio::time::sleep(Duration::from_millis(50)).await;
if Some(batch) == fail_at {
return Err(format!("batch {batch} failed"));
}
Ok(100)
}
fn progress(fail_at: Option<u32>) -> impl Stream<Item = Result<Event, String>> {
try_stream! {
let mut done = 0;
for batch in 1..=3 {
done += index_batch(batch, fail_at).await?;
println!(" [server] batch {batch} indexed");
yield Event::default().event("progress").data(format!("{batch}/3 docs={done}"));
}
yield Event::default().event("done").data(format!("docs={done}"));
}
}
async fn ok() -> Sse<impl Stream<Item = Result<Event, String>>> {
Sse::new(progress(None)).keep_alive(KeepAlive::default())
}
async fn fail() -> Sse<impl Stream<Item = Result<Event, String>>> {
Sse::new(progress(Some(2))).keep_alive(KeepAlive::default())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/reindex", get(ok)).route("/reindex-fail", get(fail));
let listener = tokio::net::TcpListener::bind("127.0.0.1:38411").await.expect("bind");
println!("listening on {}", listener.local_addr().expect("addr"));
axum::serve(listener, app).await.expect("serve");
}
axum 0.8.9 的 Sse 要求流是 Stream<Item = Result<Event, E>> + Send + 'static、E: Into<BoxError>,不要求 Sync(SseBody 里自己包了一层 SyncWrapper),所以 0.3.6 里 AsyncStream 的 Sync 跟着 async 块走(poll_next)对这里没有影响。错误类型用 String,它能转成 BoxError。
服务只绑 127.0.0.1,用 curl 测三种情况。正常跑完:
$ curl -sS -N -i http://127.0.0.1:38411/reindex
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
transfer-encoding: chunked
date: Sat, 31 Oct 2026 13:22:15 GMT
event: progress
data: 1/3 docs=100
event: progress
data: 2/3 docs=200
event: progress
data: 3/3 docs=300
event: done
data: docs=300
第 2 批失败,? 把 Err 交给 axum:
$ curl -sS -N http://127.0.0.1:38411/reindex-fail
event: progress
data: 1/3 docs=100
curl: (18) transfer closed with outstanding read data remaining
客户端在 75 毫秒时断开(--max-time 0.075),服务端再等 0.5 秒后看日志,三次结果相同:
curl: (28) Operation timed out after 82 milliseconds with 36 bytes received
-- server log
listening on 127.0.0.1:38411
[server] batch 1 indexed
两个现象都直接来自前面几章:
- 出错时客户端看不到错误。
?把Err作为最后一项交出(? 之后流就结束了),axum 拿到Err就中断响应体,curl 报传输未完成,batch 2 failed这句话没有到达客户端。想让页面显示错误,就别用?,自己match,yield一个event("error")的事件,再return。 - 客户端一断,活就停了。 断开后 axum 丢掉响应体,
AsyncStream随之被 drop,第 2 批正在等的那次睡眠连同整个 async 块一起被丢掉,batch 2 indexed再也没出现。这就是惰性:流不被 poll 就不执行(为什么需要它)。
第二个现象对搜索重建是个问题:管理员关掉页面,重建就停在半路。真实的 reindex_incremental 先改索引、最后才保存 manifest,停在中间会怎样,这里没有验证;但「关页面会中断重建」本身就不是想要的语义。如果真要推进度,形状应当是 spawn 一个任务干活、用 tokio::sync::watch 或通道发进度,SSE 只订阅进度,断开不影响任务。那正是为什么需要它里的「通道 + spawn」,不是 stream!。
现在不用:重建按 mtime 差分,结果里带 elapsed_ms,平时由定时器触发,没有人盯着进度看;手动触发的 search_reindex 也只要最后的报告。
候选二:论文解读逐篇生成
generate_day 取当天待生成的论文,逐篇调用模型,每篇之间等 ARXIV_INTERVAL(3 秒),结果写库,统计累加进 stats。逐篇产出结果在形状上正好是一个流:for 循环里每篇 yield 一个结果。
现在不用:它跑在单独的 papers-ingest 程序里,文件头注释写明由服务器的 cron 每天跑一次;结果写进数据库,页面读库就够了,没有一个在线的消费者要逐篇接收。单篇失败已经是「记下来、继续下一篇」(mark_failed 后进入下一轮循环),这层语义不需要流来表达。如果哪天要在后台页面上手动触发并实时看进度,它和候选一的问题一样:每篇要先抓全文、再调两次模型(generate_one),不应该因为关掉页面就停在半路,还是 spawn + 进度通道的形状。
候选三:全表扫描改分页流
搜索索引收集论坛主题时,collect_topics 用 .all(&db) 一次把所有已审核的主题读进内存,再逐个转成索引文档。可以改成 .paginate(&db, n).into_stream(),一页一页地读、一页一页地转,内存里同时只有一页。这正好用上 sea-orm 里那段 stream!(依赖树里的 async-stream)。
现在没有证据说该改:本卷不连服务器,没有查线上 topics 表的行数,也没有量过这次 .all() 的内存峰值。另外,collect_topics 的结果最后还是要整体交给差分(diff_for_reindex 拿的是完整的 Vec),只把读库这一步改成流,峰值省不下多少。要判断值不值得改,先要有一个数:线上行数,或者重建时的内存峰值。这个数本卷没有,所以下面不写这一条的阈值。
重估条件
「本站第一次出现流式 HTTP 响应」是一个可以直接检查的事实。在框架仓库根目录执行:
git grep -qE 'Sse::new|Body::from_stream|into_stream\(|ServerEvents' -- crates
echo $?
在 ceffc19 上退出码是 1(没有匹配),这一节的结论成立。退出码变成 0,说明有人加了 SSE、流式响应体、into_stream 或 dioxus 的 ServerEvents,那时回到这一节,看新代码属于上面哪一种形状。这条命令反向验证过:同一个模式对上面那个 SSE 示例的源文件执行,退出码是 0。
小结
- 三处候选(搜索重建进度、论文逐篇生成、全表扫描)现在都不用 async-stream:前两处没有在线的消费者,结果写日志、写库就够;第三处没有行数或内存的数据,没有证据说该改。
- 实测
try_stream!+ axum 0.8.9Sse:正常时逐条推送;?出错时 axum 中断响应,客户端只看到curl: (18),看不到错误内容;客户端断开后流被 drop,第 2 批再也没执行。 - 所以「进度推送」不一定是
stream!的场景:活要干完的,用spawn干活、通道或watch推进度;活可以跟着连接一起停的,才用try_stream!直接交给Sse。 - 重估条件是一条
git grep,ceffc19上退出码为 1,对示例源文件为 0;变成 0 时回来重看。