【笔记03】【Reactor 响应式编程② - 事务编程】

文章目录

  • 一、前言
  • [二、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 可能会失效。

这里推荐两篇有些许相关性的文章,与本篇其实相关性不大,只是临时想到了 :

  1. Spring源码分析十五:事务实现① - AutoProxyRegistrar :SpringBoot 事务原理实现说明。
  2. 【项目实践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 。


简单示例如下:

  1. 去除 Mybatis 相关依赖,引入 r2dbc 相关依赖:

    xml 复制代码
        <!--        &lt;!&ndash; JDBC 支持(MyBatis-Plus 需要 DataSource + SqlSessionFactory) &ndash;&gt;-->
        <!--        <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>
  2. 引入 DB 配置 :这里不再使用 spring.datasource 的配置方式,改为 spring.r2dbc。

    yml 复制代码
    spring.r2dbc.url=r2dbc:mysql://localhost:3306/reactor_demo?serverTimezone=Asia/Shanghai
    spring.r2dbc.username=root
    spring.r2dbc.password=root
  3. 核心代码如下:

    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 方法原生返回响应式类型 FluxMono,无需像 JDBC 那样手动通过 Mono.fromCallable() 包装。在具体的 SQL 编写方式上,Spring Data R2DBC 提供了多种选择:

  1. 编程式事务 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);
        }
  2. 方法名派生查询ProductRepository#findByProductIdfindByProductIdAndStatus 等方法遵循 Spring Data 的方法名派生查询规范(方法名中属性部分采用帕斯卡命名),Spring Data 在启动时会解析方法名的命名模式,自动生成对应的查询实现------无论底层是 JPA、R2DBC 还是 MongoDB,这套规则都通用。

  3. @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);
  4. 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();
    }

    需要注意的是,R2dbcEntityTemplateUpdate 操作仅支持赋固定值,不支持 SQL 表达式。例如 Update.update("stock", "stock - 2") 会把 "stock - 2" 当作字符串值赋给字段,而非执行 SQL 减法运算。因此涉及字段自增、自减等表达式操作时,不能使用 R2dbcEntityTemplate

  5. DatabaseClient 手写 SQL :对于 R2dbcEntityTemplate 无法覆盖的场景(如 SQL 表达式、复杂联表、子查询等),可以退回到 DatabaseClient 直接编写 SQL,灵活度最高:

    DatabaseClientR2dbcEntityTemplate 的底层实现, R2dbcEntityTemplateDatabaseClient 的高层封装,本质是相同的

    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 方法,具体如下:

  1. 如果想使用 JOOQ 的功能,我们首先需要 引入 JOOQ 的依赖:

    spring boot 提供了 jooq 的 starter,但是由于其内部引入了 spring-boot-starter-jdbc, 与我们的 r2dbc 冲突,因此我们这里直接引入 JOOQ 的核心包。

    java 复制代码
    <!--        &lt;!&ndash; spring-boot-starter-jooq 支持 jOOQ 数据库操作 &ndash;&gt;-->
    <!--        <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>
  2. 引入 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>
  1. 基于 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.豆包

相关推荐
Hello--_--World2 小时前
React:描述UI 官网笔记
笔记·react.js·ui
栀栀栀栀栀栀2 小时前
基于深度学习的自然语言处理和语音识别 阅读笔记
人工智能·笔记·深度学习·自然语言处理·语音识别
IronMurphy2 小时前
黑马点评-短信登陆笔记
笔记
Shea的笔记本3 小时前
MindSpore实战笔记:ResNet50中药炮制饮片质量判断复现全记录
笔记
YaBingSec3 小时前
玄机靶场—Apache-druid(CVE-2021-25646) WP
java·开发语言·笔记·安全·php·apache
Hello--_--World3 小时前
React:状态管理 官网笔记
前端·笔记·react.js
苦 涩3 小时前
考研408笔记之操作系统(五)——输入输出(IO)管理
笔记·操作系统·考研408
他是龙5513 小时前
DVWA SQL 注入全级别通关笔记(Low / Medium / High / Impossible)
数据库·笔记·sql
咸鱼翻身小阿橙4 小时前
C++ 与 QML 交互入门笔记
c++·笔记·交互