数据库事务处理
在现代应用开发中,确保数据的完整性和一致性至关重要。数据库事务(Transaction)是实现这一目标的核心机制。它将一系列数据库操作捆绑成一个不可分割的逻辑工作单元,确保这些操作要么全部成功执行,要么在出现任何错误时全部回滚,使数据恢复到操作前的状态。
本文将详细介绍 ActiveRecord 框架中支持的三种主流事务处理方式,并提供最佳实践建议,帮助您根据不同场景选择最合适的方案。
1. 编程式事务:Db.tx (官方推荐)
Db.tx 是框架官方首推的事务处理方式。它通过 Lambda 表达式将事务代码块包裹起来,实现了代码量最小化、控制粒度最细的事务管理。
基本用法
Db.tx 的核心优势在于其简洁的语法和直观的控制逻辑。
Db.tx(() -> {
// 业务操作1:更新 t1 表
Db.update("update t1 set f1 = ?", 123);
// 业务操作2:更新 t2 表
Db.update("update t2 set f2 = ?", 456);
// 返回 true 表示所有操作成功,提交事务
return true;
});
在上述代码中:
- 两个
Db.update操作被自动包含在同一个事务中。 - Lambda 表达式的返回值直接控制事务的最终状态:
return true;:提交事务,所有数据更改将永久生效。return false;:回滚事务,所有数据更改将被撤销。
- 这种方式无需抛出异常即可优雅地回滚事务,非常适合处理复杂的业务逻辑校验。
指定数据源与事务级别
Db.tx 方法同样支持对多数据源和事务隔离级别的精细控制。
操作非主数据源:若要对其他数据源开启事务,只需在调用
tx方法前使用Db.use(configName)指定数据源。Db.use("other_ds").tx(() -> { // 这里的数据库操作将作用于名为 "other_ds" 的数据源 return true; });指定事务隔离级别:
Db.tx方法提供了一个重载版本,允许传入 JDBC 定义的事务隔离级别。Db.tx(Connection.TRANSACTION_SERIALIZABLE, () -> { // 此事务块内的所有操作将在“可串行化”隔离级别下执行 Db.update(...); new User().setNickName("james").save(); return true; });这对于需要灵活应对不同并发场景的应用来说非常实用。
注意:请确保您的 MySQL 数据库表使用了支持事务的 InnoDB 存储引擎,MyISAM 引擎不支持事务。
2. 声明式事务:拦截器 (Tx)
声明式事务通过 AOP(面向切面编程)思想,使用拦截器将事务逻辑与业务代码解耦。开发者只需在 Controller 的 Action 方法或 Service 方法上添加注解或配置,即可实现事务管理。
基本用法
通过 @AopBefore(Tx.class) 注解,可以轻松地为整个 Action 方法添加事务支持。
// 本例仅为示例, 并未严格考虑账户状态等业务逻辑
@AopBefore(Tx.class)
public void trans_demo() {
// 1. 获取转账参数
Integer transAmount = getInt("transAmount");
Integer fromAccountId = getInt("fromAccountId");
Integer toAccountId = getInt("toAccountId");
// 2. 执行转出操作
Db.update("update account set cash = cash - ? where id = ?", transAmount, fromAccountId);
// 模拟一个异常,触发事务回滚
// if (true) throw new RuntimeException("发生未知错误,触发回滚!");
// 3. 执行转入操作
Db.update("update account set cash = cash + ? where id = ?", transAmount, toAccountId);
}
工作原理:Tx 拦截器会捕获其作用范围内抛出的任何 Exception(及其子类)。一旦捕获到异常,拦截器会自动回滚事务;如果方法正常执行完毕没有抛出异常,则自动提交事务。
异常处理与响应控制
您可以在事务方法内部使用 try-catch 块来控制异常发生时的页面响应,但必须将异常重新抛出,以便外层的 Tx 拦截器能够感知并执行回滚。
@AopBefore(Tx.class)
public void trans() {
try {
service.justDoIt(...);
render("ok.html");
} catch (Exception e) {
// 捕获异常,渲染错误页面
render("error.html");
// 必须重新抛出异常,否则 Tx 拦截器无法感知,事务不会回滚!
throw e;
}
}
多种配置方式
除了 @AopBefore 注解,框架还提供了多种全局配置方式,可以根据方法名、ActionKey 等规则批量添加事务。
public void configInterceptor(Interceptors me) {
// 根据方法名正则表达式,为所有 save 或 update 开头的方法添加事务
me.add(new TxByMethodRegex("(.*save.*|.*update.*)"));
// 根据方法名,为名为 "save" 和 "update" 的方法添加事务
me.add(new TxByMethods("save", "update"));
// 根据 ActionKey 正则表达式,为 /trans/ 开头的 Action 添加事务
me.add(new TxByActionKeyRegex("/trans.*"));
// 为指定的 ActionKey 添加事务
me.add(new TxByActionKeys("/tx/save", "/tx/update"));
}
针对非主数据源
声明式事务默认作用于主数据源。若要使其作用于其他数据源,需使用 @TxConfig 注解。
@TxConfig("otherConfigName") // 指定事务作用于名为 "otherConfigName" 的数据源
@AopBefore(Tx.class)
public void doIt() {
// ... 业务代码 ...
}
3. 与 JDBC 代码共享事务
需要复用 JDBC 代码时,可以让 Db.tx 管理事务与连接生命周期,在回调内获取当前连接。PreparedStatement 和 ResultSet 由业务代码关闭,连接由最外层事务归还;回调内不要自行提交、回滚或关闭共享连接。
import java.sql.Connection;
import java.sql.PreparedStatement;
import nexus.io.db.activerecord.Db;
public class AccountJdbcService {
public boolean debit(String accountId, int amount) {
return Db.tx(() -> {
Connection connection = Db.use().getConfig().getThreadLocalConnection();
try (PreparedStatement statement = connection.prepareStatement(
"update account set balance = balance - ? where id = ? and balance >= ?")) {
statement.setInt(1, amount);
statement.setString(2, accountId);
statement.setInt(3, amount);
return statement.executeUpdate() == 1;
}
});
}
}
事务成功提交或回滚后,框架恢复连接原有的自动提交状态与隔离级别。恢复状态发生异常时仍会尝试关闭连接,并清理线程绑定。若回滚自身失败,不切换到自动提交模式,以免提交尚未完成的事务;原始异常保留回滚失败的 suppressed 异常,供日志排查。
4. 最佳实践与选择建议
综合以上三种方式,我们给出以下建议:
首选
Db.tx:- 性能更优:事务代码块范围最小,仅包裹必要的操作,减少了事务持有时间。
- 控制更灵活:可以通过返回值
true/false控制提交或回滚,无需依赖异常机制,代码逻辑更清晰。 - 代码更简洁:Java 8 的 Lambda 语法让事务代码非常紧凑和优雅。
慎用声明式事务 (
Tx拦截器):- 虽然声明式事务能让业务代码看起来更“干净”,但它依赖异常来触发回滚,这在某些复杂业务场景下可能不够灵活。
- 当使用
Tx拦截器时,通常需要配合一个全局的ExceptionInterceptor来捕获Tx拦截器抛出的异常,并向客户端返回友好的错误信息(如 JSON 或错误页面),否则用户只会看到一个通用的 500 错误页。
避免手动管理事务:
- 手动管理事务代码冗长、逻辑复杂,且极易因忘记资源清理(如归还连接、恢复状态)而引发严重的性能问题和资源泄漏。仅在无法使用框架提供的高级抽象时,作为最后手段考虑。
5. 事务隔离级别与性能
JDBC 默认的事务隔离级别通常是 Connection.TRANSACTION_READ_COMMITTED(读已提交)。为了提供更高的数据一致性保障,JFinal 的 ActiveRecordPlugin 默认使用 Connection.TRANSACTION_REPEATABLE_READ(可重复读)。
虽然可重复读能避免“不可重复读”问题,但它会施加更严格的锁,在高并发、高竞争的场景下(例如秒杀系统中的库存扣减),可能会导致性能下降。
如果您的应用场景允许较低的隔离级别,或者遇到了由锁竞争引发的性能瓶颈,可以通过以下方式调整全局默认的事务隔离级别来提升性能:
public void configPlugin(Plugins me) {
ActiveRecordPlugin arp = new ActiveRecordPlugin(...);
// 将默认事务隔离级别调整为“读已提交”
arp.setTransactionLevel(Connection.TRANSACTION_READ_COMMITTED);
me.add(arp);
}
明智地选择事务隔离级别,是在数据一致性与系统性能之间取得平衡的关键。
返回数据的事务入口
当事务需要返回业务数据时,可以使用 Db.txResult(callback) 或 Db.txResult(transactionLevel, callback);正常返回提交,异常回滚。null 和 false 都是有效的返回数据,与 Db.tx 的 boolean 提交标志不同。嵌套事务共享连接;内层失败应传播到外层,不要捕获后继续声称成功。
完整示例与默认/命名数据源的边界见 Db 与 PostgreSQL 业务实践。
批量写入的事务边界
Db.batch 的数组、Row/Model 列表、JSON 字段列表及 SQL 列表入口共用连接生命周期管理:
| 调用方式 | 提交与失败处理 |
|---|---|
| 在事务外调用 batch | 按 batchSize 分批提交;失败时回滚当前连接尚未提交的数据,已成功提交的前序批次保留 |
| 在 Db.tx / Db.txResult 内调用 batch | 使用外层连接,不单独提交、回滚或关闭连接,由外层事务决定整体结果 |
需要整批原子性时,将 batch 放入同一数据源的事务内,并让异常传播到最外层。不要捕获批量执行异常后返回成功。
import nexus.io.db.activerecord.Db;
public class ImportService {
public int[] saveAll(Object[][] rows) {
return Db.txResult(() -> Db.batch(
"insert into import_item(name, content) values (?, ?)", rows, 500));
}
}
批量执行失败时,先完成回滚,再恢复自动提交。回滚失败时跳过自动提交恢复,并尝试关闭连接。恢复或关闭产生的附加异常不会覆盖原来的执行异常。事务外的成功批次仍按原有分批提交方式工作。
JDBC 自动提交切换规则见 Connection.setAutoCommit。
联系揭示与配额扣减:同事务提交
Db.tx 和 Db.execute 可以组合使用:事务中的 JDBC 回调取得同一线程绑定连接,适合将授权检查、原操作查询、领域事实与额度明细一起提交。以下以 PostgreSQL 为例,演示“最后一个单位只能交付一次”的实现方式。
建议先准备这些约束:额度表有 id 主键以及 used_qty + held_qty <= limit_qty 检查;交付表对 (viewer_id, operation_key) 建立唯一约束。实际业务还应独立保存“访问主体、责任主体、联系类型、实际值摘要”的长期去重事实,以及可清理的展示记录。操作幂等解决请求重试,长期去重解决跨帖、跨端和 A→B→A 重看,两者各有用途。
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public final class ContactDeliveryService {
public long deliver(long viewerId, long bucketId, String operationKey,
Runnable checkCurrentAuthorization) {
long[] result = new long[1];
boolean committed = Db.tx(Connection.TRANSACTION_READ_COMMITTED, () -> {
Db.execute(connection -> {
// viewerId 必须由服务端从当前有效身份解析,不能直接信任请求参数。
try (PreparedStatement lock = connection.prepareStatement(
"select pg_advisory_xact_lock(?)")) {
lock.setLong(1, viewerId);
lock.execute();
}
// 每次重放都重新核验当前对象、联系授权、身份与限制。
// 对可并发撤销的授权行,在此事务中使用适当的行锁。
checkCurrentAuthorization.run();
try (PreparedStatement query = connection.prepareStatement(
"select id from contact_delivery where viewer_id=? and operation_key=?")) {
query.setLong(1, viewerId);
query.setString(2, operationKey);
try (ResultSet rows = query.executeQuery()) {
if (rows.next()) {
result[0] = rows.getLong(1);
return null;
}
}
}
// 生产实现先校验请求摘要与长期同值事实,再决定是否需要新扣量。
try (PreparedStatement update = connection.prepareStatement("""
update contact_quota set used_qty=used_qty+1
where id=? and viewer_id=? and used_qty+held_qty<limit_qty
""")) {
update.setLong(1, bucketId);
update.setLong(2, viewerId);
if (update.executeUpdate() != 1) {
throw new IllegalStateException("当前来源额度不足");
}
}
long id = SnowflakeIdUtils.id();
try (PreparedStatement insert = connection.prepareStatement("""
insert into contact_delivery(id,viewer_id,operation_key,bucket_id)
values(?,?,?,?)
""")) {
insert.setLong(1, id);
insert.setLong(2, viewerId);
insert.setString(3, operationKey);
insert.setLong(4, bucketId);
insert.executeUpdate();
}
result[0] = id;
return null;
});
return true;
});
if (!committed) {
throw new IllegalStateException("交付事务未提交");
}
return result[0];
}
}
上例突出事务边界,表名是教学示例。业务落地时,日/月桶同时核验并写明细,业务日期统一按指定时区计算;同一数据库事务提交交付事实与原操作结果,HTTP 返回中断后使用相同操作标识查原。联系密文解密失败应回滚;在响应中读取联系值时仍核验当前授权,日志、通知、普通结果表只存领域引用。
游客明确接续账号时,保留双方原始事实,并按原日/月中“责任主体、类型、实际值”的集合并集计算使用量。例如双方各2次且重叠1次,合并为3次;展示剩余额度可取非负值,但原始事实不能为了迎合余额而被截断。清理展示列表也不删除长期去重事实。已有交付的外部应用打开、复制或添加结果另行解释,不改变已正确完成的数据库交付。
活动领取:预算、来源与实际授予一起提交
java-db 的线程绑定连接让业务服务中的 Db.execute 参与外层 Db.tx。活动领取可以在一个事务里完成当前资格检查、同源去重、预算条件更新和卡券插入,HTTP 返回丢失后仍能通过原来源查到实际批次。数据库唯一约束同时保护跨进程重复请求。
下面是 PostgreSQL 的最小示例。调用方先完成账号鉴权、本人领取意图确认和规则版本核验;subjectId 必须来自服务端已核责任主体,不能直接使用客户端提交的账号编号。示例仅演示一次活动的原子授予,业务中的主体月限、卡有效期、权益明细、原操作摘要也应在同一事务中处理。
create table benefit_campaign (
id bigint primary key,
package_limit bigint not null,
used_packages bigint not null default 0,
budget_fen bigint not null,
used_face_fen bigint not null default 0
);
create table benefit_grant (
id bigint primary key,
campaign_id bigint not null references benefit_campaign(id),
subject_id bigint not null,
quantity bigint not null check (quantity > 0),
unique (campaign_id, subject_id)
);
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
public final class CampaignGrantService {
public long claim(long campaignId, long subjectId, long quantity,
long faceFen, Runnable checkCurrentEligibility) {
if (quantity <= 0 || faceFen < 0) {
throw new IllegalArgumentException("数量与管理面值不合法");
}
long[] batchId = new long[1];
boolean committed = Db.tx(Connection.TRANSACTION_READ_COMMITTED, () -> {
Db.execute(connection -> {
try (PreparedStatement lock = connection.prepareStatement(
"select id from benefit_campaign where id=? for update")) {
lock.setLong(1, campaignId);
try (ResultSet rows = lock.executeQuery()) {
if (!rows.next()) {
throw new IllegalStateException("活动不存在");
}
}
}
checkCurrentEligibility.run();
try (PreparedStatement query = connection.prepareStatement(
"select id from benefit_grant where campaign_id=? and subject_id=?")) {
query.setLong(1, campaignId);
query.setLong(2, subjectId);
try (ResultSet rows = query.executeQuery()) {
if (rows.next()) {
batchId[0] = rows.getLong(1);
return null;
}
}
}
try (PreparedStatement budget = connection.prepareStatement("""
update benefit_campaign
set used_packages=used_packages+1, used_face_fen=used_face_fen+?
where id=? and used_packages<package_limit
and used_face_fen+?<=budget_fen
""")) {
budget.setLong(1, faceFen);
budget.setLong(2, campaignId);
budget.setLong(3, faceFen);
if (budget.executeUpdate() != 1) {
throw new IllegalStateException("份额或预算已用完");
}
}
long id = SnowflakeIdUtils.id();
try (PreparedStatement grant = connection.prepareStatement("""
insert into benefit_grant(id,campaign_id,subject_id,quantity)
values(?,?,?,?)
""")) {
grant.setLong(1, id);
grant.setLong(2, campaignId);
grant.setLong(3, subjectId);
grant.setLong(4, quantity);
grant.executeUpdate();
}
batchId[0] = id;
return null;
});
return true;
});
if (!committed) {
throw new IllegalStateException("领取事务未提交,请查询原结果");
}
return batchId[0];
}
}
示例按活动行串行分配最后一个份额;同一责任主体的跨活动月限还需锁定主体月预算行,且所有入口遵守一致的锁顺序。外部支付、短信或库存供应商调用不放进持锁事务;外部结果未知时保留原请求及预占,查原得到终局事实后再推进。管理面值只用于非现金预算,不构成现金支付或销售佣金。
人工授予也可以复用这一结构:先保存不可变提案,独立自然人批准时预占预算,指定执行人在当前权限仍有效时执行。执行成功同时写来源义务、权益批次与明细;取消未执行提案释放预占,撤销已授未用量不抹掉原月赠送名额或已用记录。
