DBUtils核心QueryRunner与DBCP连接池:简化JDBC开发实战
写 SQL 不难难的是把每次查询前前后后的连接获取、预编译语句、参数绑定、结果集映射、资源关闭这套“管道工程”重复几十遍还不出错。这也是我当年从原生 JDBC 转向 DBUtils 的起因。这个系列走到第三十篇正好把两块内容放在一起DBUtils 中的 QueryRunner 核心类以及 DBCP 连接池。前者负责把“执行 SQL 并返回结果”这个过程从里到外简化后者负责把数据库连接的创建和复用管起来。这两样搭配是很多中小型 Java 项目从 JDBC 裸写走向更工程化做法的第一步也是理解更重 ORM 框架前非常值得吃透的一环。读这篇内容的我默认是这样一类人已经能用 JDBC 完成简单的增删改查但每写一个 DAO 都要重复一堆 try-catch-finally面对数据库连接的管理有点力不从心或者准备在项目里引入连接池却只知道大概意思。如果你处于这个阶段这篇文章基本可以按目录直接跟着做代码都是可以落地的参数也是我实际验证过的。我先说下整体思路DBUtils 和连接池是两个独立的东西但它们解决的是同一类问题——让程序员把精力放在 SQL 本身。DBUtils 做的是消除样板代码连接池做的是消除频繁创建物理连接的开销。理解了这个分工后面所有细节都不是死记 API而是围绕“哪个环节省掉了什么”来记忆。1. 这个方案到底解决了什么问题1.1 原生 JDBC 的复杂度来自哪里很多新人在初学 JDBC 时会觉得不就是DriverManager.getConnection()、Statement.execute()、ResultSet.next()三步走吗但实际上手写一个完整 DAO 就会发现真正写业务逻辑的时间不到三分之一剩下的时间全在写资源管理和异常处理。一个典型的查询方法长这样加载驱动、获取连接、准备语句、循环设置参数、执行查询、逐行从ResultSet里按索引或者按列名取值、构造对象、关掉 ResultSet、关掉 Statement、关掉 Connection最后还要在整个方法外面套一层 try-catch避免连接泄漏。这还只是单个查询方法。如果一张表有十来个字段一个模块有十来个查询代码就开始失控了。最容易出错的地方集中在两个环节一是参数绑定。用Statement拼接字符串 SQL一旦值里包含单引号或特殊字符轻则 SQL 报错重则构成注入风险。改成PreparedStatement后虽然参数安全了但每个方法都要写一堆setString、setInt字段一多就变得非常机械。二是结果映射。Java 对象的属性与数据库列名往往不完全一致手动从ResultSet里 get 出来再 set 进对象写多了就是纯粹的体力活。DBUtils 正是针对这两个痛点设计的它把参数绑定简化成可变参数Object...把结果映射简化为一个接口ResultSetHandlerT资源关闭则完全交给框架内部处理。1.2 DBUtils 的定位不抢 SQL 的 ORM 框架这里要说清楚一件容易混淆的事DBUtils 不是 MyBatis 或者 Hibernate 那种完整 ORM 框架。它不会替你生成 SQL不会做表关系映射更不会管缓存和懒加载。它的核心价值只有一个——把 JDBC 的固定流程封装起来让 SQL 和参数暴露给你把结果转换交还给你。你可以把它理解成“JDBC 的工具箱”而不是“数据访问的全能框架”。这也是很多老项目愿意引入它的原因改造代价小对已有 SQL 零侵入开发人员仍然完全掌控数据库交互过程。在实际业务里DBUtils 最常见的应用场景是工具类、报表模块、批量数据迁移脚本以及作为学习 ORM 思想前的过渡阶段。它没有复杂的生命周期和代理机制调试起来非常直观。比如某个统计报表的 SQL 写了三四个 joinMyBatis 里要配置 resultMap而 DBUtils 中直接写一个MapHandler或自定义ResultSetHandler拿到的就是一个 Map 或者自定义对象没有配置文件的负担。这个特性让它在一部分场景下反而比重量级框架更顺手。1.3 连接池在架构里的真实角色说完 DBUtils再看连接池。Java 的标准 JDBC 连接获取方式是DriverManager.getConnection()每次调用都会经历建立 TCP 连接、数据库身份验证、分配会话资源等一套完整流程。在高并发场景下频繁创建和销毁连接的开销非常可观甚至可能超过 SQL 本身执行的时间。连接池的思路很简单预先创建一批连接放在池子里应用来借的时候给一个用完还回来而不是销毁。池子还能控制连接总数防止某个模块把数据库连接占满拖垮整个应用。DBCP 是 Apache Commons 下的连接池实现它实现了javax.sql.DataSource接口。在 Java 里DataSource是获取连接的标准入口而 DBCP 的BasicDataSource是这个接口最经典的实现之一。引入 DBCP 之后项目中几乎所有获取连接的代码都能从DriverManager切换到DataSource.getConnection()而业务层感知不到区别。这也为后续切换到 Druid、HikariCP 等其它连接池留下了余地因为上层依赖的是标准接口。2. QueryRunner 核心类从方法签名到执行流程2.1 QueryRunner 的两种构造方式决定了谁管连接QueryRunner 是 DBUtils 最核心的类它把所有 JDBC 操作浓缩成几组方法。使用它之前先要搞懂构造函数。QueryRunner有两种常见构造方式无参构造以及传入一个DataSource。// 方式一无参构造显式传 Connection QueryRunner runner1 new QueryRunner(); // 方式二传入 DataSource由 QueryRunner 自己获取和归还连接 QueryRunner runner2 new QueryRunner(dataSource);这两种方式的使用差异很容易被初学者忽略但实际上非常关键。方式一调用方法时第一个参数必须传Connection执行完后这个连接不会被 QueryRunner 关闭谁传进来的谁负责关。方式二则完全不同调用时会自动从 DataSource 获取一个连接执行完 SQL 后自动关闭归还给连接池业务代码里不需要碰 Connection 对象。提示如果你用无参构造又在 finally 里写了conn.close()那没问题但如果你用 DataSource 构造还额外去手动调用conn.close()反而可能搞乱连接池的状态或者抛 Already closed 异常。两种方式选一种不要混用。我在开发中一般有两种使用习惯一个 DAO 的常规查询用DataSource构造代码极短涉及事务的写操作则用无参构造加显式Connection因为事务需要多个操作共用同一个连接。2.2 增删改操作update 方法的重载与返回结果增删改在 DBUtils 中统一由update方法承担。这个方法的返回值是int表示受影响的行数。常见的几种重载形式如下方法签名使用场景update(String sql)无参数的更新语句update(String sql, Object... params)带参数的更新语句DataSource 构造时使用update(Connection conn, String sql, Object... params)手动传入连接事务场景使用update(Connection conn, String sql, Object param)只有一个参数的便捷版本看一个实际例子。假设用户表t_user有三个字段id、name、email。插入一个新用户用下面的代码QueryRunner runner new QueryRunner(dataSource); String sql INSERT INTO t_user(name, email) VALUES(?, ?); int rows runner.update(sql, 张三, zhangsanexample.com);这段代码背后的流程是从 DataSource 拿连接 → 创建PreparedStatement→ 把张三和邮箱逐个填入占位符 → 执行 executeUpdate → 关闭语句和连接。原本十几行的代码现在一行搞定。当 SQL 需要动态拼接时只需要保证?的顺序和参数数组的顺序一致DBUtils 会自动处理类型转换你传进去的是字符串就 setString是整数就 setInt。如果你做的是删除或更新方式一模一样只是 SQL 不同。2.3 查询操作ResultSetHandler 才是灵魂查询方法和增删改长得不太一样。因为查询除了执行 SQL还要决定“结果集怎么变成 Java 对象”。query方法的签名大致是T T query(String sql, ResultSetHandlerT rsh, Object... params)ResultSetHandlerT是一个泛型接口它的作用是把ResultSet转换成你想要的目标类型 T。DBUtils 自带了好几个现成的实现类覆盖了绝大多数需求场景。我把它们整理成一张表实现类返回类型适用场景ScalarHandlerT单行单列的值聚合查询如SELECT COUNT(*)BeanHandlerT单个 JavaBean查询一条记录映射到对象BeanListHandlerTListT查询列表映射到对象列表MapHandlerMapString,Object查询一条记录但不想建 VO 类MapListHandlerListMapString,Object动态报表、多表 join 查询ColumnListHandlerTListT取某一列的所有值KeyedHandlerTMapObject, MapString,Object按某列做 key 的字典查询ArrayHandlerObject[]每条记录映射成数组ArrayListHandlerListObject[]多条记录每条一个数组我最常用的三个是ScalarHandler、BeanListHandler和MapListHandler。看代码// 查询单个用户 String sql SELECT id, name, email FROM t_user WHERE id ?; BeanHandlerUser handler new BeanHandler(User.class); User user runner.query(sql, handler, 1001); // 查询所有用户列表 String listSql SELECT id, name, email FROM t_user; ListUser userList runner.query(listSql, new BeanListHandler(User.class)); // 查询总人数 String countSql SELECT COUNT(*) FROM t_user; Long total runner.query(countSql, new ScalarHandlerLong());这里有一个特别容易被坑的点BeanHandler和BeanListHandler在做字段映射时要求数据库列名与 JavaBean 属性名保持一致。如果数据库列名是user_nameJava 属性是userName默认映射会失败得到的就是 null。解决办法有两个一是给 SQL 起别名SELECT user_name AS userName二是使用BasicRowProcessor配合BeanProcessor开启下划线转驼峰。第二种方式更一劳永逸我后面实操部分会给出示例。2.4 批量操作batch 方法处理多条数据当需要一次性插入或更新多条记录时一条一条执行update效率太低。DBUtils 提供了batch方法底层使用 JDBC 的批量提交机制网络往返次数大幅下降。它接收一个二维数组外层是每条记录的参数内层是单条记录的所有参数。String sql INSERT INTO t_user(name, email) VALUES(?, ?); Object[][] params new Object[][] { {用户A, userAexample.com}, {用户B, userBexample.com}, {用户C, userCexample.com} }; int[] rows runner.batch(sql, params);返回值int[]的每个元素对应一条记录的受影响行数。如果其中有某条数据执行失败默认情况下会抛出SQLException之前成功执行的记录不会被自动回滚。这里要特别提醒如果你需要批量操作具备事务性就必须手动传入同一个Connection并且在调用前关闭自动提交。batch(Connection conn, String sql, Object[][] params)就是为事务场景准备的。3. DBCP 连接池的接入与参数调优3.1 连接池的基本工作逻辑在配置 DBCP 之前先把连接池的内部模型说透。一个连接池本质上维护了一个连接集合对外提供借出和归还两个动作。应用调用dataSource.getConnection()时池子先看有没有空闲连接有就借给你没有就看当前总连接数是否达到上限没到就新建一个到了上限就等待等别的连接归还或者等超过最大等待时间抛异常。这个“等待”机制非常重要它决定了系统在连接不够用的时候是排队还是报错。DBCP 中的核心类是BasicDataSource。它非常重内部维护了连接池、空闲连接驱逐线程、JMX 注册等大量设施所以不要在每次方法调用里 new 一个应该作为应用级的单例。常见做法是在一个独立的数据源工具类里创建或者在 ServletContextListener 里初始化一次。3.2 核心配置项的心得DBCP 的配置项很多但真正需要关心的是下面这几个参数建议值作用与心得initialSize5启动时预创建的连接数。设太小高并发一到就疯狂建连接设太大冷启动时间变长且浪费资源。maxTotal20最大连接数这是保护数据库的顶线。具体值要结合数据库max_connections计算别超过它。maxIdle10最大空闲连接数。空闲太多会占用数据库资源超过的部分会被逐步回收。minIdle2最低空闲连接数防止流量低谷后连接全被回收突然来请求又要重新建连接。maxWaitMillis3000借连接时最多等多久。设 -1 会导致线程无限等待应用卡死很难排查建议必设。testOnBorrowtrue借出连接前做有效性检查宁可多一次验证 SQL 的开销也别把坏连接发给业务。validationQuerySELECT 1验证连接是否有效的 SQLMySQL 用SELECT 1Oracle 要改成SELECT 1 FROM DUAL。我通常还会加上两组参数一是连接空闲回收minEvictableIdleTimeMillis设置为 1800000也就是 30 分钟让空闲过久的连接被主动关掉防止数据库端超时断开形成“半死连接”二是取消除连接池自身的 JMX 以外的一些调试输出避免日志刷屏。配置的完整代码大概是这样的BasicDataSource dataSource new BasicDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/db_demo?useUnicodetruecharacterEncodingutf8); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); dataSource.setMinIdle(2); dataSource.setMaxIdle(10); dataSource.setMaxTotal(20); dataSource.setMaxWaitMillis(3000); dataSource.setTestOnBorrow(true); dataSource.setValidationQuery(SELECT 1); dataSource.setMinEvictableIdleTimeMillis(1800000);注意URL 里的useUnicodetruecharacterEncodingutf8一定要带上否则中文很容易出现乱码。虽然新版驱动对编码的默认行为有改进但显式声明是成本最低的自保手段。3.3 与 QueryRunner 集成DBCP 和 DBUtils 的集成不需要任何第三方桥接类因为 QueryRunner 接受DataSource而BasicDataSource实现了java.sql.DataSource。直接 new 进去即可public class DataSourceUtil { private static final BasicDataSource dataSource new BasicDataSource(); static { // 这里填上面那段 BasicDataSource 配置 } public static DataSource getDataSource() { return dataSource; } } // 在 DAO 中使用 DataSource ds DataSourceUtil.getDataSource(); QueryRunner runner new QueryRunner(ds);集成完成之后DAO 里面就不再有获取连接的代码了。连接从哪来、何时关闭全部由 QueryRunner 内部处理。这带来的一个额外好处是后续如果想换连接池比如从 DBCP 换成 HikariCP只需要改DataSourceUtil一个类DAO 层的代码完全不动。4. 实操过程一个用户管理模块的完整实现4.1 环境准备与依赖版本这次实操我用的环境是 Java 8、MySQL 8.0、Maven 3.6。依赖方面需要三样commons-dbutils、commons-dbcp2以及 MySQL 驱动。如果是老项目还在用 DBCP 1.x包名是org.apache.commons.dbcp配置类叫BasicDataSource但底层实现差异不小建议新项目直接用 DBCP 2.x。Maven 依赖如下dependency groupIdcommons-dbutils/groupId artifactIdcommons-dbutils/artifactId version1.8.1/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-dbcp2/artifactId version2.10.0/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency数据库表结构非常简单CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL, email VARCHAR(100) );这里我故意把字段名设计成user_name而不是userName就是为了演示下划线转驼峰的配置方法。对应的实体类User属性名是userName。4.2 DAO 层的常规查询与写入先处理映射问题。BeanListHandler和BeanHandler默认的字段匹配是精确匹配user_name匹配不到userName。解决方式是创建一个全局的RowProcessor交给每个 Handler 使用BeanProcessor beanProcessor new BeanProcessor(true); // 开启下划线转驼峰 BasicRowProcessor rowProcessor new BasicRowProcessor(beanProcessor); // 定义 DAO public class UserDao { private QueryRunner runner new QueryRunner(DataSourceUtil.getDataSource()); public User findById(int id) throws SQLException { String sql SELECT id, user_name, email FROM t_user WHERE id ?; return runner.query(sql, new BeanHandler(User.class, rowProcessor), id); } public ListUser findAll() throws SQLException { String sql SELECT id, user_name, email FROM t_user; return runner.query(sql, new BeanListHandler(User.class, rowProcessor)); } public int insert(User user) throws SQLException { String sql INSERT INTO t_user(user_name, email) VALUES(?, ?); return runner.update(sql, user.getUserName(), user.getEmail()); } public int update(User user) throws SQLException { String sql UPDATE t_user SET user_name ?, email ? WHERE id ?; return runner.update(sql, user.getUserName(), user.getEmail(), user.getId()); } public int delete(int id) throws SQLException { String sql DELETE FROM t_user WHERE id ?; return runner.update(sql, id); } }注意insert和update方法执行完只是返回受影响行数并不会自动填充自增主键。如果你需要在插入后立刻拿到主键就要在update之外另想办法。DBUtils 1.6 版本开始提供了insert方法配合GeneratedKeysHandler使用也能拿到自增主键但实际项目中我更习惯用INSERT ... RETURN_GENERATED_KEYS配合自定义ResultSetHandler处理这里不展开免得把主线带偏。4.3 手工事务的正确姿势前面提到凡是带Connection参数的方法QueryRunner 不会自动关闭连接这正是事务控制的切入点。以转账为例完整的操作应该是从 DataSource 拿一个连接关闭自动提交执行扣款和入账两个 update全部成功再 commit任何一个失败都 rollback最后把连接还回池里。public void transfer(int fromId, int toId, BigDecimal amount) throws SQLException { Connection conn null; QueryRunner runner new QueryRunner(); try { conn DataSourceUtil.getDataSource().getConnection(); conn.setAutoCommit(false); String sql UPDATE t_account SET balance balance - ? WHERE id ?; runner.update(conn, sql, amount, fromId); sql UPDATE t_account SET balance balance ? WHERE id ?; runner.update(conn, sql, amount, toId); conn.commit(); } catch (SQLException e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }这里有一个非常容易踩的坑如果 finally 里直接把连接 close 而不先把 autoCommit 恢复成 true连接还回池子后仍然处于事务未提交状态。下一次业务代码从池子里借到这条连接可能会把上一个事务未提交的数据一起带出来或者因为残留的事务状态导致诡异的行为。虽然 DBCP 在归还连接时通常会做状态重置但不同版本的行为不完全一致自己手动恢复是最稳妥的。提示如果你只执行一条 DML不需要手动开事务用 DataSource 构造的 QueryRunner 一条 update 足够。事务只用于多条 SQL 必须同时成功或同时失败的场景不要把事务当成所有写操作的默认选项。5. 常见问题与排查技巧实录5.1 连接池被打满应用卡在 getConnection这是高并发场景下最常见的问题。现象是日志里出现Cannot get a connection, pool error TimeoutWaitingForConnection或者Connection is not available, request timed out。排查思路先看监控里的池状态如果 active 连接数长期接近 maxTotal说明有连接被借走之后没有归还也就是连接泄漏。最常见的泄漏原因有两个。一是代码里拿到了connection但没有在 finally 中关闭。二是使用了带Connection参数的 QueryRunner 方法误以为框架会负责关闭连接。正确做法是记清楚规则无参构造 显式传 Connection连接由调用方关闭DataSource 构造连接由 QueryRunner 关闭。出现泄漏时可以开启 DBCP 的removeAbandonedOnBorrow和removeAbandonedTimeout作为兜底但这两个参数属于“事后补救”会让正在执行的长事务连接被强行回收引发别的问题建议只在排查阶段临时开启。5.2 Bean 字段映射全是 null写了BeanHandler或BeanListHandler查询出来 list 长度正确但每个对象的所有属性都是 null。第一反应检查数据库列名和实体字段名是否一致。如果列名是下划线风格实体是驼峰风格默认是映射不上的。解决方法是给 SQL 起别名或者像我前面的示例那样配置BeanProcessor(true)开下划线转驼峰。还有一个容易忽略的细节实体类必须有一个无参构造器并且属性要有对应的 setter。DBUtils 的 Bean 映射基于反射内部靠PropertyDescriptor来调用 setter如果你为了“简洁”把 setter 删了运行时会直接报BeanIntrospectionException。很多测试类里面为了方便把字段设为 public也会踩这个坑。5.3 隔了一段时间后第一次请求特别慢或报连接失败数据库默认会在一段时间后断开空闲连接比如 MySQL 的wait_timeout默认可能是 8 小时。而连接池这边不知道连接已经失效仍然把这条连接当作有效连接存着。结果就是应用第二天早上第一个请求拿到一条死连接轻则连接超时重则业务报错。解决方案就是我前面配置的testOnBorrow(true)加validationQuery再加上空闲驱逐参数minEvictableIdleTimeMillis定期清理。这里补充一个经验有些团队为了追求性能把testOnBorrow设为 false只开testWhileIdle让后台线程定期验证空闲连接。理论上可行但“定期”就意味着存在验证窗口期高峰期可能正好借到一条刚过期尚未被验证的连接。我的建议是业务量没大到不可接受时testOnBorrow保持开启性能损失微乎其微换来的是极大的稳定性。5.4 事务与连接池的边界认知最后一个问题其实是个认知问题。我在一些项目里看到开发者在 SERVICE 层写了DataSourceUtils.getConnection()又在 DAO 层用了new QueryRunner(dataSource)结果事务完全没有生效因为 DAO 拿到的是数据源新建的另一条连接。记住一个原则事务必须绑定到同一条连接上。要么让 Service 把连接作为参数传到 DAO 的每个方法要么使用带Connection的 QueryRunner 重载绝对不能让 DAO 自己从 DataSource 拿新连接。如果项目里有多处需要这种跨 DAO 的事务每次都手动传连接会非常啰嗦。DBUtils 本身不提供事务模板这时我通常建议直接引入 Spring 的DataSourceTransactionManager或者至少在项目里封装一个类似TransactionTemplate的小工具。不过这已经是另一个话题了这个系列后面如果有机会我专门写一篇手撸事务模板的文章。最后分享一个小技巧。DBCP 的validationQuery不是所有数据库都通用。MySQL 用SELECT 1没问题Oracle 必须写SELECT 1 FROM DUALSQL Server 则建议SELECT 1。这个 SQL 越轻量越好因为它可能在每次借连接时都被执行重型 SQL 会把连接池变成慢速查询器。另外如果你用的是 PostgreSQL也可以直接SELECT 1只是要注意部分老版本驱动对setReadOnly等状态的处理有差异这一点结合实际环境测试为准。我在实际项目中通常会在连接池初始化之后写一个简单的自检方法从池子里取一条连接执行一次validationQuery确认通了再继续加载后续业务模块这样启动阶段就能暴露数据库配置问题而不是等到用户请求进来才报错。