7.1 R2DBC 驱动原理

R2DBC 规范与驱动原理:ConnectionFactory、Connection、Statement、Result 的响应式接口设计,与 JDBC 的对比,r2dbc-postgresql 连接创建与查询执行流程,以及背压在 Result 流中的传递机制。

R2DBC 是什么

R2DBC(Reactive Relational Database Connectivity)是响应式关系型数据库连接规范。它与 JDBC 的关系类似 Reactor 与阻塞 I/O:

维度JDBCR2DBC
API 风格同步阻塞响应式非阻塞
返回值T / List<T>Mono<T> / Flux<T>
连接获取DataSource.getConnection() 阻塞ConnectionFactory.create().subscribe()
结果遍历ResultSet.next() 循环Result.map(...) 流式
事务Connection.commit() 阻塞Connection.commitTransaction() 返回 Publisher<Void>
线程模型线程持有连接EventLoop 驱动 I/O

R2DBC 不是 JDBC 的响应式包装——它是全新协议栈,驱动层直接与数据库的异步协议交互。

核心接口

R2DBC 定义四个核心接口:

// 1. 连接工厂:创建数据库连接
io.r2dbc.spi.ConnectionFactory

// 2. 连接:执行语句、管理事务
io.r2dbc.spi.Connection

// 3. 语句:绑定参数、执行查询
io.r2dbc.spi.Statement

// 4. 结果:流式消费行数据
io.r2dbc.spi.Result

创建连接

import io.r2dbc.postgresql.PostgresqlConnectionConfiguration;
import io.r2dbc.postgresql.PostgresqlConnectionFactory;
import io.r2dbc.spi.ConnectionFactory;

ConnectionFactory factory = new PostgresqlConnectionFactory(
    PostgresqlConnectionConfiguration.builder()
        .host("localhost")
        .port(5432)
        .database("eventd")
        .username("eventd")
        .password("eventd")
        .build()
);

// 获取连接(异步)
Mono<io.r2dbc.spi.Connection> connection = Mono.from(factory.create());

factory.create() 返回 Publisher<Connection>,需要用 Mono.from() 转为 Reactor 类型。

执行查询

Mono<User> user = Mono.usingWhen(
    factory.create(),                      // 资源:连接
    conn -> Mono.from(conn.createStatement("SELECT id, name FROM users WHERE id = $1")
            .bind("$1", 1)
            .execute())
        .flatMap(result -> Mono.from(result.map((row, metadata) ->
            new User(row.get("id", Long.class), row.get("name", String.class))))),
    conn -> Mono.from(conn.close())        // 正常完成时关闭
);

Connection.close() 返回的是 Publisher<Void>,不是阻塞方法。写成 doFinally(sig -> conn.close()) 只会创建一个从未被订阅的 Publisher,连接不会被归还;必须用 Mono.from(conn.close()) 或交给 usingWhen 统一管理。usingWhen 还会在取消和异常路径上关闭连接,比手写 doFinally 更可靠。

执行链:

ConnectionFactory.create()
    ↓ Mono<Connection>
Connection.createStatement("SQL")
    ↓ Statement
Statement.bind("$1", value).execute()
    ↓ Publisher<Result>
Result.map((row, metadata) -> ...)
    ↓ Flux<User>
driver 异步读取 PostgreSQL 协议数据
    ↓ 背压:request(n) 控制读取批次
tcp socket

Statement 参数绑定

占位符风格由驱动的 Statement 实现决定,SPI 层只有两种绑定方式:按名字绑定(bind(String, ...))和按 0-based 索引绑定(bind(int, ...)):

数据库SQL 占位符绑定方式
PostgreSQL$1, $2stmt.bind("$1", value),名字就是 "$1"
MySQL?stmt.bind(0, value),0-based
H2$1 或 ?两种都支持,按实际 SQL 选择
// PostgreSQL:字符串名字绑定
Statement stmt = conn.createStatement("SELECT * FROM users WHERE id = $1 AND status = $2");
stmt.bind("$1", 1);
stmt.bind("$2", "ACTIVE");

// MySQL:0-based 索引绑定
Statement stmt = conn.createStatement("SELECT * FROM users WHERE id = ? AND status = ?");
stmt.bind(0, 1);
stmt.bind(1, "ACTIVE");

Spring Data R2DBC 的 :name 命名参数不是驱动能力:它在 Spring 层把 :name 改写成驱动认识的占位符,再逐个绑定,所以底层 Statement 里看不到 :name。

插入与返回生成键

Mono<Long> insertedId = Mono.from(conn.createStatement(
    "INSERT INTO orders (symbol, quantity, price) VALUES ($1, $2, $3)")
    .bind("$1", "BTC")
    .bind("$2", 1)
    .bind("$3", 40000)
    .returnGeneratedValues("id")  // PostgreSQL 会据此追加 RETURNING id
    .execute())
    .flatMap(result -> Mono.from(result.map((row, meta) -> row.get("id", Long.class))));

returnGeneratedValues("id") 之后,结果集里是生成的主键行,要用 result.map(...) 读;换成 getRowsUpdated() 拿到的是受影响行数,主键就丢了。两者是互斥的消费方式,不能在同一结果上依次取。PostgreSQL 用 RETURNING 实现这个语义;MySQL 没有 RETURNING(MariaDB 才有),驱动通常改用 SELECT LAST_INSERT_ID() 再查一次。

只关心影响行数时则相反:

Mono<Long> affected = Mono.from(conn.createStatement(...).execute())
    .flatMap(result -> Mono.from(result.getRowsUpdated()));

批量操作

R2DBC Statement 支持 add(分批绑定):

Statement stmt = conn.createStatement("INSERT INTO logs (level, message) VALUES ($1, $2)");
stmt.bind("$1", "INFO").bind("$2", "Order created").add();
stmt.bind("$1", "WARN").bind("$2", "Low stock").add();
stmt.bind("$1", "ERROR").bind("$2", "Payment failed").add();

// 每次 add() 会产生一个 Result;用 Mono.from 只会取第一个并取消其余结果
Flux.from(stmt.execute())
    .flatMap(Result::getRowsUpdated)
    .reduce(0L, Long::sum)
    .subscribe(total -> System.out.println("Total inserted: " + total));

三次 add() 表示三条 INSERT 在同一条 prepared statement 中批量发送,减少网络往返。

背压在 Result 流中

R2DBC Result 的 map() 返回 Publisher<T>,订阅端用 request(n) 向上游要数据。但「处理慢」不等于「自动在别的线程上处理」——如果 heavyProcess 是阻塞调用,必须显式切到弹性线程池:

result.map((row, meta) -> row.get("data", String.class))
    .flatMap(row -> Mono.fromCallable(() -> heavyProcess(row))
        .subscribeOn(Schedulers.boundedElastic()), 4)  // 最多 4 个并发
    .subscribe();

subscribeOn 写在链尾不会逐个隔离 heavyProcess:它只影响整条链的订阅线程,而 flatMap 的映射函数仍在驱动发信号的线程上执行。逐个切线程必须把 subscribeOn 放进 flatMap 内部的 Mono 里,并用并发参数限制同时占用弹性线程的数量。

背压能否传到数据库,还取决于驱动的读取方式:PostgreSQL 只有在游标/fetchSize 大于 0 时才分批取行;fetchSize 为 0 时服务端一次把结果发完,客户端再慢也挡不住服务端发送。把 request(n) 一路传到 TCP 窗口,需要驱动配置和语句级 fetchSize 配合,不是 SPI 自动保证的。

与 Spring Data R2DBC 的关系

原始 R2DBC SPI 很底层。Spring Data R2DBC 提供更高级的封装(第 7.3 节):

Spring Data R2DBC
    ↓ DatabaseClient / ReactiveRepository
R2DBC SPI (io.r2dbc.spi)
    ↓ ConnectionFactory / Connection / Statement / Result
PostgreSQL Wire Protocol
    ↓ TCP socket
PostgreSQL Server

本章先掌握底层 SPI,再理解 Spring 的封装才有意义。

eventd v2 的数据层设计

eventd-data 模块使用 R2DBC SPI 直接构建数据访问层:

eventd-data/
├── DataSourceConfig.java       # ConnectionFactory Bean
├── UserRepository.java         # 原始 R2DBC 查询(本章)
├── OrderRepository.java        # 原始 R2DBC 查询
├── TransactionConfig.java      # 响应式事务(第 7.2 节)
└── SpringDataConfig.java       # Spring Data R2DBC(第 7.3 节)

生产版本迁移到 Spring Data R2DBC,但底层 SPI 的理解是排查性能问题的必备知识。

常见错误

错误原因修复
连接未归还、池被耗尽doFinally(sig -> conn.close()) 没有订阅返回的 Publisher用 Mono.usingWhen 或 Mono.from(conn.close())
Connection is closed关闭之后还有操作在订阅所有数据库操作完成后再关闭连接
Parameter index out of boundsMySQL 驱动用 0-based,PostgreSQL 用 $1确认驱动占位符风格
Timeout waiting for connection连接池耗尽增加 maxSize 或减少并发
DataBuffer LeakByteBuf 未释放使用 DataBufferUtils.release()

小结

概念SPISpring Data 封装
连接创建ConnectionFactory.create()ConnectionFactory Bean
查询执行Statement.execute()DatabaseClient.sql()
结果消费Result.map(...)DatabaseClient.map(...)
参数绑定Statement.bind("$1", value).bind("name", value)
批量Statement.add()逐条执行或 bindValues(List) 配合多值 SQL
生成键returnGeneratedValues("id") + result.map(...).map((row, meta) -> row.get("id"))
命名参数驱动不支持SQL 里写 :name,.bind("name", value)
背压原生 Publisher 支持继承原生支持

下一节 7.2 响应式事务 :TransactionalOperator、事务传播、回滚策略、savepoint 与编程式事务。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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