本节摘要:SOURCE 第六章与第一章挑战均指向同一类问题——响应式外壳 + 命令式内核。
.block()、无界 push、忽略背压、在 EventLoop 阻塞,比不用响应式更糟。
| 反模式 | 表现 | 改法 |
|---|---|---|
| 阻塞伪装 | mono.block() everywhere |
全链路异步或隔离 boundedElastic |
| 伪异步 | Mono.fromCallable(() -> jdbc) 无切换 |
R2DBC 或明确 subscribeOn |
| 背压盲区 | create 无界 emit |
使用 RS 合规 sink + request |
| 错误黑洞 | 裸 subscribe() |
传 error consumer + 全局 Hooks |
SOURCE:精通 Spring MVC 的工程师常需 3–6 个月 才写出符合响应式精神的代码——赶工期的「Mono 包装同步」引入更隐蔽死锁。
阻塞伪装是最普遍的反模式:链中间某处调用 .block() 拿结果,导致整个 EventLoop 线程被挂起等待。更隐蔽的是隐性阻塞——在 flatMap 里调用 Thread.sleep、System.currentTimeMillis 循环、阻塞 SDK(Jedis、JDBC、Apache HttpClient),调用点没有 .block() 字样,但行为一模一样。
// 反模式:阻塞伪装 public Mono<String> get() { String db = jdbc.query("...").block(); // ← EventLoop 在此挂起 return Mono.just(db); } // 反模式:伪异步(没有切换线程) Mono<String> bad = Mono.fromCallable(() -> jdbc.query("...")); // ← 若没有 subscribeOn(boundedElastic),查询仍在 EventLoop 执行
伪异步的本质是「包装了却不在对的地方执行」:Mono.fromCallable(() -> jdbc.query()) 看起来异步,但如果后续没有 .subscribeOn(Schedulers.boundedElastic()),阻塞代码依然跑在 EventLoop 上。判断标准:阻塞代码有没有离开 EventLoop 线程? 没有,就是伪异步。
背压盲区:手写 Flux.create(emitter -> { while(...) emitter.next(x); }) 时忽略 emitter 的 requested 状态,无限发射。Reactor 的 Flux.create 支持背压(emitter 有 requested 计数),但需要生产者配合——不检查 requested,背压就只是摆设。
错误黑洞:flux.subscribe() 不传错误消费者,异常进入全局 Hooks.onErrorDropped 或被静默吞掉,运维根本看不到。所有 subscribe() 必须至少带一个 error 消费者,或使用 log() 观察。
| 场景 | 建议 |
|---|---|
| 低 QPS CRUD | 保持 MVC |
| 重 CPU 批处理 | 并行流/队列可能更简单 |
| 团队无 RS 经验 | 先网关/读路径试点 |
响应式适合:I/O 密集、高并发、流式推送、端到端延迟敏感(Netflix 事件、Uber 50ms 调度)。
响应式不是「更高端的默认选项」,它有自己的适用边界。判断标准有三个:
| 阶段 | 动作 | 风险控制 |
|---|---|---|
| 0 | 保持 MVC | — |
| 1 | 新读接口用 WebFlux | 旧接口不动,灰度 |
| 2 | 非阻塞 HTTP Client | 阻塞 DB 用 boundedElastic 隔离 |
| 3 | R2DBC 逐表迁移 | 每表留 JDBC 回退开关 |
| 4 | 全链路响应式 | StepVerifier + 背压测试门禁 |
迁移的关键纪律:每一步都必须可回退、可灰度、可测试。响应式迁移最大的坑不是技术,而是一次性「大爆炸」——把所有接口一次性改造成响应式,出问题时无法定位是哪一段引入的。按「读路径 → 非阻塞客户端 → 数据层」的次序推进,每步都有明确的验证标准,才是可持续的迁移方式。
// 逐条自查:你的代码命中了几条? // [ ] 链内出现 .block() / .blockFirst() / .blockLast() // [ ] flatMap 内直接调用阻塞 SDK(JDBC/Jedis/RestTemplate) // [ ] Mono.fromCallable 后没有 subscribeOn(boundedElastic) // [ ] Flux.create 中不看 emitter.requested() 就无限发射 // [ ] subscribe() 不传 error 消费者 // [ ] 在 EventLoop 里做 CPU 密集计算(没有 publishOn)
命中 2 条以上,代码大概率是「响应式外壳 + 命令式内核」——建议先重构 I/O 边界,再谈优化。
// 典型案例:看似响应式,实则阻塞 public Mono<List<User>> findUsers() { // 问题1:JDBC 查询是阻塞的 List<User> users = jdbc.query("SELECT * FROM users", ROW_MAPPER); // 问题2:包装成 Mono 但没有切换线程,仍在 EventLoop 执行 return Mono.just(users); }
这段代码有两个叠加的错误:JDBC 查询本身阻塞调用方线程;Mono.just(users) 包装已完成的值,没有非阻塞语义。如果它在 WebFlux 控制器里被调用,EventLoop 会在查询期间被挂起——高并发下所有请求排在这个查询后面,等价于把响应式降级成阻塞模型。
// 正确改法(分两档) // 档一:暂时无法换 R2DBC → 明确隔离阻塞 return Mono.fromCallable(() -> jdbc.query("SELECT * FROM users", ROW_MAPPER)) .subscribeOn(Schedulers.boundedElastic()); // 档二:彻底响应式 → 用 R2DBC return r2dbcTemplate.select("SELECT * FROM users", ROW_MAPPER).all();
档一承认阻塞现实但隔离它——fromCallable + subscribeOn(boundedElastic) 让查询在独立的有界线程池执行,EventLoop 不再被挂起;档二才是真正的响应式。工程上允许「隔离的阻塞」,禁止「隐性的阻塞」——后者是排障最困难的反模式。
背压盲区(create 无界发射)通常要到内存告警才暴露。主动检测手段:
// 手段一:订阅时观察 request 压力 Flux.create(emitter -> { // 每次发射前检查 requested if (emitter.requestedFromDownstream() == 0) { // 下游没有请求,等待而非继续发射 return; } emitter.next(nextItem()); }) .onBackpressureBuffer(1024); // 手段二:用指标观察在途量 Flux.range(1, 1_000_000) .name("ingest") .metrics() // 观察 on_next_delay 突增 .subscribe(consumer);
结合 6.2 的指标手段,背压盲区不再是「等 OOM 才知道」——on_next_delay 突增、内存曲线上升时,即可定位到无界发射点。

一句话总结:响应式反模式的共同根源是「把响应式当语法糖用」——.block()、伪异步、背压盲区、错误黑洞都在破坏契约;正确的做法是承认适用边界、渐进迁移、逐条自查,让每一跳都真正兑现非阻塞与背压。
回到导读:用 Publisher/Subscriber/
request(n)重画你的系统数据流。