先看默认行为:错误可能被丢弃
很多调试文章直接从「异常栈只有操作符内部帧」讲起,但更基础的问题是:subscribe() 不传错误回调时,错误根本不会抛给调用方。
import reactor.core.publisher.Flux;
public class DropDemo {
public static void main(String[] args) {
Flux.just(1, 2, 3)
.map(i -> {
if (i == 2) throw new RuntimeException("boom at " + i);
return i * 2;
})
.subscribe();
}
}
接上 SLF4J 后端后实测:
[main] ERROR reactor.core.publisher.Operators - Operator called default onErrorDropped
reactor.core.Exceptions$ErrorCallbackNotImplemented: java.lang.RuntimeException: boom at 2
Caused by: java.lang.RuntimeException: boom at 2
at DropDemo.lambda$main$0(DropDemo.java:7)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:114)
...
如果没有日志后端,这段输出是空的。错误被 onErrorDropped 记录后丢弃,程序继续运行。生产里最危险的情况不是栈不好看,而是异常完全消失。
所以调试的第一步是给 subscribe 传错误回调,或者用 blockLast() 让异常回到调用线程:
.subscribe(v -> { }, e -> e.printStackTrace());
默认栈能看到什么
同一段代码,只把 subscribe() 换成带错误回调的版本:
import reactor.core.publisher.Flux;
import reactor.core.publisher.Hooks;
public class DebugDemo {
public static void main(String[] args) {
// Hooks.onOperatorDebug();
Flux.just(1, 2, 3)
.map(i -> {
if (i == 2) {
throw new RuntimeException("boom at " + i);
}
return i * 2;
})
.subscribe(v -> { }, e -> e.printStackTrace());
}
}
实测:
java.lang.RuntimeException: boom at 2
at DebugDemo.lambda$main$0(DebugDemo.java:10)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:114)
at reactor.core.publisher.FluxArray$ArraySubscription.fastPath(FluxArray.java:171)
at reactor.core.publisher.FluxArray$ArraySubscription.request(FluxArray.java:96)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.request(FluxMapFuseable.java:172)
at reactor.core.publisher.LambdaSubscriber.onSubscribe(LambdaSubscriber.java:123)
...
at DebugDemo.main(DebugDemo.java:16)
第一帧能定位到 lambda(第 10 行),但看不到这个 lambda 属于哪条链路。栈里只有 FluxMapFuseable、FluxArray 这些操作符内部类;当项目里有十几条相似的流时,仅凭这些帧无法判断是哪一个 map。
Hooks.onOperatorDebug()
把注释打开,并且必须在装配之前调用:
public static void main(String[] args) {
Hooks.onOperatorDebug();
Flux.just(1, 2, 3)
// ...
}
实测输出:
java.lang.RuntimeException: boom at 2
at DebugDemo.lambda$main$0(DebugDemo.java:10)
Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below:
Assembly trace from producer [reactor.core.publisher.FluxMapFuseable] :
reactor.core.publisher.Flux.map(Flux.java:6628)
DebugDemo.main(DebugDemo.java:8)
Error has been observed at the following site(s):
*__Flux.map ⇢ at DebugDemo.main(DebugDemo.java:8)
Original Stack Trace:
at DebugDemo.lambda$main$0(DebugDemo.java:10)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onNext(FluxMapFuseable.java:114)
...
多了三段信息:
| 段 | 含义 |
|---|---|
Assembly trace from producer | 这个操作符在哪个类的哪一行被创建 |
Error has been observed at | 错误经过的站点,*__ 标出错处 |
Original Stack Trace | 原始栈,保留操作符内部帧 |
Assembly trace 里的 DebugDemo.java:8 是 .map(...) 所在行,不是 Flux.just 的行。Reactor 只记录出错操作符的装配位置;不要期待它把整条链的每一行都列出来。
全局开关的代价
Hooks.onOperatorDebug() 影响的是整个 JVM:开启之后组装的每个操作符都会额外包装一层 FluxOnAssembly,并在装配时抓取调用栈。代价包括:
- 装配阶段的栈抓取;
- 每个操作符多一个包装对象;
- 异常对象上挂载的装配快照。
具体慢多少取决于操作符数量、链路深度和是否命中 JIT,不能用一个固定的百分比概括。要量化就用 JMH 对比同一条链在开关前后的吞吐与分配率。
它只适合本地开发和测试。线上需要时,优先用 checkpoint();如果确实要在线开启,至少用配置中心控制,并在停止排查后调用:
Hooks.resetOnOperatorDebug();
checkpoint():只在关键位置标记
checkpoint() 不改变数据流,只在装配时给当前链打一个标记。错误发生并经过这个标记时,报告里会出现站点:
import reactor.core.publisher.Flux;
public class CheckpointDemo {
public static void main(String[] args) {
Flux.just(1, 2, 3)
.map(i -> {
if (i == 2) throw new RuntimeException("boom");
return i;
})
.checkpoint("after map")
.filter(i -> i > 2)
.subscribe(v -> { }, e -> e.printStackTrace());
}
}
实测:
java.lang.RuntimeException: boom
at CheckpointDemo.lambda$main$0(CheckpointDemo.java:7)
Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below:
Error has been observed at the following site(s):
*__checkpoint ⇢ after map
Original Stack Trace:
at CheckpointDemo.lambda$main$0(CheckpointDemo.java:7)
at reactor.core.publisher.FluxMapFuseable$MapFuseableConditionalSubscriber.onNext(FluxMapFuseable.java:284)
...
注意两点:
- 输出里是
*__checkpoint ⇢ after map,没有方法名和括号。 checkpoint报告的是错误经过的最后一个检查点。如果一条链上有多个 checkpoint,只有离错误最近的那个会出现在站点列表里,其他标记不会全部列出。
3.8.8 只有三个重载
flux.checkpoint();
flux.checkpoint("after map");
flux.checkpoint("after map", true);
第三个参数是 forceStackTrace,不是「条件模式」。Reactor 3.8.8 的 Flux 上没有 checkpoint(String, Predicate) 之类的重载,也不存在「只在错误时记录」的检查点。所谓「条件检查点」是误传。
checkpoint() 适合在关键边界打点,例如数据库查询之后、外部调用返回之后、flatMap 汇合之前。它比全局 hook 便宜,但仍然有装配开销;不要在每个操作符后面都加一个。
log():查看信号流
log() 把 onSubscribe、request、onNext、onError、onComplete、cancel 打印到 SLF4J。它需要日志后端,否则什么都看不到。
Flux.range(1, 3)
.log("myStream")
.map(i -> i * 2)
.subscribe();
接 slf4j-simple 后实测:
[main] INFO myStream - | onSubscribe([Synchronous Fuseable] FluxRange.RangeSubscription)
[main] INFO myStream - | request(unbounded)
[main] INFO myStream - | onNext(1)
[main] INFO myStream - | onNext(2)
[main] INFO myStream - | onNext(3)
[main] INFO myStream - | onComplete()
前缀 [main] INFO myStream - 是 slf4j-simple 的格式;换 Logback 后格式会变,但 | onSubscribe(...) 这部分是 Reactor 输出的。log() 适合确认「谁请求了多少、收到了什么」,不适合长期留在生产:每个信号一条日志,高频流会把磁盘打满。
生产环境:别把 MDC 当万能追踪
一个常见的错误写法:
Flux.just(1, 2, 3)
.doOnNext(i -> MDC.put("element", String.valueOf(i)))
.publishOn(Schedulers.boundedElastic())
.doOnError(e -> log.error("Failed at element {}", MDC.get("element"), e))
.subscribe();
MDC 是 ThreadLocal。publishOn 会把后续信号切到另一条线程;在 doOnNext 里 put 的值,到了 doOnError 所在的线程通常是空的。即使没有 publishOn,异步源也可能在不同线程发信号。
可行的做法有两类:
1. 在同一个回调里完成 put、日志、remove:
.doOnNext(v -> {
MDC.put("element", String.valueOf(v));
try {
log.info("processing");
} finally {
MDC.remove("element");
}
})
这能保证日志和 MDC 在同一线程,但只覆盖当前这一条日志。
2. 用 Reactor Context 做跨操作符传递。 把 traceId 写入 contextWrite,在需要的地方用 deferContextual 读取;再配合 Micrometer 的 Context Propagation 让 MDC 自动跟随 Reactor Context。代价是引入额外依赖,并且要开启自动传播,不是默认行为。
如果只是排查一条具体链路,checkpoint 加结构化日志通常比全局 MDC 更直接。
调试顺序建议
- 先确认错误没有被丢弃:
subscribe(v -> {}, e -> ...)或blockLast()。 - 需要定位装配位置:在可疑边界加
checkpoint("...")。 - checkpoint 不够、需要整条链:本地开启
Hooks.onOperatorDebug(),复现后立即关闭。 - 需要看请求与信号:临时加
log("name"),确认背压行为后移除。 - 线上:结构化日志 + traceId,不长期开启全局 hook。
小结
| 工具 | 得到什么 | 主要限制 |
|---|---|---|
| 默认栈 | 出错 lambda 与操作符内部帧 | 不知道属于哪条链;无错误回调时错误被丢弃 |
onOperatorDebug() | 装配位置、错误站点、原始栈 | 全局生效,装配开销大 |
checkpoint() | 标记处的错误站点 | 只有三个重载,无「条件模式」 |
log() | 完整信号流 | 需要日志后端,输出量大 |
| Context + Micrometer | 跨线程 traceId | 需要额外依赖与显式开启 |
阅读导航
下一篇:12.3 装配时 vs 订阅时
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。