序:从回调地狱到背压契约

本序从回调地狱的三宗罪出发,说明为什么 Promise 和 CompletableFuture 都无法解决根本问题,引出 Reactive Streams 规范作为信号契约的诞生背景;同时声明三卷分工与本书边界——不写 Netty 源码、不写 Spring WebFlux 配置、不写 Kafka Streams 拓扑。

回调地狱不是「写多了」的问题

如果你写过足够多的异步代码,一定经历过这种场景:

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)」的异步抽象。推模型的特点是:

  1. 事件由上游主动产生:数据准备好了,立刻向下游推送。
  2. 下游被动接收:下游没有能力说「先别发了,我处理不过来」。
  3. 缓冲是隐式的:中间阶段的 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. 先读第 1 章(RS 规范),理解「为什么需要 request(n)」。
  2. 读第 2–3 章,用手写代码验证每一条规则。
  3. 第 4–7 章是 Reactor 的核心,花最多时间在这里。
  4. 第 7 章(背压策略)是生产中最容易出问题的部分,务必做实验。
  5. 第 11 章(TCK)验证你的理解:自己写的 Publisher 能不能通过 22 个 required 用例?

如果你已经用过 WebFlux 或 Reactor

  1. 跳过第 4–6 章的操作符,直接从第 1 章(RS 规范)开始。
  2. 重点看第 2–3 章(手写实现)和第 7 章(背压策略)。
  3. 第 8–10 章(Hot/Cold、Sinks、RxJava 对照)帮你建立「不只是会用」的直觉。
  4. 第 12 章(调试)是生产排障必备。

如果你只想看代码和可运行示例

  1. 读前言(环境配置)。
  2. 跳到第 2–3 章(手写实现)。
  3. 跳到第 13–14 章(eventd v1 架构 + 背压演示 + 性能基线)。
  4. 附录 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: 逐条分析失败测试:

  1. 查看失败信息中的规则编号
  2. 对照附录 C 的规则表查找原因
  3. 检查实现代码,修复违规点
  4. 重新运行测试

检查清单

  • 代码已编译通过(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 GitHubgithub.com/ReactiveX/RxJava源码与 Wiki
Reactor JavaDocprojectreactor.io/docs/core/release/apiAPI 文档

推荐书籍

书名作者重点
《Reactive Programming with RxJava》Tomasz NurkiewiczRxJava 实战
《Hands-On Reactive Programming in Spring 5》Oleh DokukaSpring + 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-core3.8.x (BOM 2025.0.8)✅—
rxjava3.1.12✅—
reactive-streams1.0.4✅—
junit-jupiter5.14.4✅—

后续 Reactor 2026.x 版本可能引入新操作符,但本卷的背压原理和信号契约不会变化。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南