之前在做一个报表导出功能时,我第一次真正被扁平化操作符难住。业务本身不复杂:拿到一批订单ID,每个ID要异步去查询订单明细,明细又是多条记录,最终把所有明细合并成一个数据流写进Excel。我一开始用 map 去写,结果拿回来的不是订单明细流,而是流里面套流,Flux<Flux >,类型看着就难受。换成 flatMap 之后数据是对了,但输出顺序全乱了;再换 concatMap,顺序是保住了,可速度直线下降。也就是从那天起,我对 flatMap 和 concatMap 的态度从"会用"变成了"必须从底层搞懂"。
这篇就把 Reactor 3 里数据扁平处理的完整链路讲清楚:为什么响应式编程里总绕不开 flatMap 和 concatMap,它们底层到底怎么处理事件流,什么时候用哪个,以及我在真实项目里踩过的一些坑。适合已经掌握 map、filter 等基础操作符、但一碰到"流里面套流"就犯迷糊的读者。
1. 从 map 到 flatMap:一次不得不做的思维转换
1.1 map 的边界:只能做 1:1 的同步变换
很多刚接触 Reactor 的同学会把 flatMap 想得很神秘,其实它和 map 之间的关系非常天然。map 做的事情很单纯:上游来一个元素,我同步地把它变成另一个元素,一对一,不改变流的维度。
Flux.just("o-1", "o-2", "o-3") .map(orderId -> orderId.toUpperCase()) .subscribe(System.out::println);这段代码里,每个字符串进来,出去还是字符串,流的形状没有变。这种 1:1 变换在同步场景下非常舒服,你不需要考虑异步、不需要考虑顺序,因为 map 本身就是阻塞的。
但实际业务里"一个元素映射出一个结果"的情况远远不够。你往往碰到的是:一个元素对应着一个异步操作,而异步操作的结果又是一个容器。比如订单ID要查询出明细列表,再比如要访问某个服务去拿配置数据,返回的是一个集合。
这时候如果你继续用 map,写出来的代码会变成什么样?Flux 里的每个元素被映射成一个 Publisher,整个流就变成了嵌套流。Reactor 的泛型会把这种结构完整地暴露出来,编译器会直接告诉你这个嵌套关系存在,但你的下游订阅者并不关心这件事,它只想拿到扁平的订单明细。
这就是扁平化操作符存在的理由:把"每个上游元素 -> 一个内部流"这种结构,重新压平成一个对外统一发射元素的下游流。map 管不了这件事,因为它结构上就没有"展开"这一层。
1.2 当每个元素自己"开"出一个流
用一个最常见的场景来感受一下这种"流里套流"。假设上游是一串用户ID,下游是用户详情对象,每个用户ID都需要去远程接口拉一次数据。远程接口的函数签名是这样的:
Flux<UserDetail> fetchDetail(String userId);如果你坚持用 map:
Flux.just("u-1", "u-2", "u-3") .map(this::fetchDetail) // 返回 Flux<Flux<UserDetail>>静态类型已经清楚地告诉你,map 只是把每个字符串换成了另一个流对象,这些流对象彼此独立,并没有合并到你想要的同一个下游流里。你把这样的流交出去,订阅者要面对的是一层嵌套结构,处理起来极其别扭。
用 flatMap 就不是这个效果了。同样是这个映射函数,flatMap 会自动订阅每个由用户ID产生出来的内部流,并把它们发出的元素重新汇聚到一个外层的公共流中:
Flux.just("u-1", "u-2", "u-3") .flatMap(this::fetchDetail) .subscribe(detail -> System.out.println(detail.getName()));从订阅者的视角看,它拿到的就是一个不断的 UserDetail 流,至于这个元素是 u-1 来的还是 u-3 来的,它根本不关心。这也是"扁平化"这个词最直观的含义:把二维的流结构拉平成一维。
打个比方,map 就像一个人递给你一张纸,flatMap 则像他递给你一叠纸,同时帮你把纸摊开铺到桌面上。铺开之后你看不到原来的"一叠"这个层级,只看到一张张独立的纸张。
1.3 扁平化操作符家族:不止 flatMap 和 concatMap
Reactor 3 里和扁平化相关的操作符其实有一族,除了标题里的 flatMap、concatMap,还有 flatMapSequential、switchMap 这些。它们的共性都是"上游一个元素映射为一个内部 Publisher",区别在于合并内部流的方式,以及对顺序和并发的取舍。
我在实际项目里真正高频使用的也就三四个,flatMap 解决并发展开问题,concatMap 解决顺序强制问题,flatMapSequential 在两者之间找平衡,switchMap 则适合"最新结果覆盖旧结果"的场景。这篇先把 flatMap 和 concatMap 讲透,因为它们代表了扁平化里两个最核心的极端:完全并发无序,和完全串行有序。理解了这两个极端,flatMapSequential 和 switchMap 你自然而然就能推出来。
2. flatMap 核心机制:并发展开与无序合并
2.1 一样的代码,不一样的输出顺序
flatMap 最迷惑人的地方在于:它表面上看起来和 concatMap 的写法几乎一样,就是方法名差一个前缀,但行为差异巨大。先用一个简单的延迟模拟来观察:
Flux.just("u-1", "u-2", "u-3") .flatMap(this::fetchName) .subscribe(System.out::println);对应的映射函数,给不同 ID 制造不同延迟:
private Mono<String> fetchName(String id) { return Mono.delay(Duration.ofMillis("u-1".equals(id) ? 80 : 20)) .map(tick -> id + "-detail"); }我先卖个关子,你猜一下输出顺序是什么。直觉可能会告诉你,既然上游发射顺序是 u-1、u-2、u-3,那输出应该也是这个顺序。但 flatMap 的答案恰恰反直觉:u-1 虽然先进入流,但它延迟了 80ms,u-2 和 u-3 的延迟只有 20ms,所以先输出的反而是后面的元素。
这就是 flatMap 的核心特征:它会立刻订阅所有内部流,多个内部流之间的发射顺序是交错的,谁先有结果谁先往公共流里发。如果你依赖"先查询的先输出"这种假设,用 flatMap 就会踩到顺序坑。
2.2 flatMap 内部做了什么:多路订阅与合并
从源码层面看,flatMap 做的事情可以简化成三步。第一步,上游每到达一个元素,就通过映射函数生成一个对应的内部 Publisher;第二步,框架立即订阅这个内部 Publisher,并且这个订阅是并发的,不是等上一个内部流结束再订阅下一个;第三步,所有并发运行中的内部流,谁发射了元素,谁就把元素丢进同一个合并缓冲区,再传给下游。
这个"合并缓冲区"就是无序的根源。它本质上是一种 merge 行为,多个生产者在同时生产,生产者之间没有顺序协议,谁先生产完谁先绕过缓冲区抵达下游。
所以在实际运行中你可能看到:u-2 的结果先出来,u-3 的结果跟上,u-1 的结果最后才到。这不是偶发,而是 flatMap 的常态化表现。
2.3 背压、并发度与合并缓冲
flatMap 还有一个容易被人忽略的重载参数:并发度。默认情况下,flatMap 最多同时订阅 256 个内部流,也就是说上游来元素的速度再快,它也只会优先处理 256 个,其余的进入排队等待。
flatMap(this::fetchDetail, 16); // 限制最多16个内部流并发为什么要限制并发度?我见过不少新人在并发量小的测试里跑得挺好,一上生产就把下游数据库连接池打爆。上游可能一瞬间来一万个ID,flatMap 默认并发 256,加上每个 ID 的查询要占用一条数据库连接,等于瞬时需要 256 个连接,很多服务根本扛不住。这时候用第二参数把并发度调到 16、32,既保留并发加速,又保护下游资源。
如果你还需要更精细的控制,flatMap 还有第三个参数 prefetch,控制每个内部流预取多少元素。日常业务里先调整 concurrency 就够了,prefetch 一般用默认值即可。
2.4 flatMap 最适用的场景和明显的反例
我个人的经验,flatMap 适合下面这几类场景:
- 每个元素映射出的内部流耗时不一致,你希望更快的结果先出来,不在乎顺序。
- 下游处理逻辑不依赖上游发射顺序,比如把数据写入消息队列,消息之间互相独立。
- 你想利用并发加速,把串行等待变成并行等待。
反过来,如果存在以下信号,就别用 flatMap:
- 下游对处理顺序有硬性要求,比如必须按输入ID顺序写Excel。
- 内部流之间互相依赖,第二个元素的计算要等第一个元素的结果。
- 前端需要展示"先来先显示"的列表,但带上了明确的顺序预期。
顺序要求这种东西,刚开始写响应式代码时容易被忽略,因为小数据量下 flatMap 的乱序可能完全看不出来,顺序碰巧和输入一致。一旦数据量上来,或者某个服务偶发慢查询,乱序问题就会突然暴露,而且这种 bug 非常难定位。
3. concatMap 的顺序保证从哪里来:串行订阅与它的成本
3.1 concatMap 的"慢"是很诚实的
concatMap 同样是把上游元素映射成内部流,但它的合并方式完全不同。它不会并发订阅所有内部流,而是严格地一个接一个:先订阅第一个元素生成的内部流,等这个内部流完完整整地发出最后一个元素并结束,再订阅第二个元素生成的内部流。
所以你看到的输出序列,和上游输入序列永远是严格对应的。还是刚才那个延迟模拟:
Flux.just("u-1", "u-2", "u-3") .concatMap(this::fetchName) .subscribe(System.out::println);不管 u-1 内部的查询有多慢,u-2 的结果都只能在 u-1 的流完全结束后才开始处理。这就是 wait-for-complete 的行为。它保证了顺序,代价是彻底放弃了并行。
用生活类比来说,flatMap 是超市开了多个收银台,大家谁排完队谁先走;concatMap 是只有一个收银台,第一个人结完账走了,第二个人才能开始把商品放上收银台。
3.2 concatMap 的预取机制:为了不断流
如果你读过 concatMap 的实现,会发现它没有想象中那么死板,它内部其实有一个预取机制,默认预取数量是 2。也就是说,concatMap 当前正在处理第一个内部流时,上游可以提前把下一个元素准备好,这样一旦第一个流结束,第二个流立刻就能接着订阅,不用等上游重新发射数据。
这个设计本质上是为了解决串行场景下的"空转"问题。它让 concatMap 在保序的同时尽量保持流式节奏,但千万不要误解成 concatMap 允许并行,它只是做了流水线式的缓冲,执行仍然是串行。
concatMap(this::fetchName, 4); // 增大预取,稍微减小切换开销调大预取量在内部流很短、切换频繁的场景下有点作用,但作用有限,核心瓶颈还是被串行执行拖住的。
3.3 做一个简单的延迟账本
为了直观理解 concatMap 的成本,我们算一笔账。假设上游有 10 个元素,每个元素对应的内部操作平均耗时 200ms。
- 用 flatMap 并发执行,理论上总耗时接近最慢的那个元素,大约 200 到 300ms 就能全部完成。
- 用 concatMap 串行执行,总耗时是 10 乘以 200ms,也就是约 2000ms。
这笔延迟账在元素多、单个操作慢的场景下会被放得很大。所以我会在代码评审时特别留意:如果有人把 flatMap 直接改成 concatMap 来修乱序 bug,那他其实是在用性能换顺序,这种取舍不是不行,但要想清楚代价。
3.4 concatMap 真正不可替代的场景
既然 concatMap 这么慢,那什么时候必须用它?我的经验是,凡是内部流存在时间相关性、强依赖性的场景,都逃不开它。
比如我做过一个设备配置下发功能:一批配置项之间是有先后约束的,先写网络参数,再重启设备,最后校验状态。如果并发执行,第二步可能在第一步还没完成时就启动,整个流程都会出错。这种场景下,concatMap 的顺序保证不是性能问题,而是正确性问题。
再比如金融业务里的流水处理,依赖"上一笔状态变更完成后才能处理下一笔"的规则,这种场景你不可能用 flatMap 去赌运行时的顺序。
有一类更隐蔽的场景也适合 concatMap:内部流本身有副作用,并且副作用之间存在上下文关联。比如依次给用户发提醒短信,同一个用户的两条短信顺序颠倒了,后端不报错但前端客服会看到历史顺序混乱。这种逻辑层面的关联,靠测试不一定能覆盖,但它真实存在于业务语义里。
4. 实战选择:我在代码里按这三条规则挑操作符
4.1 规则一:先回答"顺序重不重要"
这是我选型时问自己的第一句话。判断标准很简单:把输入顺序和输出结果顺序打乱,下游能感知到变化吗?能感知,就必须放弃 flatMap,转而考虑 concatMap 或 flatMapSequential;感知不到,就大胆用 flatMap 享受并发优势。
有些场景的顺序要求藏在细节里,比如你要把处理结果回写到一个数组,数组索引按输入顺序排。这种情况下即使业务看起来不敏感,写入位置一变就会错位,本质还是顺序敏感。
下面这个表是我在评审时经常用来和同事对齐的:
| 场景特征 | 保序需求 | 并发需求 | 首选操作符 |
|---|---|---|---|
| 数据上报、日志清洗 | 无 | 高 | flatMap |
| 依赖严格的流水处理 | 高 | 低 | concatMap |
| 结果展示、报表导出 | 高 | 高 | flatMapSequential |
| 秒级超时的实时查询 | 低 | 极高 | flatMap 配合限流 |
4.2 规则二:看内部流的类型,是 Mono 还是 Flux
这里有一个很多教程没强调的细节:扁平化操作符的映射函数返回的是 Publisher,具体是 Mono 还是 Flux,会影响你对并发和顺序的预期。
内部是 Mono 时,每个内部流只会发出一个元素就结束,flatMap 和 concatMap 的顺序差异主要体现在"第一个结果何时出现",而不会出现"一个内部流的多个元素被另一个流打断"的情况。内部是 Flux 时就不一样了,一个内部流可能连续发出多个元素,flatMap 会把不同流的元素交错合并,顺序问题会被放大。
我之前排查过一个诡异的现象:上游数据量很小,flatMap 的输出顺序和输入顺序一致,同事困惑为什么会产生乱序 bug。最后发现真正的差异在内部映射函数某个分支返回了 Flux,另一个分支返回 Mono,不同分支的时序行为不一致,才导致线上偶发乱序。所以写映射函数时,明确返回值是 Mono 还是 Flux,对你的心智负担影响很大。
4.3 规则三:用并发度参数保护下游资源
不管选择 flatMap 还是 flatMapSequential,我都建议显式设置并发度,别依赖 256 这个默认值。256 这个数字对大部分内部操作来说都太大了,尤其是你的内部操作要访问数据库、调用第三方HTTP接口时,瞬间 256 路并发打出去,没有几个外部服务能稳稳接住。
我之前有一个数据同步任务,上游是几万条变更记录,每条变更要去查一个老系统的接口。最开始没做并发限制,上线第一天就把老系统的连接池打崩了,对方直接拒绝后续请求。后来改成 flatMap(..., 32),并加了重试和退避,整个任务从频繁超时变成稳定跑完。限制并发的本质,是控制你对别人的冲击程度,这和响应式本身没关系,但操作符给了你这个旋钮,不用就是浪费。
另外,如果并发度设得太低,比如 4,而每个内部流又要做网络交互,整体吞吐可能还不如串行优化得好。并发度设置不是一个固定值,要根据下游服务的响应时间、连接池大小、单次任务的体量综合估算。
4.4 flatMapSequential:既要并发,又要保序
很多人会在 flatMap 和 concatMap 之间纠结,其实 Reactor 还给了第三个选项:flatMapSequential。它结合了两边的优点:内部流是并发执行的,但最终发射到下游的元素,会按照上游输入的顺序重新排列。
用法很简单:
Flux.just("u-1", "u-2", "u-3") .flatMapSequential(this::fetchDetail)它的内部实现会为每个元素的结果保留一个槽位,哪个内部流先完成,先放进属于自己的槽位,但发射动作必须按顺序来:第一个槽位没有结果,后面的结果就算先到也得等待。
flatMapSequential 适合报表导出这类场景:既希望大量查询并发跑,又必须保证输出顺序与输入顺序一致。我自己的经验是,只要并发安全和顺序要求同时存在,无脑选 flatMapSequential 大概率比 concatMap 合适,因为它没有完全丢掉并发收益。
但它也有代价:每个内部流的生命周期变长,因为先完成的结果要缓存在槽位里等前序结果完成才能发出去,内存占用比 flatMap 和 concatMap 高一些。数据量极大时要注意内存水位。
5. 我踩过的几个扁平化相关的坑
5.1 在 flatMap 里用了阻塞调用,背压和调度全废了
刚开始写响应式时最容易犯的错,就是在 flatMap 的映射函数里直接写阻塞代码:
.flatMap(id -> { String result = jdbcTemplate.queryForObject(...); // 阻塞调用 return Mono.just(result); })从语法上说代码能跑,但语义上完全跑偏了。flatMap 的映射函数可能在事件循环线程上被回调,一个线程池就那么几个线程,你在里面 sleep 或做 JDBC 同步查询,等于把整个并发度卡死。更麻烦的是,背压机制也被绕过,因为你没有通过异步信号告知下游当前处理的进度。
正确做法是把阻塞调用包装成异步,比如使用专门的阻塞适配器,或者提前把数据查好再进入响应式流。如果实在绕不开,至少要换一个足够大的弹性线程池,避免占住事件循环线程。
5.2 内部流为空时,flatMap 会"静默跳过"
有一个很容易忽略的边界情况:映射函数返回的内部流可能一个元素都不发,直接就结束了。这时候 flatMap 的行为是默默跳过这个元素,继续处理其他内部流。它不会报错,也不会中断。
这种设计本身没问题,但容易掩盖逻辑 bug。有一次我做一个用户存档迁移,上游是用户ID,内部流去查该用户的历史订单,新用户没订单,内部流自然是空的。结果下游汇总时发现用户数少了,排查半天才发现是空流被静默过滤掉了。
如果你确实需要感知"某些元素没有结果",可以在内部流里加上默认值:
.flatMap(id -> fetchOrders(id) .defaultIfEmpty(new EmptyOrder(id)))这样每个用户至少会产出一个元素,下游更容易判断哪些是真正的空结果。
5.3 内部流异常会直接拖垮整个流程
flatMap 的另一个"默认行为"是:内部流发出 error 信号后,外层流会立刻终止。你在一个内部流里的下游服务超时,可能导致整批数据处理中断,而不是只跳过那一条。
这个行为和 Java 8 Stream 里一个元素抛异常导致整个流失败很像。响应式编程本身也是这个哲学:错误用信号表达,而非吞掉。所以如果你的业务要求"单条失败不影响整体",必须在内部流里先做错误处理,例如用 onErrorResume 把异常转成一个结果,而不是把错误抛到外层。
我一般会在扁平化操作之前做一个错误兜底策略:
.flatMap(id -> fetchDetail(id) .onErrorResume(e -> Mono.just(defaultDetail(id))))这样单个查询失败只会出来一个默认结果,外层流能继续跑完。
5.4 concatMap 在"上游快、下游慢"时也会内存膨胀
concatMap 看起来是串行的,好像不会堆积太多数据,但其实它也有预取队列。上游发射速度远大于处理速度时,上游还没来得及发完的上万个元素会源源不断进入缓冲区。等待处理的数据量越大,内存占用就越高。
我遇到过一次 OOM,场景是:从数据库一次性查了五万条记录,下游对每一条做一次远程调用,用了 concatMap 保序。远程调用平均要几百毫秒,五万条全部处理完成需要很长时间,而数据库端的数据还在往上游流里灌,背压没能把内存压住,最终堆内存被打爆。
遇到这种场景,我的建议是:先把数据分批,控制上游发射速度,或者在 concatMap 前后搭配 limitRate 进行限速。另一个思路是改用 flatMapSequential 配合合适的并发度,至少内部流并发执行能更快消化队列,降低堆积风险。
5.5 调试顺序问题:用 log 观察没那么玄
如果你遇到了疑似乱序的 bug,最有效的工具其实是 log 操作符。它会在每个事件发生时打印 onNext、onComplete、onSubscribe 等信号,配合元素内容,你能直观看到发射顺序和耗时。
Flux.just("u-1", "u-2", "u-3") .flatMap(this::fetchName) .log("flat-map-demo") .subscribe();跑一次之后把日志时间线拉出来,flatMap 和 concatMap 的差异一目了然。我个人在写复杂响应式链路时,习惯先在每个操作符之间临时加 log,等流程稳定后再删掉。这个习惯帮我定位了很多"看起来玄学"的顺序怪问题。
6. 一句话口诀和两个想推荐的练习
如果看完这篇只记住一件事,那我希望是这句:顺序重要用 concatMap,性能优先用 flatMap,既要顺序又要并发用 flatMapSequential。这句话我每次评审响应式代码时都会在脑子里过一遍,基本能解决绝大多数选型问题。
最后给你两个可以动手验证的小练习,练完对扁平化的理解会比读十篇博客都有用。
第一个练习:模拟一个"用户列表转用户详情列表"的任务,给不同用户设置不同的延迟时间,分别用 flatMap、concatMap、flatMapSequential 跑三遍,打印输出顺序。你会在控制台直观看到三种操作符的时序差异。
第二个练习:在你自己的项目里,找一个目前用 map 处理集合数据的响应式接口,试着把它改成 flatMap 并加好并发度参数,观察下游压力和耗时变化。只要不做顺序敏感的业务,大概率能发现明显吞吐提升。
我在实际使用中的一个体会是:flatMap 和 concatMap 之所以容易让人困惑,不是因为 API 复杂,而是它们迫使你直面"顺序和并发不可兼得"这个真实约束。在熟悉这两个极端之后,再去理解 flatMapSequential、switchMap 这些高阶变体,整个知识体系就串起来了。也建议你在下次遇到乱序 bug 时,先别急着把 flatMap 换成 concatMap,仔细想想业务是否真的完全串行,也许 flatMapSequential 这个中间选项才是更划算的那一个。