最近在做数据访问层改造时,把一部分同步 JDBC 调用换成了 Spring-R2DBC,连着踩了几天坑才算把这块的脾性摸清楚。如果你正在用 WebFlux,或者正在犹豫要不要把数据库访问也切到响应式,这篇应该能帮你少走不少弯路。我会按“它解决什么问题——环境怎么搭——日常 CRUD 怎么写得顺手——事务到底怎么玩——高频坑怎么排”的顺序,把 R2DBC 的实际用法和背后原理一起讲透。
1. R2DBC 到底是什么:先理解它为什么存在
1.1 JDBC 的阻塞病与线程困境
用一句话概括 JDBC 的模型:同步阻塞。每次执行 SQL,发起查询的线程会一直等着数据库返回结果,期间这个线程什么都干不了。这个模型从 Java 诞生用到现在,绝大多数企业应用都在跑,稳定归稳定,但并发上来之后,瓶颈非常明显:线程被 IO 卡住,连接被请求独占。
我习惯用一个餐厅的比方来给人解释。餐厅有 20 张桌子,每个服务员服务一桌客人,客人点完菜,服务员不去招呼别的桌,而是站在桌边等后厨出菜。高峰期来 100 桌客人,要么疯狂招服务员(加线程),要么让后面的人等着(阻塞排队)。JDBC 连接池就是这么回事:每个数据库连接同一时刻只能为一个请求服务,连接数的上限就是餐厅的桌子数,线程池的大小就是服务员的数量。
在这个模型下,即使你的业务逻辑只花了 5 毫秒,但数据库响应花了 100 毫秒,线程就要空等 100 毫秒。Tomcat 默认 200 个线程能扛的并发,其实远远达不到 200,因为每个线程都在等 IO。这也是很多系统一上量就疯狂调大线程池和连接池的原因,看起来是“调优”,实际上是在给阻塞模型打补丁。
1.2 R2DBC 的响应式模型:连接只在用时被占用
R2DBC 全称 Reactive Relational Database Connectivity,2018 年由 Spring 生态发起并推动落地的响应式关系型数据库连接规范。它跟 JDBC 最大的区别在于,把“发送 SQL 请求”和“等待数据库响应”拆开了。调用方发出查询后,线程立刻可以被队列复用去处理其他请求;等数据库结果真正到达时,事件循环会主动回调事先注册好的处理逻辑。
关键变化发生在连接的占用方式上:JDBC 里一个请求从打开 Statement 到 ResultSet 读完,连接一直被独占;R2DBC 里连接只有在真正执行语句的那一小段时间内被占用,执行完就归还给连接池。这样即使并发请求再多,也只需要少量连接,线程不会被数据库 IO 拖住,CPU 资源得以用来做真正需要计算的事情。
这套模型跟你熟悉的 WebFlux 是完全一致的。写了 WebFlux Controller 的人应该都有感觉:方法返回 Mono 或者 Flux,底层框架帮你调度线程,而不是你手动阻塞等待结果。R2DBC 只是把同样的思路延伸到了数据访问层。
1.3 什么场景才值得上 R2DBC
先说句得罪人的大实话:R2DBC 不是银弹,别为了异步而异步。如果你们团队没有响应式编程基础,或者系统本身是低并发的管理后台、报表系统,直接上 R2DBC 只会增加认知成本和排障难度。
我见过不止一个项目,为了让整个链路“看起来全异步”,硬把数据访问层切成 R2DBC,结果代码里到处是用.block()偷偷把响应式链路钉死的写法,性能没上去,Bug 还更难查了。R2DBC 真正适合的场景有两个特征:一是业务链路已经全异步化,比如 WebFlux 网关、实时推送服务、高并发读取接口;二是数据库连接确实成为系统并发瓶颈,需要把连接利用率提上去。
另外要明确,R2DBC 不会让 SQL 执行得更快,数据库本身的查询性能该怎样还是怎样。它解决的是“并发高时连接和线程被无谓占住”的问题,这一点想清楚再做技术选型,后面才不会后悔。
2. 环境准备与第一个 R2DBC 查询
2.1 Maven 依赖与版本搭配
Spring Boot 3.x 的项目里引入 R2DBC 比较简单,核心依赖是spring-boot-starter-data-r2dbc,它会帮你把 Spring Data R2DBC、响应式事务管理等基础件带进来。然后根据数据库选驱动,我用 PostgreSQL 示例,配置如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>r2dbc-postgresql</artifactId> </dependency>如果是 H2 做本地测试,驱动换成io.r2dbc:r2dbc-h2;MySQL 的话,目前维护比较活跃的驱动是io.asyncer:r2dbc-mysql。这里有个细节值得留意:Spring Boot 3 里 JDBC 相关的spring-boot-starter-jdbc依赖如果被同时引入,需要确认应用不会在同一个事务上下文里混用两套连接池,否则排查问题时会非常痛苦。
版本方面,Spring Boot 3.2 之后对 R2DBC 的支持已经比较完善,建议直接用当前 Boot 版本默认管理的驱动版本,不要手动指定,避免遇到驱动和框架协议版本不匹配的问题。
2.2 application.yml 配置与连接池
R2DBC 的连接配置前缀是spring.r2dbc,注意不是spring.datasource。很多从 JDBC 转过来的人第一步就在这栽了跟头,写了一大堆spring.datasource.url,然后怎么都启动不了。
spring: r2dbc: url: r2dbc:postgresql://localhost:5432/testdb username: postgres password: postgres pool: enabled: true initial-size: 5 max-size: 20 max-idle-time: 30mURL 协议头是r2dbc:,后面跟着具体数据库的驱动名称和地址。Spring Boot 2.x 时代 R2DBC 还是实验性支持,配置经常冲突;到了 Boot 3.x,只要你引入了 starter,连接池默认就是开启的,基于 R2DBC 官方连接池规范实现,不需要额外引入连接池依赖。
如果你需要更精细地控制连接池,直接定义一个ConnectionFactoryCustomizerBean,在自动配置创建的连接工厂基础上做定制:比如调整max-size来应对突发流量,或者设置max-lifetime防止数据库侧主动断开后客户端还在使用死连接。
2.3 五步跑通第一个响应式查询
配置好了之后,写第一个查询很简单。注入DatabaseClient,这是整个 R2DBC 的核心入口,地位类似 JDBC 时代里的JdbcTemplate。
import org.springframework.r2dbc.core.DatabaseClient; import reactor.core.publisher.Flux; @Service public class UserQueryService { private final DatabaseClient dbClient; public UserQueryService(DatabaseClient dbClient) { this.dbClient = dbClient; } public Flux<User> listUsers(int minAge) { return dbClient.sql("SELECT id, name, age FROM t_user WHERE age >= :minAge") .bind("minAge", minAge) .map((row, meta) -> new User( row.get("id", Long.class), row.get("name", String.class), row.get("age", Integer.class) )) .all(); } }对照一下 JDBC 里 JdbcTemplate 的写法:
public List<User> listUsers(int minAge) { return jdbcTemplate.query( "SELECT id, name, age FROM t_user WHERE age >= ?", new BeanPropertyRowMapper<>(User.class), minAge ); }两种写法从“构建 SQL、绑定参数”的层面看是相似的,区别就在返回类型。JdbcTemplate 返回 List,直接能拿到数据;DatabaseClient 返回 Flux,数据真正到达之前,这段代码里没有阻塞,也没有占用数据库连接。等listUsers被订阅执行时,连接才会被真正借出,执行完立刻归还。
3. DatabaseClient SQL 操作全解
3.1 查询结果:Mono 还是 Flux,先想清楚
用DatabaseClient写查询的时候,最常见的困惑是.one()和.all()到底怎么选。我习惯先问自己一个问题:这条 SQL 最多返回几行?
- 预期最多一行:用
.one(),返回Mono<T>。查到一行发射数据,查不到则返回一个空 Mono,如果查出多行会抛IncorrectResultSizeDataAccessException,这个异常行为和 JdbcTemplate 的queryForObject很像,反而能帮你提前发现 SQL 写漏条件的问题。 - 预期多行或者不确定:用
.all(),返回Flux<T>,数据流式到达,配合 WebFlux 可以直接作为响应体输出,客户端边收边解析,体验非常好。 - 还有一种是
.first(),从结果流里取第一行。当 SQL 结果可能有多行但你只关心第一个时可以用,底层的实现是把 Flux 截断后返回Mono<T>。
这里有个人经验:如果你只是做个简单查询,尽量别图省事把所有查询都写成.all()再到业务层取第一个,语义不清晰,后续维护的人也不知道你到底期望几条结果。
3.2 写入操作与自增主键返回
写入操作的套路和 JDBC 类似,只是返回值不是 int 而是Mono<Integer>,表示受影响行数:
public Mono<Integer> insertUser(String name, Integer age) { return dbClient.sql("INSERT INTO t_user(name, age) VALUES (:name, :age)") .bind("name", name) .bind("age", age) .fetch() .rowsUpdated(); }如果对受影响行数不感兴趣,只想在插入后拿到返回的自增 ID,用returnGeneratedValues再.first()取回:
public Mono<Long> insertAndReturnId(String name, Integer age) { return dbClient.sql("INSERT INTO t_user(name, age) VALUES (:name, :age)") .bind("name", name) .bind("age", age) .filter(statement -> statement.returnGeneratedValues("id")) .fetch() .first() .map(row -> row.get("id", Long.class)); }这里filter接收一个函数,出参是Statement,允许你在执行前对底层驱动语句做额外设置。返回的列名不同数据库写法略有差异,PostgreSQL 里通常直接写列名id即可。
3.3 结果映射的三种姿势
R2DBC 的结果映射没有 MyBatis 那种复杂配置,常见的做法就三种。
第一种,直接在map回调里手动取值建对象。这是最基础的方式,前面已经演示过,优点是直观、不受字段名映射策略影响;缺点是字段多的时候代码有点啰嗦。
第二种,用 Spring Data R2DBC 的实体映射,让框架帮你把行自动装配成对象:
@Table("t_user") public class User { @Id private Long id; private String name; private Integer age; // getter / setter 省略 }配合R2dbcEntityTemplate,可以完全脱离手写 SQL:
@Autowired private R2dbcEntityTemplate entityTemplate; public Flux<User> getAllUsers() { return entityTemplate.select(User.class).all(); } public Mono<User> getUserByEmail(String email) { return entityTemplate.selectOne( query(where("email").is(email)), User.class ); }R2dbcEntityTemplate的定位类似 JdbcTemplate 和 JPA 之间的过渡层,能覆盖大多数单表 CRUD,写起来很省事。表字段的映射规则默认是驼峰转下划线:userName映射到user_name,如果你的数据库表设计不是这个风格,就需要手动用@Column指定。
第三种,查询结果只关心部分字段,直接接收Map:
Flux<Map<String, Object>> rows = dbClient.sql("SELECT id, name FROM t_user") .fetch() .all();这种方式适合做报表统计、临时查询,但 Map 缺少类型信息,字段值拿回来往往需要手动作类型转换。我建议业务代码里尽量少用,只在调试和通用查询场景使用。
4. R2DBC Repository 与实体映射细节
4.1 从 Repository 接口到响应式方法
Spring Data R2DBC 也支持类似 JPA 的 Repository 风格。定义一个接口继承R2dbcRepository,框架会在运行时自动生成实现,核心方法直接返回Mono或Flux:
public interface UserRepository extends R2dbcRepository<User, Long> { Flux<User> findByName(String name); Mono<User> findByEmail(String email); @Query("SELECT * FROM t_user WHERE age > :age ORDER BY id DESC") Flux<User> findOlderThan(int age); }用法和 JPA Repository 几乎一样,唯一需要注意的是返回值。方法签名里如果写了List<User>,运行时直接报错——R2DBC Repository 只认Mono、Flux这些响应式类型。命名方法的解析规则也是通用的:findByEmail、findByNameContaining、findByAgeBetween这些关键词都能正常工作。
如果你的查询比较复杂,用@Query注解写原生 SQL,注意这里使用的是命名参数,语法上是:age而不是?1,这一点和我最开始写 DatabaseClient 时用 JPA 的习惯正好相反,容易顺手写错。
4.2 映射细节与命名策略踩坑
实体映射里有几个坑我几乎每次讲都要强调一遍。
第一,表名映射。默认情况下,类名是User,找的表就是user。如果你的表名是t_user,要么给实体加@Table("t_user"),要么自定义NamingStrategy。很多人改完实体还报“relation does not exist”,八成是忘了这个。
第二,ID 字段。@Id必须要有,否则R2dbcRepository的deleteById、findById这些方法全都用不了。自增 ID 的映射不需要额外注解,框架通过驱动返回的主键信息处理。
第三,字段类型。数据库的decimal、numeric类型映射到 Java 的BigDecimal,时间类型对应LocalDateTime,这些基本没啥问题。但row.get("age", Long.class)这种写法,如果数据库字段是int4,某些驱动对基本类型包装类的转换并不那么宽容,建议统一用包装类型,避免Unsupported conversion type这类报错。
4.3 事务与 Repository 的配合
响应式事务是这里最容易翻车的地方,背后的原因在于 Spring 声明式事务是基于线程 + AOP 代理实现的。JDBC 时代,@Transactional标记的方法执行时,事务管理器会把事务绑定到当前线程,同一个线程里后续操作都能感知到事务上下文。但在响应式编程里,你的代码不再由某一个固定线程从头跑到尾,而是由事件循环在不同线程间调度,旧的事务传播机制就失效了。
好在 Spring 从 5.2 开始提供了一套面向响应式的事务抽象。事务绑定不再依赖线程局部变量,而是放在 Reactor 的上下文(Context)里传递。只要你的操作在同一个响应式链路里,事务就能正确传播。
正确的写法长这样:
@Transactional public Mono<Void> transfer(Long fromId, Long toId, BigDecimal amount) { return userRepository.updateBalance(fromId, amount.negate()) .then(userRepository.updateBalance(toId, amount)); }这里@Transactional依然可以标注,但方法必须返回响应式类型,而且所有数据库操作都得通过flatMap、then等算子串联在同一个链路上。第一次写响应式事务的人最容易犯的错误是,在@Transactional方法里用subscribe()去触发数据库操作。
@Transactional public void wrongTransfer() { userRepository.updateBalance(fromId, amount.negate()) .subscribe(); // 错误示范:事务边界在订阅之前就结束了 userRepository.updateBalance(toId, amount) .subscribe(); }这个写法表面看没问题,实际两个订阅都是异步触发,事务上下文压根不会传播过去,出现扣了款没入账的后果时,最要命的是日志里完全看不出异常。
5. 响应式事务:边界问题与最佳实践
5.1 事务管理器与自动配置
Spring Boot 在引入spring-boot-starter-data-r2dbc后,会基于ConnectionFactory自动配置一个R2dbcTransactionManager,这个 Bean 实现了ReactiveTransactionManager接口,专门负责响应式事务的开启、提交和回滚。只要你不主动覆盖,@Transactional自动就会用它。
如果你需要多个数据源、或者想对事务管理器做个性化处理,手动定义也很简单:
@Bean public ReactiveTransactionManager transactionManager(ConnectionFactory connectionFactory) { return new R2dbcTransactionManager(connectionFactory); }注意这里的类型是ReactiveTransactionManager,而不是传统 JDBC 的PlatformTransactionManager。如果在项目里同时引入 JDBC 和 R2DBC,两个事务管理器会同时存在,届时@Transactional要指定transactionManager = "r2dbcTransactionManager",否则 Spring 会因为找不到唯一的事务管理器直接启动报错。
5.2 事务内多个数据库操作的正确串联
事务里的操作必须全部在同一个响应式链路中。用flatMap串联多个需要传递结果的操作:
@Transactional public Mono<User> createUserWithProfile(String name, Profile profile) { return userRepository.save(new User(name)) .flatMap(savedUser -> { profile.setUserId(savedUser.getId()); return profileRepository.save(profile); }) .map(savedProfile -> { User user = new User(); user.setId(savedProfile.getUserId()); return user; }); }如果中间某一步失败,响应式链路里的异常会向上传播,事务管理器收到异常信号后自动执行回滚。这里有个容易忽略的细节:链路的错误处理和事务回滚是两回事。你用onErrorResume把异常吞掉了,事务管理器就不会收到异常信号,自然也就不会回滚,最终出现“看起来成功、数据却没保存”的诡异现象。
5.3 分布式事务与编程式事务
跨库分布式事务属于另一个话题,R2DBC 目前对分布式事务的支持不像 JTA 那么成熟,没有一套统一的 XA 方案。生产环境我通常的建议是:尽量把需要强一致性的操作收敛到同一个数据库、同一个事务里;做不到就换用本地消息表或者可靠事件方案,别指望 R2DBC 帮你解决所有一致性问题。
如果不想用声明式事务,也可以手动编程式控制:
@Autowired private ReactiveTransactionManager txManager; public Mono<Void> manualTx() { TransactionTemplate template = new TransactionTemplate(txManager); return Mono.when( userRepository.updateBalance(1L, amount.negate()), userRepository.updateBalance(2L, amount) ).as(transactionalOperator(template)::transactional); }TransactionalOperator是响应式世界里替代声明式事务的一种选择,它能把一个已有的Mono或Flux包进事务里。优点是很直观,拿任何响应式操作都能包一层;缺点是代码稍显啰嗦,而且同样要求包进去的的操作都在同一个响应式链路中执行。
6. 高频问题排查与性能优化注意点
6.1 常见报错与排除思路对照表
整理几个我在实际项目中高频遇到的报错,以及对应的排查方向,方便读者在遇到问题时快速定位:
| 报错表现 | 实际问题 | 排查思路 |
|---|---|---|
| 启动报“Failed to determine a suitable driver” | R2DBC URL 配置成了 JDBC 格式 | 检查 spring.r2dbc.url,确认使用了 r2dbc: 协议前缀 |
| 运行时抛“relation does not exist” | 实体表名与数据库表名不一致 | 给实体加@Table,确认命名策略 |
| 查询报“Unsupported conversion type” | 驱动返回类型无法直接转成目标类型 | 检查row.get的类型参数,改用具体类型或手动转换 |
调用Repository.findById返回 null 但无异常 | 实体缺少@Id注解 | 确认主键字段是否标了@Id |
| 事务内操作完成后不回滚 | 使用了onErrorResume吞异常,或事务没有走代理 | 检查订单链路,确保异常向上抛给事务管理器 |
| 并发高时连接池耗尽 | 业务代码中手动block()或连接泄漏 | 排查block()调用,确认连接池参数 |
6.2 连接池与批量操作的性能细节
R2DBC 连接池和 JDBC 连接池在使用上有一个观念差异:JDBC 里连接池容量大就是“调优”,R2DBC 里连接池不应该盲目调大,因为每个连接在非阻塞模型下都很“空闲”,几十个连接足以支撑很大的并发量。我更建议关注max-size与数据库最大连接数的比例,以及max-idle-time防止空闲连接被数据库端回收后应用还保留着引用。
批量插入的操作,很多人一开始会想到saveAll:
userRepository.saveAll(Flux.fromIterable(userList));这种方式确实能批量插入,但底层实现往往是逐条 INSERT,数据量大时性能一般。想要性能接近 JDBC 的executeBatch,我建议直接拼多值 SQL:
dbClient.sql("INSERT INTO t_user(name, age) VALUES ('张三', 20), ('李四', 30), ('王五', 40)") .fetch() .rowsUpdated();参数拼接时要注意防止 SQL 注入,实际业务中推荐用bind这种方式占位后绑定参数,多值插入可以循环生成占位符再逐个绑定。
6.3 从 JDBC 迁移的思维转变
最后想花点篇幅聊聊迁移的思维转变,这比任何 API 细节都重要。
第一个转变:不要block()。很多人在响应式链路的末尾写一句.block()把数据取出来用,表面上看是拿到了结果,实际上连接会被占用到结果返回,线程也会被阻塞在事件循环线程上。一个两个还好,流量一大,整个应用的响应式优势全会被这种写法毁掉。正确的做法是持续返回Mono、Flux,让异步贯穿到 Controller 层。
第二个转变:错误发生时机变了。JDBC 时代,SQL 执行出错,调用栈能直接定位到具体代码行。R2DBC 时代,SQL 真正执行发生在订阅时,异常会通过onError信号在链路里传播。日志打印的位置经常离真正出错的代码很远,排查时需要把 Reactor 的组装栈信息打开,或者用checkpoint()给链路加标记,定位是哪一段操作出的问题。
第三个转变:不要在一个类里同时浓郁使用同步和异步数据访问。一旦项目开了 R2DBC 的头,最好保持全链路的响应式风格统一,否则调试时你既要关心 JDBC 事务的线程绑定,又要关心 R2DBC 的上下文传递,心智负担会翻倍。
我个人在实际迁移中的体会是:R2DBC 最值得投入的场景是那些并发高、链路长的实时类系统,它带来的收益不是 SQL 快了多少,而是系统在同等资源下的吞吐量上了一个台阶。但如果是普通业务系统、团队又没有响应式基础,JDBC 或者 ORM 依然是非常稳妥的选择,不必为了追新而自我折磨。真到了要上的那一天,从本文的 DatabaseClient 起步,抓几个核心接口跑通一个查询,你自然会知道下一步该往哪走。