☰
HoRain云--JDBC 事务与批量写入:先保证一致性,再谈吞吐
2026/10/17 16:04:56 网站建设 项目流程

JDBC 事务与批量写入:先保证一致性,再谈吞吐

适用版本:Java 21;数据库驱动通过 JDBC 接口接入。SQL 的冲突语法和批处理细节因数据库与驱动而异。

订单导入是典型案例:一条订单需要写入主表和若干明细,可能还要登记一条待发送事件。写入一半失败时,不应留下只有主表没有明细的订单。把每条 SQL 都包上 try-catch 并不能解决一致性问题;需要明确事务边界,知道数据库什么时候提交,以及异常发生后谁负责回滚。

一、先认识自动提交

JDBC Connection 默认处于自动提交模式。每条独立执行的 SQL 完成后通常会提交。因此,顺序执行 insert order、insert items,并不天然构成一个原子操作。要把它们纳入同一事务,先关闭自动提交:

booleanoldAutoCommit=connection.getAutoCommit();try{connection.setAutoCommit(false);insertOrder(connection);insertItems(connection);connection.commit();}catch(SQLExceptionfailure){try{connection.rollback();}catch(SQLExceptionrollbackFailure){failure.addSuppressed(rollbackFailure);}throwfailure;}finally{connection.setAutoCommit(oldAutoCommit);}

这段结构仅用于解释顺序,不是可以直接复制到任何连接池的最终模板:setAutoCommit恢复本身可能失败,并可能覆盖原异常;如果失败发生在 Java 参数校验阶段,也要决定是否回滚。连接交还连接池前必须恢复状态,不能让下一个请求继承上一个请求的事务设置。实际项目优先使用经过验证的事务管理器;手写 JDBC 时要清楚记录原始异常与清理异常。

事务也不是“关闭自动提交以后随便做什么”。远程 HTTP 调用、生成大文件、等待用户确认都不应长时间占住数据库连接和锁。把外部调用放在事务外,使用业务状态、幂等键或 outbox 处理跨系统流程。数据库事务只能覆盖同一数据库连接上的可参与操作,不会自动回滚已发出的邮件和 HTTP 请求。

二、一个最小的批量插入流程

假定有order_item(order_id, sku, quantity)表,下面的方法接收外部管理的连接,在一个事务内写入一批明细。示例刻意不依赖具体数据库驱动:

importjava.sql.Connection;importjava.sql.PreparedStatement;importjava.sql.SQLException;importjava.util.List;recordItem(Stringsku,intquantity){}staticvoidinsertItems(Connectionconnection,longorderId,List<Item>items)throwsSQLException{Stringsql="INSERT INTO order_item(order_id, sku, quantity) VALUES (?, ?, ?)";try(PreparedStatementstatement=connection.prepareStatement(sql)){for(Itemitem:items){if(item==null||item.sku()==null||item.sku().isBlank()||item.quantity()<=0){thrownewIllegalArgumentException("invalid item");}statement.setLong(1,orderId);statement.setString(2,item.sku());statement.setInt(3,item.quantity());statement.addBatch();}statement.executeBatch();}}

占位符绑定解决值参数的转义与注入问题,也让 SQL 结构稳定;表名、列名不能直接用问号占位,动态标识符必须由可信白名单选择。addBatch把多组参数加入批次,executeBatch请求驱动执行。它可能减少往返,但不能保证所有驱动都以相同方式发送 SQL,也不是自动事务。批量执行之前仍需有明确的连接、提交和回滚策略。

对于几十万行,不要一次把所有参数留在客户端。分批积累,例如每 500 条调用一次 executeBatch 并 clearBatch;批次大小应基于驱动、包大小、数据库负载和压测决定。分批执行与分批提交不是同一回事:如果业务要求整个文件原子成功,可以执行多个批次但只在最后提交;如果允许按批次成功,需要记录每批结果和断点,并向用户明确部分成功语义。

三、异常后的状态并不总是“全没写”

executeBatch失败时可能抛出BatchUpdateException,其中更新计数可帮助诊断哪些命令已执行。驱动可能在出错时停止,也可能继续尝试后续命令;某些计数还会标示“成功但没有精确行数”。不要把数组下标直接当成数据库最终状态:当前事务若回滚,已经执行的语句仍会被撤销;是否发生触发器、外部副作用或不可事务化操作,则取决于数据库设计。

如果批次失败就要求整单失败,最简单可靠的策略是立即回滚整个事务,不根据部分计数做“补插”。若需求允许尽量导入有效行,就必须定义独立行或独立小批的事务边界、错误报告格式和唯一键约束。两种产品语义不能靠 catch 里的几个条件随意切换。

SQL 异常至少保留 SQLState、厂商错误码、业务请求标识与批次编号;日志中的参数要脱敏。不要记录完整身份证号、令牌或大段原始数据。错误码分类也应按所用数据库驱动文档处理,不能以一条通用字符串判断所有死锁、唯一键冲突与连接故障。

四、事务隔离与锁范围

事务能保证一组写入一起提交或回滚,却不意味着并发读写都天然符合业务期待。比如两个请求同时读取“剩余库存为 1”,各自判断足够后写入;如果只依赖应用层先读后写,可能超卖。可考虑单条带条件的更新:

UPDATEinventorySETavailable=available-?WHEREsku=?ANDavailable>=?

随后检查影响行数是否为 1,再决定提交。这把“判断并扣减”尽量压在数据库中的同一条操作上。不同数据库隔离级别、锁行为和 SQL 方言可能影响具体方案;涉及跨行约束时还需要唯一约束、适当的锁或版本字段。不要只凭“已经开启事务”就认为并发安全。

事务过大也会带来长时间持锁、日志增长和连接池耗尽。导入任务应在业务要求的原子范围内尽量缩短事务:解析和基本校验可在事务开始前完成;大批量数据可先进入暂存表,再按确定的批次迁移。若选择按批提交,就必须接受并设计部分成功。数据库监控要同时观察慢 SQL、死锁、连接池等待、提交延迟和回滚数。

五、重试之前先回答是否幂等

超时不等于事务必然失败。客户端在commit()附近断线时,数据库可能已经提交,但调用方没有收到确认。此时盲目重试可能生成重复订单。要为业务操作设计唯一请求键,在数据库上建立唯一约束;重试时查询或按约束处理已有结果。数据库特定的 upsert 可以使用,但必须清楚它在冲突时更新了哪些字段,不应把任意冲突静默吞掉。

死锁或可重试的瞬时故障,可在事务整体回滚后进行有限次数重试,并加入退避与抖动。只重跑出错的最后一条 SQL 往往破坏前后依赖。重试必须重新获取或重置连接状态,重新执行业务判断;对外部系统调用还需独立幂等与补偿策略。不要对参数错误、永久约束冲突无限重试。

六、连接池与资源关闭

Connection、PreparedStatement和ResultSet都需要关闭。连接池返回的 Connection.close 通常是把连接归还池,而不是物理断线;前提是所用 DataSource 的约定如此。使用 try-with-resources 能确保语句和连接在异常时进入清理路径,但关闭异常也可能发生,故业务日志应保留主异常与 suppressed 异常。一个请求占用一个连接期间,不要在同一事务里做长时间网络调用。

如果项目已经采用 Spring 等框架的声明式事务,手写connection.commit()可能与框架托管冲突;应由同一事务管理机制决定边界。并发虚拟线程也不改变连接池大小:线程可以更多地等待,但数据库可同时处理的事务不会因此增加。入口并发、连接池和数据库容量需要一起调节。

七、上线前的检查清单

用集成测试连接目标数据库,而不只用模拟对象。至少验证:中途违反约束时整单回滚;同一请求键重复提交不产生第二单;两个请求争夺最后一件库存时只有一个成功;批次超过限制后能按预期分批;连接异常后不把脏状态传给下次借用者。再用与生产接近的数据量测试批次大小与事务耗时。

八、一个导入任务的决策例子

假设一份文件含十万行,产品要求“合法行尽量导入,错误行可下载”。这与“整个文件要么全部成功、要么全部失败”是两个不同的需求。前者可以先解析文件,记录行号和校验错误,将合法行按小批提交,给每一批分配处理状态;任务结束后汇总成功、失败和待重试数量。后者则要评估单个超大事务的锁、日志和连接占用是否可承受,必要时先写入暂存表,再用一笔受控事务发布到正式表。

按批提交的结果不适合只返回一个布尔值。任务记录至少包含文件标识、幂等键、批次起止行、已提交的批次和失败原因。调用方中断后重新执行时,先查询已有状态,不能从第一批盲目重插;即使应用层做了去重,也应在数据库建立与业务唯一性一致的约束,防止两个任务同时执行产生重复数据。

在集成测试中,可以在第 N 批人为制造唯一键冲突,分别验证“整单原子”和“允许部分成功”两种模式的结果。还要测试批次为空、单行过大、连接中断以及提交结果不确定等边界。性能优化应使用真实驱动和目标数据库测试,因为仅从 JDBC 接口无法推断批量重写策略和服务器执行计划。

若一批里同时写主表和明细表,必须在同一连接和同一事务中组织语句。框架中的连接获取方式也要确认:从 DataSource 两次获取连接并不意味着自动共享事务。编写集成测试时可以查询提交后的真实表,而不能只验证executeBatch()被调用了多少次。业务正确性以持久化结果为准。

保存点可以在一个事务内标记局部回滚位置,但并非所有驱动、数据库或业务约束都适合依赖它。对于“跳过错误行”的导入,逐行回滚到保存点可能非常慢,还会让错误语义复杂化。先确定产品能否接受局部成功,再用目标数据库压测保存点、小批事务和暂存表方案,不要把某个 JDBC API 当作通用答案。

JDBC 的正确使用不是“调用了 executeBatch 就变快”,而是先把原子范围、并发规则、错误可见性和重试语义写清楚,再调整吞吐。事务和约束守住正确性,批量写入才是值得优化的性能路径。

参考资料:Connection API、PreparedStatement API、BatchUpdateException API。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询