先问一个我自己当年学的时候卡了很久的问题:只查一张表、返回三行数据,为什么代码能写出三四十行?初学 JDBC 时,我也是老老实实写Class.forName、DriverManager.getConnection、PreparedStatement、ResultSet,最后在finally里把五个资源轮流关一遍。等到了真实项目里,连接池和 JdbcTemplate 几乎是标配,这篇笔记就把我对这两块的理解完整梳理一遍:连接池到底在解决什么问题、参数怎么定、主流实现怎么选,以及 JdbcTemplate 怎么和连接池无缝配合。适合刚学完 JDBC 基础、准备面向真实项目的同学,也适合想回过头把连接原理彻底理清的开发者。
1. 手写JDBC的痛,痛在哪:一类被重复了一百遍的模板代码
1.1 一次查询背后,连接发生了什么
先看一段最朴素的 JDBC 查询代码,感受一下成本藏在哪:
public List<User> findByAge(int age) { List<User> list = new ArrayList<>(); Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { Class.forName("com.mysql.cj.jdbc.Driver"); conn = DriverManager.getConnection( "jdbc:mysql://localhost:3306/test", "root", "123456"); ps = conn.prepareStatement("select id, name, age from t_user where age > ?"); ps.setInt(1, age); rs = ps.executeQuery(); while (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setName(rs.getString("name")); u.setAge(rs.getInt("age")); list.add(u); } } catch (Exception e) { e.printStackTrace(); } finally { // 依次关闭 rs、ps、conn } return list; }这段代码看起来只是啰嗦,真正的成本集中在getConnection这一步。DriverManager.getConnection背后做了这些事:建立 TCP 连接、完成握手和身份认证、初始化会话变量,数据库端还要为这个会话分配独立的内存结构和线程资源。一次连接的建立,在本地网络条件下也要几十毫秒,跨机房甚至能到两三百毫秒。而你的 SQL 本身,可能只要 5 毫秒就执行完了。
我习惯拿做饭类比:手写 JDBC 相当于每次点外卖都要重新买一套锅碗瓢盆,做完这一顿,把锅碗瓢盆全扔了,下一顿再买。买厨具花的时间比做饭还长。更糟的是,如果订单从一小时 5 单瞬间涨成 200 单,你不仅要同时买 200 套厨具,厨房台面就那么大,锅放不下,所有人只能排队等着——这就是连接数打满的前兆。
1.2 三个必踩的老问题
- 性能损耗:连接建立是重量级操作,频繁创建销毁,让每一次查询都背上几十倍于 SQL 自身的开销。并发越高,这个损耗越放大。
- 资源泄漏:任何一条执行路径漏掉
close,连接就永远占着。最典型的是查询抛了异常,finally没写全,连接既不关闭也不归还,日积月累,数据库连接数被缓慢蚕食。 - 代码冗余:驱动加载、连接获取、异常处理、资源关闭这一套骨架,在每个 DAO 里复制粘贴。哪天想换数据库(比如从 MySQL 换到 Oracle),驱动类名和 URL 散落在各个类里,改到怀疑人生。
这三个痛点,正好对应两样工具的职责边界:连接池管连接的生命周期与复用,JdbcTemplate 管代码骨架的收敛。理解了这个分工,后面所有细节都能串起来。
2. 连接池到底在"池化"什么:借、还、验、养一套机制
2.1 从 DriverManager 换成 DataSource
JDBC 规范里早就预留了解决方案:DataSource接口。它不直接给你物理连接,而是给你一个“连接工厂”。连接池的本质,就是实现一个DataSource,内部维护一批物理连接,对外返回一个“加强版 Connection 代理”。
调用方的代码几乎不用变:从dataSource.getConnection()拿连接,用完照旧调close()。玄机在于代理类重写了close(),实际动作是“把连接还回池里”,而不是真正关闭底层物理连接。这是一个很聪明的设计:已有的 JDBC 代码不用大规模重构,只换一个DataSource实现,就完成了从“每次直连”到“池化复用”的切换。JdbcTemplate 之所以能跟连接池天然配合,也是因为它只依赖DataSource这个标准接口。
2.2 一次借还的完整生命周期
用我理解的方式拆解连接池的一次交互:
- 初始化:池启动时按
initialSize建好一批连接等待调用。 - 借用:调用方
getConnection(),池从空闲队列取一条连接,标记为“忙碌”,用代理包装后返回。 - 校验:如果配置了
testOnBorrow,借用前先执行validationQuery(比如 MySQL 的select 1)确认连接还活着;没配的话,用户可能拿到一条早被数据库端断开的死连接。 - 使用:调用方正常执行 SQL。
- 归还:调用方调
close(),代理把连接状态改回“空闲”,塞回队列。如果归还时发现连接已经失效,就直接销毁并触发补建。 - 保养:后台守护线程按
timeBetweenEvictionRunsMillis周期扫描,把空闲过久或已损坏的连接清掉,同时保证池里不低于minIdle数量。
这套机制里最容易翻车的是第 3 步。很多人只配了validationQuery没配testWhileIdle,默认探测发生在空闲驱逐线程里,和实际借用时机有偏差。更隐蔽的场景是:MySQL 的wait_timeout默认 8 小时,池里某条连接空闲超过这个时间被服务端关闭,而客户端驱逐线程还没探测到,下一个借用的人拿到死连接,报异常时一脸茫然。
2.3 参数不是抄来的:一组能说清原理的配置
| 参数 | 作用 | 我的建议 |
|---|---|---|
| initialSize | 启动时预建连接数 | 参考 minIdle,别设 0,否则第一批请求要现场建连接,慢 |
| minIdle | 池内最少空闲连接 | 一般等于 initialSize,保证低谷期也有活水 |
| maxActive / maximumPoolSize | 池内最大连接数 | 按下面的估算思路来,不是越大越好 |
| maxWait | 拿不到连接时的最长等待时间 | 常见 5000ms,等太久会把排队请求全部拖死 |
| socketTimeout | SQL 执行超时 | 单独配,避免慢 SQL 无限期拖住线程 |
| validationQuery | 探测连接用 SQL | MySQL 用select 1,Oracle 用select 1 from dual |
| testWhileIdle | 空闲时是否探测 | 建议 true,配合驱逐周期使用 |
| testOnBorrow | 借用时是否探测 | 建议 false,每次借用都探一次白费数据库 CPU |
关于maxActive定多大,有一个很实用的估算思路:连接数并不直接等于并发用户数,而是取决于“请求在数据库上停留的时间占比”。简化公式是:连接数 ≈ 并发请求数 × (单请求中数据库占用时间 ÷ 单请求总耗时)。举个例子,一个接口总耗时 100ms,其中查库 20ms,同时 100 个并发请求,那么同一时刻占用数据库连接的期望数约为 100 × 20 ÷ 100 = 20。再留 30% 余量,maxActive设 30 左右够用。把这个数盲目设成 200 的连锁后果,后面重点关注。
3. 三种主流连接池怎么选:参数、监控与真实场景
3.1 横评:C3P0、Druid、HikariCP
| 维度 | C3P0 | Druid | HikariCP |
|---|---|---|---|
| 定位 | 老牌连接池 | 功能全、带监控 | 轻量高性能 |
| 配置复杂度 | 略繁琐 | 配置项多,功能全 | 极简,默认值合理 |
| 监控能力 | 弱 | 自带监控页、慢 SQL 统计、SQL 防火墙 | 依赖外部指标暴露 |
| 性能 | 一般 | 中等 | 最佳 |
| 维护活跃度 | 长期低活跃 | 活跃,中文文档全 | 事实标准 |
| 适合场景 | 维护老系统 | 需要 SQL 审计、监控大屏 | 新项目、微服务、追求性能 |
关于选型,我的切身体会:新项目直接选 HikariCP,它配置少、性能强,社区里各种异常场景的处理方案也多,已经成了实际上的默认选择。Druid 的价值在于“带运维视角”:如果团队没有独立的 SQL 慢查询分析平台,Druid 监控页能直接告诉你哪个 SQL 最慢、哪条连接被借走最久没归还、有多少次获取连接超时。这些信息排查连接泄漏时价值极高。C3P0 现在最大的意义就是老项目里还挂着它,没必要在新代码里引入。
3.2 一份可以直接抄的 Druid 配置
这里放一份我实际用过的 Druid 配置片段,项目环境是 Spring Boot,配置项本身也适用于普通 Java 工程:
@Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8"); ds.setUsername("root"); ds.setPassword("123456"); ds.setInitialSize(5); ds.setMinIdle(5); ds.setMaxActive(20); ds.setMaxWait(5000); ds.setValidationQuery("select 1"); ds.setTestWhileIdle(true); ds.setTestOnBorrow(false); ds.setTimeBetweenEvictionRunsMillis(60000); ds.setMinEvictableIdleTimeMillis(300000); // 打开监控统计 ds.setFilters("stat,wall"); return ds; }几个容易被忽略的点:testOnBorrow默认建议 false,每次借用都执行一次select 1,在高并发下是实打实的数据库 CPU 浪费,交给testWhileIdle定时探测就够了。filters里加stat才会产生监控统计数据,不加这个,监控页面打开全是零。wall是 SQL 防火墙,可以拦截带明显注入特征的 SQL,但要注意它和某些手写动态 SQL 拼接可能冲突,上线前记得做回归验证。
3.3 HikariCP 的最小配置
HikariCP 的优点是默认值已经比较合理,最少只需要关心四个参数:maximumPoolSize、minimumIdle、connectionTimeout、idleTimeout。给一份 yaml 示例:
spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 5000 idle-timeout: 600000这里要多说一句监控的事。HikariCP 不带监控页面,但它暴露的指标(活跃数、空闲数、等待数)接进现有监控系统后非常够用。没有外部监控系统的时候,我一般会在服务里加一个定时任务,把HikariDataSource的getActiveConnections()、getConnectionCreationDurationAvg()打日志或挂到一个监控端点,出问题时翻日志能看到趋势,而不是等事故发生了才两眼一抹黑。顺手开启leak-detection-threshold(比如 60000ms),超过阈值时日志会打印疑似泄漏的连接和调用栈,排查泄漏能省一半时间。
4. JdbcTemplate:把模板代码收进框架,只留SQL和映射
4.1 模板方法模式的典型应用
JdbcTemplate 是 Spring 对 JDBC 的封装,走的是模板方法模式:固定的骨架(拿连接、编译语句、执行、处理异常、关资源)交给框架,变化的只有三样——SQL 文本、参数绑定、结果集映射。你从“写全套流程”变成“回答核心三问”,代码量直接砍掉一大半。
关键衔接点是:JdbcTemplate 构造时需要传入一个DataSource。它本身不管理连接,每次执行时从DataSource拿一条,用完归还。所以 JdbcTemplate 和连接池是天生搭档:前者负责代码简洁,后者负责连接复用,谁也不越界。
4.2 核心 API 够用就这几招
queryForObject:查单条,接RowMapper或指定返回值类型。查不到结果会抛EmptyResultDataAccessException,判断“没有数据”要么 try-catch,要么先 count。query:查列表,传RowMapper。Java 8 之后直接写 lambda,(rs, rowNum) -> new User(...),比匿名内部类清爽太多。update:执行增删改,支持可变参数绑定?占位符,返回受影响行数。batchUpdate:批量操作,传List<Object[]>,一次批量执行,性能比循环单条update高一个量级。execute:执行任意 SQL,适合建表、清理表这类操作。
给一个完整的 DAO 示例:
@Repository public class UserDao { private final JdbcTemplate jdbc; public UserDao(JdbcTemplate jdbc) { this.jdbc = jdbc; } public User findById(Long id) { return jdbc.queryForObject( "select id, name, age from t_user where id = ?", (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"), rs.getInt("age")), id); } public List<User> findByAge(int age) { return jdbc.query("select id, name, age from t_user where age >= ?", (rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name"), rs.getInt("age")), age); } public int insert(User user) { return jdbc.update("insert into t_user(name, age) values(?, ?)", user.getName(), user.getAge()); } public int[] batchInsert(List<User> users) { List<Object[]> batch = users.stream() .map(u -> new Object[]{u.getName(), u.getAge()}) .collect(Collectors.toList()); return jdbc.batchUpdate("insert into t_user(name, age) values(?, ?)", batch); } }这段代码里没有一行连接管理和异常处理,但行为依然稳健:SQL 执行期间拿连接,异常由框架统一处理,资源由框架保证释放。以后排查某条 SQL 的问题,范围也就缩小到了 SQL 本身和RowMapper。
4.3 参数太多时的救星:NamedParameterJdbcTemplate
当 SQL 里的?超过五个,很容易对错位置。这时候换NamedParameterJdbcTemplate,占位符从?变成:name,参数用Map或专门的MapSqlParameterSource绑定:
NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(jdbc); MapSqlParameterSource params = new MapSqlParameterSource(); params.addValue("name", "张三"); params.addValue("minAge", 18); params.addValue("maxAge", 60); List<User> list = named.query( "select * from t_user where name = :name and age between :minAge and :maxAge", params, (rs, i) -> new User(rs.getLong("id"), rs.getString("name"), rs.getInt("age")));这个 API 对动态 SQL 尤其友好:拼接 where 条件的时候,不用再数第几个问号对应第几个参数。谁看了这 SQL 都知道:name是名字,minAge和maxAge是年龄上下界,可读性和可维护性上了一个大台阶。
4.4 事务是怎么和 JdbcTemplate 串起来的
Spring 的事务管理器(DataSourceTransactionManager)需要拿到同一个DataSource才能工作。原理是:事务开启后,事务管理器把连接绑定到当前线程的 ThreadLocal,JdbcTemplate 执行 SQL 时优先从这个 ThreadLocal 拿连接,而不是重新从池里 take。所以同一个事务里的多条 JdbcTemplate 操作,用的是同一条物理连接,保证隔离性和回滚原子性。
这也是为什么“Service 层标@Transactional、DAO 层用 JdbcTemplate”的写法能跑通:连接是同一个,事务边界由 AOP 切面控制。要提醒的是,@Transactional只对由 Spring 管理的 Bean 通过代理调用时生效,同类内部this.method()自调用会绕过代理,事务静默不生效。这个坑面试常问,线上也常犯,务必备注。
5. 真实环境里踩过的坑:从连接泄漏到救场监控
5.1 连接泄漏:表现与定位
最常见的坑来自“吃了代理对象自动归还的便利”后放松了警惕。有人会在代码里混用原生 JDBC 和连接池:从连接池拿到代理 Connection 后,finally里只关了PreparedStatement,忘了关 Connection,于是连接永不归还。表现是:连接池活跃连接数缓慢爬升,达到maxActive后,新请求全部在maxWait超时后报connection is not available, request timed out。
定位方法:盯活跃连接数曲线,只增不减基本就是泄漏。Druid 监控页里“活跃连接数”和“连接借出记录”两张表配合看,能找出哪个线程长期持有连接不还。HikariCP 则直接开leak-detection-threshold,超过阈值日志会打印嫌疑连接和调用栈,省事得多。
5.2 maxActive 设置过大的连锁反应
有次我接手一个老系统,连接池maxActive被设为 200,而数据库端的max_connections只有 150。平时流量低看不出问题,活动流量一上来,连接池先把数据库打满,其他服务连不上库,数据库端日志里全是连接拒绝的报错。这个案例给我的教训是:连接池参数必须和数据库端配额联动,别只盯着应用侧。maxActive宁可设小一点,配合maxWait让请求排队,也好过把数据库直接拖垮,毕竟数据库挂了影响的是所有下游服务。
5.3 探测SQL配置失配:一个迁移引发的雪崩
另一个高频问题:项目从 MySQL 迁到 Oracle 后,validationQuery还留着select 1,Oracle 直接报语法错误,所有连接校验失败,连接池反复重建,应用跟着雪崩。类似的还有不同数据库对空闲连接的回收策略差异,客户端探测时机稍微滞后,慢请求就会堆积。这类问题没有捷径,迁移数据库时配置清单要逐项过一遍,尤其是驱动、URL、validationQuery、方言这四件套。
5.4 慢SQL不是连接池的锅,但连接池会把问题放大
最后聊一个容易被误解的场景:接口变慢,第一反应是调大连接池。但很多时候瓶颈在慢 SQL,调大连接池表面解决了“获取连接超时”,实际是把更多并发压到数据库端,让负载雪上加霜。正确顺序是:先看慢 SQL 日志,建索引或改写 SQL,再考虑连接池参数。Druid 的慢 SQL 统计页在这里非常好用,stat过滤器会把执行时间超阈值的 SQL 记下来,配合监控能定位到具体哪一条语句是元凶。
写在最后的一点点私货
写完这篇,相当于把自己当年踩坑的顺序又过了一遍:先是手写 JDBC 被连接数打脸,老老实实把连接池原理啃了一遍;后来被事务自调用和 RowMapper 的空指针教育过,才把 JdbcTemplate 的边界摸清楚。最后留一个我觉得最实用的小技巧:不管用哪个连接池,上线前一定要做压测,压测时盯着活跃连接数曲线,每次调整参数后记录下最高并发、最大等待时间、活跃连接峰值。连接池参数和 JdbcTemplate 的用法都可以背下来,但只有在自己系统里验证过的那组配置,才是真正属于你的那组。