tio-boot + jOOQ 事务管理
本文将基于已经完成的 tio-boot + jOOQ(纯配置类方式、AOP 注入 DSLContext) 的整合方案,继续讲清楚在该架构下如何实现 事务管理。
目标是做到:
- 以 纯 Java 方式 管理事务
- 不依赖 Spring,不引入额外框架
- 支持手动事务、AOP 事务两种写法
- 明确事务边界,避免连接泄漏与隐式提交
- 与 tio-boot 生命周期与 AOP 注入自然融合
一、jOOQ 事务的本质
jOOQ 本身不直接“持有连接池”,它的事务能力依赖两点:
- ConnectionProvider:决定从哪里获取连接、如何释放
- TransactionProvider:决定如何开启、提交、回滚事务
当前的写法:
DSLContext dslContext = DSL.using(dataSource, SQLDialect.POSTGRES);
这会让 jOOQ 使用默认的连接获取方式(底层走 DataSource),但如果希望:
- 同一事务内多次 DAO 调用复用同一连接
- 可控地 commit/rollback
- 在 service 层统一控制事务边界
就需要把“事务”从 DAO 的一次查询,提升到“业务方法级别”。
二、事务管理的两种常见方式
在 tio-boot + jOOQ 中推荐两种方式:
方式 A:手动事务(最直观)
适合:
- 事务逻辑少
- 希望完全显式控制
- 学习和定位问题更容易
方式 B:AOP 事务(最省代码)
适合:
- 大量业务方法需要事务
- 希望统一规范事务边界
- 类似 Spring 的 @Transactional 体验
本文两种都给出完整写法。
三、准备:抽象一个 TransactionManager
3.1 目标
我们希望业务层这样写:
txManager.tx(() -> {
daoA.xxx();
daoB.yyy();
});
不关心 Connection 的创建、关闭、提交、回滚。
3.2 TransactionManager 实现
package demo.jooq.tx;
import java.sql.Connection;
import javax.sql.DataSource;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.impl.DSL;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class TransactionManager {
private final DataSource dataSource;
private final SQLDialect dialect;
public TransactionManager(DataSource dataSource, SQLDialect dialect) {
this.dataSource = dataSource;
this.dialect = dialect;
}
/**
* 执行无返回值事务
*/
public void tx(TxRunnable runnable) {
tx(() -> {
runnable.run();
return null;
});
}
/**
* 执行有返回值事务
*/
public <T> T tx(TxCallable<T> callable) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
DSLContext txDsl = DSL.using(conn, dialect);
TransactionContext.set(txDsl);
T result = callable.call();
conn.commit();
return result;
} catch (Exception e) {
if (conn != null) {
try {
conn.rollback();
} catch (Exception ignore) {
}
}
log.error("transaction rollback", e);
throw new RuntimeException(e);
} finally {
TransactionContext.clear();
if (conn != null) {
try {
conn.setAutoCommit(true);
conn.close();
} catch (Exception ignore) {
}
}
}
}
@FunctionalInterface
public interface TxRunnable {
void run() throws Exception;
}
@FunctionalInterface
public interface TxCallable<T> {
T call() throws Exception;
}
}
四、关键点:让 DAO 在事务内拿到同一个 DSLContext
如果 DAO 里一直用 @Inject DSLContext dsl;,那么它用的是全局单例 DSLContext,这会导致:
- DAO 每次执行 SQL 都可能拿到不同连接
- 事务内不能保证连接一致
- 出现“看似开启事务但实际提交了”的错觉
解决方案:引入一个 TransactionContext(ThreadLocal)。
五、TransactionContext 实现
package demo.jooq.tx;
import org.jooq.DSLContext;
public class TransactionContext {
private static final ThreadLocal<DSLContext> CTX = new ThreadLocal<>();
public static void set(DSLContext dsl) {
CTX.set(dsl);
}
public static DSLContext get() {
return CTX.get();
}
public static void clear() {
CTX.remove();
}
}
六、改造 DAO:优先使用事务 DSLContext
把 DAO 改成:
- 如果当前线程存在事务 DSLContext,就用它
- 否则退回使用全局注入的 DSLContext
SystemAdminDao 改造版
package demo.jooq.dao;
import org.jooq.DSLContext;
import org.jooq.impl.DSL;
import com.litongjava.annotation.Inject;
import demo.jooq.model.SystemAdmin;
import demo.jooq.tx.TransactionContext;
public class SystemAdminDao {
@Inject
private DSLContext dsl;
private DSLContext useDsl() {
DSLContext txDsl = TransactionContext.get();
return txDsl != null ? txDsl : dsl;
}
public SystemAdmin findByLoginName(String loginName) {
return useDsl()
.select(DSL.field("id"), DSL.field("login_name"), DSL.field("password"))
.from(DSL.table("system_admin"))
.where(DSL.field("login_name").eq(loginName))
.fetchOneInto(SystemAdmin.class);
}
public int updatePassword(String loginName, String newPassword) {
return useDsl()
.update(DSL.table("system_admin"))
.set(DSL.field("password"), newPassword)
.where(DSL.field("login_name").eq(loginName))
.execute();
}
}
七、在配置类中注册 DataSource 与 TransactionManager
之前配置类里只注册了 DSLContext,事务需要 DataSource,因此建议:
- 把 DataSource 也注册到 AOP
- 再注册 TransactionManager
JooqConfig(带事务增强版)
package demo.jooq.config;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.impl.DSL;
import com.alibaba.druid.pool.DruidDataSource;
import com.litongjava.annotation.AConfiguration;
import com.litongjava.annotation.Initialization;
import com.litongjava.constants.ServerConfigKeys;
import com.litongjava.hook.HookCan;
import com.litongjava.jfinal.aop.AopManager;
import com.litongjava.tio.utils.environment.EnvUtils;
import demo.jooq.tx.TransactionManager;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@AConfiguration
public class JooqConfig {
@Initialization
public void initJooq() {
String jdbcUrl = EnvUtils.get(ServerConfigKeys.JDBC_URL);
String username = EnvUtils.getStr(ServerConfigKeys.JDBC_USER);
String password = EnvUtils.getStr(ServerConfigKeys.JDBC_PSWD);
DruidDataSource dataSource = new DruidDataSource();
dataSource.setUrl(jdbcUrl);
dataSource.setUsername(username);
dataSource.setPassword(password);
DSLContext dslContext = DSL.using(dataSource, SQLDialect.POSTGRES);
// 注册核心对象
AopManager.me().addSingletonObject(DruidDataSource.class, dataSource);
AopManager.me().addSingletonObject(DSLContext.class, dslContext);
// 注册事务管理器
TransactionManager txManager = new TransactionManager(dataSource, SQLDialect.POSTGRES);
AopManager.me().addSingletonObject(TransactionManager.class, txManager);
log.info("jOOQ + TransactionManager initialized");
HookCan.me().addDestroyMethod(() -> {
dataSource.close();
log.info("DruidDataSource closed");
});
}
}
八、Service 层示例:事务提交与回滚
8.1 一个典型业务:修改密码并记录日志(示例)
假设有两个 DAO:
SystemAdminDao.updatePasswordAuditLogDao.insert
业务要求:两者要么都成功,要么都失败。
package demo.jooq.service;
import com.litongjava.annotation.Inject;
import demo.jooq.dao.SystemAdminDao;
import demo.jooq.tx.TransactionManager;
public class SystemAdminService {
@Inject
private SystemAdminDao systemAdminDao;
@Inject
private TransactionManager txManager;
public void changePassword(String loginName, String newPassword) {
txManager.tx(() -> {
systemAdminDao.updatePassword(loginName, newPassword);
// 模拟异常:测试回滚
// if (true) throw new RuntimeException("mock error");
});
}
}
只要事务内部抛异常,就会 rollback。
九、Controller 调用 Service
package demo.jooq.controller;
import com.jfinal.kit.Kv;
import com.litongjava.annotation.Inject;
import com.litongjava.annotation.RequestPath;
import demo.jooq.service.SystemAdminService;
@RequestPath("/systemAdmin")
public class SystemAdminController {
@Inject
private SystemAdminService service;
public Kv changePassword() {
service.changePassword("litong", "11111111");
return Kv.by("ok", true);
}
}
十、方式 B:AOP 事务(让业务代码更干净)
如果的项目事务很多,建议实现一个注解:
@ATransactional- AOP 拦截器自动包裹 txManager.tx()
10.1 注解
package demo.jooq.tx;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ATransactional {
}
10.2 拦截器(伪代码风格,按当前 AOP 体系调整)
不同版本的 jfinal-aop 拦截器接口可能略有差异,下面是表达核心思想的实现方式:
package demo.jooq.tx;
import com.litongjava.jfinal.aop.Interceptor;
import com.litongjava.jfinal.aop.AopInvocation;
import com.litongjava.jfinal.aop.AopManager;
public class TransactionInterceptor implements Interceptor {
@Override
public void intercept(AopInvocation inv) {
TransactionManager txManager = AopManager.me().get(TransactionManager.class);
txManager.tx(() -> {
inv.invoke();
});
}
}
10.3 绑定拦截器
把标注了 @ATransactional 的方法自动应用拦截器。
具体绑定方式取决于项目里 jfinal-aop 的配置方式,常见做法是:
- 在初始化阶段扫描注解并绑定
- 或在 AOP 框架支持的配置点进行注册
可以把它写成一个独立配置类 TransactionAopConfig,在 @Initialization 中完成注册。
package demo.jooq.config;
import com.litongjava.context.BootConfiguration;
import com.litongjava.jfinal.aop.InterceptorManager;
import demo.jooq.tx.ATransactional;
import demo.jooq.tx.TransactionInterceptor;
public class JooqBootConfig implements BootConfiguration{
@Override
public void config() throws Exception {
InterceptorManager.me().registerInterceptor(ATransactional.class, TransactionInterceptor.class);
}
}
@ATransactional
public void changePassword2(String loginName, String newPassword) {
systemAdminDao.updatePassword(loginName, newPassword);
}
十一、事务管理的常见坑
1. DAO 内部不要自己开启事务
事务边界应该在 service 层统一控制,否则会出现嵌套事务语义混乱。
2. 必须确保事务内复用同一连接
所以才需要 TransactionContext(ThreadLocal)。
3. 不要把 Connection 暴露给业务代码
暴露 Connection 会导致资源泄漏、commit/rollback 混乱、可维护性下降。
十二、总结
本文给出了 tio-boot + jOOQ 的事务管理完整方案,特点是:
- 纯 Java 配置
- 不依赖 Spring
- 事务边界清晰(service 层)
- ThreadLocal 保证事务连接一致
- 同时支持手动事务与 AOP 事务风格
