JDBC 本地事务与批处理:提交、保存点和批次失败恢复
账户扣除 125 分,同时写入一条扣款流水,这两步需要一起成功。SQL 批处理则解决另一个问题:把多组参数集中交给驱动执行,减少逐条往返的成本。一个事务可以包含多个批次;一个批次何时提交,由连接的事务设置决定。
addBatch() 把本组参数加入待执行批次
executeBatch() 执行已积累的批次,返回更新计数
commit() 提交当前连接上的事务
显式事务:批次 A 执行 → 批次 B 执行 → commit
└─ 失败 → rollback,撤销整个事务的数据库修改两条 SQL 怎样成为一次原子操作
自动提交改变了成功的单位
JDBC 新连接默认开启自动提交。语句完成时,相关事务随之提交;查询语句的“完成”还涉及结果集的读取与关闭。关闭自动提交后,应用通过 commit() 或 rollback() 结束事务。Connection 事务契约
扣款业务包含:
UPDATE account
SET balance_cents = balance_cents - ?
WHERE id = 1 AND balance_cents >= ?;
INSERT INTO ledger(entry_key, account_id, delta_cents)
VALUES (?, 1, ?);在自动提交模式中,第一条更新成功后余额已经变更;第二条流水插入失败,无法自动撤销第一条已提交的更新。显式事务把两个数据库修改归入同一个提交单元,失败时可以一起回滚。PostgreSQL 事务
这要求两条语句使用同一条连接。两个辅助方法各自调用 dataSource.getConnection(),得到的可能是不同物理会话。即使它们属于同一个线程、位于同一个 Java 方法中,原生 JDBC 也不会自动把两条会话合成一个本地事务。
事务控制应由哪一层承担
事务入口负责取得连接、选择事务设置、提交或回滚;内部数据访问方法接收该连接并执行语句。这样可以组合多步业务,也能明确哪个方法有权结束事务。
public static void debit(int amountCents, String entryKey) throws SQLException {
try (Connection connection = Database.open()) {
connection.setAutoCommit(false);
try {
debitOn(connection, amountCents, entryKey);
connection.commit();
} catch (SQLException | RuntimeException | Error failure) {
Database.rollback(connection, failure);
throw failure;
}
}
}debitOn 使用传入的同一连接。更新语句的受影响行数必须为 1;为 0 可能表示账户不存在或余额不足,不能继续生成成功流水。金额使用整数分,避免把十进制金额交给浮点近似计算。
try (PreparedStatement update = connection.prepareStatement(
"update account set balance_cents = balance_cents - ? " +
"where id = 1 and balance_cents >= ?")) {
update.setInt(1, amountCents);
update.setInt(2, amountCents);
if (update.executeUpdate() != 1) {
throw new IllegalStateException("Account missing or insufficient balance");
}
}
try (PreparedStatement insert = connection.prepareStatement(
"insert into ledger(entry_key, account_id, delta_cents) values (?, 1, ?)")) {
insert.setString(1, entryKey);
insert.setInt(2, -amountCents);
insert.executeUpdate();
}异常处理中,回滚本身也可能失败。让回滚异常覆盖原始错误,会丢失最先失败的 SQL 或业务判断,因此辅助方法把它附加到原异常:
public static void rollback(Connection connection, Throwable original) {
try {
connection.rollback();
} catch (SQLException cleanup) {
original.addSuppressed(cleanup);
}
}如果应用使用连接池,还需要在提交或回滚之后归还连接;原生连接则要关闭物理会话。JDBC、DataSource 与连接池解释了两类关闭行为。不要用 setAutoCommit(true) 代替回滚:从关闭自动提交切换到开启,可能提交当前事务。
在独立数据库中完成扣款实验
工程、表结构和身份
下载完整工程 ZIP。核心源文件是 Database.java、Transfer.java 和 TransactionLab.java;pom.xml固定 pgJDBC 42.7.13 和编译插件。
使用 Linux Bash、Docker Engine、Compose 插件、unzip 和 openssl。Maven 镜像为 maven:3.9.12-eclipse-temurin-25,源码目标 Java 17,也可用对应的 eclipse-temurin-17 Maven 镜像运行。数据库为 PostgreSQL 18.6;Java 容器使用宿主普通用户 UID/GID,数据库进程使用 999:999。
test "$(id -u)" -ne 0 || exit 1
docker version
docker compose version
unzip jdbc-transaction-batch-lab.zip
cd jdbc-transaction-batch
export LAB_DIR="$PWD"
export LAB_CACHE="$LAB_DIR/.m2-cache"
mkdir -p "$LAB_CACHE"
export LAB_DB_PASSWORD="$(openssl rand -hex 24)"
export LAB_APP_PASSWORD="$(openssl rand -hex 24)"
export LAB_DB_URL='jdbc:postgresql://db:5432/transaction_lab'
docker compose -p da10-transaction up -d --wait所有命令在同一个 Bash 会话中执行。compose.yaml 不向宿主发布数据库端口,数据库目录使用 tmpfs;仅适合可丢弃的实验数据。初始化使用管理角色 lab_owner,Java 使用普通角色 lab_app,授予表的增删改查与生成序列的使用权限。初始化目录和 PostgreSQL 18 数据目录布局见官方镜像说明。
init.sh 建立三张表:
CREATE TABLE account (
id integer PRIMARY KEY,
balance_cents bigint NOT NULL CHECK (balance_cents >= 0)
);
CREATE TABLE ledger (
entry_key text PRIMARY KEY,
account_id integer NOT NULL REFERENCES account,
delta_cents bigint NOT NULL
);
CREATE TABLE import_item (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
external_key text NOT NULL UNIQUE,
amount_cents integer NOT NULL
);
INSERT INTO account VALUES (1, 1000);ledger.entry_key 用于制造可预测的唯一约束冲突;import_item 用于批量与生成键实验。每个实验只重置这套独立数据库的实验行,不访问其他库。不要把实验 URL 改成真实业务库。
确认初始化成功:
docker compose -p da10-transaction exec -T db \
psql -X -U lab_owner -d transaction_lab -v ON_ERROR_STOP=1 \
-c "SELECT id, balance_cents FROM account"预期 1 | 1000。未进入 healthy 或找不到表时,先执行 docker compose -p da10-transaction logs --tail=80 db,检查初始化脚本及变量;不要先运行扣款。
驱动连接与执行入口
Database.open() 使用 PGSimpleDataSource,每次新建独立连接,便于用第二会话检查提交可见性:
PGSimpleDataSource dataSource = new PGSimpleDataSource();
dataSource.setURL(required("LAB_DB_URL"));
dataSource.setUser("lab_app");
dataSource.setPassword(required("LAB_APP_PASSWORD"));
dataSource.setConnectTimeout(3);
dataSource.setSocketTimeout(5);
dataSource.setApplicationName("transaction-lab");
dataSource.setReWriteBatchedInserts(rewriteBatch);
return dataSource.getConnection();pgJDBC 的驱动加载、连接参数及 reWriteBatchedInserts 属性见驱动连接说明。
定义执行函数:
lab_java() {
docker run --rm \
--user "$(id -u):$(id -g)" --read-only \
--network da10-transaction_default \
--tmpfs /tmp:rw,exec,mode=1777 \
--mount "type=bind,src=$LAB_DIR,dst=/src,readonly" \
--mount "type=bind,src=$LAB_CACHE,dst=/cache" \
-e LAB_DB_URL -e LAB_APP_PASSWORD -e MAVEN_CONFIG=/tmp/maven \
maven:3.9.12-eclipse-temurin-25 bash /src/run.sh "$@"
}
lab_java firstrun.sh 严格编译三份源码,再运行指定模式;编译产物放在容器临时目录,退出时清理。源码只读挂载,依赖缓存由当前用户写入。
成功输出:
balanceCents=875 ledgerRows=1 independentRead=true程序提交以后,另开连接查询账户与流水。这个独立查询排除了“只在原事务内看到了自己的未提交修改”。current_database 和连接账号的配置也应保持为上面的实验库与普通角色。
比较回滚和自动提交
先执行带显式事务的失败路径:
lab_java rollback程序先插入一条键为 duplicate 的既有流水,然后执行扣款。余额更新成功,第二条 SQL 命中唯一约束;回滚后独立连接看到余额仍为 1000:
duplicateRejected=true sqlState=23505 debitRolledBack=true再执行自动提交对照:
lab_java autocommit同样的第二条 SQL 失败,但第一条更新已经提交:
autoCommitPartialDebit=true balanceCents=875这个负例结束后会重置实验数据。观察重点是两种模式的余额差异,而非异常文本:唯一约束使用 SQLSTATE 23505 判断,不依赖服务端语言设置。PostgreSQL 错误码
事务失败以后,保存点能恢复到哪里
PostgreSQL 的失败事务状态
显式事务中,一条 SQL 因约束、语法或取消而失败后,PostgreSQL 通常将该事务置为失败状态。随后继续执行普通 SQL,会返回 25P02,直到完整回滚,或回滚到错误之前的保存点。
Java 的 catch 只改变程序控制流。它没有向数据库发送任何恢复事务的命令,因此“捕获异常再继续”并不能让数据库忘记错误。
保存点为同一事务提供局部撤销位置:保留保存点之前的修改,丢弃它之后的修改。所有保留下来的修改仍要等待最外层事务提交。ROLLBACK TO SAVEPOINT
用两个错误码观察恢复过程
执行:
lab_java savepoint实验顺序为:
实际输出:
failureState=23505 abortedState=25P02 savedRows=2 savepointRecovered=true源码中的关键几步:
execute(connection, "insert into ledger values ('A', 1, 0)");
Savepoint point = connection.setSavepoint("before_optional");
// 重复 A 失败后,先核对错误,再恢复数据库事务。
expect("23505", () -> execute(connection, "insert into ledger values ('A', 1, 0)"));
expect("25P02", () -> scalar(connection, "select 1"));
connection.rollback(point);
execute(connection, "insert into ledger values ('B', 1, 0)");
connection.releaseSavepoint(point);
connection.commit();expect 是实验中的错误码断言方法;实际业务应识别允许局部失败的具体操作和错误种类。网络断连、事务整体超时或连接失效时,不能照搬这个分支继续使用连接。
保存点不是子事务的独立提交
releaseSavepoint 释放一个恢复位置,不会把该段修改单独提交。外层最终回滚,A 与 B 仍会一起撤销。保存点也不会撤回已经发出的 HTTP 请求、邮件或消息;这些外部副作用需要事务后执行或可靠的事件投递方案。
在批量导入中,保存点可以保护“允许单行失败”的业务,但每行都建立保存点会增加往返和资源消耗。先确定接受全部失败、分块失败还是逐行跳过,再选择保存点粒度。跳过失败行时,应保存稳定业务键和失败原因,避免只留下“成功 99 条”的总数而无法追查缺失项。
JDBC 的隔离级别决定并发事务之间允许观察到什么。PostgreSQL 默认 Read Committed,以语句为单位取得快照;它的 Repeatable Read 与 Serializable 各有额外保证和失败条件。不要把 MySQL 的间隙锁、快照创建规则或 DDL 隐式提交行为套到所有 JDBC 数据库上。PostgreSQL 事务隔离
批量执行怎样交付计数与生成键
每组参数何时发送
PreparedStatement.addBatch() 把当前参数组加入批次;executeBatch() 或 executeLargeBatch() 执行批次并返回对应计数。一次方法调用背后可以有多条协议消息,也可能被驱动改写成多值 INSERT。PreparedStatement API
try (PreparedStatement insert = connection.prepareStatement(
"insert into import_item(external_key, amount_cents) values (?, ?)")) {
for (int i = 1; i <= 3; i++) {
insert.setString(1, "batch-" + i);
insert.setInt(2, i * 100);
insert.addBatch();
}
long[] counts = insert.executeLargeBatch();
// 检查 counts 和业务结果后,再由事务入口决定 commit。
}执行批次以后,本事务可以查询到新行,其他事务在提交之前仍看不到这些未提交修改。实验分别在当前连接和独立连接读取行数,不把驱动返回值直接当成最终入库结果。
lab_java batch这次运行关闭和开启批量改写各一次:
rewrite=false counts=[1, 1, 1] invisibleBeforeCommit=true committedRows=3
rewrite=true counts=[-2, -2, 1] invisibleBeforeCommit=true committedRows=3-2 对应 Statement.SUCCESS_NO_INFO:驱动知道该项成功,但不提供精确受影响行数。这里开启改写后,驱动合并了部分 INSERT;返回值的形态发生变化,提交后的业务行数仍是 3。升级驱动、调整批量大小或 SQL 形式后,数组可能不同,程序应接受符合 JDBC 契约的计数,而非硬编码这个例子的数组。
成功计数、失败计数和事务结果
| 返回项 | 含义 | 可据此执行的动作 |
|---|---|---|
| 大于等于 0 | 已知受影响行数 | 结合业务期望检查更新数量 |
SUCCESS_NO_INFO = -2 | 执行成功,精确行数未知 | 必要时按业务键查询结果 |
EXECUTE_FAILED = -3 | 该命令执行失败 | 从异常及事务状态决定回滚和恢复 |
发生错误时,驱动可能停止处理后续命令,也可能继续并返回各项结果。JDBC 允许两种策略;异常中的计数长度和内容因此不能脱离驱动行为解释。大计数场景使用 executeLargeBatch() 与 getLargeUpdateCounts(),避免把 long 计数挤进 int。BatchUpdateException API
运行包含重复业务键的批次:
lab_java batch-failure固定版本下的实际输出:
batchFailed=true sqlState=23505 counts=[-3, -3, -3] rowsAfterRollback=0驱动返回三个失败标记,程序显式回滚后,再用独立连接确认表中没有本批行。不能从 -3 推断服务器“从来没有执行过这一项”,也不能将异常前看起来成功的条目认作已经持久提交。批次失败与事务回滚处于不同层次。
分批发送和分批提交
再执行:
lab_java chunks实验在同一显式事务里调用两次 executeBatch()。每次执行后,当前连接能读到累计结果,独立连接仍为 0;最终回滚,两个批次都被撤销:
twoExecuteBatchCalls=true noEarlyCommit=true rollbackRemovedBoth=true分批发送可以限制客户端积累的参数量,但如果一直不提交,服务端的事务、锁和版本保留仍会延续。每 500 行发送一次、每 10,000 行提交一次,是两个不同的设计决定;数字要根据单行大小、锁竞争、日志量和失败重放成本测量。
逐块提交会改变业务原子性:后续块失败时,前面已提交的块保留。此时任务需要记录已提交进度,采用稳定业务键去重,并在恢复时从确认位置继续。只有允许部分完成的业务才能选择这种方案。
生成键在提交之前就可能返回
运行:
lab_java keys程序插入一行后从 getGeneratedKeys() 取得 identity ID,再用独立连接检查尚不可见,随后回滚:
generatedKeyReturned=true invisibleBeforeCommit=true rolledBackRowAbsent=truePostgreSQL identity 通常由序列生成。序列取值不会随着事务回滚而补回,编号有间断是正常现象;不要将生成 ID 的连续性用作业务行数或提交成功的判断。PostgreSQL 序列函数
请求特定返回列时,代码使用 prepareStatement(sql, new String[]{"id"})。批量改写与生成键、RETURNING 等形式的组合要按具体驱动验证;对外部业务的逐项映射,优先保留稳定的 external_key,不要只靠本地位置猜测服务器结果。
大结果读取与失败后的下一步
fetchSize 控制取数,不控制提交
pgJDBC 默认把查询结果一次收集到客户端。对大结果设置正数 fetch size,可以让驱动按游标分块取数,但有前提:关闭自动提交、使用 forward-only 结果集、查询为单条语句。条件不满足时,驱动可能退回一次读取全部结果。pgJDBC 查询与游标
实验使用 13 行结果和 fetch size 5:
connection.setAutoCommit(false);
try (PreparedStatement statement = connection.prepareStatement(
"select i from generate_series(1, 13) as s(i)",
ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
statement.setFetchSize(5);
try (ResultSet rows = statement.executeQuery()) {
while (rows.next()) {
int value = rows.getInt(1);
// 处理当前行,不把全部结果重新积累到一个大集合。
}
}
connection.rollback();
}lab_java cursor真实输出:
namedPortalObserved=true fetchSize=5 readRows=13
autoCommitCursorFallback=true readRows=13第一组在结果集仍打开时,通过同一会话的 pg_cursors 找到驱动创建的命名 portal;第二组保持自动提交,设置相同 fetch size,却没有留下该命名游标。检查语句只查与实验 SQL 匹配的非空名称,不把系统内部游标也计入。PostgreSQL pg_cursors
13 行只是让行为容易复现,不能据此得出大数据量内存上限。读取端若又把每行加入 List,客户端仍会随总结果增长;每行还涉及对象、字符串及二进制数据大小。游标存续期间连接和事务也被持续占用,长时间导出需要关注数据库清理、连接池和消费端速度。
根据失败阶段决定恢复单位
| 失败阶段 | 已知事实 | 下一步 |
|---|---|---|
| 还未取得连接 | 本次业务 SQL 尚未通过该连接发送 | 在总预算内判断是否重试连接获取 |
| SQL 返回约束错误 | 已知某条语句失败;显式事务可能进入 failed 状态 | 回滚事务或符合设计的保存点 |
| 批量抛异常 | 驱动提供部分或全部更新计数 | 先确定事务结果,再制定重放粒度 |
commit() 正常返回 | 数据库已确认提交 | 返回业务成功,保留业务键 |
| 发送提交后连接断开 | 客户端缺少最终确认 | 用新连接按业务键核实;保持“结果待确认” |
| 回滚本身失败 | 原会话无法可靠清理 | 保留两个异常、关闭或淘汰连接,核查业务结果 |
一个本地数据库事务不能把数据库以外的副作用一起回滚。需要提交后可靠发布事件时,可在同一事务内写业务表与待投递事件,再由独立投递器处理;状态一致性的整体关系见从写入到一致状态。
事务遭遇死锁或序列化失败时,通常需要重试整个事务,而不是只重试最后一条 SQL。重试要有上限、总时间预算和业务幂等条件;先排查一致的加锁顺序、过长事务及无谓锁竞争。PostgreSQL 的 40001、40P01 与 23505 处理意图不同,应保留原始 SQLSTATE。
entry_key 的唯一约束在实验中负责阻止重复流水,但它还不是完整的请求幂等协议:同一个键若被错误地配给不同金额,还需要比较原请求含义及已完成结果。不要把唯一冲突一律翻译成“前一次已经成功”。
超时和取消之后仍要收尾
Statement.setQueryTimeout、驱动 socket 超时和数据库 statement_timeout 分别发生在不同层。查询被取消后,显式事务仍可能需要回滚;客户端超时也可能没有收到服务器的最终写入结果。服务端还可以用 lock_timeout 限制锁等待,用 idle_in_transaction_session_timeout 终止事务内长期空闲会话。PostgreSQL 连接与超时参数
诊断先找第一条错误和业务键,再沿调用栈确认连接、事务与批次的所有者。若日志只有“批处理失败”,应补上 SQL 模板标识、SQLSTATE、批次业务键范围以及最终提交或回滚结果;不要输出密码和整批敏感参数。
结束实验:
docker compose -p da10-transaction ps
docker compose -p da10-transaction down
unset LAB_DB_PASSWORD LAB_APP_PASSWORD LAB_DB_URL
unset -f lab_java只删除本实验容器和网络,tmpfs 中的数据不可恢复;源码和依赖缓存保留。后续进入 MyBatis Mapper 主链,这些事务与资源规则仍然由真实 JDBC 连接承载。
权威资料与规范地址
JDBC 契约按 Java SE 25 查阅,PostgreSQL 使用 18 系列。批处理计数、游标行为及 SQLSTATE 的例子来自 pgJDBC 42.7.13 与 PostgreSQL 18.6 的组合。
