附录 D:从 CompletableFuture 到响应式流迁移指南

CompletableFuture 与 Mono/Flux 的完整 API 映射表,涵盖异步创建、变换、组合、错误处理、超时与并发控制六大类操作。列出迁移过程中的常见陷阱(阻塞 get、异常处理差异、背压缺失)并给出修正代码,同时明确「不应迁移」的场景以避免过度设计。

为什么迁移

CompletableFuture 和 Mono 都解决了「异步编程」的问题,但设计目标不同:

特性CompletableFutureMono/Flux
数据量0 或 1 个结果0、1 或 N 个元素
背压❌ 无✅ 内置
组合能力thenXxx 链丰富的操作符
超时/重试手动实现内置
线程控制supplyAsyncSchedulers
取消cancelDisposable.dispose

迁移的收益:统一背压控制、更丰富的错误恢复、管道化的数据流处理。但不是所有场景都需要迁移。

API 映射表

创建

CompletableFutureMono说明
CompletableFuture.completedFuture(v)Mono.just(v)已有值
CompletableFuture.supplyAsync(() -> v)Mono.fromCallable(() -> v)延迟计算
new CompletableFuture<>()Mono.create(sink -> ...)手动完成
CompletableFuture.failedFuture(e)Mono.error(e)错误
CompletableFuture.allOf(cf1, cf2)Mono.when(m1, m2)等待全部并丢弃结果;要保留结果用 Mono.zip(...)
CompletableFuture.anyOf(cf1, cf2)Mono.firstWithSignal(m1, m2)取最先发出信号的 Mono

变换

CompletableFutureMono说明
thenApply(f)map(f)同步变换
thenCompose(f)flatMap(f)异步展开
thenRun(r)thenEmpty(Mono.fromRunnable(r))副作用

错误处理

CompletableFutureMono说明
exceptionally(f)onErrorResume(e -> Mono.just(f.apply(e)))f 是 Function<Throwable,T>;常量默认值才用 onErrorReturn(v)
exceptionallyCompose(f)onErrorResume(f)切换备用流
handle((v,e) -> ...)map + onErrorResume,或 Mono.handle(...)值路径与错误路径要分开写

超时与重试

CompletableFutureMono说明
orTimeout(duration)timeout(duration)超时
completeOnTimeout(v, duration)timeout(duration, Mono.just(v))超时默认值
无内置重试retry(n)重试 N 次
无内置退避retryWhen(Retry.backoff(...))退避重试

并发控制

CompletableFutureMono说明
supplyAsync(fn, executor)subscribeOn(Schedulers.xxx())指定线程
无背压onBackpressureBuffer()背压缓冲

迁移示例

示例 1:链式调用

CompletableFuture:

CompletableFuture<User> userFuture = fetchUser(id)
    .thenCompose(user -> fetchOrders(user.getAccountId()))
    .thenApply(orders -> calculateTotal(orders))
    .exceptionally(e -> {
        log.error("Failed", e);
        return User.empty();
    });

Mono:

Mono<User> userMono = fetchUser(id)
    .flatMap(user -> fetchOrders(user.getAccountId()))
    .map(orders -> calculateTotal(orders))
    .onErrorResume(e -> {
        log.error("Failed", e);
        return Mono.just(User.empty());
    });

示例 2:多个并行调用

CompletableFuture:

CompletableFuture<User> f1 = fetchUser(1);
CompletableFuture<User> f2 = fetchUser(2);
CompletableFuture<User> f3 = fetchUser(3);

CompletableFuture<List<User>> usersFuture = CompletableFuture.allOf(f1, f2, f3)
    .thenApply(v -> List.of(f1.join(), f2.join(), f3.join()));

旧写法在 allOf 里调用一次 fetchUser,又在 thenApply 里各调用一次,等于发出了两倍的请求;allOf 只负责等待,不会把结果带进 thenApply。

Mono:

Mono<List<User>> usersMono = Mono.zip(
    fetchUser(1), fetchUser(2), fetchUser(3),
    (u1, u2, u3) -> List.of(u1, u2, u3)
);

示例 3:超时处理

CompletableFuture:

fetchUser(id).orTimeout(Duration.ofSeconds(5));

Mono:

fetchUser(id).timeout(Duration.ofSeconds(5));

常见陷阱

陷阱 1:阻塞 get/join

// 错误:阻塞了响应式线程
User user = userMono.block();  // 等同于 future.get()!

// 正确:在订阅中处理
userMono.subscribe(u -> process(u));

// 测试中用 StepVerifier
StepVerifier.create(userMono)
    .expectNext(expectedUser)
    .verifyComplete();

陷阱 2:异常类型丢失

// CompletableFuture:异常可能被 CompletionException 包装
future.exceptionally(e -> {
    Throwable cause = (e instanceof CompletionException && e.getCause() != null)
        ? e.getCause()
        : e;
    return fallback(cause);
});

// Mono:onErrorResume 收到的是操作符抛出的原始异常
mono.onErrorResume(e -> Mono.just(fallback(e)));

不要简单地说「CF 一定是 CompletionException,Mono 一定不是」。CompletableFuture.failedFuture(new IOException()) 传给 exceptionally 的就是 IOException;只有经过 thenApply/thenCompose 等依赖阶段后,异常才可能被包装成 CompletionException。判断时看 instanceof,不要无脑 getCause()。

陷阱 3:背压缺失

// CompletableFuture:没有背压概念
for (int i = 0; i < 1000000; i++) {
    fetchUser(i);  // 100 万个请求同时发出!
}

// Mono:用 flatMap 的 concurrency 控制
Flux.range(0, 1000000)
    .flatMap(i -> fetchUser(i), 100)  // 最多 100 个并发
    .subscribe();

陷阱 4:线程池混淆

// CompletableFuture 默认用 ForkJoinPool
CompletableFuture.supplyAsync(() -> compute());

// Mono 默认在订阅线程执行,需要显式切换
Mono.fromCallable(() -> compute())
    .subscribeOn(Schedulers.boundedElastic());

不应迁移的场景

场景原因建议
单次异步调用CompletableFuture 更简单保持 CF
已有成熟 CF 代码库迁移成本高在边界处转换
不需要背压CF 足够保持 CF
团队不熟悉响应式学习成本高培训后再迁移

边界转换

如果需要在 CF 和 Mono 之间转换:

// CompletableFuture → Mono
Mono<String> mono = Mono.fromFuture(completableFuture);

// Mono → CompletableFuture
CompletableFuture<String> future = mono.toFuture();

// Mono → Flux
Flux<String> flux = mono.flux();

// Flux → Mono(取第一个)
Mono<String> first = flux.next();

阅读导航

上一篇:附录 C 背压规则清单

下一篇:附录 E RS TCK 测试方法参考

混合使用 CF 和 Mono

项目中不需要全部迁移,可以在边界处转换:

在现有 CF 代码中引入 Mono

// 现有 CF 代码
public CompletableFuture<User> getUser(String id) {
    return userClient.get(id);
}

// 新代码使用 Mono,在边界处转换
public Mono<Order> getOrderWithUser(String orderId) {
    return orderRepository.findById(orderId)
        .flatMap(order -> Mono.fromFuture(getUser(order.getUserId()))
            .map(user -> {
                order.setUser(user);
                return order;
            }));
}

在 Mono 链中调用 CF

public Mono<PaymentResult> processPayment(PaymentRequest request) {
    return validate(request)
        .flatMap(valid -> Mono.fromFuture(
            legacyPaymentService.charge(request)  // CF
        ))
        .timeout(Duration.ofSeconds(10))
        .onErrorResume(e -> Mono.just(PaymentResult.failure(e)));
}

Spring WebFlux 中的迁移

Spring WebFlux 默认返回 Mono/Flux,从 Spring MVC(同步)迁移时:

// Spring MVC(同步)
@GetMapping("/user/{id}")
public User getUser(@PathVariable String id) {
    return userService.findById(id);  // 阻塞!
}

// Spring WebFlux(异步)
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable String id) {
    return userService.findById(id);  // 非阻塞
}

数据库层迁移(R2DBC):

// Spring Data JPA(同步)
public interface UserRepository extends JpaRepository<User, String> {
    User findByEmail(String email);
}

// Spring Data R2DBC(异步)
public interface UserRepository extends ReactiveCrudRepository<User, String> {
    Mono<User> findByEmail(String email);
}

迁移检查清单

检查项通过标准
无 .block() 调用除测试外全部移除
无 .get() / .join() 调用除边界转换外全部移除
数据库访问使用 R2DBC 或 reactive driverMongo/MariaDB/Postgres 非阻塞驱动
HTTP 客户端使用 WebClient替代 RestTemplate
错误处理使用 onErrorResume替代 try-catch + 返回值
线程切换使用 subscribeOn/publishOn替代 supplyAsync
超时使用 .timeout()替代 orTimeout
重试使用 .retryWhen()替代手动循环

性能对比:先别急着比

旧版给过「单次调用 CF 675μs、Mono 680μs」的数字,但没有说明机器、JDK、预热、测量方式和调用内容。这类数字不能作为迁移依据:一次异步调用的耗时主要由 I/O 和线程池决定,Mono 与 CompletableFuture 的封装差异通常落在噪声里。

要比较,至少固定这些条件:

项必须记录
JDK 与 JVM flags版本、堆大小、GC
线程池核心数、队列、拒绝策略
调用内容空操作、固定延迟、真实 I/O
测量点从订阅到终止,还是从提交到回调
预热与轮数至少多轮,报告中位数和 p99
并发度1、10、100 的结论可能相反

更有意义的比较不是「谁快几微秒」,而是「在同样并发下谁更稳」:CompletableFuture 没有背压,Flux.flatMap 可以把并发限制在连接池容量内;Mono 的超时、重试、取消传播是内置的,CF 需要自己拼。这些是结构性差异,不依赖某次微基准。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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