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- 的,說明已經切過去了。
