JDBC 本地事务与批处理:原子性如何落在同一条连接上
一个方法里连续执行两条 SQL,并不自动拥有原子性;一次 executeBatch 返回,也不代表每条语句都成功。事务和批处理的共同难点,是中间状态都保存在 Connection 与数据库事务里,应用必须读懂部分成功、回滚和提交结果未知。
原子性首先由同一条 Connection 保证
关闭 autoCommit 后,语句只进入当前连接的事务上下文,直到 commit 或 rollback。把两个 DAO 调用放在同一 Java 方法里并不自动形成事务:如果它们各自从池中取得不同连接,就可能分别提交。反过来,在 Spring 事务中手工打开新连接,也会绕过线程绑定的事务资源。检查事务问题时先确认实际 Connection 身份、autoCommit、隔离级别和提交者。
Savepoint 只在同一数据库事务内提供局部回退,不会让外部 HTTP、消息或文件写入一起回滚。释放 savepoint 也不是提交;真正 commit 前仍可能遇到约束、网络与数据库故障。commit 抛异常时不能一律判断“没有提交”,因为请求可能已到数据库而响应丢失。非幂等写入进入结果未知状态后,应查询业务唯一键或事务日志确认,而不是盲目重试。
批处理减少协议往返,但 JDBC updateCounts 允许混合具体行数、SUCCESS_NO_INFO(-2) 与 EXECUTE_FAILED(-3)。驱动可能在首个失败处停止,也可能继续其余语句;rewriteBatchedStatements 等驱动优化还可能改变错误粒度与生成键行为。应用必须按驱动和数据库做集成测试,并在任一不可接受结果时回滚整个业务子批次。
批次太小浪费往返,太大会延长事务、锁、undo/WAL、内存与失败重做窗口。工程上按行数和字节双重切分,每个子批次有幂等键和进度水位;只有业务允许部分完成时才逐批提交,并把完成状态持久化。若业务要求全有或全无,分批 executeBatch 仍应处于同一事务,但必须接受更长的锁持有与回滚成本。
隔离级别控制可见性,不自动消除业务竞争。先读余额再更新在 READ_COMMITTED 下仍会丢失更新;应使用条件更新、版本列、唯一约束或适当锁,把业务不变量交给数据库原子判断。超时与死锁异常通常要求整个事务回滚,捕获后继续复用当前事务可能只会得到更多错误。
批量写入:分批、短事务、可恢复
场景说明:批量导入最容易把数据库和连接池拖垮。常见错误是文件解析、校验、远程查询、逐条插入全放进一个事务,失败后既慢又难恢复。
直接做法,先在事务外完成解析和基础校验,再分批进入短事务。
public ImportResult importOrders(List<OrderImportCommand> commands) {
List<OrderImportCommand> validCommands = commands.stream()
.filter(OrderImportCommand::basicValid)
.toList();
int batchSize = 500;
for (int from = 0; from < validCommands.size(); from += batchSize) {
int to = Math.min(from + batchSize, validCommands.size());
orderImportTxService.importBatch(validCommands.subList(from, to));
}
return ImportResult.success(validCommands.size());
}@Service
public class OrderImportTxService {
private final OrderWriteMapper orderWriteMapper;
public OrderImportTxService(OrderWriteMapper orderWriteMapper) {
this.orderWriteMapper = orderWriteMapper;
}
@Transactional(rollbackFor = Exception.class)
public void importBatch(List<OrderImportCommand> batch) {
orderWriteMapper.batchInsert(batch);
}
}XML 用 foreach 生成多值插入。批次不要拍脑袋,先从 200、500、1000 做压测,看 SQL 长度、锁等待和复制延迟。
<insert id="batchInsert">
insert into biz_order
(order_no, tenant_id, customer_id, status, amount, created_at)
values
<foreach collection="list" item="item" separator=",">
(#{item.orderNo}, #{item.tenantId}, #{item.customerId},
#{item.status}, #{item.amount}, #{item.createdAt})
</foreach>
</insert>如果你使用 ExecutorType.BATCH,要把 flushStatements() 的失败语义写进代码评审。BatchExecutor 会把多次 update 收集成 JDBC batch,真正发送和拿到影响行数是在 flush 阶段;中间某一批失败时,MyBatis 会抛 BatchExecutorException,异常里能拿到已经成功的 BatchResult 和失败的批次,但这不等于业务已经安全提交。只要外层事务回滚,数据库最终仍应回滚已执行的批次。
Spring 项目里不要在业务代码中随手 sqlSessionFactory.openSession()。如果确实需要 MyBatis batch executor,优先单独配置一个 batch 用的 SqlSessionTemplate,让它仍然走 Spring 事务管理:
@Bean
public SqlSessionTemplate batchSqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH);
}@Service
public class OrderBatchImportService {
private final SqlSessionTemplate batchSqlSessionTemplate;
public OrderBatchImportService(@Qualifier("batchSqlSessionTemplate") SqlSessionTemplate batchSqlSessionTemplate) {
this.batchSqlSessionTemplate = batchSqlSessionTemplate;
}
@Transactional(rollbackFor = Exception.class)
public void importWithBatchExecutor(List<OrderImportCommand> commands) {
OrderWriteMapper mapper = batchSqlSessionTemplate.getMapper(OrderWriteMapper.class);
for (OrderImportCommand command : commands) {
mapper.insertOne(command);
}
List<BatchResult> results = batchSqlSessionTemplate.flushStatements();
recordBatchResult(results);
}
}生产里更推荐让批量导入通过明确的短事务服务分批执行,而不是在普通业务链路里临时切换 ExecutorType.BATCH。如果必须使用它,至少要记录批次号、批次范围、BatchResult、失败 SQL 所属 Mapper、业务幂等键和回滚验证 SQL。
验证结果:每批记录影响行数、失败批次、失败原因要能查到。导入后抽样核对数量和唯一键。
select count(*) from biz_order where import_batch_no = 'BATCH-DEMO-001';
select order_no, count(*)
from biz_order
where import_batch_no = 'BATCH-DEMO-001'
group by order_no
having count(*) > 1;常见坑:
批量 SQL 太长,触发数据库包大小限制或网络传输变慢。全量导入放一个事务,锁持有时间过长,线上接口跟着卡。失败后没有幂等键,只能人工清表重跑。
全局配置 executor-type=BATCH,影响普通接口的执行语义。flushStatements() 抛异常后只看 Java 异常,不核对数据库是否因事务回滚恢复到导入前状态。
生产建议:大批量任务要有导入批次号、幂等键、进度表、失败明细表和暂停开关。核心在线库不要在业务高峰跑大导入,必要时拆到任务库、临时表或消息分片。
事务边界:事务不是越大越安全
事务的目标是让必须一起成功或失败的数据库操作成为一个原子单元。它不会自动理解业务语义,也不会帮你处理外部系统、缓存、消息和文件。
先看一个订单支付状态流转:
START TRANSACTION;
SELECT id, status, total_amount
FROM user_order
WHERE order_no = 'ORD-DEMO-0001'
FOR UPDATE;
UPDATE user_order
SET status = 20,
pay_at = NOW(3),
version = version + 1
WHERE order_no = 'ORD-DEMO-0001'
AND status = 10;
COMMIT;这里 FOR UPDATE 是当前读,会对读取到的记录加锁。它适合冲突代价高、必须串行的场景。问题是,事务内不能做慢操作。不要在 START TRANSACTION 和 COMMIT 中间调用支付网关、发短信、写文件、跑复杂计算。
更常见的写法是先在事务外准备好数据,事务内只做短写入:
START TRANSACTION;
UPDATE user_order
SET status = 20,
pay_at = NOW(3),
version = version + 1
WHERE order_no = 'ORD-DEMO-0001'
AND status = 10;
INSERT INTO order_item (order_id, sku_id, quantity, sale_price)
VALUES (100001, 200001, 1, 99.00)
ON DUPLICATE KEY UPDATE
quantity = VALUES(quantity),
sale_price = VALUES(sale_price);
COMMIT;验证方式:
SELECT ROW_COUNT();如果状态更新影响行数为 0,要进入幂等分支,查询当前订单状态,而不是把整个事务重试一遍。
undo、redo、binlog 分别管什么
事务提交时,开发最容易混淆三类日志:
| 日志 | 主要作用 | 开发侧要关心什么 |
|---|---|---|
| undo log | 回滚和 MVCC 历史版本 | 长事务会拖住历史版本清理,导致 undo 压力上升 |
| redo log | InnoDB 崩溃恢复 | 大事务会制造集中刷盘压力,提交延迟可能抖动 |
| binlog | 复制和基于日志的恢复 | 影响主从复制、CDC、审计和部分回放场景 |
你不需要在业务代码里操作这些日志,但要理解它们对写入路径的影响。一个大事务不是“只占一个连接”,它还会持有锁、保留 undo 版本、增加 redo 压力,并让 binlog 提交变成一个更大的原子单元。
最小观察入口:
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW VARIABLES LIKE 'sync_binlog';
SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';
SHOW GLOBAL STATUS LIKE 'Binlog_cache_disk_use';这些参数属于数据库配置边界,不在这里扩成 DBA 调参手册。开发侧要拿它们判断风险:如果批量任务让 Innodb_log_waits 上升,或者 Binlog_cache_disk_use 明显增加,说明事务大小、写入频率和提交节奏已经影响到底层日志链路。
提交链路可以按这几个阶段理解:先修改 Buffer Pool 中的数据页并写 undo,提交时 redo 进入 prepare 状态;随后 server 层写 binlog;最后 InnoDB redo commit,事务对外完成。这个链路保证崩溃恢复和复制口径能对齐,但也意味着一次“大事务提交”会同时压住 redo、binlog、锁持有时间和复制延迟。
排查提交抖动时,可以把这些证据放在一起看:
SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';
SHOW GLOBAL STATUS LIKE 'Binlog_cache_disk_use';
SHOW GLOBAL STATUS LIKE 'Binlog_stmt_cache_disk_use';
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW VARIABLES LIKE 'sync_binlog';生产建议:开发不要为了“看起来原子”把十万行修复放进一个事务。对在线库来说,更稳的方案是分批提交、每批有幂等标记、失败可重跑,并把每批影响行数和耗时落日志。
生产建议:核心在线事务尽量短,小批量提交;大导入、大修复、大归档走任务化治理,有批次、有暂停、有回滚点。不要把“一个事务包住全部”当成安全感。
乐观锁适合冲突低的更新
配置编辑、用户资料、低冲突状态更新,可以使用版本号:
UPDATE user_account
SET nickname = 'new-name',
version = version + 1
WHERE user_no = 'U10001'
AND version = 7;验证:
SELECT ROW_COUNT() AS affected_rows;0 表示版本已经变化。接口应该提示用户刷新或合并,而不是静默覆盖。
MVCC:快照读和当前读不是一回事
InnoDB 的普通一致性查询通常走快照读,UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... FOR SHARE 这类操作是当前读。一个事务里混用两者时,可能看到不同时间点的数据。
你可以用两个会话观察:
会话 A:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT status
FROM user_order
WHERE order_no = 'ORD-DEMO-0001';会话 B:
UPDATE user_order
SET status = 30
WHERE order_no = 'ORD-DEMO-0001';
COMMIT;会话 A 再查普通查询,可能仍然看到事务开始后的快照;但如果会话 A 执行当前读:
SELECT status
FROM user_order
WHERE order_no = 'ORD-DEMO-0001'
FOR UPDATE;它读取的是当前版本并加锁。这个差异会影响业务判断,所以不要把“事务内查过一次”当成所有后续操作的绝对事实。
Read View 可以理解成一致性读的可见性快照。它会结合事务 ID 和 undo 版本链判断某行的哪个版本对当前查询可见。REPEATABLE READ 下,同一事务内普通一致性读通常复用事务级视图;READ COMMITTED 下,每条一致性读语句会看到更新的已提交版本。这个差异会影响“我刚才查过了,为什么后面又不一样”这类问题。
把 Read View 和 undo 版本链放到一起看,会更容易区分快照读和当前读:
这张图不能替代命令排查,因为 Read View 本身不是一个让开发直接查询的业务对象。它的用法是辅助复现实验和设计评审:遇到“事务里两次查询结果不一致”时,先确认隔离级别和语句类型;评审“先查后改”逻辑时,要求最终 UPDATE 带业务状态、版本号或唯一约束,不能只依赖前面那次快照读的判断。
隔离级别不要靠默认值猜,先查:
SELECT @@transaction_isolation;需要临时验证时,可以只改当前会话:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 执行你的验证 SQL
COMMIT;常见坑是把隔离级别当成应用配置随手改。比如从 REPEATABLE READ 切到 READ COMMITTED 后,间隙锁行为、binlog 行为、业务重复读预期都会跟着变化。生产变更必须和业务一致性、复制格式、测试用例一起评审。
生产建议:事务里判断状态再更新时,最终的 UPDATE 仍然要带上状态条件或版本条件。这样即使前面的读取和并发窗口有变化,最后写入也能兜住。
锁等待和死锁:先看索引,再看资源顺序
锁问题经常表现成接口慢、偶发失败、线程堆积,日志里可能是:
Lock wait timeout exceeded; try restarting transaction
Deadlock found when trying to get lock; try restarting transaction先看是否有事务长时间不提交:
SELECT *
FROM information_schema.INNODB_TRX\G再看锁等待关系:
SELECT *
FROM performance_schema.data_locks\G
SELECT *
FROM performance_schema.data_lock_waits\G必要时看 InnoDB 最近一次死锁信息:
SHOW ENGINE INNODB STATUS\G常见原因:
更新条件没命中索引,锁范围扩大。事务里做远程调用或大循环,锁持有时间太长。多条业务路径更新相同资源,但访问顺序不一致。
大批量更新一次锁太多行,和在线请求互相阻塞。
定位时不要只看“有锁等待”。要把等待关系拆成三步:
data_lock_waits 看哪个事务在等、被哪个事务挡住。data_locks 看锁对象、锁模式、索引名和记录范围。INNODB_TRX 看事务开始时间、状态和正在执行的语句。
可以把三张信息表联起来做一次粗排:
SELECT
r.trx_id AS waiting_trx_id,
r.trx_started AS waiting_started,
b.trx_id AS blocking_trx_id,
b.trx_started AS blocking_started,
dl.OBJECT_SCHEMA,
dl.OBJECT_NAME,
dl.INDEX_NAME,
dl.LOCK_TYPE,
dl.LOCK_MODE
FROM performance_schema.data_lock_waits w
JOIN information_schema.INNODB_TRX r
ON w.REQUESTING_ENGINE_TRANSACTION_ID = r.trx_id
JOIN information_schema.INNODB_TRX b
ON w.BLOCKING_ENGINE_TRANSACTION_ID = b.trx_id
JOIN performance_schema.data_locks dl
ON w.REQUESTING_ENGINE_LOCK_ID = dl.ENGINE_LOCK_ID\G字段名在不同版本上要以当前实例为准;如果联表不成功,就分开查,不要为了一个排查 SQL 卡住。关键是拿到“等待事务、阻塞事务、表、索引、锁模式、事务持续时间”这几个证据。
直接做法:
EXPLAIN
UPDATE user_order
SET status = 30
WHERE user_id = 10001
AND status = 10
AND created_at < '<EVENT_TIME>';如果更新条件没有合适索引,先不要上线。更新也需要执行计划,尤其是批量更新。生产里建议把大更新拆成小批次:
UPDATE user_order
SET status = 30
WHERE status = 10
AND created_at < '<EVENT_TIME>'
ORDER BY id
LIMIT 500;执行后检查:
SELECT ROW_COUNT();循环执行时要有间隔、最大次数和回滚策略。不要写一个无限循环脚本直接打生产库。
锁类型不要只背名字,要能从 SQL 形态判断风险:
| SQL 形态 | 常见锁范围 | 典型风险 | 验证方式 |
|---|---|---|---|
| 唯一索引等值命中一行 | 主要是记录锁 | 并发更新同一行等待 | data_locks 看唯一索引记录 |
| 普通索引范围查询并当前读 | next-key lock,记录锁加间隙锁 | 阻止范围内插入,接口看起来像“插入也被查询挡住” | 两会话做 FOR UPDATE 和 INSERT |
| 条件没索引或索引选择差 | 扫描范围扩大,锁跟着扩大 | 小业务更新变成大范围阻塞 | 先 EXPLAIN,再看 LOCK_MODE 和 INDEX_NAME |
| 向被 gap lock 覆盖的范围插入 | insert intention lock 等待 | 新增请求卡住但阻塞者是范围查询或更新 | SHOW ENGINE INNODB STATUS\G 看 insert intention waiting |
READ COMMITTED 下范围更新 | 间隙锁通常减少,但并非所有场景都消失 | 幻读预期、复制口径和业务一致性变化 | 同一脚本分别在 RC/RR 跑 |
最小复现实验如下,先建索引:
ALTER TABLE user_order ADD INDEX idx_user_status_created (user_id, status, created_at);会话 A:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT id
FROM user_order
WHERE user_id = 10001
AND status = 10
AND created_at BETWEEN '<EVENT_TIME>' AND '<EVENT_TIME>'
FOR UPDATE;会话 B 尝试插入同一索引范围:
INSERT INTO user_order (
order_no, user_id, status, total_amount, created_at, updated_at
) VALUES (
'NO-LOCK-TEST-001', 10001, 10, 10.00,
'<EVENT_TIME>', NOW()
);如果 B 被阻塞,不要只看应用超时,马上在第三个会话查:
SELECT ENGINE_TRANSACTION_ID, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'user_order'\G
SELECT *
FROM performance_schema.data_lock_waits\G用两个状态模型拆掉框架错觉
下面两个 Java 17 程序只保留本篇最关键的状态与分支。它们不连接真实数据库,因此不能证明驱动或数据库的厂商行为;它们用来证明调用方必须维持的不变量,真实集成测试再负责验证 SQL、锁和网络。
javac --release 17 -Xlint:all -Werror examples/backend-development/data-access/jdbc-transaction-batch/TransactionStateDemo.java examples/backend-development/data-access/jdbc-transaction-batch/BatchFailureDemo.java
java -cp examples/backend-development/data-access/jdbc-transaction-batch TransactionStateDemo
java -cp examples/backend-development/data-access/jdbc-transaction-batch BatchFailureDemoautoCommit=false durable=[] rolledBack=true connectionReset=true
updateCounts=[1, -2, -3] rollbackRequired=true输出的价值在于固定中间状态,而不是展示 API 能运行。修改实现后,如果资源没有复位、冲突被误报为成功、缓存跨越了更新边界或调用身份发生变化,模型应先失败,随后真实数据库测试再给出厂商级证据。
