ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DBUtils 实战:QueryRunner、结果集映射、事务与批量写入

DBUtils 实战:QueryRunner、结果集映射、事务与批量写入 第一次把 JDBC 那套 Connection、PreparedStatement、ResultSet 的样板代码抄到第十遍的时候我就开始琢磨有没有东西能把这些重复劳动压下去。后来在项目里用上了 Apache Commons 的 DBUtils 工具类才算把写 SQL 顺手、映射结果省心、关资源不出错这三件事同时凑齐了。它不是 ORM不会帮你生成 SQL也不会帮你做多表关联和懒加载它做的事情非常克制把参数绑定的体力活接过去把 ResultSet 到 Java 对象的搬运接过去把容易写漏的 close 接过去。DBUtils 工具类最适合两类人一类是手写 SQL 已经写得很熟、只是厌倦了样板代码的后端同学另一类是想搞明白ORM 到底替我做了哪些事、想从 JDBC 往上爬一层的学习者。下面我按真实项目里的使用顺序把 QueryRunner、ResultSetHandler、连接池接线、事务边界、批量写入和几个踩过的坑一次说清楚代码都是能直接跑的写法参数和现象也尽量给全。1. DBUtils 在 JDBC 之上到底包了什么1.1 一段原生 JDBC 查询里最烦人的三件事先用一段最典型的原生代码把问题摆出来。假设要从用户表按主键查一条记录老老实实写的版本大概是这样public User findById(long id) { Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DriverManager.getConnection(url, user, pwd); ps conn.prepareStatement(select id, name, age from t_user where id ?); ps.setLong(1, id); rs ps.executeQuery(); if (rs.next()) { User u new User(); u.setId(rs.getLong(id)); u.setName(rs.getString(name)); u.setAge(rs.getInt(age)); return u; } return null; } catch (SQLException e) { throw new RuntimeException(e); } finally { if (rs ! null) { try { rs.close(); } catch (SQLException ignore) {} } if (ps ! null) { try { ps.close(); } catch (SQLException ignore) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignore) {} } } }看着不多但这段代码里真正跟业务有关的只有第 8 行那句 SQL 和后面三个 setter。剩下的东西全是编程语言要求你写、但跟需求一点关系都没有的部分。我把它们归成三类第一类是资源关闭的样板三层 try-catch 嵌套写一百次就得丑一百次而且只要手抖漏掉一层连接池迟早被拖垮第二类是参数绑定ps.setXxx(n, value)的下标要跟问号位置严格对齐中等长度的 SQL 很容易数错第三类是结果集搬运rs.getLong(id)、rs.getString(name)一个个往对象上套字段一多就是一屏的机械劳动。DBUtils 干的活精准地对应这三类。它的QueryRunner接过了连接管理和参数绑定它的各种ResultSetHandler接过了结果集搬运它的DbUtils类库接过了静默关闭。你剩下要写的还是那句 SQL 和那个实体类。这个分工是我比较喜欢它的原因——它没有试图替你思考只是把你不想干的那部分体力活接走了。1.2 它的能力边界做映射不做会话管理有一点必须提前说清楚不然容易踩空。DBUtils 不是 Hibernate也不是 MyBatis。它没有 Session 概念没有一级缓存二级缓存没有脏检查没有延迟加载也不管多表关联怎么拆。它甚至不认识实体之间的引用关系——你在 User 里放一个Department department字段DBUtils 是没法帮你把关联对象填进去的因为 ResultSetHandler 只看当前这一行有哪些列。所以它的定位很明确SQL 依然由你手写事务依然由你控制DBUtils 只负责把 SQL 和 Java 对象之间的那一层胶水抹平。这个定位带来一个隐形好处——出问题时排查路径极短。分页写错了就是 SQL 写错了字段没映射上就是列名对不上没有框架行为这层黑盒挡在中间。我在接手别人代码的时候看到 Dao 层用的是 DBUtils 而不是某个重型 ORM心里通常会松一口气因为读代码的成本低很多。很多团队会在 QueryRunner 之上再包一层自己的工具类比如把常用的单表增删改查抽成BaseDaoT或者把 SQL 集中放到配置文件里统一管理。这种再包一层的思路在工具类设计里很常见DBUtils 官方其实也给了个参考——QueryLoader就是专门用来从.properties文件里按 key 加载 SQL 的后面第 3 章会说到。核心思路是一致的把变化的部分SQL和不变的部分执行流程分开。1.3 依赖引入与版本选择DBUtils 的坐标很好记只有主包不带任何传递依赖dependency groupIdcommons-dbutils/groupId artifactIdcommons-dbutils/artifactId version1.8.1/version /dependencyGradle 写法是implementation commons-dbutils:commons-dbutils:1.8.1。需要单独说明的是DBUtils 本身不包含任何数据库驱动MySQL 的mysql-connector-j、PostgreSQL 的postgresql这些还是要自己引否则启动时报No suitable driver found的时候你会以为是 DBUtils 的问题。版本之间的差异不算大但有几个点值得留意版本关键变化实际影响1.5 及以前基础功能完整Handler 泛型不友好用BeanListHandler要强转代码里全是 unchecked 警告1.6新增GenerousBeanProcessor、AsyncQueryRunner、QueryLoader、DataSourceUtils下划线列名终于有官方解法了1.7Handler 全面泛型化columnToPropertyOverrides的 key 大小写不敏感异常信息更完整新项目基本应该从这一版起步1.8.x以 Java 8 为编译基线主要是修 bug 和零散增强API 层面和老版本基本兼容我的建议很直接新项目直接用 1.8.1老项目如果还在 1.5 及以下升级到 1.7 以上的收益是实打实的——GenerousBeanProcessor一个类就能省掉大量手写别名的工作。升级风险很低因为核心 API 十几年没大变过唯一要注意的是 Handler 泛型化之后某些原来靠裸类型跑通的代码在编译期会报出来改起来也就是加个尖括号的事。2. QueryRunner 的构造方式与增删改查落地2.1 构不构造 DataSource决定了谁负责关连接QueryRunner有两个构造函数这个选择直接决定了连接的归属权是整章最容易被忽略却最关键的一点// 方式一不持有 DataSource QueryRunner qr new QueryRunner(); // 方式二持有 DataSource QueryRunner qr new QueryRunner(dataSource);方式一的情况下qr.update(sql, params)这类不带 Connection 参数的重载会直接抛异常因为它没有地方去拿连接。你只能用带 Connection 的重载比如qr.update(conn, sql, params)而且这条连接的开关责任全在你自己手里——QueryRunner不会帮你关。方式二的情况下不带 Connection 的重载可以正常用QueryRunner内部会从 DataSource 取连接执行完在 finally 里还回去。但注意即使它持有 DataSource只要你调用的是带 Connection 参数的重载它依然不会关这条连接因为那是外部传进来的它没资格替你做决定。这个规则用一句话总结就是谁拿的连接谁负责关QueryRunner 只关自己拿的。我在代码审查里见过太多以为它会帮我关导致的连接泄漏根因都是没搞清这条规则。另外补充一句QueryRunner本身是无状态的所有需要的数据都在方法参数里传递所以它是线程安全的全局初始化一个单例就够了没必要每次操作都 new 一个。2.2 update 执行增删改返回值和自增主键update方法的语义就是执行 DML返回值是受影响的行数QueryRunner qr new QueryRunner(dataSource); String sql update t_user set age ? where id ?; int rows qr.update(sql, 26, 1001L); System.out.println(影响行数 rows);这里有个小习惯值得养成对返回值做判断。比如按主键更新时rows 0往往意味着记录不存在或者 id 传错了这时候如果业务语义要求必须更新成功就应该主动抛异常而不是让它静默通过。我见过线上事故就是更新密码的语句没判断返回值用户以为改成功了实际上 id 压根不存在。拿自增主键是另一个高频需求1.6 之后QueryRunner提供了insert方法专门处理String sql insert into t_user(name, age) values(?, ?); Long newId qr.insert(sql, new ScalarHandlerLong(), 张三, 26);insert的第二个参数是结果集处理器它会在内部用RETURN_GENERATED_KEYS打开语句然后把生成的主键结果集交给这个处理器。用ScalarHandlerLong接单列主键是最省事的写法。有一点要提醒返回值类型要跟数据库里的类型对齐MySQL 的bigint自增主键在 JDBC 层给出来的是Long你用ScalarHandlerInteger接会在运行时抛类型转换异常不是编译期错误很容易到测试环境才炸出来。2.3 query 的三种常用 Handler 与泛型查询是 DBUtils 最出彩的地方因为它把结果集到对象的搬运彻底抹掉了。实际项目里用得最多的三个处理器是这几个// 1. 查单条记录映射成一个 JavaBean String sql1 select id, name, age from t_user where id ?; User user qr.query(sql1, new BeanHandler(User.class), 1001L); // 2. 查多条映射成 List String sql2 select id, name, age from t_user where age ?; ListUser users qr.query(sql2, new BeanListHandler(User.class), 18); // 3. 查单值比如总数、某列最大值 String sql3 select count(*) from t_user; long total qr.query(sql3, new ScalarHandlerLong());三者的分工很清晰BeanHandler处理最多一行BeanListHandler处理零到多行ScalarHandler处理单个值。ScalarHandler在 1.7 之后支持泛型写new ScalarHandlerLong()就能拿到强类型返回值不用再强转。除了这三个还有几个冷门但好用的MapHandler把一行映射成一个MapString, Object适合表结构不固定或者临时排查数据的场景MapListHandler是它的多行版本ArrayHandler/ArrayListHandler把行映射成Object[]按列顺序取值列顺序一变代码就错我基本不用KeyedHandler可以把多行按某一列的值分组放进 Map做按部门分组用户这类需求时比手写循环干净。实测下来能用 BeanHandler 系列就别用 Map 系列。Map 看起来灵活代价是字段名写错不会有任何提示——map.get(nmae)返回 null一路空指针到业务层才发现而 Bean 至少 IDE 能帮你补全。2.4 参数是可变长数组注意这三件事update和query的参数部分都是Object... params底层就是PreparedStatement的占位符绑定所以有几个必须注意的点。第一问号的数量要和参数个数严格一致不一致会直接抛SQLException信息里会带上完整的 SQL 和参数数组这点 DBUtils 做得比手写友好。第二不要自己拼 SQL 字符串参数化不只是为了防注入还能让数据库复用执行计划性能也更好。第三null值可以直接传QueryRunner内部用setObject处理但不同驱动对setObject(i, null)的容忍度不一样——某些老驱动在获取参数元数据时会报错这时候就要用到QueryRunner那个带布尔参数的构造函数把 pmdKnownBroken 标志位设成true来跳过参数元数据探测。虽然现在的 MySQL 8 驱动已经不需要这个开关了但如果你对接的是某些国产库或者老版本驱动遇到莫名其妙的参数异常时可以往这个方向试试。3. 结果集到实体的映射细节3.1 BeanProcessor 的匹配规则忽略大小写但不忽略下划线这一章讲的是整个 DBUtils 里事故率最高的部分。默认情况下BeanHandler背后的BeanProcessor是把 ResultSet 的列名和 JavaBean 的属性名做忽略大小写的等值比较。也就是说数据库列叫NAME、属性叫name能对上但数据库列叫user_name、属性叫userName对不上结果是userName字段永远为 null。这个现象极其误导人因为不报任何异常。查询能跑通、对象能拿到、就是某个字段是空的新手往往会怀疑是数据库里没数据一把 SQL 拿到客户端执行发现数据好好的然后就开始怀疑人生。我第一次遇到这个问题时排查了快两个小时最后是靠打印 ResultSet 的元数据才定位到。BeanProcessor在匹配时也不是完全不讲道理它会跳过没有 setter 的列也会跳过没有对应列的属性——比如实体里有个serialVersionUID或者计算属性不会因为它没出现在结果集里就报错。理解这一点很重要DBUtils 的映射是尽力而为不做完整性校验。列多了它不管列少了它也不管只有能对上但对不上类型的时候才会抛异常。3.2 列名带下划线时的三种解法既然下划线是绕不开的绝大多数团队的建表规范都是下划线命名那就得选一种处理方式。我整理了三套方案各有用武之地方案写法优点缺点用GenerousBeanProcessornew BeanListHandler(User.class, new BasicRowProcessor(new GenerousBeanProcessor()))一次配置全局生效语义清晰需要在每个 Handler 上都带上或者封一层工厂方法SQL 里写别名select user_name as userName from t_user直观零配置兼容任何版本SQL 变长列多的时候很啰嗦用columnToPropertyOverridesnew BeanProcessor(map)传入列名到属性名的映射表不用改 SQL映射关系集中可查需要配置映射表1.7 之前对 key 大小写敏感GenerousBeanProcessor是 1.6 引入的它的匹配策略比默认的宽松会把列名里的下划线去掉、统一小写之后再跟属性名比所以user_name、USER_NAME、userName都能落到同一个属性上。如果你项目里表结构已经定型又不想在每个查询里写别名这个是性价比最高的选择。实际落地时我建议包一个工厂方法避免到处 newpublic final class Handlers { private static final RowProcessor ROW_PROCESSOR new BasicRowProcessor(new GenerousBeanProcessor()); private Handlers() {} public static T BeanListHandlerT beanList(ClassT type) { return new BeanListHandler(type, ROW_PROCESSOR); } public static T BeanHandlerT bean(ClassT type) { return new BeanHandler(type, ROW_PROCESSOR); } }这样业务代码里写qr.query(sql, Handlers.beanList(User.class), 18)就行映射策略统一在一处控制将来要是换成自定义的 BeanProcessor改一个文件就够了。如果你还想更彻底一点QueryLoader能把 SQL 统一放到.properties文件里按 key 取配合这套 Handler 工厂Dao 层能薄到几乎只有取 SQL、调 query、返回结果三行。3.3 基本类型、日期、LocalDateTime 的处理基本类型的空值问题必须单独拎出来说。假设实体类里写的是private int age;而数据库里这条记录的age列是 NULLBeanProcessor会试图调用setAge(null)反射层面就会失败抛出的SQLException信息里通常带着 Cannot set age 这样的字样。这个问题在第 6 章还会展开讲排查过程结论先放在这里实体类里的字段一律用包装类型Integer、Long、Boolean、BigDecimal不要图省事用基本类型。代价只是多几个字符收益是永远不用为 NULL 提心吊胆。日期类型是另一个雷区。java.sql.Timestamp是java.util.Date的子类所以属性声明成java.util.Date时能直接接住TIMESTAMP列的值反过来就不行。而LocalDateTime、LocalDate这些 Java 8 的日期类型DBUtils 并没有内建支持BeanProcessor不认识它们。要接住这些类型最干净的做法是继承BeanProcessor并覆盖processColumnpublic class Java8TimeBeanProcessor extends BeanProcessor { Override protected Object processColumn(ResultSet rs, int index, Class? propType) throws SQLException { if (propType LocalDateTime.class) { Timestamp ts rs.getTimestamp(index); return ts null ? null : ts.toLocalDateTime(); } if (propType LocalDate.class) { Date d rs.getDate(index); return d null ? null : d.toLocalDate(); } if (propType LocalTime.class) { Time t rs.getTime(index); return t null ? null : t.toLocalTime(); } return super.processColumn(rs, index, propType); } }processColumn是BeanProcessor里真正负责从 ResultSet 取一个原始值的钩子覆盖它比在实体里到处放Timestamp再手动转换要清爽得多。把这个 Processor 塞进BasicRowProcessor再配合前面的 Handler 工厂LocalDateTime就能像普通字段一样直接映射。这个写法我在三个项目里都用过很稳。3.4 自定义 ResultSetHandler 的两个典型场景Handler 体系是可扩展的实现ResultSetHandlerT接口的handle(ResultSet rs)方法就行。我实际写过两类自定义 Handler都挺实用。第一类是需要跨行聚合的场景。比如要把结果集按某个维度拼成一个嵌套结构KeyedHandler的默认行为不完全符合需求时直接自己写public class DeptUserTreeHandler implements ResultSetHandlerMapString, ListUser { Override public MapString, ListUser handle(ResultSet rs) throws SQLException { MapString, ListUser result new LinkedHashMap(); while (rs.next()) { String dept rs.getString(dept_name); User u new User(); u.setId(rs.getLong(id)); u.setName(rs.getString(name)); result.computeIfAbsent(dept, k - new ArrayList()).add(u); } return result; } }第二类是计算结果需要做类型兜底的场景。某些聚合函数在特定驱动下返回的类型不稳定sum()可能返回BigDecimal也可能返回Double这时候用一个自定义的ScalarHandler子类做归一化比在业务层做instanceof判断干净public class ToLongScalarHandler extends ScalarHandlerLong { Override public Long handle(ResultSet rs) throws SQLException { Object value super.handle(rs); if (value null) return null; if (value instanceof Number) return ((Number) value).longValue(); return Long.parseLong(value.toString()); } }写自定义 Handler 有个纪律必须在方法里把 ResultSet 游标遍历完或者明确不管剩余行。QueryRunner会在调用完handle之后自己关闭 ResultSet但如果你在handle里提前 return 且外层用的是某些对游标状态敏感的驱动可能触发额外开销。另外handle方法抛出的SQLException会被QueryRunner包装后重新抛出异常信息里会带上 SQL 语句这对排查很有帮助。4. 接连接池、划事务边界4.1 为什么别在生产用 DriverManagerConnectionFactoryDBUtils 确实提供了一个DriverManagerConnectionFactory可以在完全不借助连接池的情况下给QueryRunner提供连接。写个 Demo 演示没问题但生产环境绝对不要这么干。原因是物理连接的建立成本很高TCP 三次握手、数据库端的认证、会话初始化整套流程走下来几毫秒到几十毫秒不等而这个开销是每次查询都要付一遍。连接池的价值就在于把这部分成本摊薄到启动时的一次性投入。我的原则是QueryRunner永远只跟DataSource搭配使用无论测试还是生产测试环境哪怕用连接池的最小配置比如最大连接数 2也比DriverManager强因为至少能让连接池相关的 bug 在测试阶段就暴露出来而不是上线后才炸。4.2 与 HikariCP / Druid 的接线方式接线本身非常简单因为QueryRunner只认javax.sql.DataSource接口对上层的具体池实现完全无感HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/demo?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghairewriteBatchedStatementstrue); config.setUsername(app); config.setPassword(******); config.setMaximumPoolSize(16); config.setMinimumIdle(4); config.setConnectionTimeout(3000); config.setLeakDetectionThreshold(60_000); // 超过 60 秒未归还就告警 DataSource ds new HikariDataSource(config); QueryRunner qr new QueryRunner(ds); // 全局单例几点说明。leakDetectionThreshold这个参数强烈建议开它会在连接被借出超过阈值还没归还时打印警告堆栈是排查连接泄漏最省力的手段后面第 6.2 节会用到。maximumPoolSize不是越大越好它应该跟数据库端的最大连接数、应用的并发线程数一起考虑一般来说单实例 8 到 32 之间是个合理区间盲目调到几百只会让数据库侧排队更严重。还有一个容易被忽略的连接串参数rewriteBatchedStatementstrue。它跟批量写入的性能直接相关第 5 章会详细算这笔账这里先埋个伏笔——这个参数开不开批量插入的性能可能差一个数量级而且它默认是关闭的。4.3 手写事务的模板与连接释放顺序DBUtils 不管事务事务边界完全由你控制标准写法如下public void transfer(long fromId, long toId, BigDecimal amount) throws SQLException { Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); qr.update(conn, update t_account set balance balance - ? where id ?, amount, fromId); qr.update(conn, update t_account set balance balance ? where id ?, amount, toId); conn.commit(); } catch (SQLException e) { try { conn.rollback(); } catch (SQLException rollbackEx) { e.addSuppressed(rollbackEx); // 保留原始异常别把回滚失败吞掉 } throw e; } finally { try { conn.setAutoCommit(true); // 归还池前恢复现场 } catch (SQLException ignore) { // 记录日志即可 } DbUtils.closeQuietly(conn); } }这段模板里有三个细节值得展开。第一事务里的每一次操作都必须传conn。写成qr.update(sql, params)不带 conn是新手最常犯的错那样它会从池里另拿一条连接跟当前事务完全无关等于事务根本没生效。第二setAutoCommit(true)在 finally 里做。主流连接池归还连接时一般会重置这个状态但依赖池的实现细节不如自己显式做一遍稳尤其是你从 Hikari 换到别的池的时候。第三回滚失败的异常不要吞。用addSuppressed挂到原异常上日志里能看到完整链路否则线上只会看到一句数据库异常根本不知道回滚有没有成功。4.4 close 方法到底该用哪一个DBUtils 里跟关闭有关的方法有点多容易挑花眼我按使用频率排一下方法作用说明DbUtils.closeQuietly(conn)静默关闭连接不抛异常事务模板里最常用1.6 之后官方更推荐用下面这个DataSourceUtils.close(conn)同上语义更明确1.6 引入专为 DataSource 场景设计DbUtils.commitAndCloseQuietly(conn)先 commit 再关闭适合没有回滚分支的简单场景DbUtils.rollbackAndCloseQuietly(conn)先 rollback 再关闭catch 块里很顺手DbUtils.close(rs/ps/conn)会抛 SQLException 的关闭用的少因为关闭失败通常没法处理我个人的习惯是事务代码统一用DbUtils.closeQuietly(conn)简单查询交给QueryRunner自己管。commitAndCloseQuietly和rollbackAndCloseQuietly虽然短但它们把事务提交和资源释放两件事混在一起代码的可读性反而下降出问题时也不容易插入日志。另外提一句DbUtils.loadDriver(com.mysql.cj.jdbc.Driver)它的本质是Class.forName加异常包装。JDBC 4 之后驱动会通过 SPI 自动加载这个方法基本可以不用了。5. 批量写入的性能账5.1 batch 的参数结构长什么样QueryRunner的批量接口是batch参数是一个二维数组外层数组的长度决定执行次数内层数组就是每条语句的参数String sql insert into t_user(name, age, city) values(?, ?, ?); Object[][] params new Object[users.size()][]; for (int i 0; i users.size(); i) { User u users.get(i); params[i] new Object[]{ u.getName(), u.getAge(), u.getCity() }; } int[] affected qr.batch(sql, params);这个结构第一次见容易懵其实理解起来就是每一行是一次独立的参数绑定。内层数组的长度必须跟问号数一致长度不一致时抛出的异常信息里会带上行号定位很快。返回值int[]的长度跟外层数组一样每个元素表示对应那条语句的影响行数。batch底层其实就是在一个PreparedStatement上循环addBatch()最后调一次executeBatch()。这意味着它复用的是同一个编译好的语句省掉了重复解析 SQL 的开销这是它比循环调用 update快的第一个原因。5.2 开启 rewrite 之后返回值和真实行数会分家第二个、也是更重要的原因是 JDBC 驱动层面的批处理重写。MySQL 的 Connector/J 在默认配置下addBatch攒起来的多条 INSERT依然是一条条发给服务器的只是省了网络往返的协商开销。只有在连接串里加上rewriteBatchedStatementstrue驱动才会把连续的同构 INSERT 合并成一条insert into t_user(name, age, city) values (?,?,?),(?,?,?),(?,?,?)...发给服务端服务端只解析一次 SQL性能提升非常明显。代价是返回值的语义变了。合并之后服务端没法告诉你每一条各自影响了多少行executeBatch()返回的数组里会出现Statement.SUCCESS_NO_INFO值是 -2。如果你写了类似统计 affected 里大于 0 的个数来判断成功条数的逻辑开启重写之后这个数字会直接变成 0看起来像全部失败。我在一个数据同步任务里就踩过这个坑本地测试环境没开重写统计逻辑一切正常测试环境连了另一套连接串配置开了重写日志里全是成功 0 条但实际上数据一条不少地写进去了。后来的处理方式很简单——批量任务不再依赖 executeBatch 的返回值做成功判断而是用输入条数 vs 无异常来判断需要精确统计就用受影响行数在别的地方对账。这个坑不大但排查起来很花时间因为现象和数据是矛盾的。下面这张表是我本机环境MySQL 8.0 本地实例、单表三个字段、无索引跑 10000 条的粗略对比只作量级参考不代表你的环境写入方式耗时量级说明循环调用update十几秒每次都是一次完整的往返最慢batch未开 rewrite三到五秒复用了 PreparedStatement省了编译开销batch开启 rewrite一秒以内合并成一条多值 INSERT量级变化batch 分片 1000 事务一秒以内稳定性最好内存占用也可控5.3 分片、事务与失败处理批量不是条数越多越好。一次性塞十万条参数进内存光Object[][]的构造就可能把堆撑起来而且整批执行期间连接一直被占用遇到锁等待的时候影响面会放大。我的经验值是每批 500 到 2000 条具体看单条参数的大小和数据库端的max_allowed_packet超了会在服务端报包过大的错。另外必须强调batch本身不提供事务保证。QueryRunner.batch内部是在一个连接上循环addBatch如果第 500 条因为唯一键冲突抛异常前面 499 条是否已经落库完全取决于你的autoCommit设置。所以批量写的标准姿势是分片 显式事务int shardSize 1000; Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); for (int start 0; start params.length; start shardSize) { int end Math.min(start shardSize, params.length); Object[][] shard Arrays.copyOfRange(params, start, end); qr.batch(conn, sql, shard); } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DbUtils.closeQuietly(conn); }分片之后有个好处整个任务只占一条连接事务范围可控出错时回滚的代价也小。缺点是一次失败整批回滚如果业务上允许部分成功就要在分片粒度上做补偿——每片独立提交并记录断点这样重跑时能跳过已完成的部分这种幂等 断点续跑的设计在数据迁移类任务里几乎是标配。6. 三个真实踩坑的完整排查链路6.1 Cannot set xxx 的根因定位过程现象接口返回 500日志里是一条SQLException信息大致是Cannot set age: ...SQL 语句完整打印在前面。数据库客户端执行同样的 SQL数据完全正常age列有值。排查链路是这样走的。第一步看异常信息它明确指出了是哪个属性设置失败这是BeanProcessor的callSetter抛出来的说明问题在映射阶段而不是查询阶段SQL 本身没问题。第二步打开实体类看age字段的类型发现是private int age;。第三步回到数据上如果这条记录的age恰好是 NULL那就对上了——反射调用setAge(null)时参数是基本类型int而传进来的是 null直接失败。但这里有个隐藏信息如果异常只在部分请求里出现说明不是所有记录的age都是 NULL而是个别记录。这时候一定要查一下表结构里这个列是否允许 NULL往往会发现虽然业务上认为这个字段必有值但建表语句里并没有加NOT NULL或者历史数据里存在 NULL。找到根因之后有两个修法改实体用Integer或者给列加约束并清洗历史数据。两个都做才是最稳的只改 Java 端的话下次换个实体类、换个人写代码同样的坑还会再来一遍。同一类异常还可能来自另外两种情况。一是 setter 的参数个数不为 1比如手写了重载的 setterBeanProcessor会明确报出签名有问题。二是某个字段只写了 getter 没写 setter或者用了 Lombok 但注解处理器没生效这种情况异常会指向那个属性名检查一下编译产物里有没有对应的setXxx方法就能确认。6.2 连接池连接数只涨不降的排查过程现象服务跑几个小时之后开始报获取连接超时重启能缓一阵之后复发监控里连接池的活跃连接数曲线是一条持续上升的斜线从来没降下来过。排查的时候我先打开了连接池的泄漏检测Hikari 的leakDetectionThreshold设成 60000重启后等它告警。告警日志非常有价值它会把借出连接的那一段堆栈打出来直接指向了出问题的代码位置。定位到的是一个导出功能代码大概是这样// 反面教材 public ListUser exportAll() throws SQLException { Connection conn dataSource.getConnection(); QueryRunner qr new QueryRunner(); ListUser list qr.query(conn, select * from t_user, new BeanListHandler(User.class)); if (list.isEmpty()) { return Collections.emptyList(); // 这里直接返回了conn 没关 } // ... 后续处理逻辑里还有几个分支也会提前 return conn.close(); return list; }根因很清楚连接是手工从池里拿的但释放路径只覆盖了正常流程的最后一行任何提前 return 或者中途抛异常都会漏掉。修法不是到处补close而是把这个模式整体换掉——改用new QueryRunner(dataSource)加上不带 Connection 的重载让QueryRunner自己在 finally 里还连接private final QueryRunner qr new QueryRunner(dataSource); // 单例 public ListUser exportAll() throws SQLException { return qr.query(select * from t_user, new BeanListHandler(User.class)); }改完之后连接数曲线立刻变得平稳。这件事给我留下的教训是只要一段代码需要手工管理连接它就有泄漏的可能能交给框架管的就一定要交出去。DBUtils 提供了这个能力前提是你得用它持有 DataSource 的那个构造函数。顺带说一个容易被误判的现象有时候连接数居高不下不是泄漏而是某条 SQL 执行特别慢连接被长时间占用。这两种情况的表现很像区分方法是看泄漏检测有没有告警——有堆栈就是泄漏没有堆栈但连接数高就去看慢查询日志。别一上来就怀疑代码方向错了会浪费很多时间。6.3 事务写了却没生效的几种写法这个问题我在代码审查里见过不止一次列出三种最典型的写法都是看着像有事务其实没有。第一种事务里混用了带 conn 和不带 conn 的重载。比如转账逻辑里扣款用了qr.update(conn, ...)加钱那条手滑写成了qr.update(sql, ...)。后者会从池里另借一条连接执行并立刻提交而它自己的提交不受外层事务控制。结果是扣款失败了会回滚加钱却已经落库账目直接对不上。这种 bug 在测试环境很难发现因为两条语句通常都是成功的只有出异常的时候才会暴露。第二种忘了setAutoCommit(false)。连接池给出的连接默认是自动提交的你不显式关掉commit()调用虽然不报错但每条 SQL 早就各自提交完了rollback()也回滚不了任何东西。判断方法很简单在事务代码里打一行conn.getAutoCommit()的日志如果是true就说明少了一步。第三种把SQLException吞掉了。典型的写法是在事务方法内部catch (SQLException e) { log.error(...); }然后方法正常返回外层看到没有异常就执行了commit()。这时候明明出过错却提交了一半的数据。正确的做法是异常必须往外抛让事务模板的 catch 分支去回滚。如果业务上确实需要部分失败也继续那就要在业务层面明确设计成多个独立事务而不是靠吞异常来实现。还有一个环境层面的原因值得检查表的存储引擎。早期 MySQL 的默认引擎是 MyISAM它根本不支持事务rollback()调用不会报错但也不会有任何效果。现在 MySQL 8 默认都是 InnoDB 了但如果你接手的是一个有年头的库show table status看一眼引擎类型还是很有必要的一分钟的事能省掉半天的怀疑。7. 我个人用下来的一些取舍DBUtils 在项目里的适用面比很多人想的要宽。它最舒服的场景是SQL 相对固定、表结构清晰、不需要复杂关联的后台管理和数据类服务Dao 层代码量能压到 MyBatis 的三分之一左右而且没有 XML 或者注解这层间接读代码的人一眼就能看到最终执行的 SQL。反过来如果你的业务里有大量多表关联、动态条件拼接、结果集需要嵌套映射那手写 SQL 维护成本会迅速超过 ORM 带来的便利这时候上 MyBatis 会更合适。有一点我特别想提醒别在QueryRunner之上再封装一层万能 Dao。我见过一些项目试图用泛型和反射做一个BaseDaoT支持任意实体的增删改查连 SQL 都自动拼。这个思路走到底就是重造一个功能残缺的 ORM可维护性远不如直接写 SQL。工具类的价值在于边界清晰一旦试图无所不能它离失控就不远了。最后分享一个我常年在用的小技巧。在项目里初始化QueryRunner的时候我会顺手写一个包装类把 SQL 的执行和耗时日志放在一起public class LoggingQueryRunner extends QueryRunner { private static final Logger log LoggerFactory.getLogger(LoggingQueryRunner.class); public LoggingQueryRunner(DataSource ds) { super(ds); } Override public T T query(String sql, ResultSetHandlerT rsh, Object... params) throws SQLException { long start System.nanoTime(); try { return super.query(sql, rsh, params); } finally { long cost (System.nanoTime() - start) / 1_000_000; if (cost 200) { log.warn(slow query {}ms, sql{}, params{}, cost, sql, Arrays.toString(params)); } } } }慢查询的现场信息SQL 加参数在事后排查时价值极高而这些东西在你真正需要的时候往往已经找不到了。把日志埋在这一层成本几乎为零收益是每次线上抖动都能立刻定位到是哪条 SQL、带了什么参数。这个做法我从早期项目一直用到现在的项目中间换过连接池、换过驱动版本这段代码从来没改过。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进