回调地狱不是「写多了」的问题
如果你写过足够多的异步代码,一定经历过这种场景:
fetchUser(userId, user -> {
fetchOrders(user.getAccountId(), orders -> {
validateInventory(orders, valid -> {
chargePayment(valid.getTotal(), receipt -> {
sendNotification(receipt, ignored -> {
log.info("done");
});
});
});
});
});
这被称为「回调地狱(Callback Hell)」。常见的解决思路是把它拍扁——Java 8 引入了 CompletableFuture:
fetchUser(userId)
.thenCompose(u -> fetchOrders(u.getAccountId()))
.thenCompose(this::validateInventory)
.thenCompose(v -> chargePayment(v.getTotal()))
.thenCompose(this::sendNotification);
看起来好很多。但如果你在深夜收到一条告警——某个服务因为下游响应变慢,内存持续爬升直到 OOM——你会发现 CompletableFuture 并没有解决根本问题。
根本问题是:生产者不知道消费者有多快。
在 CompletableFuture 的链式调用里,上游一旦产生结果,就会立刻向下游推送。如果下游处理速度只有上游的十分之一,结果会在下游的缓冲区里堆积。Java 的默认线程池队列有界吗?CompletableFuture 默认用 ForkJoinPool.commonPool(),它的队列是有界的,但缓冲在中间阶段——每个 thenCompose 返回的新 CompletableFuture 都会持有下游引用,而下游还没被调用。当链够长、流量够大时,这些未被消费的中间对象会把堆填满。
这不是代码写多了的问题。这是缺乏速率协商机制的问题。
Promise/Future 的局限:推模型没有刹车
CompletableFuture 是一种「推模型(Push Model)」的异步抽象。推模型的特点是:
- 事件由上游主动产生:数据准备好了,立刻向下游推送。
- 下游被动接收:下游没有能力说「先别发了,我处理不过来」。
- 缓冲是隐式的:中间阶段的 Future 对象充当了隐式队列。
Java 的 Stream API 提出了另一种思路——中间操作是惰性的,但 Stream 仍然是同步的。你不能在 stream.filter().map().forEach() 的管道中间插入「慢一点」的指令。
Reactive Streams 要做的是:在异步推模型里加一个「拉」的刹车。
Reactive Streams:不是框架,是契约
2013–2015 年间,Netflix 的 RxJava、Pivotal 的 Reactor、Lightbend 的 Akka Streams 各自实现了响应式流,但 API 不兼容。一个 Netty 项目用了 Reactor,另一个服务用了 RxJava,两者之间的数据流无法直接对接。
2015 年,四家组织在 reactive-streams.org 下发布了 Reactive Streams 规范。它不是一个框架,而是一组接口契约:
Publisher<T>:数据的生产者Subscriber<T>:数据的消费者Subscription:两者之间的速率协商通道Processor<T,R>:既是消费者又是生产者的桥接
最核心的约束是:Subscriber 通过 Subscription.request(n) 告诉 Producer「我可以再处理 n 个元素」,Producer 只能在收到 request 后才发 onNext。
这就是「背压(Backpressure)」——不是缓冲区变大,而是消费者主动控制生产者的发射节奏。
背压为什么重要:先算积压,再谈内存
假设 Fast Producer 每秒产生 10,000 条消息,Slow Consumer 每秒只能处理 100 条。两者的差值是每秒 9,900 条,1 分钟积压:
(10,000 - 100) × 60 = 594,000 条
按每条消息原始负载 1 KB 估算,约 594 MB;实际堆占用还要加上对象头、引用、队列节点和扩容余量,所以这个数字只能用来判断量级,不能当作 JVM 实测值。
| 策略 | 1 分钟后的状态 | 结果 |
|---|---|---|
| 无背压(推模型) | 约 594,000 条待处理 | 取决于队列:有界队列满后阻塞或拒绝,无界队列持续增长 |
| Buffer(无限缓冲) | 约 594,000 条且继续增长 | 迟早 OOM |
| Buffer(有界 1000) | 消费 6,000 条,缓冲 1,000 条,丢弃约 593,000 条 | 内存可控,但丢数据 |
| Drop Latest | 缓冲只保留最新 1 条,丢弃约 593,999 条 | 内存可控,只保留最新 |
| Error | 缓冲满后 onError | 立刻暴露问题 |
有界缓冲的丢弃量算法是「总产生 - 已消费 - 仍在缓冲」:600,000 - 6,000 - 1,000 = 593,000。上表取约数,实际值取决于缓冲实现和消费进度。
Reactive Streams 规范不要求你用哪种策略,但它要求你必须显式选择。你不能假装这个问题不存在。
本书的边界
Java 生态已经有大量响应式相关内容。本书不是框架使用说明书,而是从规范到实现的系统书:
| 已有的内容 | 本书不写 | 本书写什么 |
|---|---|---|
| 《Spring Boot 高级》第 8 章 | WebFlux 自动配置、Spring 容器启动 | RS 规范的 43 条规则逐条分析 |
| 《Netty 高性能网络编程》 | ByteBuf 引用计数、ChannelPipeline 源码 | 调度器线程模型、背压策略选择 |
| 《Java 并发编程》三卷 | JMM happens-before、AQS 源码 | Executor 与 Scheduler 的映射关系 |
| 《Java 消息中间件》三卷 | Kafka Producer/Consumer API、分区日志 | Kafka Streams DSL(卷三) |
| 《Java 服务端通信工程》 | gRPC 四种调用模式、服务发现 | 流式调用的背压衔接(卷二) |
三卷分工:
- 卷一(本卷):Reactive Streams 规范、从零实现 Publisher/Subscriber、Project Reactor 核心、背压策略、RS TCK 合规测试。
- 卷二:Reactor Netty、WebFlux 请求管线、R2DBC 响应式数据访问、响应式安全与测试。
- 卷三:Kafka Streams、Flink DataStream、窗口、状态管理、Exactly-Once、CEP。
贯穿项目 eventd(Event-Driven Demo)在三卷中逐步演化:
eventd v1(卷一):自实现 RS Publisher → Project Reactor 重写 → 背压演示
eventd v2(卷二):Reactor Netty 网关 → WebFlux API → R2DBC 数据管道
eventd v3(卷三):Kafka Streams 清洗 → Flink 窗口聚合 → 实时指标面板
如何使用本书
如果你完全没接触过响应式编程
- 先读第 1 章(RS 规范),理解「为什么需要 request(n)」。
- 读第 2–3 章,用手写代码验证每一条规则。
- 第 4–7 章是 Reactor 的核心,花最多时间在这里。
- 第 7 章(背压策略)是生产中最容易出问题的部分,务必做实验。
- 第 11 章(TCK)验证你的理解:自己写的 Publisher 能不能通过 22 个 required 用例?
如果你已经用过 WebFlux 或 Reactor
- 跳过第 4–6 章的操作符,直接从第 1 章(RS 规范)开始。
- 重点看第 2–3 章(手写实现)和第 7 章(背压策略)。
- 第 8–10 章(Hot/Cold、Sinks、RxJava 对照)帮你建立「不只是会用」的直觉。
- 第 12 章(调试)是生产排障必备。
如果你只想看代码和可运行示例
- 读前言(环境配置)。
- 跳到第 2–3 章(手写实现)。
- 跳到第 13–14 章(eventd v1 架构 + 背压演示 + 性能基线)。
- 附录 A–E 作为速查手册。
一个提醒:不要神化响应式
响应式编程不是银弹。它擅长处理高并发、数据流持续演进、上下游速率不匹配的场景。但如果你的系统只是偶尔发一条 HTTP 请求、等一个数据库查询结果,CompletableFuture 或同步阻塞代码更简单、更容易理解。
本书的目标不是让你在所有场景都用 Flux,而是让你在需要时,能做出可验证的正确选择。
全书路线
序(本页)→ 前言(环境)→ 目录 → 第 1 章(RS 规范)
→ 第 2–3 章(手写实现)→ 第 4–6 章(Reactor 核心)
→ 第 7 章(背压策略)→ 第 8–10 章(Hot/Cold + Sinks + RxJava)
→ 第 11 章(TCK)→ 第 12 章(调试)→ 第 13–14 章(eventd 验收)
下一章从零开始:第 1.1 节,我们先看看异步编程是怎么从回调走到今天的。
常见问题 FAQ
Q: 生产环境应该用哪种背压策略?
A: 取决于数据语义:
- 不能丢数据 →
onBackpressureBuffer(必须设上限)+ 监控缓冲深度和溢出 - 只关心最新状态 →
onBackpressureLatest - 允许丢数据 →
onBackpressureDrop+ 死信队列 - 宁可失败也不静默丢/无限缓冲 →
onBackpressureError
Q: 背压策略和调度器有什么关系?
A: 两者正交。调度器决定「在哪条线程执行」,背压策略决定「上游发得比下游快时怎么办」。publishOn 会引入自己的预取队列,默认 Queues.SMALL_BUFFER_SIZE(256),所以「只是换个线程」也可能改变缓冲行为。subscribeOn 不引入预取:它只是把上游的 subscribe/request/cancel 搬到指定调度器执行,下游请求多少就向上游转多少。
大部分 Reactor 操作符本身遵守 request(n),不需要逐个挂背压操作符;onBackpressure* 主要用在无法响应请求的源(如某些回调式 API)或确实要改变速率的边界点。
Q: 多个操作符链时背压如何传递?
A: 请求信号沿链反向传递,每个操作符负责把它转发给上游并按自己的语义折算。map/filter 原样转发,flatMap 会按 prefetch 预取并拥有内部队列:
source.onBackpressureBuffer() // 只在源处缓冲
.map(...) // 转发 request,不缓冲
.flatMap(..., 16, 32) // 自己的预取与内部队列
.subscribe();
Q: TCK 测试失败怎么办?
A: 逐条分析失败测试:
- 查看失败信息中的规则编号
- 对照附录 C 的规则表查找原因
- 检查实现代码,修复违规点
- 重新运行测试
检查清单
- 代码已编译通过(
javac --release 25) - 单元测试通过(
mvn test) - TCK 测试通过(如适用)
- 背压策略已选择并测试
- 调度器选择合理
- 错误处理覆盖所有异常路径
- 超时和取消逻辑正确
- 内存无泄漏(长时间运行测试)
- 日志输出合理
- 文档已更新
常见陷阱速查
| 陷阱 | 原因 | 解决方案 |
|---|---|---|
block() 在主线程 | 阻塞响应式线程 | 使用 subscribe() 或 StepVerifier |
flatMap 默认并发过高 | 默认 256,可能压垮下游 | 显式设置 concurrency |
onBackpressureBuffer 无界 | 内存无限增长 | 设置上限和溢出策略 |
cache() + 无限流 | 内存泄漏 | 用 cache(n) 限条数,或改用 Sinks 带界重放 |
retry 无限重试 | 雪崩效应 | 始终限制重试次数 |
timeout + 阻塞操作 | 超时无法中断阻塞线程 | 使用非阻塞 API |
single() 全局共享 | 所有流串行 | 创建独立调度器 |
parallel() + 阻塞 | 线程饿死 | 切换到 boundedElastic() |
忘记 dispose() | 资源泄漏 | 保存 Disposable 并在 doFinally/生命周期结束时释放 |
| 异常被静默吞掉 | 未处理 onError | 始终提供错误处理器 |
相关阅读
| 主题 | 推荐阅读 |
|---|---|
| Reactive Streams 规范 | reactive-streams.org |
| Project Reactor 文档 | projectreactor.io/docs |
| RxJava 文档 | github.com/ReactiveX/RxJava |
| Reactor 背压指南 | Project Reactor Reference Guide §8 |
| 《Java 并发编程》 | 本站「Java 并发基础」系列 |
| 《Netty 高性能网络编程》 | 本站「java-netty」系列 |
| 《Java 消息中间件》 | 本站「java-mq-*」系列 |
延伸阅读与参考
官方文档
| 资源 | 链接 | 说明 |
|---|---|---|
| Reactive Streams 规范 | reactive-streams.org | 规范原文与接口定义 |
| Project Reactor 参考文档 | projectreactor.io/docs | 完整的操作符参考 |
| RxJava GitHub | github.com/ReactiveX/RxJava | 源码与 Wiki |
| Reactor JavaDoc | projectreactor.io/docs/core/release/api | API 文档 |
推荐书籍
| 书名 | 作者 | 重点 |
|---|---|---|
| 《Reactive Programming with RxJava》 | Tomasz Nurkiewicz | RxJava 实战 |
| 《Hands-On Reactive Programming in Spring 5》 | Oleh Dokuka | Spring + Reactor |
| 《Java 编程实战》 | 本站 | 工程基础 |
| 《Java 并发编程》 | 本站 | 线程与调度 |
在线资源
本机实验环境
本卷所有代码在以下环境验证:
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8
Maven 3.9.12
Project Reactor BOM 2025.0.8
RxJava 3.1.12
RS TCK 1.0.4
版本兼容性说明
| 组件 | 本卷版本 | JDK 25 兼容 | 注意事项 |
|---|---|---|---|
| reactor-core | 3.8.x (BOM 2025.0.8) | ✅ | — |
| rxjava | 3.1.12 | ✅ | — |
| reactive-streams | 1.0.4 | ✅ | — |
| junit-jupiter | 5.14.4 | ✅ | — |
后续 Reactor 2026.x 版本可能引入新操作符,但本卷的背压原理和信号契约不会变化。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。