ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring R2DBC 实战:从响应式编程原理到高并发数据库访问落地

Spring R2DBC 实战:从响应式编程原理到高并发数据库访问落地 Spring 系列写到第十二篇这次聊聊数据访问层的响应式模块 Spring-R2DBC。说实话刚开始接触 R2DBC 那会儿我也有点懵——JDBC 用得好好的为什么要引入一套新的数据库访问规范后来真正在高并发场景下把 R2DBC 落地后才理解它的价值JDBC 是阻塞式的一个请求占用一个线程在高并发下线程资源是最大的瓶颈而 R2DBC 基于 Reactive Streams在等待数据库返回的间隙不占线程同样的硬件能扛下多得多的连接数。这篇文章我会把 Spring-R2DBC 从底层原理讲到项目落地踩过的坑也一并列出适合正在做响应式后端、或者想了解 Spring 数据访问模块新方向的开发者。1. 先聊清楚为什么会有 R2DBC1.1 JDBC 的阻塞问题到底出在哪先看一个我很久之前写过的场景用户登录接口里查一次数据库正常情况下耗时 10ms 左右这 10ms 里发生了什么呢JDBC 执行statement.executeQuery()的那一刻当前线程就进入等待等数据库把结果集网络传回来这期间线程一直在原地阻塞。在高并发时Tomcat 默认 200 个线程很快就被打满。每个线程虽然只等 10ms但并发一上来200 个线程全部堵在数据库 IO 上后续请求全部排队。这时候就算把数据库调到 8 核 16G能扛住的并发量依然受 Web 容器线程数限制。加线程池可以缓解但线程上下文切换的成本、内存占用都不是免费的。响应式编程要解决的就是这个在等待数据库返回结果的那段时间线程不去干等而是被释放出来处理其他请求等数据库结果回来了再通过事件机制通知线程继续处理。这就是“非阻塞 IO”和“背压”的核心思路。1.2 R2DBC 是一套规范不是某个数据库驱动R2DBC 的全称是 Reactive Relational Database Connectivity2018 年由 Spring 官方团队推动的一套响应式数据库访问规范。它不是像 ShardingSphere 那样的中间件也不是一个具体的数据库驱动——它定义了一套基于 Reactive Streams 的接口标准各数据库厂商和社区按这套标准实现各自的驱动。目前比较成熟的驱动有这么几个PostgreSQL 官方支持最好r2dbc-postgresql一直在活跃维护MySQL 有社区维护的io.asyncer:r2dbc-mysql原来 dev.miku 的后续分支H2 也有自己的 R2DBC 支持测试环境很好用MSSQL 也有官方驱动。Oracle 的支持相对滞后这一点在选型时必须提前确认。前面说的这层关系理清楚很重要Spring-R2DBC 是 Spring 对 R2DBC 规范的上层封装提供DatabaseClient、R2dbcEntityTemplate、R2dbcRepository这些 APIR2DBC 驱动则负责底层的网络通信和协议解析类似于 JDBC Driver 的角色。1.3 R2DBC 和 WebFlux 必须配合使用吗如果你在做 Web MVC 架构只把 DAO 层换成 R2DBC——可以但收益不大因为阻塞点只是从数据库 IO 移到了其他地方。比如你在 Controller 里返回数据之前调用了.block()那线程还是会阻塞就失去了响应式的意义。R2DBC 真正的价值是在全链路响应式环境下体现的WebFlux 接收请求 → Service 层返回MonoT/FluxT→ R2DBC 异步查询数据库 → 数据流式返回给前端。整条链路没有一处是阻塞的一个线程能同时处理成千上万个连接。我个人的经验是如果不是新起响应式项目不要硬在旧项目里把 JDBC 换成 R2DBC。R2DBC 不是 JDBC 的替代品而是面向不同类型的应用场景。老项目老老实实用 JdbcTemplate 反而更稳。2. Spring-R2DBC 核心组件速查2.1 ConnectionFactory对应 JDBC 的 DataSource连接工厂的作用是创建和管理数据库连接。R2DBC 里ConnectionFactory等价于 JDBC 的DataSourceSpring Boot 会自动把我们配置的spring.r2dbc.url解析成对应的 ConnectionFactory。常见配置分两步先在 Maven 里引入驱动再在application.yml里写连接参数。Maven 引入 PostgreSQL 驱动dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-r2dbc/artifactId /dependency dependency groupIdorg.postgresql/groupId artifactIdr2dbc-postgresql/artifactId /dependency配置文件spring: r2dbc: url: r2dbc:postgresql://127.0.0.1:5432/test_db username: postgres password: 123456注意 URL 协议前缀是r2dbc:postgresql://和 JDBC 的jdbc:postgresql://不同。如果你用的是 MySQL那就是r2dbc:mysql://。这个前缀如果写错了Spring Boot 启动时会提示找不到对应的 ConnectionFactory。2.2 DatabaseClient数据访问的门面如果说 JdbcTemplate 是 JDBC 时代的核心 API那 R2DBC 时代对应的是DatabaseClient。它支持 SQL 操作、结果映射、流式返回。最简单的查询长这样Autowired private DatabaseClient databaseClient; public FluxUser findAll() { return databaseClient.sql(SELECT id, name, email FROM user_account) .map((row, metadata) - { User user new User(); user.setId(row.get(id, Long.class)); user.setName(row.get(name, String.class)); user.setEmail(row.get(email, String.class)); return user; }) .all(); }.all()返回FluxT代表这里是流式的多行结果如果你只想取一条数据用.one()返回MonoT。这个语义上和 WebFlux 的Mono/Flux完全对应。带参数查询时注意占位符的写法这一点不同数据库驱动不一样public MonoUser findByName(String name) { return databaseClient.sql(SELECT id, name, email FROM user_account WHERE name $1) .bind(0, name) .map((row, metadata) - { User user new User(); user.setId(row.get(id, Long.class)); user.setName(row.get(name, String.class)); return user; }) .one(); }PostgreSQL 驱动用$1、$2这种位置占位符从 1 开始计数MySQL 驱动用的是?和 JDBC 一样。另外.bind(0, name)里的索引也是从 0 开始。这一点非常容易踩坑——我第一次写的时候就因为搞混了$1和bind(0)的关系执行报错后查了半天文档。2.3 实体映射与 Table 注解体系Spring Data R2DBC 提供了和 Spring Data JPA 类似的映射注解但它不是完整的 ORM。它没有懒加载、级联、持久化上下文这些概念只是简单地做行和对象的互相转换。一个映射实体长这样import org.springframework.data.annotation.Id; import org.springframework.data.relational.core.mapping.Table; Table(user_account) public class User { Id private Long id; private String name; private String email; // getter/setter }关键点Table来自org.springframework.data.relational.core.mapping.Table不是 JPA 的javax.persistence.Table别引错包Id来自org.springframework.data.annotation.Id用来标识主键JSON 序列化和反序列化靠的是无参构造 setter和 MyBatis 的自动化映射思路更接近R2DBC 的映射体系没有像 Hibernate 那样完善的字段约定默认情况下实体字段加Column可以自定义列名。如果表结构里的字段命名和 Java 属性命名不一致建议把所有字段都显式标出来避免启动时映射失败。2.4 事务管理ReactiveTransactionManager响应式事务和 JDBC 事务管理最大的不同在于控制范围不是线程级别的而是跟消息事件流绑定的。Spring 提供R2dbcTransactionManager作为响应式事务管理器。你可以在配置里手动声明Bean public ReactiveTransactionManager transactionManager(ConnectionFactory connectionFactory) { return new R2dbcTransactionManager(connectionFactory); }如果是纯 Spring Boot 项目只要 classpath 里有spring-boot-starter-data-r2dbcSpring Boot 会自动装配R2dbcTransactionManager。需要注意它和 JDBC 的DataSourceTransactionManager是两套东西不能混用。3. 项目落地从零搭建 Spring Boot R2DBC3.1 依赖和目录结构我习惯的项目结构是这样的src/main/java └── com/example/r2dbc ├── Application.java ├── config ├── entity ├── repository └── service如果用 Gradle 的话核心依赖这几行就够了implementation org.springframework.boot:spring-boot-starter-webflux implementation org.springframework.boot:spring-boot-starter-data-r2dbc runtimeOnly org.postgresql:r2dbc-postgresql testImplementation org.springframework.boot:spring-boot-starter-test testImplementation io.projectreactor:reactor-test我把项目建立在 WebFlux 之上因为 R2DBC 的返回值是Mono/Flux配 WebFlux 的处理器最自然。如果你用的是 Web MVC也不是不行但尽量不要在响应式链路上调block()。3.2 数据库表结构与数据初始化实战里我用的是用户和订单两张表方便讲清一对多的查询场景。建表语句CREATE TABLE user_account ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE ); CREATE TABLE order_info ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES user_account(id), amount DECIMAL(10, 2) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now() );R2DBC 不会像 JPA 的ddl-auto那样自动建表表结构需要自己执行 DDL。这也是我第一次用 R2DBC 时不适应的地方——因为用惯了 JPA 的update自动加字段。这个问题在文章第 5 节里再展开。3.3 Repository 接口继承 ReactiveCrudRepositorySpring Data R2DBC 提供了类似 Spring Data JPA 的 Repository 抽象。最常用的基类是ReactiveCrudRepository提供save、findById、findAll、deleteById等基础方法。public interface UserRepository extends ReactiveCrudRepositoryUser, Long { MonoUser findByName(String name); FluxUser findByNameContaining(String keyword); }这里要注意命名规范。Spring Data 的方法名解析在响应式 Repository 里同样生效比如findByName会被自动翻译成按name字段查询。如果方法名里的属性名拼错了启动时就会报错不会等运行时才发现。还需要知道一个细节ReactiveCrudRepository不带分页和排序的findAll(Pageable)支持这也是 R2DBC Repository 和 JPA Repository 的一个明显区别。R2DBC 的R2dbcRepository接口里有findAll(Pageable)但其底层实现并不像 JPA 那样生成 SQL 级分页——它是先把数据全部捞到内存再在内存里截取分页结果。数据量大时不建议用这个接口做分页老老实实手动写 SQL 限定 limit 和 offset。4. 实战 CRUD 与事务完整实现4.1 基础 CRUD用 ReactiveCrudRepository 快速完成增删改查先定义实体类映射。字段名和列名保持一致可以减少映射规则不一致带来的麻烦。Table(user_account) public class User { Id private Long id; private String name; private String email; // getter/setter注意要生成完整的 }然后定义 Repositorypublic interface UserRepository extends ReactiveCrudRepositoryUser, Long { }Service 层的 CRUD 就非常简洁了Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } public MonoUser getUser(Long id) { return userRepository.findById(id); } public MonoUser createUser(String name, String email) { User user new User(); user.setName(name); user.setEmail(email); return userRepository.save(user); } public MonoVoid deleteUser(Long id) { return userRepository.deleteById(id); } }有人会问save之后返回的MonoUser里的User有没有带自增主键 IDH2 和 PostgreSQL 的 R2DBC 驱动在插入后会自动回填生成的 IDMySQL 驱动在某些版本里不保证这点需要靠数据库端返回的last_insert_id处理。这个属不属于坑属于后面第五节详细说。4.2 复杂查询Query 注解与参数绑定方法名解析满足不了复杂 SQL就该用 Query 了。R2DBC 的 Query 支持原生 SQLpublic interface UserRepository extends ReactiveCrudRepositoryUser, Long { Query(SELECT * FROM user_account WHERE email :email) MonoUser findByEmail(String email); Query(SELECT * FROM user_account WHERE name LIKE % || :keyword || %) FluxUser searchByName(String keyword); }命名参数用:email这种写法。你也可以用原生占位符$1但命名参数在 SQL 复杂度上升时更不容易搞混。多表联查时返回 DTO 而不是实体是另一个常见的需求。注意这时不能依赖 Repository 的泛型映射了需要自己写RowMapper一样的逻辑只是 R2DBC 里没有RowMapper这个接口而是直接在DatabaseClient的map里写public FluxOrderUserDTO getOrdersWithUserName() { return databaseClient.sql( SELECT o.id AS order_id, o.amount, u.name AS user_name FROM order_info o JOIN user_account u ON o.user_id u.id) .map((row, metadata) - new OrderUserDTO( row.get(order_id, Long.class), row.get(amount, BigDecimal.class), row.get(user_name, String.class) )) .all(); }这种方法适合读多写少的报表类场景。写操作要涉及多个表时更推荐用事务。4.3 手动控制事务TransactionOperator 的用法用 Transactional 注解控制响应式事务有个问题注解本身只能约束方法级别如果方法内调用了另一个同类的方法内部调用注解是不生效的。这和 Spring MVC 里 Transactional 内部调用失效是同一个原理只是响应式里更难排查——因为异常如果被 Reactor 吞进 Mono/Flux 里控制台还不一定立刻打印出来。我推荐一种更可控的写法用TransactionOperator。它是响应式事务编程的常用工具允许你把事务边界显式地包在函数式代码里。先声明 BeanBean public TransactionOperator transactionOperator(ReactiveTransactionManager txManager) { return new ReactiveTransactionTemplate(txManager); }然后在 Service 里使用Service public class OrderService { private final TransactionOperator txOperator; private final OrderRepository orderRepository; private final UserRepository userRepository; // 构造器省略 public MonoVoid createOrderWithUser(Long userId, BigDecimal amount) { OrderInfo order new OrderInfo(); order.setUserId(userId); order.setAmount(amount); return txOperator.execute(status - userRepository.findById(userId) .switchIfEmpty(Mono.error(new RuntimeException(用户不存在))) .flatMap(user - orderRepository.save(order)) .then() ); } }execute里返回的是一个Publisher?事务会在这个Publisher执行期间保持开启直到这个流终止正常onComplete或异常onError才提交或回滚。这种方式比 Transactional 注解更显式出了问题时事务边界一清二楚。我个人的建议是在响应式 Service 层尽量少用 Transactional多用 TransactionOperator排查问题省非常多时间。5. 高频问题与排查技巧实录5.1 连接池耗尽与背压处理R2DBC 连接池如果耗尽现象是请求全部卡在等待连接的阶段日志里会有类似Connection pool is exhausted的报错。原因通常有两个一是连接池配小了二是某个响应式链路里不小心调用了block()——它占着连接不放导致池子空不出来。Spring Boot 自动配置的默认连接池是r2dbc-pool可以在 yml 里调整spring: r2dbc: pool: max-size: 20 initial-size: 5 max-idle-time: 60s排查建议先把最大连接数调大观察是否缓解同时全局搜一下代码里有没有.block()、blockLast()、toFuture().get()这类阻塞调用。如果发现了一个不要只删掉它——要追查它为什么存在是不是上游操作符组合错了。5.2 占位符与驱动之间的差异前面提到过PostgreSQL 驱动用$1MySQL 驱动用?。这一点真正触发问题的时候往往不是启动报错而是运行时把参数拼错。我在一个项目里同时用了 PostgreSQL 和 H2 两种数据库本地开发和测试环境H2 的 R2DBC 占位符风格和 PostgreSQL 一致还是不一致H2 的 R2DBC 驱动遵循 PostgreSQL 风格的$1占位符所以那段 SQL 在两个环境间切换时不怎么受罪。但 MySQL 驱动的?风格会让人猝不及防。建议写 SQL 前先确认你面对的是什么驱动把这段 SQL 拿到数据库客户端工具里先跑一遍确认没有问题再接进代码。R2DBC 的预编译绑定支持和 JDBC 的PreparedStatement类似但它在网络层面走的是扩展查询协议占位符数量不对会导致 “bind message supplies 0 parameters” 这类报错排查起来很头疼。5.3 自动建表缺失和自增主键回填问题R2DBC 不会自动建表这是和 JPA 最大的体验差距之一。如果团队习惯用 JPA 的ddl-auto开发切换到 R2DBC 后第一天肯定不习惯。我的做法是用 Flyway 管理表结构变更。// 在启动类或者配置类里 // 依赖增加implementation org.flywaydb:flyway-coreFlyway 的 SQL 脚本会跑在 R2DBC 的 ConnectionFactory 前面保证表结构是最新的。自增主键回填方面BIGSERIAL类型的 PostgreSQL 表插入后R2DBC 会通过RETURN_GENERATED_KEYS或者RETURNING子句拿到自增 ID。实测中 PostgreSQL 驱动做得不错插入后的实体 ID 有值。MySQL 驱动对这个特性的支持和 PostgreSQL 不完全一致哪怕同一个实体 save 两次拿到的 ID 也可能和你预期不符。如果主键回填有问题变通方案是插入时不依赖数据库自增改用应用层生成主键比如雪花 ID这样就不关心驱动回填了。这个方案在分库分表场景下也更有优势。5.4 响应式链路中的线程模型坑R2DBC 的查询最终执行的线程并不一定是你Controller进来的线程它由响应式运行时调度。如果在map操作符里访问了ThreadLocal拿到的很可能不是原来的线程——经典例子是RequestContextHolder和SecurityContext的丢失。遇到需要传递上下文信息的场景不要依赖ThreadLocal用 Reactor 的Context机制或者把用户 ID 等必要信息作为方法参数显式传下去。这块是最容易让有经验的后端也翻车的地方因为它通常不报错只是数据或认证信息莫名缺失而且时有时无。5.5 延迟和吞吐量的认知误区有人以为把 JDBC 换成 R2DBC单次查询延迟会变低——实际上R2DBC 减少的不是延迟而是线程资源的浪费。单条 SQL 的网络往返时间还在那里甚至因为事件机制的额外调度单次查询的 CPU 开销略高于 JDBC 直连。它真正的价值场景是大量并发请求同时访问数据库每个请求的等待时间重叠时非阻塞 IO 让线程不空转单位时间内能处理的请求总数显著提升。我在一个实时数据推送服务里做过对比实验同样 4 核 8G 的机器JDBC Tomcat 的架构在 2 万并发时线程池疯狂线程切换CPU 跑到 95% 以上吞吐量上不去切到 WebFlux R2DBC 后同样配置下能扛住 4 万并发CPU 只在 60% 左右。单次查询的 p99 延迟没有明显下降但整机吞吐量翻了一倍。6. 性能考量与选型建议6.1 什么时候果断用 R2DBC我总结三类场景比较适合第一实时数据推送、行情推送、IoT 设备数据上报端。这类服务的特征是连接数多、单个连接上数据量不大、但并发连接总数高。R2DBC 的非阻塞模型简直是为此设计的。第二API 网关或 BFF 层Backend for Frontend。它要聚合下游多个微服务的数据同时响应大量前端请求。阻塞模型下网关很容易成为瓶颈R2DBC WebFlux 是常见组合。第三高并发读多写少的秒杀/活动类接口。热点数据用 Redis 缓存顶住冷数据落到 R2DBC PostgreSQL 上。应用层代码的响应式技巧也很关键。比如一定不要用flatMap做串行的数据库批量查询那会被压成单线程连续查询性能还不如 JDBC 的批处理。合理做法是Flux.fromIterable(userIds) .flatMap(this::findById, 16)第二参数 16 是并发度表示同时最多有 16 个查询在途。这个值要根据连接池大小调整不是越大越好。6.2 什么时候别硬上 R2DBC后台管理平台、内部 OA 系统、报表系统这些并发量不高的场景JDBC 仍然更合适。原因有几个团队学习和调试 WebFlux 的成本高遇到问题排查难度大事务场景复杂时响应式事务的语义不好理解出错概率大生态问题很多数据库连接池、监控组件对 R2DBC 支持不如 JDBC 那么成熟有些公司上来就把所有接口改成 WebFlux最后接口 GET 请求里调了两次block()性能反而更差。这不是 R2DBC 的问题是把响应式当成银弹的问题。6.3 和 Spring Data R2DBC 的边界问题标题里写的是 Spring-R2DBC但这个模块实际上包含两个层级核心的spring-r2dbc提供DatabaseClient和spring-data-r2dbc提供 Repository 抽象和实体映射。日常开发中两者都会被用到。简单理解就是Repository 帮你省掉样板代码DatabaseClient 帮你写复杂灵活的原生 SQL。两者的存在不冲突甚至可以在同一个 Service 里混用——Repository 负责标准的单表 CRUDDatabaseClient 负责多表联查和更新语句。我习惯的边界是场景用哪个单表 CRUDRepository多表联查、报表DatabaseClient批量更新/删除DatabaseClient依赖方法名自动翻译的简单查询Repository需要手动控制分页和锁的查询DatabaseClient最后再分享一个写 R2DBC 代码时的小技巧所有返回值都先想清楚该用 Mono 还是 Flux再写实现。一个方法如果业务逻辑上不可能返回多条数据就返回MonoT不要返回FluxT然后让调用方去next()那样一方面增加理解成本另一方面错误场景下Flux的异常传播路径更隐蔽。我在实际项目里见过太多因为Flux用得太随意、导致异常被吞掉的问题排查时往往要先看返回类型再看异常是不是被转换成了空流。写了这么多年 Spring数据访问这块的变化其实一直没有停过JdbcTemplate、JPA、MyBatis、R2DBC、Spring Data JDBC……每个框架的出现都针对特定场景的痛点。R2DBC 是响应式这条路上很重要的一环现在 PostgreSQL 的驱动已经比较成熟了MySQL 也有社区解决方案。如果你正在做高并发项目或者想给团队技术栈做一次“查漏补缺”R2DBC 值得认真玩一玩。
返回列表