JDBC样板代码太烦?DBUtils搭配DBCP连接池实战指南
记得刚开始写Java的时候最烦的就是JDBC那套样板代码。每次增删改查都要注册驱动、获取连接、创建Statement、处理结果集、关资源一个流程下来十几行代码打底而且只要有一个资源忘记关闭连接就会泄漏。后来项目越做越大光靠手动写JDBC已经撑不住了我才开始认真用DBUtils和连接池这套组合。说实话DBUtils本身不是什么新东西属于Apache Commons里的老牌工具但它的QueryRunner核心类搭配DBCP连接池至今仍是很多中小型项目里最务实的数据库操作方案。这篇文章就围绕DBUtils的QueryRunner核心类展开把查询、增删改的典型写法讲清楚再聊DBCP连接池的接入方式和参数调优。适合正在学JavaWeb的初学者也适合想把DAO层代码写得更干净、同时还没引入重量级ORM框架的开发者。你不需要会什么高深的东西只要对原生JDBC的Connection、PreparedStatement、ResultSet有基本概念就能完全看懂并直接用到自己的项目里。1. 为什么JDBC写多了会想扔进垃圾桶先聊聊痛点不然你不会真正感激DBUtils。1.1 原生JDBC的样板代码到底有多烦我最早写DAO层的时候接口里每个方法几乎都是同一种结构。拿最简单的查询单个用户来说代码大概长这样public User findById(Integer id) { Connection conn null; PreparedStatement ps null; ResultSet rs null; User user null; try { conn DriverManager.getConnection(url, username, password); String sql SELECT id, name, age FROM user WHERE id ?; ps conn.prepareStatement(sql); ps.setInt(1, id); rs ps.executeQuery(); if (rs.next()) { user new User(); user.setId(rs.getInt(id)); user.setName(rs.getString(name)); user.setAge(rs.getInt(age)); } } catch (SQLException e) { e.printStackTrace(); } finally { // 关闭rs、ps、conn每个都要单独try-catch if (rs ! null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (ps ! null) { try { ps.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } return user; }这还只是查询一条记录。如果是查询列表遍历ResultSet手工封装List的代码还要再膨胀一圈。如果是更新操作虽然不用ResultSet但资源关闭一样跑不掉。这段代码的问题不只是“看起来啰嗦”它带来了几个实际麻烦首先ResultSet到实体对象的映射完全靠手动getInt、getString字段一多就容易漏其次连接资源关闭必须写在finally里万一中间业务代码抛了非SQL异常连接依然可能泄漏最后每个方法都自己管连接连接的生命周期完全失控并发一上来数据库连接数直接被打满。1.2 DBUtils解决了哪几个具体问题DBUtils是Apache Commons生态里一个轻量级的JDBC封装工具它没有像MyBatis、Hibernate那样去做复杂的ORM映射也不帮你管理SQL它的目标非常简单把JDBC标准流程里那些固定不变的“机械动作”封装掉。具体来说DBUtils主要做了三件事。第一件事封装资源关闭。你不再需要写那三行重复的finally关闭代码QueryRunner在内部执行完SQL之后会自动处理ResultSet、PreparedStatement以及由它自己创建的Connection。第二件事封装结果集映射。QueryRunner配合ResultSetHandler接口可以把ResultSet自动转换成Bean、List、Map、单列值等等你不再需要手写rs.getXxx然后setter一行代码就能拿到对象。第三件事封装SQL执行。增删改调用update查询调用query参数通过可变参数按顺序传入内部帮你setObjectSQL注入问题也和原生PreparedStatement一样有基本防护。不过要强调一点DBUtils并不替你做SQL生成和对象关系映射SQL还是要自己写。所以它非常透明出了问题你能直接预测SQL执行情况这也是我后来一直愿意在某些模块里留用它的原因。2. QueryRunner核心类上手构造方式决定代码风格QueryRunner是DBUtils里最核心的类几乎所有的增删改查都是经由它来执行的。你在使用之前要做的第一件事是理解它的三种构造方式因为选哪一种直接决定你的连接从哪来、事务怎么控制。2.1 无参构造、传DataSource构造、传Connection调用QueryRunner有三个常见的用法代码层面分别对应不同的构造方式。第一种直接new QueryRunner()然后每个方法调用时手动传入Connection。这种方式的控制权完全在自己手里适合需要手动管理事务的场景因为你能保证同一事务里的所有操作使用同一条连接。QueryRunner runner new QueryRunner(); Connection conn dataSource.getConnection(); try { int rows runner.update(conn, UPDATE user SET age ? WHERE id ?, 20, 1); conn.commit(); } catch (SQLException e) { conn.rollback(); } finally { conn.close(); }第二种new QueryRunner(DataSource)这是最省心的方式。QueryRunner内部每次执行SQL时会通过这个DataSource获取连接执行完后再自动关闭。你不需要在业务代码里写任何连接获取和释放的逻辑。QueryRunner runner new QueryRunner(dataSource); int rows runner.update(UPDATE user SET age ? WHERE id ?, 20, 1);第三种new QueryRunner(DataSource, true)第二个boolean参数表示是否自动提交事务。默认情况下如果连接是从连接池取出来的自动提交状态取决于连接池的配置通常是true。当你传入false时需要你手动提交或回滚。从日常项目实践来看大部分业务场景配合DBCP连接池使用第二种就够了。事务方面如果遇到多表操作需要保证原子性再单独用第一种方式获取连接并控制commit/rollback这是比较务实的做法。2.2 底层执行流程简单说明我在刚开始接触QueryRunner时心里一直有个疑问它到底是怎么做到自动关闭资源的实际去看源码会发现它内部执行完SQL后会把ResultSet、PreparedStatement按顺序关闭随后根据你是否自己传入了Connection来决定要不要关闭连接。如果你是用DataSource构造的它会把连接归还给连接池如果是外部传入的Connection它会保留连接不关以便你继续做事务操作。这个设计逻辑非常重要理解了它你就能解释很多踩坑问题。比如为什么自己传入Connection配合QueryRunner时如果方法执行完你忘了关闭连接连接会一直占用又比如为什么用DataSource构造时你不需要关心关闭因为QueryRunner会在finally里帮你归还。3. 增删改查的四种写法从update到query这一节是真正干活的环节。我会按照实际开发中最常用的写法把增删改查挨个过一遍。3.1 增加、删除、更新统一走update方法DBUtils里的update方法本质上是执行UPDATE、DELETE、INSERT等任何会改变数据的SQL。参数按顺序放在SQL后面用可变参数传值。比如新增一条用户记录一个insert就完事QueryRunner runner new QueryRunner(dataSource); String insertSql INSERT INTO user (name, age) VALUES (?, ?); int rows runner.update(insertSql, 张三, 25);返回值是受影响的行数。如果插入一条数据时设置了自增主键想要拿到这个主键DBUtils也提供了方法。在MySQL 5.7及以上的JDBC驱动中可以通过KeyHolder相关机制或者直接在update方法之后另行查询但DBUtils本身没有像MyBatis那种直接返回自增主键的API。我一般是用PreparedStatement的getGeneratedKeys或者执行完insert后调用查询函数查一下最大值虽然有点土但胜在简单可控。删除和更新同理String deleteSql DELETE FROM user WHERE id ?; runner.update(deleteSql, 1); String updateSql UPDATE user SET age ? WHERE id ?; runner.update(updateSql, 30, 1);可变参数的数量和顺序必须和SQL里占位符一致这是最常见的报错来源之一。DBUtils底层用的是setObject所以参数类型会自动匹配但如果SQL占位符是数字类型、数组类型这类特殊情况建议提前确认JDBC驱动的兼容性。3.2 使用batch方法做批量增删改批量操作是DBUtils的一个亮点。如果你在一个循环里反复调用update每执行一次都会走一次SQL解析和网络往返性能明显不够看。正确的做法是用batch方法一次提交。QueryRunner runner new QueryRunner(dataSource); String insertSql INSERT INTO user (name, age) VALUES (?, ?); Object[][] params new Object[3][2]; params[0] new Object[]{a, 20}; params[1] new Object[]{b, 25}; params[2] new Object[]{c, 30}; int[] rows runner.batch(insertSql, params);返回的int数组里每个元素代表对应批次受影响的行数。我在实际项目里用它批量插入数据一次性处理几千条时速度提升非常明显。注意batch方法执行完并不代表一定提交了事务如果外层开启了手动事务还是要手动commit。3.3 query方法配合ResultSetHandler的各种姿势查询是DBUtils里花样最多的地方核心在于第二个参数ResultSetHandler。它是一个接口作用就是从ResultSet里提取数据并转换成你想要的对象。我按使用频率从高到低说几个常用的Handler实现。BeanListHandler和BeanHandler最常用。BeanHandler把查询结果的第一行记录封装成单个Bean对象BeanListHandler则把整个结果集封装成List 。前提是这个Bean是一个标准的JavaBean有对应字段的setter方法并且数据库列名和Java属性名一致或能对应上。// 查询单个用户 QueryRunner runner new QueryRunner(dataSource); User user runner.query(SELECT id, name, age FROM user WHERE id ?, new BeanHandler(User.class), 1); // 查询用户列表 ListUser userList runner.query(SELECT id, name, age FROM user, new BeanListHandler(User.class));你需要知道一个细节数据库列名默认情况下是下划线风格比如user_name而Java属性是userName时DBUtils默认不会自动做驼峰转换。我用的办法是在SQL里写别名SELECT user_name AS userName这样映射就不会出问题。ScalarHandler用来取单行单列的值最常见的就是统计总数Long count runner.query(SELECT COUNT(*) FROM user, new ScalarHandler(Long.class));用它可以省去手工读取ResultSet的麻烦而且返回类型可以通过泛型指定。如果你只是想获取第一行第一列的数据这个handler是首选。MapHandler和MapListHandler也很有用。它们把每条记录封装成MapString, Objectkey是列名value是列值。MapListHandler返回ListMapString, Object。这种方式的优点是无需定义Bean类适合处理动态表结构或者临时报表查询。缺点也很明显拿到的都是Object类型转换要自己做所以一般只在查询结果结构不固定时使用。ArrayHandler和ArrayListHandler用起来稍微少见一点。ArrayHandler返回第一行记录的Object数组ArrayListHandler返回所有行的Object数组列表。当你既不想建Bean、又不想用Map只想快速打日志或者转JSON时这种结构比较方便。// 查询所有用户每行以Object数组形式返回 ListObject[] rows runner.query(SELECT id, name FROM user, new ArrayListHandler());还有一个KeyedHandler它返回一个Mapkey是某列的值value是整行的Map结构。通常会指定一个业务主键字段作为key适合做数据字典类的内存映射但在实际项目中我用得不多多数情况下MapListHandler遍历一遍也够用了。3.4 一个完整的增删改查示例串起来光讲抽象概念不够我写一个完整的小例子。假设我们有一个用户表字段是id自增主键name和age。对应的User类有三个属性以及setter/getter。代码里使用DBCP连接池提供DataSourcepublic class UserDao { private QueryRunner runner; public UserDao() { this.runner new QueryRunner(DataSourceHelper.getDataSource()); } public int addUser(User user) throws SQLException { String sql INSERT INTO user (name, age) VALUES (?, ?); return runner.update(sql, user.getName(), user.getAge()); } public int deleteUser(Integer id) throws SQLException { String sql DELETE FROM user WHERE id ?; return runner.update(sql, id); } public int updateUser(User user) throws SQLException { String sql UPDATE user SET name ?, age ? WHERE id ?; return runner.update(sql, user.getName(), user.getAge(), user.getId()); } public User getUserById(Integer id) throws SQLException { String sql SELECT id, name, age FROM user WHERE id ?; return runner.query(sql, new BeanHandler(User.class), id); } public ListUser getAllUsers() throws SQLException { String sql SELECT id, name, age FROM user; return runner.query(sql, new BeanListHandler(User.class)); } public long countUsers() throws SQLException { String sql SELECT COUNT(*) FROM user; return runner.query(sql, new ScalarHandler(Long.class)); } }有没有发现代码里几乎看不到Connection、ResultSet、try-catch-finally这些和业务无关的噪音。SQL放进去参数传进去结果拿回来DAO层瞬间清爽很多。这也是DBUtils最好的地方它不干涉你写SQL的思路只帮你把JDBC管道上的脏活累活扛下来。4. DBCP连接池从创建到参数调优的完整接入流程光有DBUtils还不够生产环境里不可能每次请求都现场创建连接。数据库连接的创建和销毁是非常昂贵的操作连接池的核心作用就是预先创建一批连接放在池子里需要时借出去用完还回来。4.1 为什么连接池是Web项目的标配可以这样想象你每天在办公室进出自己开门进去走的时候自己锁门如果一天来回来回几十次光开关门的时间就能占到一半。连接池就是给你配了一个专门管钥匙的前台你进门前告诉前台要开会前台直接给你一间已经打扫好的房间开完会交回钥匙前台再安排给下一个人使用。具体到数据库层面一个MySQL连接的建立至少涉及TCP握手、权限校验、创建会话等流程通常需要几十毫秒到上百毫秒。对于单次请求来说这不算长但Web应用一秒可能要处理几十上百个请求每个请求都做一遍完整建连流程数据库很快就扛不住了。连接池把连接复用起来性能提升是几何级别的同时还能限制连接上限防止满并发时把数据库拖垮。DBCP是Apache Commons提供的连接池实现它的特点是稳定、轻量、依赖少。虽然现在也有一些更年轻、功能更全的连接池比如某些国内团队开源的连接池在监控、防SQL注入上做得更好但在中规中矩的JavaWeb项目里DBCP依然是常见选项尤其是和DBUtils搭配使用时两者的依赖体系非常契合。4.2 通过BasicDataSource配置连接池DBCP使用起来就是一个BasicDataSource类。你可以把它理解成一个连接工厂加连接池管理器的结合体。最原始的方式是手动set每一项配置BasicDataSource dataSource new BasicDataSource(); dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/my_db?useUnicodetruecharacterEncodingutf8); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); // 初始连接数 dataSource.setMaxTotal(20); // 最大连接数 dataSource.setMaxIdle(10); // 最大空闲连接数 dataSource.setMinIdle(5); // 最小空闲连接数 dataSource.setMaxWaitMillis(3000); // 获取连接最大等待时间毫秒这里我把几个核心参数集中说明一下initialSize是连接池启动时预创建的连接数。设置成5时项目加载后数据库里会立即可见5个连接。如果应用启动时压力不大这个值可以稍微小一点没必要一上来占满资源。maxTotal是连接池能同时提供的最大连接数。超过这个数后新的连接请求会进入等待队列。这个值不是越大越好它受数据库最大连接数限制。MySQL默认最大连接数是151所以如果你的应用有多个节点每个节点配置的maxTotal之和不要超过数据库上限。maxIdle和minIdle用来控制池中空闲连接的数量上限和下限。空闲连接太多会占用数据库资源太少则高峰时来不及创建新连接一般maxIdle设为maxTotal的一半左右比较合理。maxWaitMillis是当连接池耗尽时调用方等待连接的最长时间。设置太短容易出现直接抛异常的情况设置太长又会让请求长时间卡住。我一般在3000到10000毫秒之间选择具体看业务接口的耗时要求。4.3 用配置文件管理连接池参数手动set参数适合快速测试但上了项目就不适合了。通常我会把连接池配置写在一个properties文件里让工具类加载。这样修改数据库密码、调大连接数时不用重编译代码。properties文件示例db.drivercom.mysql.cj.jdbc.Driver db.urljdbc:mysql://localhost:3306/my_db?useUnicodetruecharacterEncodingutf8 db.usernameroot db.password123456 db.initialSize5 db.maxTotal20 db.maxIdle10 db.minIdle5 db.maxWaitMillis3000然后写一个工具类来读取并创建DataSourcepublic class DataSourceHelper { private static DataSource dataSource; private DataSourceHelper() {} static { try (InputStream in DataSourceHelper.class.getClassLoader() .getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); BasicDataSource ds new BasicDataSource(); ds.setDriverClassName(props.getProperty(db.driver)); ds.setUrl(props.getProperty(db.url)); ds.setUsername(props.getProperty(db.username)); ds.setPassword(props.getProperty(db.password)); ds.setInitialSize(Integer.parseInt(props.getProperty(db.initialSize))); ds.setMaxTotal(Integer.parseInt(props.getProperty(db.maxTotal))); ds.setMaxIdle(Integer.parseInt(props.getProperty(db.maxIdle))); ds.setMinIdle(Integer.parseInt(props.getProperty(db.minIdle))); ds.setMaxWaitMillis(Long.parseLong(props.getProperty(db.maxWaitMillis))); dataSource ds; } catch (IOException e) { throw new ExceptionInInitializerError(数据库配置加载失败); } } public static DataSource getDataSource() { return dataSource; } }注意静态初始化块的用途程序启动时配置加载一次整个应用生命周期内复用同一个DataSource实例。这样做可以避免每次调用new BasicDataSource重新初始化连接池也方便其他类通过DataSourceHelper.getDataSource()拿到同一份连接池。4.4 DBCP的连接回收机制与常见误区DBCP会回收哪些连接连接池通过内部的PoolableConnection来追踪连接的使用状态。当你调用connection.close()时DBCP会把连接归还到池中而不是真正关闭。但要注意如果你在使用完连接后没有调用close比如忘记归还连接池只能通过空闲超时、泄漏检测等机制来回收这部分依赖于一些额外配置比如removeAbandonedOnBorrow、removeAbandonedTimeout。我在项目里见过不少新人直接把从连接池拿到的Connection当作普通连接使用用完也不close然后抱怨线上连接数被打满。这个问题的本质是他没有理解“close”对连接池来说不是销毁而是归还。一定要记住从连接池拿连接、用完关连接这个规范不能破。还有一个常见误区是以为连接池配置好之后项目就不会再有连接不够用的报错了。实际上如果单个连接被长时间占用不释放比如一个慢SQL跑了5秒期间其他请求拿不到连接依然会抛出连接超时异常。所以连接池参数调优不能脱离SQL性能和业务并发量单独讨论。5. 组合实战DBUtils加DBCP封装一个通用的BaseDao这一节我把前面的内容串起来做一个真正能在项目里用的组合封装。设计思路是一个基础的BaseDao提供通用增删改查方法子类继承后直接调用不需要重复编写连接管理代码。5.1 为什么还要再封装一层有人可能会问都已经有DBUtils了为什么DAO层还要再包一层直接new QueryRunner然后调update不就行了吗从代码复用角度说如果你有几十张表每张表CRUD方法的结构都差不多那每张表DAO里重复写QueryRunner初始化、SQL装配逻辑会非常枯燥。封装BaseDao可以把通用的查询、更新逻辑收敛起来子类只需要关心表名、实体类型和具体SQL。另外遇到分页、批量操作、事务处理这些公共逻辑写在BaseDao里也能统一管理。不过封装也要有度。DBUtils本身已经帮我们省去了JDBC资源管理再封一层主要是为了减少DAO方法里的重复代码。不要把它封装成一个功能臃肿的通用数据层否则反而削弱了DBUtils简单透明的优势。5.2 通用BaseDao设计我设计了一个简单的BaseDao核心思路是用泛型表示实体类型用ResultSetHandler作为查询转换器。public class BaseDaoT { private QueryRunner runner; public BaseDao() { this.runner new QueryRunner(DataSourceHelper.getDataSource()); } // 通用的增删改 public int executeUpdate(String sql, Object... params) throws SQLException { return runner.update(sql, params); } // 通用查询单个对象 public T queryOne(String sql, ResultSetHandlerT handler, Object... params) throws SQLException { return runner.query(sql, handler, params); } // 通用查询列表 public E ListE queryList(String sql, ResultSetHandlerListE handler, Object... params) throws SQLException { return runner.query(sql, handler, params); } // 批量更新 public int[] batchUpdate(String sql, Object[][] params) throws SQLException { return runner.batch(sql, params); } }使用的时候子类只需要传入SQL和参数即可。比如一个用户DAO可以这样写public class UserDao extends BaseDaoUser { public User findUserById(Integer id) throws SQLException { String sql SELECT id, name, age FROM user WHERE id ?; return queryOne(sql, new BeanHandler(User.class), id); } public ListUser findUserListByName(String name) throws SQLException { String sql SELECT id, name, age FROM user WHERE name LIKE ?; String param % name %; return queryList(sql, new BeanListHandler(User.class), param); } public int updateUserAge(Integer id, Integer age) throws SQLException { String sql UPDATE user SET age ? WHERE id ?; return executeUpdate(sql, age, id); } }跟在业务Service层调用时你几乎感觉不到连接的存在。DAO层就是一个纯粹描述SQL和参数的地方连接池和DBUtils在底层把连接管理、结果集映射全都消化掉了。5.3 事务处理时的正确打开方式前面提过使用DataSource构造的QueryRunner每次执行SQL都会自动获取连接执行结束后自动归还这种模式在单条SQL操作时非常好用但不适合多表事务。比如转账场景A扣款B加款两条SQL必须同时成功或同时失败。此时如果让QueryRunner分别从连接池拿两次连接第一次提交后第二次失败就无法回滚。正确做法是手动拿到连接手动控制事务并把它传给QueryRunner。public void transfer(Integer fromId, Integer toId, BigDecimal amount) throws SQLException { Connection conn DataSourceHelper.getDataSource().getConnection(); QueryRunner runner new QueryRunner(); try { conn.setAutoCommit(false); String deductSql UPDATE account SET balance balance - ? WHERE id ?; String addSql UPDATE account SET balance balance ? WHERE id ?; runner.update(conn, deductSql, amount, fromId); runner.update(conn, addSql, amount, toId); conn.commit(); } catch (SQLException e) { conn.rollback(); throw new SQLException(转账失败已回滚, e); } finally { conn.setAutoCommit(true); // 恢复自动提交避免归还连接时状态遗留 conn.close(); // 归还连接给连接池 } }注意这里几个细节。第一用new QueryRunner()无参构造执行方法时手动传入conn这样QueryRunner不会自己去获取连接。第二finally里要恢复默认的autoCommit再归还连接否则连接池中的连接可能一直保持手动事务状态给下一个使用者带来噩梦般的莫名问题。第三事务期间的SQL执行也要把异常抛出来不能只打印日志否则事务边界内的业务代码无法感知失败。5.4 DBCP连接池常见报错与处理思路我在实际项目中遇到过几次和DBCP相关的报错简单归纳一下常见的错误是连接超时。表现为Info: exhausted或者等待获取连接超时。解决方向通常是排查代码里是否有连接未关闭的地方查看慢SQL是否长时间占用连接然后结合数据库最大连接数合理提升maxTotal。还有一种情况是连接无效。当数据库重启或网络闪断后池里的旧连接可能已经失效。DBCP默认会在borrow连接时做一次校验你可以把testOnBorrow设为true这样每次取连接前都检测一下连接是否alive。这个配置会带来一点额外开销但相比连接失效导致的异常大多数人都会选择开启。配置参考ds.setTestOnBorrow(true); ds.setValidationQuery(SELECT 1);validationQuery是用于探测连接有效性的SQL语句MySQL用SELECT 1就够开销非常小。6. 从我实际使用的角度补充几个扎心的细节文章写到这核心内容基本结束了。我再从实战角度补几个坑这些都曾是让我熬夜排查过的问题。第一个坑是MySQL驱动版本和DBCP兼容性问题。早年用com.mysql.jdbc.Driver后来MySQL官方驱动类名改成了com.mysql.cj.jdbc.Driver两者不仅类名不同行为也有细微差别。如果你用的是MySQL 8以上版本但还在配老驱动连接池初始化时会报错。配置驱动类名时不要想当然抄旧项目先去确认本地的驱动jar版本。第二个坑是字符编码。连接池的URL里如果没有显式指定characterEncoding写入中文时很容易变成乱码或直接报错。我的建议是一开始就在URL里把useUnicodetruecharacterEncodingutf8配上顺便加serverTimezoneAsia/Shanghai否则MySQL 8还可能出现时区报错。第三个坑是数据库连接泄漏的排查。如果连接池的maxTotal不变但数据库连接数持续上涨大多数情况下是代码里某个分支忘了调用close。DBUtils的DataSource构造模式下如果查询方法中抛出了非SQL异常或者你在自定义Handler里抛了业务异常它能否正常关闭连接也值得注意。稳妥的做法是让数据库连接池开启removeAbandonedOnBorrow超时未归还的连接自动回收。这个功能对排查泄漏很有帮助但生产环境要谨慎使用误回收正在执行的事务连接会造成数据问题。第四个坑和ResultSetHandler有关。BeanHandler和BeanListHandler在映射时依赖反射和setter方法如果你的实体类没有无参构造器、字段名拼写不对、或者setter是私有的映射就会失败。有的报错信息很隐晦只说SQLException实际上错误是属性找不到对应的setter。第五个我特别想说的是DBUtils虽小但非常稳定。它的API十几年没大改社区里也很少看到它出严重Bug。这种“老而稳”的库在今天这个框架更新换代极快的环境里反而变成了一种优势。你不必担心它像某些框架那样升一个版本就破坏一大片接口。所以如果你团队里的项目还在用原生JDBC花半天时间引入DBUtils加DBCP代码质量能立竿见影地提升一个档次。最后再说一个小技巧如果你想直观确认连接池是否正常工作可以在启动后执行两条相同的查询把拿到的Connection对象打印出来。第一次和第二次如果来自同一个池连接你在日志里会看到hashCode相同。但要注意DBCP返回的是池化的代理对象直接打印结果可能每次都一样你可以借助数据库端查询连接线程ID来验证。不过日常开发不用搞这么复杂能用起来、不报错、性能没问题就是最好的状态了。