附录 E:迁移检查清单

Spring MVC + JDBC 到 WebFlux + R2DBC 迁移检查清单:技术栈替换对应表(JPA→R2DBC/HikariCP→ConnectionPool/RestTemplate→WebClient)、阻塞陷阱与 ThreadLocal 替代方案、读→写→网关迁移优先级、渐进式双写策略与验证 checklist。

技术栈替换对应表

阻塞式(MVC/JDBC)响应式(WebFlux/R2DBC)注意
spring-boot-starter-webspring-boot-starter-webflux可共存,但同时引入时 MVC 优先、WebFlux 路由不生效;迁移时只保留 webflux
spring-boot-starter-data-jpaspring-boot-starter-data-r2dbcAPI 不同
JPA RepositoryReactiveCrudRepository无 Lazy Loading
HikariCPConnectionPool (r2dbc-pool)配置参数名不同
JdbcTemplateDatabaseClient方法链式
@TransactionalTransactionalOperator推荐编程式
ThreadLocal (MDC)Reactor Context传递方式不同
Servlet FilterWebFilterMono<Void> 返回
RestTemplateWebClient响应式 API
TomcatNetty (默认)线程模型不同

常见陷阱

陷阱 1:混用阻塞库

// 错误:在 WebFlux 中使用阻塞 JDBC
@GetMapping("/users")
public List<User> getUsers() {
    return jdbcTemplate.query("SELECT * FROM users", ...);  // 阻塞!
}

修复:能替换的替换为 R2DBC。短期无法替换的阻塞库必须显式隔离:

Mono.fromCallable(() -> jdbcTemplate.query(...))
    .subscribeOn(Schedulers.boundedElastic());

不要因为「反正有 boundedElastic」就把阻塞调用散落在整条链里;隔离点越少,线程池和超时越可控。

陷阱 2:在响应式链里 .block()

// 错误:把响应式结果阻塞回同步值
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
    return userService.findById(id).block();  // 阻塞 Netty 事件循环
}

返回普通对象本身不是问题(Spring 会正常序列化),问题是用 .block() 把异步链拉回同步等待;在 Netty 事件循环线程上调用会直接拖住整个 I/O 线程。修复:Controller 返回 Mono<User>/Flux<User>,让框架订阅。

如果第三方接口只有阻塞实现,用 Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) 隔离,而不是在事件循环上阻塞。

陷阱 3:忘记释放 DataBuffer

// 错误:手动订阅 body,既破坏背压,也容易漏掉释放
request.getBody().subscribe(buffer -> {
    byte[] bytes = new byte[buffer.readableByteCount()];
    buffer.read(bytes);
    // 未释放!
});

// 正确:交给 join 聚合,再显式释放
Mono<byte[]> body = DataBufferUtils.join(request.getBody())
    .map(buffer -> {
        try {
            byte[] bytes = new byte[buffer.readableByteCount()];
            buffer.read(bytes);
            return bytes;
        } finally {
            DataBufferUtils.release(buffer);
        }
    });

如果只是把请求体读成字节数组或字符串,优先用 bodyToMono(byte[].class)/bodyToMono(String.class),让框架处理缓冲区的分配与释放,不要自己订阅 getBody()。

陷阱 4:WebFlux 与 MVC 共存

Spring Boot 同时引入 spring-boot-starter-web 和 spring-boot-starter-webflux 时,自动配置以 MVC 为主,WebFlux 路由不生效。

修复:只保留 webflux starter。

迁移优先级

建议按以下顺序迁移:

  1. 基础设施:R2DBC 连接、Redis Reactive、WebClient
  2. 读操作:查询接口先迁移(风险低)
  3. 写操作:创建/更新接口(需验证事务)
  4. 复杂业务:Saga、事件驱动
  5. 网关层:最后迁移(影响面最大)

渐进式迁移:双写策略

无法一次性全量迁移时,使用双写:

public Mono<User> saveUser(User user) {
    // 1. 新系统写入只执行一次;cache() 让下面的订阅和返回的订阅共享结果
    Mono<User> reactiveSave = r2dbcRepo.save(user).cache();

    // 2. 异步写旧系统(JDBC),失败记录日志/告警,不影响主链
    reactiveSave
        .doOnNext(saved -> {
            try {
                jdbcTemplate.update("INSERT INTO users (id, name) VALUES (?, ?)",
                    saved.id(), saved.name());
            } catch (Exception e) {
                log.error("Legacy write failed, userId={}", saved.id(), e);
            }
        })
        .subscribeOn(Schedulers.boundedElastic())
        .subscribe();

    return reactiveSave;
}

这里有两个容易踩的坑:

  • 如果直接 r2dbcRepo.save(user) 后又 subscribe() 再 return,同一个 Mono 会被订阅两次,插入执行两次。cache() 或 share() 可以避免,但要明确它也会缓存错误。
  • 旧系统写入失败后没有自动补偿,只能靠日志/告警人工处理。真要做到可恢复,需要 outbox 表或消息队列,而不是 fire-and-forget。

运行一段时间后,对比新旧系统数据一致性,确认无误后再停用旧系统。双写期间要明确「谁是主」:通常以新系统为准,旧系统只做读兼容。

验证清单

  • 所有 Controller 返回 Mono/Flux
  • 所有数据库操作使用 R2DBC
  • 没有 .block() 在生产代码中(测试除外)
  • 所有 DataBuffer 已释放
  • MDC 已替换为 Reactor Context
  • ThreadLocal 已清理
  • 连接池配置正确
  • 超时层级合理
  • 单元测试使用 StepVerifier
  • 集成测试使用 @SpringBootTest + WebTestClient

小结

从 MVC 迁移到 WebFlux 不是「替换注解」那么简单。核心变化是思维模型:从「线程持有请求」到「事件驱动回调」。建议小步快跑,先迁移低风险读接口,积累信心后再动核心业务。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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