第一次把 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 之上再包一层自己的工具类,比如把常用的单表增删改查抽成BaseDao<T>,或者把 SQL 集中放到配置文件里统一管理。这种"再包一层"的思路在工具类设计里很常见,DBUtils 官方其实也给了个参考——QueryLoader就是专门用来从.properties文件里按 key 加载 SQL 的,后面第 3 章会说到。核心思路是一致的:把变化的部分(SQL)和不变的部分(执行流程)分开。
1.3 依赖引入与版本选择
DBUtils 的坐标很好记,只有主包,不带任何传递依赖:
<dependency> <groupId>commons-dbutils</groupId> <artifactId>commons-dbutils</artifactId> <version>1.8.1</version> </dependency>Gradle 写法是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.7 | Handler 全面泛型化,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 ScalarHandler<Long>(), "张三", 26);insert的第二个参数是结果集处理器,它会在内部用RETURN_GENERATED_KEYS打开语句,然后把生成的主键结果集交给这个处理器。用ScalarHandler<Long>接单列主键是最省事的写法。有一点要提醒:返回值类型要跟数据库里的类型对齐,MySQL 的bigint自增主键在 JDBC 层给出来的是Long,你用ScalarHandler<Integer>接会在运行时抛类型转换异常,不是编译期错误,很容易到测试环境才炸出来。
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 > ?"; List<User> users = qr.query(sql2, new BeanListHandler<>(User.class), 18); // 3. 查单值,比如总数、某列最大值 String sql3 = "select count(*) from t_user"; long total = qr.query(sql3, new ScalarHandler<Long>());三者的分工很清晰:BeanHandler处理"最多一行",BeanListHandler处理"零到多行",ScalarHandler处理"单个值"。ScalarHandler在 1.7 之后支持泛型,写new ScalarHandler<Long>()就能拿到强类型返回值,不用再强转。
除了这三个,还有几个冷门但好用的:MapHandler把一行映射成一个Map<String, 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 列名带下划线时的三种解法
既然下划线是绕不开的(绝大多数团队的建表规范都是下划线命名),那就得选一种处理方式。我整理了三套方案,各有用武之地:
| 方案 | 写法 | 优点 | 缺点 |
|---|---|---|---|
用GenerousBeanProcessor | new BeanListHandler<>(User.class, new BasicRowProcessor(new GenerousBeanProcessor())) | 一次配置全局生效,语义清晰 | 需要在每个 Handler 上都带上,或者封一层工厂方法 |
| SQL 里写别名 | select user_name as userName from t_user | 直观,零配置,兼容任何版本 | SQL 变长,列多的时候很啰嗦 |
用columnToPropertyOverrides | new BeanProcessor(map)传入列名到属性名的映射表 | 不用改 SQL,映射关系集中可查 | 需要配置映射表,1.7 之前对 key 大小写敏感 |
GenerousBeanProcessor是 1.6 引入的,它的匹配策略比默认的宽松:会把列名里的下划线去掉、统一小写之后再跟属性名比,所以user_name、USER_NAME、userName都能落到同一个属性上。如果你项目里表结构已经定型,又不想在每个查询里写别名,这个是性价比最高的选择。
实际落地时我建议包一个工厂方法,避免到处 new:
public final class Handlers { private static final RowProcessor ROW_PROCESSOR = new BasicRowProcessor(new GenerousBeanProcessor()); private Handlers() {} public static <T> BeanListHandler<T> beanList(Class<T> type) { return new BeanListHandler<>(type, ROW_PROCESSOR); } public static <T> BeanHandler<T> bean(Class<T> 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列是 NULL,BeanProcessor会试图调用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并覆盖processColumn:
public 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 体系是可扩展的,实现ResultSetHandler<T>接口的handle(ResultSet rs)方法就行。我实际写过两类自定义 Handler,都挺实用。
第一类是需要跨行聚合的场景。比如要把结果集按某个维度拼成一个嵌套结构,KeyedHandler的默认行为不完全符合需求时,直接自己写:
public class DeptUserTreeHandler implements ResultSetHandler<Map<String, List<User>>> { @Override public Map<String, List<User>> handle(ResultSet rs) throws SQLException { Map<String, List<User>> 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 ScalarHandler<Long> { @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 为什么别在生产用 DriverManagerConnectionFactory
DBUtils 确实提供了一个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?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true"); 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 之间是个合理区间,盲目调到几百只会让数据库侧排队更严重。
还有一个容易被忽略的连接串参数:rewriteBatchedStatements=true。它跟批量写入的性能直接相关,第 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依然是一条条发给服务器的,只是省了网络往返的协商开销。只有在连接串里加上rewriteBatchedStatements=true,驱动才会把连续的同构 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,比如手写了重载的 setter,BeanProcessor会明确报出签名有问题。二是某个字段只写了 getter 没写 setter,或者用了 Lombok 但注解处理器没生效,这种情况异常会指向那个属性名,检查一下编译产物里有没有对应的setXxx方法就能确认。
6.2 连接池连接数只涨不降的排查过程
现象:服务跑几个小时之后开始报"获取连接超时",重启能缓一阵,之后复发,监控里连接池的活跃连接数曲线是一条持续上升的斜线,从来没降下来过。
排查的时候我先打开了连接池的泄漏检测(Hikari 的leakDetectionThreshold设成 60000),重启后等它告警。告警日志非常有价值,它会把借出连接的那一段堆栈打出来,直接指向了出问题的代码位置。定位到的是一个导出功能,代码大概是这样:
// 反面教材 public List<User> exportAll() throws SQLException { Connection conn = dataSource.getConnection(); QueryRunner qr = new QueryRunner(); List<User> 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 List<User> 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"。我见过一些项目试图用泛型和反射做一个BaseDao<T>,支持任意实体的增删改查,连 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, ResultSetHandler<T> 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、带了什么参数。这个做法我从早期项目一直用到现在的项目,中间换过连接池、换过驱动版本,这段代码从来没改过。