WebFlux 的请求跑在
reactor-http-nio-*事件循环线程上,这个线程不允许阻塞。chatClient...call().content()底层会阻塞等整段响应,于是 Reactor 直接抛IllegalStateException,而不是死等。WebFlux 里要流式:用.stream().content()返回Flux<String>;确实需要阻塞时切到Schedulers.boundedElastic()。
Issue & Env(案发现场与环境)
- 业务场景:Spring WebFlux 项目里接 Spring AI。在 WebFlux controller、WebFilter 或 gateway filter 里直接调
chatClient.prompt()...call().content();或者拿到chatModel.stream(prompt)的Flux,想收集成字符串,就在事件循环线程里blockLast()。 - 环境:Spring AI 1.1.x + Spring WebFlux(Netty)。线程名里带
reactor-http-nio-。
StackTrace(堆栈日志)
java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-3
at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:83)
at reactor.core.publisher.Mono.block(Mono.java:1758)
关键信息有两个:方法名(block 系列)和线程名(reactor-http-nio-*)。线程名对上了,基本就是这个问题。
Reproduce(复现代码)
WebFlux controller 里直接 call:
@GetMapping("/chat")
public Mono<String> chat(@RequestParam String q) {
// 事件循环线程上执行,call() 会阻塞
String answer = chatClient.prompt().user(q).call().content();
return Mono.just(answer);
}
或者把流式结果在事件循环里收集:
@GetMapping("/chat/stream")
public Mono<String> stream(@RequestParam String q) {
// 想"先收齐再返回",block 直接触发异常
String all = chatClient.prompt().user(q).stream().content()
.collectList()
.block()
.stream()
.collect(Collectors.joining());
return Mono.just(all);
}
Root Cause(根因分析)
事件循环线程禁止阻塞
Netty(WebFlux 默认容器)用少量线程处理所有请求,线程名形如 reactor-http-nio-N。Reactor 在这些线程上装了“禁止阻塞”的检查:一旦调用 block()、blockFirst()、blockLast(),直接抛 IllegalStateException。
抛异常是保护,不是 bug
为什么不干脆等下去?因为线程一阻塞,同一个事件循环上排队的其他请求全卡住,吞吐直接崩。Reactor 宁可当场报错,也不让一个慢调用拖垮整条事件循环。
call() 本质上就是阻塞
ChatClient 的 .call() 会等模型把整段回答生成完,再返回完整文本;.content() 只是取其中字符串。它内部最终会走到阻塞等待,所以在 WebFlux 请求线程上调用,等于主动阻塞事件循环。
call() 和 stream() 不能在同一次请求里混用
一次请求要么走 .call()(一次性拿到完整响应),要么走 .stream()(拿 Flux)。别在流式链路里再 call() 一把,也别指望同一个 response spec 两种都来一遍。
The Fix(解决方案)
1. WebFlux 里返回 Flux,别中途收集
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String q) {
return chatClient.prompt().user(q).stream().content();
}
把 Flux<String> 直接交给框架,由它订阅、写出。中间不要 collectList(),也不要 block()。
2. 确实要阻塞就切线程池
写库、调同步 SDK 这类必须阻塞的操作,切到 Schedulers.boundedElastic():
@GetMapping("/chat")
public Mono<String> chat(@RequestParam String q) {
return Mono.fromCallable(() -> chatClient.prompt().user(q).call().content())
.subscribeOn(Schedulers.boundedElastic());
}
boundedElastic 是给阻塞任务准备的弹性线程池,不会占用事件循环。别用 Schedulers.parallel(),那是给 CPU 密集任务用的,同样不该塞阻塞调用。
3. 别替库擦屁股
如果调用栈里的 block 不是你自己写的,而是某个库内部干的,先把那个库换成响应式版本(比如用 WebClient 而不是 RestClient),比在外面套一层线程池更干净。
4. 排查办法
看异常里的线程名。带 reactor-http-nio- 的,就是事件循环;带 boundedElastic- 的,说明已经切过去了。
