臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

核心结论

Reactor 不是 JDK 自带功能,而是一个基于 Reactive Streams 规范的 Java 响应式编程库。它用 Mono 表示 0 或 1 个异步结果,用 Flux 表示 0 到多个异步结果,并通过操作符组合数据处理流程。

最容易记住的比喻是“数据传送带”:

生产数据 → 转换数据 → 筛选数据 → 处理结果
Publisher    Operator      Operator      Subscriber

这个比喻只用来帮助理解;在真实概念中,Reactor 处理的是一系列 onNext、onError 和 onComplete 信号。

二、🧭 理论:Reactor 是什么

2.1 Reactor 与 JDK、Spring 的关系

Reactor 是第三方库,不是 Java 语法,也不是 JDK 标准 API。

内容归属例子大白话解释
Lambda 表达式Java/JDKchunk -> send(chunk)-> 是 Java 自带的简写语法。意思是:每拿到一个 chunk,就执行右边的 send(chunk)。
CompletableFutureJDK表示一个异步结果可以把它想成一张“取结果的小票”:任务先去执行,结果出来后再通知你,通常最终只有一个结果。
StreamJDK处理内存中的集合数据它像一条“集合加工流水线”,常用来对已经放在内存里的 List 做筛选、转换和统计,不是 Reactor 那种持续到达的异步数据流。
Mono、FluxReactor表示异步数据序列数据可能现在还没有,以后才到。Mono 最多等到一个结果,Flux 可以一段一段地收到多个结果。
Spring WebFluxSpring基于 Reactor 构建的响应式 Web 框架它让 Spring 接口可以直接返回 Mono 或 Flux。例如 AI 每生成一段文字,服务器就可以立即发给浏览器,不用等整篇回答完成。
SseEmitterSpring MVC在 Servlet 技术栈中手动发送 SSE 事件它像服务器和浏览器之间一条暂时不关闭的通道。后端每调用一次 send,前端就收到一次消息;它本身不属于 Reactor。

看代码时怎么快速区分

看到 -> 只能说用了 Java Lambda;看到 Mono、Flux、subscribe 才说明用了 Reactor;看到 SseEmitter 则说明代码在使用 Spring MVC 的 SSE 能力。

你没有直接引入 reactor-core 也可能使用到 Reactor,因为 Spring WebFlux、Spring WebClient 或某些 AI SDK 会间接引入它。

2.2 Reactor 要解决什么问题

传统同步代码往往是:调用一个方法,当前线程等待它返回,然后继续执行。

// 当前线程在 remoteService.call() 返回前可能一直等待。
String result = remoteService.call();
System.out.println(result);

响应式代码先声明“数据到达后怎么处理”:

Mono<String> result = reactiveService.call();
 
result
        .map(String::trim)
        .subscribe(System.out::println);

它尤其适合:

  • AI 逐段生成文字;
  • HTTP 流式响应;
  • WebSocket 和 SSE 实时推送;
  • 消息队列或持续事件流;
  • 需要组合多个异步调用的场景。

非阻塞不是自动获得的

把阻塞的 JDBC、Thread.sleep() 或同步 HTTP 调用放进 Mono/Flux,不会自动把它们变成非阻塞。

2.3 Mono 和 Flux

类型数据个数典型场景用途
Mono<T>0 或 1查询一个用户、一次 HTTP 响应、一个保存结果用来表示“最终最多只需要一个结果”的任务。例如根据 ID 查用户:查到时发出一个用户,没查到时不发出数据,最后结束。
Flux<T>0 到 NAI 文字片段、列表数据、持续消息流用来表示“结果可能有很多个,而且可以一个接一个到达”的任务。例如 AI 每生成一段文字就立即发出,直到整个回答完成。

怎么选

业务结果最多一个,选 Mono;可能有多个或需要持续推送,选 Flux。不要因为 Flux 看起来更强就一律使用 Flux,类型本身应该表达真实的数据个数。

// 创建一个最多只产生一个值的 Mono。
Mono<String> userName = Mono.just("小明");
 
// 创建一个可以连续产生多个值的 Flux。
Flux<String> answer = Flux.just("你", "好", "呀");

在这个例子中,userName 被订阅后只发出一次“小明”就结束;answer 被订阅后会依次发出“你”、“好”、“呀”,适合模拟 AI 逐段返回文字。

Mono 不是“单个值本身”,Flux 也不是 List。它们表示的是一个可能尚未执行、随时间产生信号的计算过程。

三、⚙️ 理论:Reactor 是怎么工作的

3.1 Publisher 和 Subscriber

Reactor 建立在 Reactive Streams 规范之上。理解入门代码时,先记住两个角色:

  • Publisher:数据发布者,Mono 和 Flux 都实现了这个角色。
  • Subscriber:订阅者,接收数据、错误和完成信号。

3.2 三种核心信号

一条 Reactor 流最值得记住的是三种信号:

信号含义是否结束数据流
onNext(value)发出一个数据否
onComplete()正常结束是
onError(error)异常结束是

onComplete 和 onError 互斥。一条流结束时只会走其中一条路径。

大白话理解:

  • onNext:快递员正在一件一件送包裹,后面还可能继续送。
  • onComplete:包裹全部正常送完,这一轮结束。
  • onError:运送中出了无法继续的问题,立即进入失败结束,后面的包裹不再送。

对应到 subscribe 的三个参数:

flux.subscribe(
        value -> 处理每个正常数据, // onNext
        error -> 处理异常,        // onError
        () -> 处理正常完成           // onComplete
);

3.2.1 正常时怎么走

Flux.just("你", "好", "呀")
        .subscribe(
                value -> System.out.println("收到:" + value),
                error -> System.err.println("失败:" + error.getMessage()),
                () -> System.out.println("正常结束")
        );

对应的信号顺序是:

onNext("你")
onNext("好")
onNext("呀")
onComplete()

因为中间没有出错,所以先多次执行第一个回调,最后执行第三个回调。

数据源
  ↓
onNext("你") → 第一个回调
  ↓
onNext("好") → 第一个回调
  ↓
onNext("呀") → 第一个回调
  ↓
onComplete()  → 第三个回调,数据流结束

3.2.2 错误时怎么走

错误可以来自数据源,也可以是 map、flatMap 等操作符内的代码抛出异常。下面的代码在遇到 0 时会除零失败:

Flux.just(10, 5, 0, 2)
        .map(number -> 100 / number)
        .subscribe(
                value -> System.out.println("收到:" + value),
                error -> System.err.println("失败:" + error.getMessage()),
                () -> System.out.println("正常结束")
        );

它的实际路径是:

10 → 100 / 10 = 10 → onNext(10) → 第一个回调
 5 → 100 / 5  = 20 → onNext(20) → 第一个回调
 0 → 100 / 0  抛出 ArithmeticException
                         ↓
                   onError(error) → 第二个回调
                         ↓
                    数据流立即结束

输出类似:

收到:10
收到:20
失败:/ by zero

这里有两个重点:

  1. 0 后面的 2 不会再被处理,因为 onError 已经结束了原数据流。
  2. 第三个 onComplete 回调不会执行,因为这次是异常结束,不是正常结束。

onErrorResume 不是让原数据流从出错位置继续

它是用一条新的兜底数据流替换已经失败的原数据流。原流中出错位置之后的数据仍然不会继续处理。

3.2.3 对应到你的 AI 流式代码

你的代码中,AI 正常返回 chunk 时走第一个回调;AI 调用超时或抛异常时,会跳到第二个回调:

AI 正常返回 chunk
  → onNext(chunk)
  → 向前端发送 delta
 
AI 服务超时或异常
  → onError(error)
  → 向前端发送 error
  → emitter.complete()
  → 本轮结束,不会再发送 done

如果 AI 从头到尾都没有出错,才会走 onComplete(),向前端发送 done。

3.3 惰性执行与订阅

惰性执行的意思是:先把处理步骤定义好,等真正有人需要数据时再开始执行。

可以把 Reactor 链理解成一张菜谱:

创建 Flux/Mono、添加 map/filter → 只是写好菜谱
subscribe()                      → 顾客真正下单
数据开始流动                    → 厨房按菜谱开始做菜

这是帮助理解的类比。真实机制是:订阅者订阅 Publisher 后,订阅信号向上游传递,数据源才开始向下游发送信号。

3.3.1 只声明流程时,map 还没有执行

下面的代码只创建了一条处理流程:

Flux<String> pipeline = Flux.just("a", "b", "c")
        .map(value -> {
            // 只有数据真正流过 map 时,这行才会执行。
            System.out.println("正在转换:" + value);
            return value.toUpperCase();
        });
 
System.out.println("流程已定义,但还没有订阅");

这时只会输出:

流程已定义,但还没有订阅

map 中的“正在转换”不会出现,因为没有数据流过它。

3.3.2 subscribe() 做了什么

下面这行表示:“我需要这条流的数据,现在开始执行吧。”

pipeline.subscribe(value -> System.out.println("收到:" + value));

订阅后的完整路径:

subscribe()
    ↓
Flux.just 开始发出 "a"
    ↓
map 把 "a" 转换成 "A"
    ↓
订阅者收到 "A"
    ↓
继续处理 "b" 和 "c"
    ↓
数据全部发完,发出 onComplete

实际输出:

正在转换:a
收到:A
正在转换:b
收到:B
正在转换:c
收到:C

subscribe() 不是“把数据保存起来”,而是把 Publisher 和一个 Subscriber 连接起来,从而触发整条处理链。

3.3.3 为什么说“多数”是惰性的

这里先认识“冷流”和“热流”的最小概念,后面的 3.9 节会继续深入讲解。

类型大白话理解订阅者加入时会发生什么
冷流(Cold Publisher)像点播视频,每个人点击播放后,都从头获得属于自己的播放流程每次订阅通常都会重新生成数据;没有人订阅时,数据源通常不开始生成数据
热流(Hot Publisher)像电视直播,节目可以按自己的时间继续播放后来的订阅者可能只能看到加入之后的数据,错过前面已经发出的内容

当前小节只需要记住

我们现在讲的“订阅后才执行”,主要针对冷流。例如把 HTTP 请求包在 Mono.defer 中,每来一个订阅者,都可能重新发起一次 HTTP 请求。

“没有订阅就什么都不执行”是学习冷流时的核心规则,但不能误解为 Java 方法参数也会自动延迟求值。

下面的 loadUser() 会立即执行:

// Java 需要先执行 loadUser(),才能把返回值传给 Mono.just。
Mono<User> userMono = Mono.just(loadUser());

真正希望等到订阅时再调用方法,可以使用 fromCallable:

Mono<User> userMono = Mono.fromCallable(() -> {
    // 只有 userMono 被订阅时,loadUser() 才会执行。
    return loadUser();
});

也可以使用 defer 延迟创建整条流:

Mono<User> userMono = Mono.defer(() -> {
    // 每次有订阅者时,都会重新执行这个函数。
    User user = loadUser();
    return Mono.just(user);
});

多次订阅可能多次执行

对于普通冷流,每次 subscribe() 都可能重新触发数据生成。如果上游是 AI 付费请求、数据库写入或发送消息,重复订阅可能造成重复调用。

3.3.4 WebFlux Controller 为什么不用手动 subscribe()

不是 WebFlux 没有订阅,而是订阅这件事由 Spring WebFlux 框架完成了。

@GetMapping("/names")
public Flux<String> names() {
    // Controller 只负责返回处理流程,不自己订阅。
    return Flux.just("小明", "小红")
            .map(String::toUpperCase);
}

一次 HTTP 请求的大致流程是:

浏览器请求 /names
        ↓
Spring WebFlux 调用 Controller
        ↓
Controller 返回 Flux<String>
        ↓
Spring WebFlux 作为订阅者调用 subscribe
        ↓
Flux 开始发出数据
        ↓
Spring 把每个数据编码后写入 HTTP 响应
        ↓
onComplete:正常结束响应
onError:进入异常处理
客户端断开:取消订阅

因此,在 WebFlux Controller 里手动调用 subscribe() 通常是错误做法:

@GetMapping("/wrong")
public Flux<String> wrong() {
    Flux<String> result = service.loadNames();
 
    // 不要在 WebFlux Controller 里抢先手动订阅。
    result.subscribe(System.out::println);
 
    // Spring 处理返回值时可能又订阅一次。
    return result;
}

这可能导致上游被执行两次,并且手动订阅的那次执行脱离了 HTTP 请求的错误处理和取消管理。

3.3.5 为什么你最初的 SseEmitter 代码又手动订阅了

SseEmitter 是 Spring MVC 提供的 SSE(Server-Sent Events,服务器推送事件)工具。它可以让一次 HTTP 响应暂时不结束,后端在这条连接上分多次向浏览器发送消息。

大白话理解:普通 HTTP 响应像“一次把整个包裹交给客户”;SseEmitter 则像“先保持一条通道,有新内容就往里送一件”。

浏览器发起 HTTP 请求
        ↓
Controller 返回 SseEmitter
        ↓
HTTP 连接暂时保持打开
        ↓
后端可多次调用 emitter.send(...)
        ↓
全部结束后调用 emitter.complete()
API用途大白话解释
new SseEmitter(timeout)创建 SSE 连接对象准备一条可以持续发消息的通道,timeout 表示最长等待时间
emitter.send(...)发送一个 SSE 事件AI 每生成一段文字,就给前端送一次
emitter.complete()正常结束请求告诉 Spring:数据都发完了,可以收尾
emitter.completeWithError(error)以异常结束请求把错误交回 Spring MVC 的异常处理机制
onTimeout / onCompletion / onError注册生命周期回调超时、完成或发生错误时执行清理逻辑

SSE 和 WebSocket 不是一回事

SSE 主要是服务器单向持续向浏览器推送数据,很适合 AI 回答、日志和进度更新。WebSocket 更偏向客户端和服务器双向长时间通信。

SseEmitter 不是 Flux,也不是 Reactor 的 Subscriber。它只知道“如何把某个对象写入 SSE 响应”,并不会自动去消费你另外创建的 Flux。

你最初的代码不是把 Flux 作为 Controller 返回值交给框架,而是先返回 SseEmitter,再把 Reactor 数据流手动转发到它里面:

Flux<String> 产生 AI chunk
        ↓
你的 subscribe() 消费 chunk
        ↓
send(emitter, "delta", ...)
        ↓
SseEmitter 把事件发给浏览器

因此,中间必须有一段“转接”代码。下面的片段假设 Controller 已经创建了 emitter、AI 服务已返回 aiFlux,并且已有 conversationId:

aiFlux.subscribe(
        // Flux 每发出一个 chunk,就转发给 SseEmitter。
        chunk -> send(emitter, "delta", new ChatEvent(
                conversationId, chunk, null, null)),
        // Flux 异常结束时,向前端发送错误事件并收尾。
        error -> {
            send(emitter, "error", new ChatEvent(
                    conversationId,
                    null,
                    "AI_STREAM_FAILED",
                    "AI 服务暂时不可用或响应超时,请稍后重试"
            ));
            emitter.complete();
        },
        // Flux 正常结束时,向前端发送 done 并收尾。
        () -> {
            send(emitter, "done", new ChatEvent(
                    conversationId, null, null, null));
            emitter.complete();
        }
);

在这种“Reactor → Spring MVC SseEmitter”的手动桥接代码中,subscribe() 负责让 Flux 真正开始产生数据,send() 负责把已经产生的数据通过 SSE 发给浏览器。这和 WebFlux Controller 直接返回 Flux 是两种不同模式。

最后只记一句

Flux 是数据来源,subscribe() 是启动并接收这些数据,SseEmitter.send() 是把收到的数据发给浏览器。

3.4 操作符链

Reactor 通过操作符组合逻辑。每个操作符一般都返回一个新的 Mono 或 Flux:

Flux<Integer> result = Flux.range(1, 6)
        // 只保留偶数。
        .filter(number -> number % 2 == 0)
        // 将保留下来的数字乘以 10。
        .map(number -> number * 10);
 
result.subscribe(System.out::println);

输出:

20
40
60

操作符需要接住返回值

flux.map(...) 不会就地修改原来的 flux。链式调用或保存新返回值,才能得到处理后的流。

3.5 map 和 flatMap 的区别

map 用于“一个普通值变成另一个普通值”:

Mono<String> upperName = Mono.just("小明")
        .map(String::toUpperCase);

flatMap 用于“一个值变成另一个 Mono 或 Flux”,它会把嵌套的响应式类型展平:

Mono<User> user = Mono.just("1001")
        // findById 本身返回 Mono<User>,因此使用 flatMap。
        .flatMap(userService::findById);

判断方法:

转换函数返回普通值      → map
转换函数返回 Mono/Flux → flatMap

flatMap 可以并发处理内部数据流,因此不必然保持原始顺序。需要严格保持顺序时,可根据场景考虑 concatMap。

3.6 错误是终止信号

在 Reactor 中,错误不是一个可以继续正常流动的普通数据。一旦出现 onError,当前流就结束。

Flux<Integer> numbers = Flux.just(10, 5, 0, 2)
        .map(number -> 100 / number);
 
numbers.subscribe(
        value -> System.out.println("结果:" + value),
        error -> System.err.println("计算失败:" + error.getMessage()),
        () -> System.out.println("完成")
);

当数字为 0 时会进入 onError,后面的 2 不会继续处理,onComplete 也不会执行。

常用错误处理方式:

Mono<String> result = callRemoteService()
        // 失败时返回一个固定兜底值。
        .onErrorReturn("暂时无法获取")
        // 无论成功、失败还是取消,都适合在这里做清理或记录。
        .doFinally(signalType -> System.out.println("结束信号:" + signalType));
操作符用途
onErrorReturn返回固定兜底值
onErrorResume切换到另一条数据流
onErrorMap把异常转换成另一种异常
doOnError记录日志等副作用,不会自动恢复
retry / retryWhen重新订阅上游流程

3.7 线程与 Scheduler

Reactor 不保证每个操作符都自动切换线程。如果没有指定 Scheduler,许多同步数据源会在发起订阅的线程中执行。

如果对线程、线程池和 Future 还不熟悉,可以先阅读 线程。

Mono.fromCallable(() -> blockingCall())
        // 将订阅及上游阻塞调用安排到有界弹性线程池。
        .subscribeOn(Schedulers.boundedElastic())
        .subscribe(System.out::println);
  • subscribeOn 主要影响订阅和上游数据源在哪里执行。
  • publishOn 从它所在位置开始,影响后续操作符的执行线程。
  • Schedulers.boundedElastic() 适合必须包装的阻塞 I/O,但并不等于阻塞调用本身变成了非阻塞。

3.8 背压 Backpressure

背压解决的是:生产者生产得太快,消费者来不及处理怎么办。

在 Reactive Streams 中,订阅者可以通过 request(n) 表达需求量。不要把它简单理解为“限流注解”,它是数据流上下游之间的需求协调机制。

常见策略包括:

  • 限制请求量;
  • 缓冲来不及处理的数据;
  • 丢弃旧数据或新数据;
  • 只保留最新值。

AI 流式文字通常每个 chunk 较小,但在高并发、慢客户端或大流量时,仍需要关注队列增长和连接取消。

3.9 冷流与热流

  • 冷流(Cold Publisher):每个订阅者通常独立触发一次数据生成。
  • 热流(Hot Publisher):数据可能不依赖某个具体订阅者而产生,后来者可能错过早期数据。
Flux<String> cold = Flux.defer(() -> {
    System.out.println("每次订阅都重新创建数据");
    return Flux.just("A", "B");
});
 
cold.subscribe(value -> System.out.println("订阅者1:" + value));
cold.subscribe(value -> System.out.println("订阅者2:" + value));

对远程 AI 请求来说,如果每次订阅都会触发一次真实调用,重试、多次 subscribe() 或错误的共享操作都可能造成重复计费,需要特别注意。

四、🧰 理论:常用 API 速查

4.1 创建数据流

Mono<String> one = Mono.just("一个值");
Mono<String> emptyOne = Mono.empty();
Mono<String> failedOne = Mono.error(new IllegalStateException("失败"));
 
Flux<Integer> several = Flux.just(1, 2, 3);
Flux<Integer> range = Flux.range(1, 5);
 
// defer 会等到真正订阅时再执行 Supplier。
Mono<Long> lazyTime = Mono.defer(() -> Mono.just(System.currentTimeMillis()));

4.2 转换与筛选

API作用
map同步地将一个值转成另一个值
flatMap将一个值转成响应式流并展平
filter只保留符合条件的值
distinct去重
take(n)只取前 n 个
collectList将 Flux<T> 收集为 Mono<List<T>>

4.3 组合数据流

Mono<User> userMono = findUser();
Mono<Account> accountMono = findAccount();
 
// 两个结果都就绪后组合。
Mono<UserProfile> profile = Mono.zip(userMono, accountMono)
        .map(tuple -> new UserProfile(tuple.getT1(), tuple.getT2()));
API特点
concat按顺序订阅,前一条完成后再处理下一条
merge同时订阅多条流,按实际到达时间发出
zip等待多个来源的对应值并组合

4.4 日志与生命周期

Flux.just("A", "B")
        .doOnSubscribe(subscription -> System.out.println("已订阅"))
        .doOnNext(value -> System.out.println("流经:" + value))
        .doOnError(error -> System.err.println("异常:" + error.getMessage()))
        .doOnComplete(() -> System.out.println("正常完成"))
        .doFinally(signal -> System.out.println("最终信号:" + signal))
        .subscribe();

doOnXxx 主要用于日志、监控和调试等副作用。它们通常不改变流经的数据。

4.5 block 和 subscribe

  • subscribe() 发起订阅,并通过回调接收信号。
  • block() 阻塞当前线程,直到 Mono 完成。
  • blockLast() 阻塞当前线程,直到 Flux 完成,并取最后一个值。

block 可用于命令行演示、测试或合理的同步边界。在 WebFlux 事件循环中随意 block() 会破坏非阻塞模型,甚至引发线程耗尽或报错。

五、🚀 实践:从准备到验证

5.1 前置准备

本教程的完整实践是一个最小 Spring WebFlux SSE 项目,它会模拟 AI 逐段返回文字。

项目要求
JDK17 或更高,本示例使用 JDK 17
Maven3.6.3 或更高版本
Spring Boot4.1.1,用于固定本教程的示例环境
网络首次构建时需要下载 Maven 依赖

版本说明

Reactor 本身的实际版本由 Spring Boot 依赖管理统一选择,pom.xml 不手动混用其他 Reactor 版本。在已有 Spring Boot 项目中,优先使用项目当前 BOM 管理的版本。

5.2 可以拿来干什么

5.2.1 处理一个异步结果

前置:方法返回 Mono<User>。

userService.findById("1001")
        // 把用户对象转成显示名称。
        .map(User::displayName)
        // 无数据时使用默认文字。
        .defaultIfEmpty("未知用户")
        .subscribe(System.out::println);

输入是用户 ID,输出是用户名称。适用于返回 0 或 1 个结果的调用。

5.2.2 处理多个数据

前置:orderRepository.findAll() 返回 Flux<Order>。

orderRepository.findAll()
        // 只保留已支付订单。
        .filter(Order::paid)
        // 取出订单金额。
        .map(Order::amount)
        // 累加所有金额。
        .reduce(BigDecimal.ZERO, BigDecimal::add)
        .subscribe(total -> System.out.println("已支付总额:" + total));

输入是订单流,输出是一个总金额,因此 reduce 后的类型是 Mono<BigDecimal>。

5.2.3 推送 AI 流式回答

前置:AI SDK 返回 Flux<String>,每个字符串是一段 chunk。

aiService.stream(prompt)
        // 将每段文字包装成 delta 事件。
        .map(chunk -> ServerSentEvent.builder(chunk)
                .event("delta")
                .build())
        // AI 正常结束后追加 done 事件。
        .concatWithValues(ServerSentEvent.builder("")
                .event("done")
                .build());

输入是用户问题,输出是 SSE 事件流。这是 Reactor 在 AI 聊天场景中最直观的用法。

5.3 完整实践:创建项目

项目目录结构:

reactor-sse-demo/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/example/reactordemo/
    │   │   ├── ReactorDemoApplication.java
    │   │   ├── ChatEvent.java
    │   │   └── ChatController.java
    │   └── resources/application.yml
    └── test/java/com/example/reactordemo/
        └── ChatControllerTest.java

5.3.1 Maven 配置

文件位置:reactor-sse-demo/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
 
    <!-- Spring Boot 父 POM 统一管理 Spring、Reactor 和测试依赖版本。 -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>
 
    <groupId>com.example</groupId>
    <artifactId>reactor-sse-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>reactor-sse-demo</name>
 
    <properties>
        <java.version>17</java.version>
    </properties>
 
    <dependencies>
        <!-- WebFlux 会引入 reactor-core,并支持非阻塞 HTTP 和 SSE。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
 
        <!-- 提供 JUnit、WebTestClient 和 reactor-test 等测试能力。 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
 
    <build>
        <plugins>
            <!-- 支持打包和运行 Spring Boot 应用。 -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

5.3.2 应用入口

文件位置:src/main/java/com/example/reactordemo/ReactorDemoApplication.java

package com.example.reactordemo;
 
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
 
/**
 * Spring Boot 应用入口。
 * spring-boot-starter-webflux 会配置响应式 Web 服务器。
 */
@SpringBootApplication
public class ReactorDemoApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ReactorDemoApplication.class, args);
    }
}

5.3.3 SSE 事件对象

文件位置:src/main/java/com/example/reactordemo/ChatEvent.java

package com.example.reactordemo;
 
/**
 * 发送给前端的聊天事件数据。
 *
 * @param conversationId 会话 ID,用于区分不同聊天
 * @param content        delta 事件中的文字片段
 * @param errorCode      error 事件中的稳定错误码
 * @param errorMessage   可以展示给用户的错误提示
 */
public record ChatEvent(
        String conversationId,
        String content,
        String errorCode,
        String errorMessage
) {
}

5.3.4 流式接口

文件位置:src/main/java/com/example/reactordemo/ChatController.java

package com.example.reactordemo;
 
import java.time.Duration;
import java.util.List;
import java.util.UUID;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
 
@RestController
@RequestMapping("/api/chat")
public class ChatController {
 
    private static final Logger log = LoggerFactory.getLogger(ChatController.class);
 
    /**
     * 以 SSE 方式模拟 AI 流式回答。
     * curl 传入 prompt=fail 时会走错误分支。
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<ChatEvent>> stream(
            @RequestParam(defaultValue = "介绍Reactor") String prompt) {
 
        String conversationId = UUID.randomUUID().toString();
 
        // defer 确保每次 HTTP 订阅时再创建本次回答流。
        Flux<ServerSentEvent<ChatEvent>> answerEvents = Flux.defer(() -> {
            if ("fail".equalsIgnoreCase(prompt)) {
                // 人为制造异常,用于验证 error 事件。
                return Flux.error(new IllegalStateException("模拟 AI 服务超时"));
            }
 
            List<String> chunks = List.of(
                    "Reactor ",
                    "是 Java ",
                    "的响应式编程库。"
            );
 
            return Flux.fromIterable(chunks)
                    // 模拟 AI 每 500 毫秒返回一段文字。
                    .delayElements(Duration.ofMillis(500))
                    // 每个 chunk 都转换为 delta SSE 事件。
                    .map(chunk -> sse("delta", new ChatEvent(
                            conversationId,
                            chunk,
                            null,
                            null
                    )))
                    // 只有上游正常完成时才会追加 done。
                    .concatWithValues(sse("done", new ChatEvent(
                            conversationId,
                            null,
                            null,
                            null
                    )));
        });
 
        return answerEvents
                .doOnSubscribe(subscription ->
                        log.info("开始流式对话,conversationId={}", conversationId))
                .doOnNext(event ->
                        log.debug("发送 SSE,event={}", event.event()))
                // 异常时将 onError 转换成一个可被客户端识别的 error 事件。
                // 转换后的替代流只发送 error,不会伪造 done。
                .onErrorResume(error -> {
                    log.error("流式对话失败,conversationId={}", conversationId, error);
                    return Flux.just(sse("error", new ChatEvent(
                            conversationId,
                            null,
                            "AI_STREAM_FAILED",
                            "AI 服务暂时不可用或响应超时,请稍后重试"
                    )));
                })
                // 成功、失败或客户端断开都会收到最终信号,适合做清理和观测。
                .doFinally(signalType ->
                        log.info("流式对话结束,conversationId={}, signal={}",
                                conversationId, signalType));
    }
 
    /**
     * 统一构建 SSE 事件,避免在业务逻辑中重复 builder 代码。
     */
    private static ServerSentEvent<ChatEvent> sse(String event, ChatEvent data) {
        return ServerSentEvent.<ChatEvent>builder()
                .event(event)
                .data(data)
                .build();
    }
}

这里没有手动调用 subscribe()。Controller 返回 Flux 后,Spring WebFlux 负责订阅、把信号写入 HTTP 响应,并在客户端断开时传播取消信号。

5.3.5 应用配置

文件位置:src/main/resources/application.yml

server:
  # 演示服务监听端口。
  port: 8080
 
logging:
  level:
    # 调试时查看每个 SSE 事件的发送日志。
    com.example.reactordemo: DEBUG

5.3.6 接口测试

文件位置:src/test/java/com/example/reactordemo/ChatControllerTest.java

package com.example.reactordemo;
 
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
 
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class ChatControllerTest {
 
    @Autowired
    private WebTestClient webTestClient;
 
    @Test
    void shouldReturnEventStream() {
        webTestClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/api/chat/stream")
                        .queryParam("prompt", "介绍Reactor")
                        .build())
                .accept(MediaType.TEXT_EVENT_STREAM)
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
                // 只验证流中能解析出 ChatEvent;
                // 精确验证事件顺序可在项目中再用 StepVerifier 扩展。
                .expectBodyList(ChatEvent.class)
                .hasSize(4);
    }
}

测试补充

SSE 序列化和 WebTestClient 对 ServerSentEvent 包装的处理会随 Spring 版本及解码方式有差异。业务项目中建议把“生成事件的 Flux”抽成 Service,用 StepVerifier 直接验证 delta → done 和 error 两条路径,Controller 测试只验证 HTTP 协议。

5.4 启动与验证

5.4.1 运行测试

在 reactor-sse-demo 目录执行:

mvn test

预期结果:Maven 显示测试成功,无 Failures 和 Errors。

5.4.2 启动应用

mvn spring-boot:run

预期结果:日志显示应用启动完成,并监听 8080 端口。

5.4.3 验证正常流程

curl 的 -N 参数用于关闭客户端输出缓冲,便于直接看到逐段输出:

curl -N 'http://localhost:8080/api/chat/stream?prompt=%E4%BB%8B%E7%BB%8DReactor'

预期会间隔约 500 毫秒收到一个事件:

event:delta
data:{"conversationId":"...","content":"Reactor ","errorCode":null,"errorMessage":null}
 
event:delta
data:{"conversationId":"...","content":"是 Java ","errorCode":null,"errorMessage":null}
 
event:delta
data:{"conversationId":"...","content":"的响应式编程库。","errorCode":null,"errorMessage":null}
 
event:done
data:{"conversationId":"...","content":null,"errorCode":null,"errorMessage":null}

5.4.4 验证错误流程

curl -N 'http://localhost:8080/api/chat/stream?prompt=fail'

预期只收到 error 事件,不应该再收到 done:

event:error
data:{"conversationId":"...","content":null,"errorCode":"AI_STREAM_FAILED","errorMessage":"AI 服务暂时不可用或响应超时,请稍后重试"}

5.5 将完整实践替换为真实 AI 服务

当 AI SDK 已能返回 Flux<String> 时,只需用真实返回值替换示例中的 Flux.fromIterable(chunks):

Flux<String> chunks = aiService.stream(prompt);
 
return chunks
        .map(chunk -> sse("delta", new ChatEvent(
                conversationId,
                chunk,
                null,
                null
        )))
        .concatWithValues(sse("done", new ChatEvent(
                conversationId,
                null,
                null,
                null
        )))
        .onErrorResume(error -> {
            log.error("AI 流式调用失败,conversationId={}", conversationId, error);
            return Flux.just(sse("error", new ChatEvent(
                    conversationId,
                    null,
                    "AI_STREAM_FAILED",
                    "AI 服务暂时不可用或响应超时,请稍后重试"
            )));
        });

注意:如果 concatWithValues(done) 放在 onErrorResume 之后,错误被替换成普通数据流并正常完成后,可能继续追加 done。如果协议规定失败时只发 error,就要像示例一样明确安排操作符顺序。

六、🔍 你最初的 subscribe 代码怎么理解

6.1 代码中的三个回调

.subscribe(
        chunk -> send(emitter, "delta", new ChatEvent(...)),
        error -> {
            send(emitter, "error", new ChatEvent(...));
            emitter.complete();
        },
        () -> {
            send(emitter, "done", new ChatEvent(...));
            emitter.complete();
        }
);

它等价于:

Reactor 信号你的处理SSE 事件
onNext(chunk)发送一段 AI 文字delta
onError(error)发送稳定错误并关闭连接error
onComplete()发送完成标记并关闭连接done

6.2 这种写法的技术栈

这段代码很可能是把两种模型接在一起:

AI SDK 返回 Flux<String>
            ↓
      手动 subscribe
            ↓
Spring MVC SseEmitter
            ↓
          浏览器

这在 Spring MVC 项目中是可以理解的桥接方式,但需要自己负责:

  • 客户端断开后取消 Reactor Disposable;
  • send 抛出异常时正确结束连接;
  • 避免 complete() 和 completeWithError() 竞态;
  • 设置合理超时时间;
  • 记录真实异常,但不向客户端泄露敏感细节。

一个更稳妥的思路是保存 subscribe() 返回的 Disposable,并在 emitter 超时、完成或异常时取消上游。但是否需要这样修改,要结合你的 send 实现、Spring MVC 版本和 AI SDK 行为判断。

6.3 MVC 和 WebFlux 不要混为一个概念

  • Spring MVC 中可以使用 SseEmitter,也可以消费某个库返回的 Flux。
  • Spring WebFlux 通常直接返回 Flux<ServerSentEvent<T>>。
  • 使用了 Flux 并不能单独证明整个项目就是 WebFlux 项目。

七、⚠️ 常见误区与排查

7.1 写了操作符却没有生效

错误写法:

Flux<String> flux = Flux.just("a", "b");
flux.map(String::toUpperCase);
flux.subscribe(System.out::println);

输出仍然是小写,因为没有使用 map 返回的新 Flux。

正确写法:

Flux<String> flux = Flux.just("a", "b")
        .map(String::toUpperCase);
 
flux.subscribe(System.out::println);

7.2 没有订阅,代码没执行

先检查当前场景由谁订阅:

  • 命令行或普通 Java 程序中,通常需要自己 subscribe 或在边界 block。
  • WebFlux Controller 返回 Mono/Flux 时,不要再手动订阅。
  • Service 层通常返回组合好的 Mono/Flux,把订阅权留给框架边界。

7.3 调用 block() 后性能反而更差

如果在 Netty 事件循环或 WebFlux 请求处理链中阻塞,少量线程就可能被占满。排查时搜索:

.block(
.blockFirst(
.blockLast(
Thread.sleep(

同时检查 JDBC、同步 HTTP SDK 和文件 I/O 等隐性阻塞调用。

7.4 onErrorResume 后意外发送 done

错误被 onErrorResume 转换为替代数据流后,替代流可以正常完成。因此操作符顺序很重要:

// 业务含义:正常才追加 done,异常改发 error。
source
        .concatWith(doneEvent)
        .onErrorResume(error -> errorEvent(error));

单元测试必须分别覆盖正常和失败路径。

7.5 重试导致重复调用

retry 的本质是重新订阅上游。如果上游是付费 AI 接口、创建订单或发送消息,必须考虑:

  • 操作是否幂等;
  • 是否会重复扣费或重复写入;
  • 哪些异常才值得重试;
  • 是否需要指数退避和最大次数。

7.6 日志里没有根异常

向客户端返回稳定错误码是对的,但服务端仍应记录原始异常:

.doOnError(error ->
        log.error("AI 流式调用失败,conversationId={}", conversationId, error))

客户端获得可识别但不敏感的信息,服务端日志保留定位问题所需的堆栈。

八、📌 总结

  • Reactor 是第三方 Java 库,不是 JDK 内置 API。
  • Mono<T> 表示 0 或 1 个结果,Flux<T> 表示 0 到 N 个结果。
  • Reactor 流通过 onNext、onError、onComplete 传递信号。
  • onError 和 onComplete 都是终止信号,并且互斥。
  • map 处理普通值转换,flatMap 处理返回 Mono/Flux 的异步组合。
  • 使用 Reactor 不代表代码自动非阻塞,需要检查整条调用链。
  • WebFlux Controller 通常直接返回 Mono/Flux,不应手动 subscribe()。
  • 你最初的代码是在用 subscribe 把 Reactor 的三种信号转换为 SSE 的 delta、error 和 done 事件。

记忆句

Reactor 就是用 Mono 和 Flux 表达随时间到达的数据,用操作符组合处理流程,最后由订阅者处理数据、错误和完成信号。

九、📚 官方资料