文章目录
- 一、前言
- [二、Reactor 下的事务](#二、Reactor 下的事务)
-
- [1. 传统事务的失效](#1. 传统事务的失效)
- [2. Reactor 下的事务方案](#2. Reactor 下的事务方案)
-
- [2.1 MyBatis-Plus](#2.1 MyBatis-Plus)
- [2.2 R2DBC](#2.2 R2DBC)
- 三、JOOQ
-
- [1. 与 Mybatis-plus 的区别](#1. 与 Mybatis-plus 的区别)
- [2. JOOQ 示例](#2. JOOQ 示例)
- 四、参考内容
一、前言
本篇作为 Reactor 响应式编程的笔记文章,因为以前虽然有过学习,但是长时间不使用又忘了,恰逢某些机缘巧合又需要了该特性的使用,趁着回忆的时候将笔记记录下来。
如果后面有机会,就将"机缘巧合"的部分也写下来。
本篇完整代码 :webflux-reactor-hwl
在 【笔记02】【Reactor 响应式编程① - 基础介绍】 中我们介绍了Reactor 响应式编程的基础用法,但是我们也知道 Reactor 编程模式下,同一个方法中的多次 DB 操作可能游离在多个线程上,那么传统的 @Transactional 就不能完成事务的 ACID 特性。
因此,本篇来探究下 Reactor 编程下如何保证事务。
二、Reactor 下的事务
1. 传统事务的失效
Spring 中的事务基于两大核心机制:
- ThreadLocal 线程上下文:将数据库连接、事务状态绑定到当前执行线程中
- 阻塞式 JDBC 连接:SQL 执行时线程会阻塞等待结果返回
Reactor 框架的核心特性是基于事件驱动的异步非阻塞处理,通过 subscribeOn、publishOn 等操作符频繁切换线程:
- 同一个请求的处理逻辑可能分布在多个不同线程中执行
- ThreadLocal 存储的事务信息会随着线程切换彻底丢失
- 即使通过 Mono.fromCallable 包装同步操作,也无法避免线程切换导致的上下文丢失
综上所述 :在传统 Spring 编程中,当我们使用 @Transactional 或 TransactionTemplate 开启事务时,Spring 会从数据源中获取一个数据库连接,并手动开启事务。随后,框架会将事务上下文(包括事务名称、隔离级别、数据库连接、事务状态等)绑定到当前线程的 ThreadLocal 中。在传统场景下,因为一次请求始终由同一个线程从头到尾处理,ThreadLocal 中的事务上下文不会丢失,事务能够正常提交或回滚。
但在 Reactor / WebFlux 响应式模型下,一个请求的处理流程会被切分成多个执行阶段,并可能在多个不同线程之间调度执行。由于 ThreadLocal 是与线程强绑定的,一旦执行链路切换了线程,原本线程中的事务上下文就会丢失,导致后续数据库操作无法加入到同一个事务中,最终事务完全失效。
也就是说 :在 Spring WebFlux + Reactor 的响应式架构中,与 MySQL 交互时,传统的事务机制 @Transactional 可能会失效。
这里推荐两篇有些许相关性的文章,与本篇其实相关性不大,只是临时想到了 :
- Spring源码分析十五:事务实现① - AutoProxyRegistrar :SpringBoot 事务原理实现说明。
- 【项目实践07】【多线程下事务的一致性】 :有简单讨论过多线程下如何保证事务一致性的问题。
2. Reactor 下的事务方案
既然上面我们讨论到了 Reactor 场景下,传统的事务方式会失效,所以这里我们要研究下如何在 Reactor 场景下保证事务。
以下是目前主流方案的深度对比:
| 选型方案 | 核心特性 | 事务支持能力 | 优势场景 | 劣势 |
|---|---|---|---|---|
| Spring Data R2DBC | 官方原生响应式框架,非阻塞驱动 | 完美支持(基于 Reactor Context) | 新项目从零搭建、追求纯响应式架构 | 缺乏逻辑删除、自动填充、分页等插件 |
| MyBatis-Plus + 阻塞JDBC | 熟悉的生态、丰富功能插件 | 无法与响应式事务兼容 | 存量项目迁移、依赖 MyBatis-Plus 生态 | 线程切换导致事务失效,混合架构尴尬 |
| jasync-sql | 纯异步底层客户端,极致性能 | 支持 | 性能极致追求、底层定制化场景 | 生态薄弱,无上层封装功能 |
这里我们主要介绍两种方案 :
2.1 MyBatis-Plus
前面我们分析过,传统事务在 Reactor 场景下失效的根本原因在于------Spring 的事务上下文依赖 ThreadLocal,而 Reactor 的响应式调度可能将一次请求的操作分散到不同线程中执行,导致事务上下文丢失。因此解决思路也很直接:只要保证所有数据库操作都在同一个同步阻塞方法中完成,事务就能正常生效。
具体做法是将事务逻辑封装在一个 @Transactional 方法内,再通过 Mono.fromCallable() 包裹并调度到 Schedulers.boundedElastic() 线程池,既不阻塞 Reactor 事件循环,又能确保事务上下文在同一线程内传递。
需要注意的是,这种方式并不推荐,原因如下 :
- 本质上是"伪响应式"。整个事务方法内部还是同步阻塞的,只是外面套了一层 Mono.fromCallable()。数据库操作期间线程是被占住的,并没有真正释放线程资源,Reactor 的非阻塞优势在数据库这一层完全没发挥出来。
- 线程池容量成为瓶颈。所有阻塞操作都调度到 Schedulers.boundedElastic(),这个线程池虽然可以弹性扩展,但有上限(默认是 CPU 核心数 × 10)。高并发场景下,如果数据库操作耗时较长,线程池很容易打满,后续请求会排队等待,吞吐量反而可能不如传统 Spring MVC。
- 事务粒度受限。因为必须把所有数据库操作塞进同一个同步方法里,没办法在 Reactor 链中灵活地穿插非阻塞操作(比如中间调一个远程接口再回来继续操作数据库),否则事务上下文就断了。代码组织上不够灵活。
- 与响应式生态割裂。虽然能保证事务一致性,但无法发挥 WebFlux 非阻塞的性能优势,仅适合存量项目过渡,不推荐新项目使用。
因此这种场景针对的是 已经深度依赖 MyBatis-Plus 生态的团队,直接切换到 R2DBC 成本过高,可采用混合架构方案临时过渡。如果是全新的 Reactor 项目搭建,更推荐R2DBC + jOOQ的方式,完美契合响应式编程的思想。
如下方案,整个事务操作都在 transactionService.executeCreateOrder 方法中,保证了事务执行过程中线程不会切换,因此此时的事务时有效的:
核心代码如下:
java
/**
* 创建订单并扣减库存(事务方法)
* <p>
* 核心逻辑:查询商品 → 校验库存 → 扣减库存 → 创建订单,整个流程在同一事务中完成。
* 由于使用 JDBC 数据源,事务绑定在线程上,因此将所有阻塞操作包裹在
* {@code Mono.fromCallable()} 中,通过 {@code Schedulers.boundedElastic()} 调度,
* 并在同一个 Callable 内完成,确保事务生效。
*
* @param productId 商品ID
* @param quantity 购买数量
* @return 创建成功的订单信息
*/
public Mono<OrderDO> createOrder(Long productId, Integer quantity) {
// 将整个事务操作包裹在同一个 Callable 中,保证 JDBC 事务在同一线程内执行
return Mono.fromCallable(() -> transactionService.executeCreateOrder(productId, quantity))
.subscribeOn(Schedulers.boundedElastic())
.doOnSuccess(order -> log.info("下单成功,订单号:{},商品ID:{},数量:{}",
order.getOrderNo(), productId, quantity))
.doOnError(e -> log.error("下单失败,商品ID:{},数量:{},原因:{}",
productId, quantity, e.getMessage()));
}
/**
* 执行创建订单的事务操作(同步阻塞方法)
* <p>
* 使用 {@code @Transactional} 保证查询商品、扣减库存、插入订单在同一事务中,
* 任一步骤异常将触发回滚。
*
* @param productId 商品ID
* @param quantity 购买数量
* @return 创建成功的订单
*/
@Transactional(rollbackFor = Exception.class)
public OrderDO executeCreateOrder(Long productId, Integer quantity) {
// 1. 查询商品并校验状态、库存
ProductDO product = productMapper.selectById(productId);
// TODO : 正常应该校验库存,再扣减库存再创建订单,这里为了验证事务,调整了顺序
// if (product == null) {
// throw new IllegalArgumentException("商品不存在,商品ID:" + productId);
// }
// if (product.getStock() < quantity) {
// throw new IllegalStateException("库存不足,当前库存:" + product.getStock()
// + ",需要数量:" + quantity);
// }
// 3. 构建订单并插入
OrderDO order = buildOrder(productId, quantity, product.getPrice());
orderMapper.insert(order);
log.info("订单插入成功,订单ID:{},订单号:{}", order.getId(), order.getOrderNo());
// 2. 扣减库存(乐观方式,仅库存充足时扣减)
LambdaUpdateWrapper<ProductDO> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(ProductDO::getProductId, productId)
.ge(ProductDO::getStock, quantity)
.setSql("stock = stock - " + quantity);
int updatedRows = productMapper.update(null, updateWrapper);
if (updatedRows == 0) {
throw new IllegalStateException("扣减库存失败,可能存在并发竞争,商品ID:" + productId);
}
return order;
}
2.2 R2DBC
在 Reactor 场景下,最理想的方案是采用 Spring Data R2DBC。作为 Spring 官方推出的响应式关系型数据库访问方案,它是 WebFlux 场景下的首选原生方案,核心优势包括:
-
底层完全非阻塞:基于 R2DBC 驱动实现,从连接获取到结果返回全链路无线程阻塞
-
原生响应式返回:接口定义直接返回
Mono/Flux,无需手动通过Mono.fromCallable()包装阻塞调用 -
极简开发范式:继承
ReactiveCrudRepository即可获得基础增删改查能力,写法与 Spring Data JPA 高度一致,上手成本极低java// 极简示例:定义响应式用户仓库 public interface UserRepository extends ReactiveCrudRepository<UserDO, Long> { // 框架根据方法名自动生成响应式查询实现,无需手动编写 Mono<UserDO> findByUserId(Long userId); Flux<UserDO> findByStatus(Integer status); }
当我们选择使用 R2DBC 后,事务管理则由 TransactionalOperator 接管。与 JDBC 依赖 ThreadLocal 不同,TransactionalOperator 通过 Reactor Context 传递事务状态------事务信息直接绑定在响应式订阅链路上,而非绑定在线程上。因此无论中途如何通过 subscribeOn 或 publishOn 切换线程,事务上下文始终跟随 Reactor 的信号链路传播,不会因线程切换而丢失,事务一致性天然得到保障,因此我们可以向普通项目使用 @Transactional 一样,在 Reactor 场景下使用 @Transactional 。
简单示例如下:
-
去除 Mybatis 相关依赖,引入 r2dbc 相关依赖:
xml<!-- <!– JDBC 支持(MyBatis-Plus 需要 DataSource + SqlSessionFactory) –>--> <!-- <dependency>--> <!-- <groupId>org.springframework.boot</groupId>--> <!-- <artifactId>spring-boot-starter-jdbc</artifactId>--> <!-- </dependency>--> <!-- <dependency>--> <!-- <groupId>mysql</groupId>--> <!-- <artifactId>mysql-connector-java</artifactId>--> <!-- <version>8.0.33</version>--> <!-- </dependency>--> <!-- <dependency>--> <!-- <groupId>com.baomidou</groupId>--> <!-- <artifactId>mybatis-plus-spring-boot3-starter</artifactId>--> <!-- <version>3.5.7</version>--> <!-- </dependency>--> <!-- R2DBC 响应式数据库驱动(替代 JDBC) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency> <!-- MySQL 的 R2DBC 驱动实现 --> <dependency> <groupId>io.asyncer</groupId> <artifactId>r2dbc-mysql</artifactId> </dependency> -
引入 DB 配置 :这里不再使用 spring.datasource 的配置方式,改为 spring.r2dbc。
ymlspring.r2dbc.url=r2dbc:mysql://localhost:3306/reactor_demo?serverTimezone=Asia/Shanghai spring.r2dbc.username=root spring.r2dbc.password=root -
核心代码如下:
java--------------------------------------------- ProductRepository -------------------------------------------------------- /** * 商品响应式仓库(R2DBC) * <p> * 对应原 transactional-webflux 模块中的 ProductMapper, * 基于 Spring Data R2DBC 实现,所有方法原生返回响应式类型。 */ public interface ProductRepository extends R2dbcRepository<ProductDO, Long> { /** * 根据商品ID查询商品 * * @param productId 商品ID * @return 商品信息 */ Mono<ProductDO> findByProductId(Long productId); /** * 扣减库存(乐观方式,仅库存充足时扣减) * <p> * 通过 WHERE 条件 stock >= :quantity 保证不会超卖, * 返回影响行数,0 表示库存不足或商品不存在。 * * @param productId 商品ID * @param quantity 扣减数量 * @return 影响行数 */ @Modifying @Query("UPDATE product SET stock = stock - :quantity WHERE product_id = :productId AND stock >= :quantity") Mono<Long> deductStock(Long productId, Integer quantity); /** * 查询有效商品(状态为上架且库存充足) * <p> * 等价于原 ProductMapper#findValidProduct, * 校验逻辑建议在 Service 层通过 Reactor 操作符实现,此处仅提供基础查询。 * * @param productId 商品ID * @param status 商品状态 * @return 商品信息 */ Mono<ProductDO> findByProductIdAndStatus(Long productId, Integer status); } --------------------------------------------- TransactionService -------------------------------------------------------- /** * 创建订单并扣减库存(R2DBC 响应式事务) * <p> * 整条 Reactor 链路被 {@code @Transactional} 包裹,事务通过 Reactor Context 传递, * 无论中途如何切换线程,事务上下文始终跟随订阅链路,不会丢失。 * <p> * 业务流程:查询商品 → 构建订单并插入 → 扣减库存(为验证事务回滚,故意将扣减放在插入之后) * * @param productId 商品ID * @param quantity 购买数量 * @return 创建成功的订单信息 */ @Transactional(rollbackFor = Exception.class) public Mono<OrderDO> createOrder(Long productId, Integer quantity) { // 1. 查询商品 return productRepository.findByProductId(productId) .switchIfEmpty(Mono.error( new IllegalArgumentException("商品不存在,商品ID:" + productId))) // 2. 构建订单并插入(为验证事务,先插入订单再扣库存) .flatMap(product -> { OrderDO order = buildOrder(productId, quantity, product.getPrice()); return orderRepository.save(order); }) .doOnNext(order -> log.info("订单插入成功,订单ID:{},订单号:{}", order.getId(), order.getOrderNo())) // 3. 扣减库存(乐观方式),若失败则抛异常触发事务回滚,前面插入的订单也会回滚 .flatMap(order -> productRepository.deductStock(productId, quantity) .flatMap(updatedRows -> { if (updatedRows == DEDUCT_STOCK_FAIL_ROWS) { return Mono.error(new IllegalStateException( "扣减库存失败,可能存在并发竞争,商品ID:" + productId)); } return Mono.just(order); })) .doOnSuccess(order -> log.info("下单成功,订单号:{},商品ID:{},数量:{}", order.getOrderNo(), productId, quantity)) .doOnError(e -> log.error("下单失败,商品ID:{},数量:{},原因:{}", productId, quantity, e.getMessage())); }
结合上面的示例,我们再说明一下:
R2DBC 方式下,Repository 方法原生返回响应式类型 Flux 或 Mono,无需像 JDBC 那样手动通过 Mono.fromCallable() 包装。在具体的 SQL 编写方式上,Spring Data R2DBC 提供了多种选择:
-
编程式事务 TransactionalOperator : 除了使用 @Transactional 注解声明事务外,R2DBC 也提供了编程式事务方式(类似 TransactionTemplate 的方式)TransactionalOperator ,如下使用
as(transactionalOperator::transactional)等效于@Transactional:java@Resource private TransactionalOperator transactionalOperator; // @Transactional(rollbackFor = Exception.class) public Mono<OrderDO> createOrder(Long productId, Integer quantity) { // 1. 查询商品 return productRepository.findByProductId(productId) ... // 编程式事务包裹 .as(transactionalOperator::transactional); } -
方法名派生查询 :
ProductRepository#findByProductId、findByProductIdAndStatus等方法遵循 Spring Data 的方法名派生查询规范(方法名中属性部分采用帕斯卡命名),Spring Data 在启动时会解析方法名的命名模式,自动生成对应的查询实现------无论底层是 JPA、R2DBC 还是 MongoDB,这套规则都通用。 -
@Query+@Modifying注解 :ProductRepository#deductStock涉及条件更新和 SQL 表达式(stock = stock - ?),无法通过方法名派生自动生成,因此通过@Query注解手写 SQL 实现,其中@Modifying标识当前操作为写操作(非查询)。java@Modifying @Query("UPDATE product SET stock = stock - :quantity WHERE product_id = :productId AND stock = :quantity") Mono<Long deductStock(Long productId, Integer quantity); -
R2dbcEntityTemplate编程式查询 :对于需要动态拼接条件的场景,可以使用R2dbcEntityTemplate,写法更贴近面向对象风格:java@Resource private R2dbcEntityTemplate template; /** * 根据条件动态查询商品 */ public Mono<ProductDO> findProduct(Long productId, Integer status) { return template.select(ProductDO.class) .matching(query(where("product_id").is(productId) .and("status").is(status))) .one(); } /** * 查找订单,and or 语法 * * @return */ public Flux<OrderDO> findOrder() { // 待发货订单 且 一天内创建的订单 Criteria criteria = where("createTime") .greaterThan(LocalDateTime.now().minusDays(1)); Criteria group1 = where("order_status") .is(1); // 金额大于 100 元的订单 且 一天内创建的订单 Criteria group2 = where("totalAmount") .greaterThan(100); // 执行的SQL 如下:SELECT `order`.* FROM `order` WHERE `order`.create_time > ? AND (`order`.order_status = ? OR (`order`.total_amount > ?)) return template.select(OrderDO.class) .matching( Query.query(criteria.and(group1.or(group2))) ).all(); }需要注意的是,
R2dbcEntityTemplate的Update操作仅支持赋固定值,不支持 SQL 表达式。例如Update.update("stock", "stock - 2")会把"stock - 2"当作字符串值赋给字段,而非执行 SQL 减法运算。因此涉及字段自增、自减等表达式操作时,不能使用R2dbcEntityTemplate。 -
DatabaseClient手写 SQL :对于R2dbcEntityTemplate无法覆盖的场景(如 SQL 表达式、复杂联表、子查询等),可以退回到DatabaseClient直接编写 SQL,灵活度最高:DatabaseClient是R2dbcEntityTemplate的底层实现,R2dbcEntityTemplate是DatabaseClient的高层封装,本质是相同的java@Resource private DatabaseClient databaseClient; /** * 扣减库存(通过 DatabaseClient 编程式实现) */ public Mono<Long> deductStock(Long productId, int quantity) { return databaseClient.sql("UPDATE product SET stock = stock - :quantity " + "WHERE product_id = :productId AND stock = :quantity") .bind("productId", productId) .bind("quantity", quantity) .fetch() .rowsUpdated(); }
三、JOOQ
Spring Data R2DBC 本身偏轻量,缺少 MyBatis-Plus 内置的逻辑删除、字段自动填充、条件构造器、分页插件等开箱即用的功能。面对复杂查询场景,仅靠 @Query 注解手写 SQL 或 DatabaseClient 拼接会比较繁琐,因此通常搭配 jOOQ 来补齐复杂 SQL 构建的能力------jOOQ 提供类型安全的 DSL 构造器,既能生成可读性强的 SQL,又能与 R2DBC 的响应式事务无缝配合。
1. 与 Mybatis-plus 的区别
MyBatis-Plus 底层绑定的是 JDBC 驱动,而 JDBC 本身是阻塞式的------每次数据库操作都会占住当前线程,直到结果返回才释放。在 WebFlux 场景下,只能通过 Mono.fromCallable() + Schedulers.boundedElastic() 将阻塞操作包装一层,但本质上仍然是在弹性线程池中执行同步阻塞调用,Reactor 的非阻塞优势在数据库访问层完全无法发挥。事务方面同样受限:JDBC 事务依赖 ThreadLocal 传递上下文,必须将所有数据库操作集中在同一个同步方法内完成,一旦操作分散到不同线程,事务上下文就会丢失。
jOOQ 则不同------它本身只是一个类型安全的 SQL 构造器,不绑定任何特定的数据库驱动。生成的 SQL 可以交给任意执行层运行:交给 JDBC 执行就是阻塞模式,交给 R2DBC 的 DatabaseClient 执行就是完全非阻塞模式。配合 R2DBC 使用时,整条链路从 SQL 构建、执行到结果返回全程无线程阻塞,事务通过 Reactor Context 传递,不依赖 ThreadLocal,无论中途如何切换线程,事务上下文始终跟随订阅链路传播。
具体对比如下:
| 对比项 | MyBatis-Plus + JDBC | jOOQ + R2DBC |
|---|---|---|
| 数据库驱动 | JDBC(阻塞) | R2DBC(非阻塞) |
| 线程模型 | 每次查询占住一个线程直到返回 | 全程非阻塞,不占用线程 |
| 响应式适配方式 | Mono.fromCallable() 手动包装 |
原生返回 Mono/Flux |
| 事务传递机制 | ThreadLocal,跨线程丢失 |
Reactor Context,跟随订阅链路 |
| 高并发表现 | 受限于弹性线程池容量上限 | 少量线程即可支撑大量并发连接 |
因此,如果项目选择了 WebFlux 作为 Web 层,数据库访问层继续使用 MyBatis-Plus 实际上只是"半响应式"------Web 层实现了非阻塞,但数据库层仍然是阻塞的,整体吞吐量的瓶颈会卡在弹性线程池的容量上。而 jOOQ + R2DBC 的组合才能实现从请求接入到数据库访问的全链路非阻塞,真正释放响应式架构的性能潜力。
2. JOOQ 示例
我们实现 JOOQ 版本的 TransactionService#createOrder 方法,具体如下:
-
如果想使用 JOOQ 的功能,我们首先需要 引入 JOOQ 的依赖:
spring boot 提供了 jooq 的 starter,但是由于其内部引入了
spring-boot-starter-jdbc, 与我们的 r2dbc 冲突,因此我们这里直接引入 JOOQ 的核心包。java<!-- <!– spring-boot-starter-jooq 支持 jOOQ 数据库操作 –>--> <!-- <dependency>--> <!-- <groupId>org.springframework.boot</groupId>--> <!-- <artifactId>spring-boot-starter-jooq</artifactId>--> <!-- </dependency>--> <!-- jOOQ 核心包(不引入 starter-jooq,避免 JDBC 自动配置与 R2DBC 冲突) --> <dependency> <groupId>org.jooq</groupId> <artifactId>jooq</artifactId> </dependency> -
引入 jooq-codegen-maven 插件 :借助 jooq-codegen-maven 插件插件读取表结构并生成 jOOQ 所需的 POJO、Record、TableImpl 等类。引入后通过
mvn jooq-codegen:generate命令可以在指定目录自动生成对应类。
xml
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<version>3.19.12</version>
<!-- 在这里给插件单独加 JDBC 驱动,项目主代码不会用到, 防止根 R2DBC 驱动冲突 -->
<dependencies>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
</dependencies>
<!-- Maven 在 编译项目时,自动运行 jOOQ 代码生成!如果不加,则需要自己手动执行 mvn jooq-codegen:generate 来执行 -->
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<!-- 数据库连接(用 JDBC 生成,不影响 R2DBC 运行) -->
<jdbc>
<driver>com.mysql.cj.jdbc.Driver</driver>
<url>jdbc:mysql://localhost:3306/reactor_demo</url>
<user>root</user>
<password>root</password>
</jdbc>
<generator>
<database>
<name>org.jooq.meta.mysql.MySQLDatabase</name>
<inputSchema>reactor_demo</inputSchema>
<!-- 排除哪些表 / 库不生成 -->
<excludes>sys|mysql</excludes>
</database>
<!-- 生成的类名策略, 添加 DO 后缀 -->
<strategy>
<matchers>
<tables>
<table>
<pojoClass>
<!-- 拼接策略 : 表名 + DO,使用 _ 是为了让 DO 保持大写,否则会拼接成小写的 do -->
<expression>$0_d_o</expression>
<!-- 指定 AS_IS 策略 : 原样保留,不做任何大小写转换-->
<transform>PASCAL</transform>
</pojoClass>
</table>
</tables>
</matchers>
</strategy>
<generate>
<!-- 生成 POJO -->
<pojos>true</pojos>
<!-- 生成 jOOQ 专用的 Record(可直接插入更新) -->
<records>true</records>
<!-- 生成简单的 CRUD DAO 一般关闭,自己写 Service 更灵活。-->
<daos>false</daos>
</generate>
<!-- 生成的目录 -->
<target>
<packageName>com.kingfish.reactor.domain.model.jooq</packageName>
<directory>src/main/java</directory>
</target>
</generator>
</configuration>
</plugin>
-
基于 JOOQ 重写 createOrder 方法,如下:
java--------------------------------------------- ReactiveBaseDao (通用 CURD Dao)-------------------------------------------------------- /** * jOOQ + R2DBC 响应式基础数据访问层 * <p> * 封装通用的增删改查操作,子类只需定义表结构和字段映射即可使用。 * jOOQ 负责类型安全的 SQL 构建,DatabaseClient 负责响应式执行。 * * @param <T> 实体类型 */ public abstract class ReactiveBaseDao<T> { @Resource protected DSLContext dslContext; @Resource protected DatabaseClient databaseClient; /** * 返回当前操作的表 * * @return jOOQ 表定义 */ protected abstract Table<?> getTable(); /** * 返回主键字段 * * @return 主键 Field 定义 */ protected abstract Field<Long> getIdField(); /** * 将数据库行映射为实体对象 * * @return 行映射函数 */ protected abstract Function<Map<String, Object>, T> getRowMapper(); /** * 根据主键查询单条记录 * * @param id 主键值 * @return 实体对象 */ public Mono<T> findById(Long id) { String sql = dslContext.select(DSL.asterisk()) .from(getTable()) .where(getIdField().eq(DSL.param("id", Long.class))) .getSQL(); return databaseClient.sql(sql) .bind("id", id) .fetch() .one() .map(getRowMapper()); } /** * 根据条件查询单条记录 * * @param condition 查询条件 * @param params 绑定参数(按顺序对应 SQL 中的占位符) * @return 实体对象 */ public Mono<T> findOne(Condition condition, Map<String, Object> params) { String sql = dslContext.select(DSL.asterisk()) .from(getTable()) .where(condition) .getSQL(); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql); for (Map.Entry<String, Object> entry : params.entrySet()) { spec = spec.bind(entry.getKey(), entry.getValue()); } return spec.fetch().one().map(getRowMapper()); } /** * 根据条件查询多条记录 * * @param condition 查询条件 * @param params 绑定参数 * @return 实体列表 */ public Flux<T> findList(Condition condition, Map<String, Object> params) { String sql = dslContext.select(DSL.asterisk()) .from(getTable()) .where(condition) .getSQL(); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql); for (Map.Entry<String, Object> entry : params.entrySet()) { spec = spec.bind(entry.getKey(), entry.getValue()); } return spec.fetch().all().map(getRowMapper()); } /** * 插入单条记录 * * @param columns 字段与值的映射(有序,保证字段和值一一对应) * @return 影响行数 */ public Mono<Long> insert(LinkedHashMap<Field<?>, Object> columns) { // 构建 INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...) Field<?>[] fields = columns.keySet().toArray(new Field[0]); Object[] values = columns.values().toArray(); // 使用参数化占位符 org.jooq.Param<?>[] params = new org.jooq.Param[fields.length]; for (int i = 0; i < fields.length; i++) { params[i] = DSL.param(fields[i].getName(), fields[i].getDataType()); } @SuppressWarnings("unchecked") String sql = dslContext.insertInto(getTable()) .columns(fields) .values(params) .getSQL(); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql); for (int i = 0; i < fields.length; i++) { spec = spec.bind(fields[i].getName(), values[i]); } return spec.fetch().rowsUpdated(); } /** * 根据条件更新记录(支持 SQL 表达式,如 stock = stock - ?) * * @param setSql SET 子句的原生 SQL(如 "stock = stock - :quantity") * @param condition WHERE 条件 * @param params 绑定参数 * @return 影响行数 */ public Mono<Long> updateBySql(String setSql, Condition condition, Map<String, Object> params) { // 手动拼接 UPDATE 语句,因为 jOOQ 的 set() 不支持原生 SQL 表达式赋值 String whereSql = dslContext.renderInlined(condition); String sql = "UPDATE " + getTable().getName() + " SET " + setSql + " WHERE " + whereSql; DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql); for (Map.Entry<String, Object> entry : params.entrySet()) { spec = spec.bind(entry.getKey(), entry.getValue()); } return spec.fetch().rowsUpdated(); } /** * 根据条件更新记录(固定值赋值) * * @param setValues 字段与新值的映射 * @param condition WHERE 条件 * @param params WHERE 条件中的绑定参数 * @return 影响行数 */ public Mono<Long> update(Map<Field<?>, Object> setValues, Condition condition, Map<String, Object> params) { UpdateSetStep<?> step = dslContext.update(getTable()); // 逐个设置字段值 var setStep = step.set(DSL.field("1"), (Object) null); boolean first = true; StringBuilder setSqlBuilder = new StringBuilder(); Map<String, Object> allParams = new LinkedHashMap<>(params); for (Map.Entry<Field<?>, Object> entry : setValues.entrySet()) { if (!first) { setSqlBuilder.append(", "); } String paramName = "set_" + entry.getKey().getName(); setSqlBuilder.append(entry.getKey().getName()).append(" = :").append(paramName); allParams.put(paramName, entry.getValue()); first = false; } String whereSql = dslContext.renderInlined(condition); String sql = "UPDATE " + getTable().getName() + " SET " + setSqlBuilder + " WHERE " + whereSql; DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql); for (Map.Entry<String, Object> entry : allParams.entrySet()) { spec = spec.bind(entry.getKey(), entry.getValue()); } return spec.fetch().rowsUpdated(); } /** * 根据主键删除记录 * * @param id 主键值 * @return 影响行数 */ public Mono<Long> deleteById(Long id) { String sql = dslContext.deleteFrom(getTable()) .where(getIdField().eq(DSL.param("id", Long.class))) .getSQL(); return databaseClient.sql(sql) .bind("id", id) .fetch() .rowsUpdated(); } } --------------------------------------------- OrderDao -------------------------------------------------------- @Component public class OrderDao extends ReactiveBaseDao<OrderDO> { public static final Table<?> TABLE = DSL.table("`order`"); public static final Field<Long> ID = DSL.field("id", SQLDataType.BIGINT); public static final Field<Long> ORDER_ID = DSL.field("order_id", SQLDataType.BIGINT); public static final Field<Long> USER_ID = DSL.field("user_id", SQLDataType.BIGINT); public static final Field<Long> PRODUCT_ID = DSL.field("product_id", SQLDataType.BIGINT); public static final Field<Integer> QUANTITY = DSL.field("quantity", SQLDataType.INTEGER); public static final Field<String> ORDER_NO = DSL.field("order_no", SQLDataType.VARCHAR(64)); public static final Field<BigDecimal> TOTAL_AMOUNT = DSL.field("total_amount", SQLDataType.DECIMAL(10, 2)); public static final Field<Integer> PAY_STATUS = DSL.field("pay_status", SQLDataType.INTEGER); public static final Field<Integer> ORDER_STATUS = DSL.field("order_status", SQLDataType.INTEGER); @Override protected Table<?> getTable() { return TABLE; } @Override protected Field<Long> getIdField() { return ID; } @Override protected Function<Map<String, Object>, OrderDO> getRowMapper() { return row -> { OrderDO order = new OrderDO(); order.setId((Long) row.get("id")); order.setOrderId((Long) row.get("order_id")); order.setUserId((Long) row.get("user_id")); order.setProductId((Long) row.get("product_id")); order.setQuantity((Integer) row.get("quantity")); order.setOrderNo((String) row.get("order_no")); order.setTotalAmount((BigDecimal) row.get("total_amount")); order.setPayStatus((Integer) row.get("pay_status")); order.setOrderStatus((Integer) row.get("order_status")); order.setCreateTime((LocalDateTime) row.get("create_time")); order.setUpdateTime((LocalDateTime) row.get("update_time")); order.setDeleted((Integer) row.get("deleted")); return order; }; } /** * 将订单实体转换为字段映射(用于插入操作) * * @param order 订单实体 * @return 字段与值的有序映射 */ public LinkedHashMap<Field<?>, Object> toColumnMap(OrderDO order) { LinkedHashMap<Field<?>, Object> columns = new LinkedHashMap<>(); columns.put(ORDER_ID, order.getOrderId()); columns.put(USER_ID, order.getUserId()); columns.put(PRODUCT_ID, order.getProductId()); columns.put(QUANTITY, order.getQuantity()); columns.put(ORDER_NO, order.getOrderNo()); columns.put(TOTAL_AMOUNT, order.getTotalAmount()); columns.put(PAY_STATUS, order.getPayStatus()); columns.put(ORDER_STATUS, order.getOrderStatus()); return columns; } } --------------------------------------------- ProductDao -------------------------------------------------------- @Component public class ProductDao extends ReactiveBaseDao<ProductDO> { public static final Table<?> TABLE = DSL.table("product"); public static final Field<Long> ID = DSL.field("id", SQLDataType.BIGINT); public static final Field<Long> PRODUCT_ID = DSL.field("product_id", SQLDataType.BIGINT); public static final Field<String> PRODUCT_NAME = DSL.field("product_name", SQLDataType.VARCHAR(128)); public static final Field<BigDecimal> PRICE = DSL.field("price", SQLDataType.DECIMAL(10, 2)); public static final Field<Integer> STOCK = DSL.field("stock", SQLDataType.INTEGER); public static final Field<Integer> STATUS = DSL.field("status", SQLDataType.INTEGER); @Override protected Table<?> getTable() { return TABLE; } @Override protected Field<Long> getIdField() { return ID; } @Override protected Function<Map<String, Object>, ProductDO> getRowMapper() { return row -> { ProductDO product = new ProductDO(); product.setId((Long) row.get("id")); product.setProductId((Long) row.get("product_id")); product.setProductName((String) row.get("product_name")); product.setPrice((BigDecimal) row.get("price")); product.setStock((Integer) row.get("stock")); product.setStatus((Integer) row.get("status")); product.setCreateTime((LocalDateTime) row.get("create_time")); product.setUpdateTime((LocalDateTime) row.get("update_time")); product.setDeleted((Integer) row.get("deleted")); return product; }; } /** * 根据商品ID查询商品 * * @param productId 商品ID * @return 商品信息 */ public Mono<ProductDO> findByProductId(Long productId) { return findOne( PRODUCT_ID.eq(DSL.param("productId", Long.class)), Map.of("productId", productId) ); } /** * 扣减库存(乐观方式,仅库存充足时扣减) * <p> * 使用 SQL 表达式 stock = stock - :quantity, * 通过 {@link ReactiveBaseDao#updateBySql} 实现,避免 R2dbcEntityTemplate 不支持表达式的问题。 * * @param productId 商品ID * @param quantity 扣减数量 * @return 影响行数(0 表示库存不足或商品不存在) */ public Mono<Long> deductStock(Long productId, Integer quantity) { // 使用 renderInlined 将条件内联,SET 子句使用命名参数 String setSql = "stock = stock - :quantity"; // 条件:product_id = productId AND stock >= quantity(内联渲染,无需绑定) org.jooq.Condition condition = PRODUCT_ID.eq(productId).and(STOCK.greaterOrEqual(quantity)); return updateBySql(setSql, condition, Map.of("quantity", quantity)); } } --------------------------------------------- JooqService#createOrder-------------------------------------------------------- /** * 创建订单并扣减库存(jOOQ + R2DBC 响应式事务) * <p> * 业务流程与 {@link TransactionService#createOrder} 完全一致: * 查询商品 → 构建订单并插入 → 扣减库存。 * 区别在于数据访问层使用 jOOQ 基类 {@link com.kingfish.reactor.domain.model.dao.ReactiveBaseDao}, * 通过类型安全的 DSL 构建 SQL。 * * @param productId 商品ID * @param quantity 购买数量 * @return 创建成功的订单信息 */ @Transactional(rollbackFor = Exception.class) public Mono<OrderDO> createOrder(Long productId, Integer quantity) { // 1. 通过 ProductDao 查询商品 return productDao.findByProductId(productId) .switchIfEmpty(Mono.error( new IllegalArgumentException("商品不存在,商品ID:" + productId))) // 2. 构建订单并通过 OrderDao 插入 .flatMap(product -> { OrderDO order = buildOrder(productId, quantity, product.getPrice()); return orderDao.insert(orderDao.toColumnMap(order)) .doOnNext(rows -> log.info("jOOQ 订单插入成功,订单号:{}", order.getOrderNo())) .thenReturn(order); }) // 3. 通过 ProductDao 扣减库存 .flatMap(order -> productDao.deductStock(productId, quantity) .flatMap(updatedRows -> { if (updatedRows == DEDUCT_STOCK_FAIL_ROWS) { return Mono.error(new IllegalStateException( "扣减库存失败,可能存在并发竞争,商品ID:" + productId)); } return Mono.just(order); })) .doOnSuccess(order -> log.info("jOOQ 下单成功,订单号:{},商品ID:{},数量:{}", order.getOrderNo(), productId, quantity)) .doOnError(e -> log.error("jOOQ 下单失败,商品ID:{},数量:{},原因:{}", productId, quantity, e.getMessage())); }
四、参考内容
1.豆包