先问个问题:你上一次自己写Connection conn = DriverManager.getConnection(...)是什么时候?我猜很多人从入门起就直接上了MyBatis-Plus,SQL写在XML里,表结构靠工具生成,确实省事。但一旦线上冒出“连接池打满”“事务没回滚”“游标泄漏”这类故障,你会发现IDE里根本无从下手。问题不在框架,而在JDBC这层地基被完全包住了。这篇内容我会把Java JDBC实战从头走一遍,再讲Spring Boot里的集成链路,主要包括:原生JDBC的完整写法、为什么所有ORM框架都绕不开它、JdbcTemplate和连接池的正确用法、事务与连接泄漏的典型坑,以及一套实际排障的链路复盘。适合打算补基础的Java新人、被线上连接问题折磨过的工程师,以及面试前想把这部分原理讲透的人。
1. 先泼一盆冷水:被MyBatis惯坏之后,为什么还要回头摸JDBC
1.1 MyBatis和JPA再香,底层还是这层皮
很多新人以为MyBatis、JPA是“更高级的数据库访问方式”,其实它们只是JDBC上的封装。JDBC(Java Database Connectivity)是JDK自带的java.sql和javax.sql包定义的一组标准接口,它负责和关系型数据库建立连接、发送SQL、接收结果集。MyBatis的执行器最终还是拿到一个Connection,把参数填进PreparedStatement,执行后把ResultSet映射成对象。Hibernate/JPA也是一样的逻辑,只是映射方式和缓存策略不同。
我用一张表把对应关系说清楚,很多面试题考的也是这个层面的理解:
| 原生JDBC概念 | Spring JDBC(JdbcTemplate) | MyBatis |
|---|---|---|
| Connection | 由DataSource提供 | SqlSession中的连接 |
| PreparedStatement | PreparedStatementSetter/参数数组 | #{}占位符 |
| ResultSet手动逐行处理 | RowMapper | resultMap / 自动映射 |
| 事务边界 | DataSourceTransactionManager | 底层与Spring事务管理器同源 |
如果只停留在“会用框架”的程度,上面这张表可能看不懂。但这也正是我想强调的:所有持久层框架做的事情,本质上就是替你生成那段从Connection到ResultSet的样板代码,再帮你完成对象映射。地基永远是JDBC。
1.2 必须自己写JDBC的几种真实场景
除了面试,工作中你也会遇到绕不开JDBC的时候。我举几个真实场景:
- 轻量工具类或数据迁移脚本,不想为一个小功能引入ORM全家桶。
- 大批量数据操作,例如把一张历史表的数据搬到新表,用框架反而可能被实体映射拖慢,直接JDBC批量处理更可控。
- 排查线上问题,看到连接池告警、慢SQL、死锁日志时,日志里全是
com.mysql.cj.jdbc.ConnectionImpl这类类名,不懂底层连日志都看不太明白。 - 自己封装通用组件,比如做一个多数据源动态切换的需求,绕不开JDBC的
Connection和DataSource抽象。
JDBC不是“过时技术”,它更像自动挡汽车内部的发动机。你可以只会开不会修,但真在路上抛锚了,还是得回到发动机层面找人帮忙。理解了JDBC,你再去用MyBatis遇到问题,至少知道框架是在哪一环出了岔子。
2. 原生JDBC开发:那套从Class.forName到finally块释放的固定舞步
2.1 六步套路,一行都不能想当然
原生JDBC的固定流程,我建议新手背下来。看代码:
// 第1步:加载驱动(JDBC 4.0之后可以省略,但写上更清楚) Class.forName("com.mysql.cj.jdbc.Driver"); String url = "jdbc:mysql://localhost:3306/demo" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; // 第2步:获取连接 try (Connection conn = DriverManager.getConnection(url, "root", "password")) { // 第3步:创建预编译语句 String sql = "SELECT id, name, email FROM user WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, 1001L); // 第4步:执行SQL try (ResultSet rs = ps.executeQuery()) { // 第5步:处理结果集 while (rs.next()) { System.out.println(rs.getLong("id") + " - " + rs.getString("name") + " - " + rs.getString("email")); } } } } // 第6步:释放资源,try-with-resources自动完成很多人第一次看到这段代码会问:为什么资源释放这么麻烦?因为Connection、PreparedStatement、ResultSet都对应数据库端或客户端的一类资源,连接不关闭会占用数据库连接数,语句不关闭可能占用游标,结果集不关闭可能占用内存。生产环境连接池通常是有上限的,你要是漏掉一个关闭,连接池慢慢就被吃满,应用就“卡死”了。try-with-resources写法的好处是,任何情况下都能自动按逆序关闭:先ResultSet,再PreparedStatement,最后Connection,这个顺序没问题。
2.2 为什么PreparedStatement比Statement更值得信任
很多教材上会说“PreparedStatement可以防止SQL注入”,这句话没错,但值得理解一下为什么。如果用Statement做字符串拼接:
String name = "'; DROP TABLE user; --"; String sql = "SELECT * FROM user WHERE name = '" + name + "'";拼接出来的SQL就变成了一条危险语句。PreparedStatement的做法是先把SQL骨架发给数据库预编译,参数通过setXxx以独立的数据形式传过去。数据库知道?位置是“值”,不是“SQL片段”,所以参数里再奇怪的内容都只是数据,不会被当成命令执行。除此之外,数据库端还能缓存预编译好的SQL,同一条SQL反复执行时省去了解析和优化时间,这才是它名字里“Prepared”的真正含义。
顺带记住两个小点:占位符的索引从1开始,不是0;executeQuery()用于SELECT,返回ResultSet;executeUpdate()用于INSERT/UPDATE/DELETE,返回受影响行数。这些是面试里最容易问的基础题,也是自己写代码时最容易犯迷糊的地方。
2.3 DriverManager到DataSource:为什么生产环境不能裸写连接
上面的代码用了DriverManager.getConnection(url, user, password)。这种方式每次请求都新建一个物理连接,用完就关。数据库建立连接的开销很大,包含TCP握手、认证、权限校验等,在高并发下完全撑不住。于是DataSource出现了,它是连接池的标准抽象。连接池里预先放一批连接,应用每次从池里“借”,用完“还”,真正的新建连接只发生在池不够用的时候。
Spring Boot集成的JDBC,核心其实不是JDBC本身,而是DataSource。你下面看到的配置、自动装配、事务管理,全是围绕这个接口展开的。
3. Spring Boot集成实战:从DataSource Bean到JdbcTemplate的落地细节
3.1 spring-boot-starter-jdbc究竟帮我们干了什么
Maven加一个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency>这个starter会引入spring-jdbc和默认连接池HikariCP。之后只需要在application.yml里配数据源信息:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 leak-detection-threshold: 60000Spring Boot的DataSourceAutoConfiguration会读取spring.datasource前缀的配置,当你没有自己定义DataSourceBean时,自动创建一个基于HikariCP的连接池。这个“约定优于配置”的机制帮了大忙,但也导致一个问题:很多人根本不知道自己的DataSource长什么样,遇到连接问题就两眼一抹黑。我建议你自己加一个DataSourceProperties的查看接口,或者在调试时把spring.datasource.hikari.*的参数打印出来,先搞清楚自己是靠什么连上数据库的。
3.2 JdbcTemplate三板斧:查询、更新、批量
注入JdbcTemplate之后,最常见的三种操作方式:
@Service public class UserService { private final JdbcTemplate jdbcTemplate; public UserService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } // 查询单个对象 public User findById(Long id) { return jdbcTemplate.queryForObject( "SELECT id, name, email FROM user WHERE id = ?", new BeanPropertyRowMapper<>(User.class), id ); } // 更新 public int updateEmail(Long id, String email) { return jdbcTemplate.update( "UPDATE user SET email = ? WHERE id = ?", email, id ); } // 批量插入 public void batchInsert(List<User> users) { jdbcTemplate.batchUpdate( "INSERT INTO user(name, email) VALUES(?, ?)", users, 500, (ps, user) -> { ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); } ); } }这里要留意BeanPropertyRowMapper的一个细节:它默认要求数据库列名和Java属性名能对应上,比如email列对应email属性,create_time列对应createTime属性(开启下划线转驼峰后才行)。如果你的实体字段名和表列名对不上,或者返回的是嵌套对象,写一个匿名RowMapper反而更稳:
jdbcTemplate.query(sql, rs -> { User u = new User(); u.setId(rs.getLong("id")); u.setName(rs.getString("name")); return u; }, id);JdbcTemplate在内部帮你完成了Connection的获取和释放,所以你不会像原生JDBC那样写出“忘记关闭连接”的代码。但注意,它只管数据访问,不管事务边界。事务边界是下一章的主角。
3.3 连接池选型:HikariCP还是Druid
Spring Boot默认用HikariCP,它代码小、启动快、并发性能好。不过国内很多团队习惯用Druid,理由是Druid自带监控面板、慢SQL记录、SQL防火墙和敏感操作审计。这两者的选择,我理解是这样的:
| 对比项 | HikariCP | Druid |
|---|---|---|
| 性能 | 非常快,社区公认 | 成熟稳定,性能略逊但差距有限 |
| 监控 | 提供基础指标,需配合Micrometer/Actuator | 自带Web监控和慢SQL日志 |
| SQL防火墙 | 无 | 有 |
| Spring Boot默认 | 是 | 否 |
| 常见使用场景 | 中小项目、默认选择 | 需要可视化监控、强管控场景 |
我的建议是:如果不是特别需要Druid的监控面板,就先用HikariCP,因为它是Spring Boot默认支持,踩坑的人少,资料也最多。要是团队有统一的数据库治理平台,那Druid会更合适。但无论选哪个,连接池参数都必须根据实际并发来调,默认值只保证“能跑”,不保证“抗压”。
4. 事务和连接泄漏:两类让线上应用“半死不活”的隐藏杀手
4.1 @Transactional不是银弹:三个最容易踩碎的场景
互联网上关于@Transactional的讨论已经很多,但实际踩坑的人从来没少过。我先讲原理:Spring启动时会为Bean生成代理对象,当你调用被@Transactional标记的方法时,实际执行的是代理增强逻辑——在方法进入前从DataSource拿到连接并开启事务,方法正常结束就提交,抛出异常就回滚。这一切最终都作用在Connection上。
坑一:同类内部自调用。
@Service public class OrderService { public void saveOrder(Order order) { createOrder(order); // 同类方法直接调用,没用代理 } @Transactional public void createOrder(Order order) { // 业务逻辑 } }this.createOrder(order)根本没有经过代理对象,@Transactional不会生效。解决办法是:把事务方法放到另一个Bean里,或者注入自身代理,或者使用AopContext.currentProxy()。
坑二:异常被业务代码吞掉。
@Transactional public void update() { try { // 业务逻辑,会抛出异常 } catch (Exception e) { log.warn("do nothing", e); // 异常没抛出去 } }Spring不知道异常发生了,认为方法正常完成,于是提交事务。你看到的结果是数据“好像没更新”,但也没有回滚。处理方式很简单:捕获异常后至少抛出RuntimeException,或者把整个try-catch交给外层。
坑三:默认回滚范围不是你想的全部异常。@Transactional默认只对RuntimeException和Error回滚,如果你抛出一个受检异常Exception,默认情况下事务照样提交。所以业务代码里如果显式捕获并抛出了受检异常,务必要写@Transactional(rollbackFor = Exception.class)。
关于传播行为,不需要背全,但REQUIRED和REQUIRES_NEW必须搞懂。REQUIRED是默认值:有事务就用当前事务,没有就新建。REQUIRES_NEW是先把当前事务挂起,新开一个独立事务,适合“记录操作日志不能影响主业务回滚”的场景。这两个理解了,其他传播行为基本都是边角。
4.2 连接泄漏的典型画像:事务方法里做了“不该做的事”
连接池满了,多半不是因为流量太大,而是因为连接被“借”走之后没有按时“还”。最常见的场景是:
- 事务方法里调用了RPC接口,一个事务内等对方接口20秒,这20秒连接一直占着。
- 事务方法里写了文件上传、Excel解析这类耗时操作。
- 一条慢SQL执行了5秒,高峰期100个并发同时跑这种SQL,连接池很快就见底。
- 手动获取了
Connection但没关闭,这个问题在原生JDBC里最常见,JdbcTemplate可以避免,但如果有人绕过工具类直接拿连接,仍然会发生。
连接泄漏的特点是:应用本身不报错,只是从某个时间点开始,所有请求都卡在“获取连接”,然后超时。日志里会出现类似“Connection is not available, request timed out after 30000ms”的报错。此时你去数据库端看,连接数居高不下,每个连接都处于Sleep状态或长时间执行某个SQL。
4.3 用连接池自带能力抓泄漏
排障的第一步不是怀疑框架,而是让连接池自己“说话”。
HikariCP提供一个leak-detection-threshold参数。如果连接从池里借出后超过这个阈值还没有归还,HikariCP会在日志里打出一次警告,并带上创建连接时的调用栈。配置多少合适?一般要小于connection-timeout,比如连接超时30秒,泄漏检测设为60秒就有意义;如果设成30秒以内,可能出现误报,因为正常慢SQL也会超时。
Druid对应的参数是removeAbandoned和removeAbandonedTimeout,打开后会在超时后强制回收连接,适合快速止血,但也会中断正在执行的SQL,要谨慎。
我自己的习惯是:本地开发把spring.datasource.hikari.connection-timeout调到5000ms,并打开com.zaxxer.hikari=DEBUG日志。一旦有连接获取超时,很快就能看到是哪个方法持有连接太久。这比单纯调大连接池参数要有效得多。
5. 性能实测:预编译复用、批量提交和连接池参数该调到什么程度
5.1 预编译和参数缓存:MySQL驱动上的两个关键URL参数
原生PreparedStatement能预编译SQL,但MySQL驱动默认是否真的开启预编译,取决于URL参数。如果没开,驱动可能只是在客户端做参数拼接,数据库端并没有缓存执行计划。在生产环境,建议在JDBC URL上加上这样几个参数:
useServerPrepStmts=true cachePrepStmts=true prepStmtCacheSize=250 prepStmtCacheSqlLimit=2048 rewriteBatchedStatements=trueuseServerPrepStmts=true表示让MySQL服务端做真正的预编译;cachePrepStmts=true开启驱动端缓存,配合prepStmtCacheSize控制缓存条数;rewriteBatchedStatements=true非常关键,它会将批量插入的addBatch重写成一条多值INSERT语句,性能提升非常明显。
我在一个数据迁移任务中对比过:同样插入1万条数据,不开rewriteBatchedStatements时,JDBC批量插入耗时约4.5秒;打开后耗时降到0.9秒左右。当然这个数字和环境有关,但量级差异是真实的。如果你用框架,比如MyBatis的ExecutorType.BATCH,本质上也是在驱动层面调用addBatch和executeBatch,所以这类URL参数对框架同样有效。
5.2 批量写入:实测数据对比与分批策略
先看原生批量的标准写法:
public void batchInsert(List<User> users) throws SQLException { DataSource dataSource = getDataSource(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); String sql = "INSERT INTO user(name, email) VALUES(?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { int batchSize = 500; int count = 0; for (User user : users) { ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); ps.addBatch(); if (++count % batchSize == 0) { ps.executeBatch(); // 分批执行 } } ps.executeBatch(); // 收尾 conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } } }这段代码的关键有三点:
setAutoCommit(false)之后,事务由你控制,否则每一条插入都会自动提交,性能极差。- 分批是为了避免一条巨大的SQL把网络和数据库内存打满。500到1000条一批是常用区间,具体可以压测后调整。
- 批次内任何一条失败,整批回滚,由业务决定是否接受“批次内部分成功”的情况。
顺便提醒一下:不要在一个事务里塞几十万条数据去做批量插入,长时间不提交会带来两件事,一是undo log膨胀,二是连接被占用。大事务按业务维度拆成多个小事务,更符合线上习惯。
5.3 连接池参数该调到什么程度:一个可用的估算模型
连接池的maximum-pool-size不是越大越好。每个物理连接在数据库端都是进程或线程,连接太多会浪费内存,也会加剧锁竞争。常见的估算方法很多,我用一个简单模型:
池大小 ≈ 高峰并发请求数 × 每个请求平均持有连接的时间 ÷ 目标响应时间
假设你有2000个并发请求,平均每个请求持有连接0.5秒,目标响应时间2秒,那么需要的连接数大约是2000 × 0.5 / 2 = 500。但这个数通常偏高,因为并发请求不会同时到达,真实系统里也可以做排队。反过来,如果你的数据库只跑一些简单查询,每秒能处理几千次,连接池反而不用设太大,10-20就够。
遇到连接池告警,最忌讳的做法是盲目把maximum-pool-size调到200。你要先回答一个问题:连接是被正常使用但效率不够,还是被某个长事务/慢SQL霸占不还?如果是后者,调大连接池只是延缓崩溃而已。先看慢SQL,再看事务耗时,最后才调连接池参数。数据库端的SHOW PROCESSLIST能直接看到哪些连接长时间处于Query状态,结合连接池的活跃线程数,基本可以定位。
6. 一个真实的排查复盘:从“No suitable driver”到连接池打满的完整链路
6.1 排障路径一:驱动类和URL连不上
有一次同事反馈,一个Spring Boot服务在测试环境启动后,第一次访问数据库接口直接抛错:
java.sql.SQLException: No suitable driver found for jdbc:mysql://localhost:3306/demo看到这个异常,无非三类原因:
mysql-connector-java没有引入,或者版本被依赖冲突覆盖。driver-class-name写错,比如MySQL 8的驱动类应该是com.mysql.cj.jdbc.Driver,写成了旧的com.mysql.jdbc.Driver。- JDBC URL前缀写错,比如
mysql://而不是jdbc:mysql://。
排查顺序很简单:先看依赖里有没有驱动jar,用mvn dependency:tree看清楚版本;再检查配置里的driver-class-name和URL。Spring Boot 2.x之后,即使你不写driver-class-name,它也能根据URL自动推断驱动类,但推荐显式写,版本升级后不容易被绕弯。
6.2 排障路径二:连接池超时与打满
另一种现象更隐蔽。某天中午高峰期,服务开始大量报错:
SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms这个报错说明连接池里的连接全部被占用,新请求等不到连接。我当时做的排查链路是:
- 打开HikariCP的日志,确认池大小、活跃连接数。看Actuator的
hikaricp.connections.active指标,可以确认是不是真的满了。 - 到数据库端执行
SHOW PROCESSLIST,发现大量连接长时间处于Query状态,SQL内容高度相似,都是同一张统计报表的查询。 - 用
EXPLAIN分析那条SQL,发现索引没生效,全表扫描扫了千万行。 - 进一步看事务日志,发现该查询所在的
@Transactional方法里,还有一个第三方接口调用,锁连接时间被拉长了。
根因很清楚:慢SQL + 长事务,连接借出后迟迟不还,池子被打满。解决办法是组合拳:给大表查询的WHERE字段补上索引;把第三方接口调用移出事务;事务范围缩小到只做必要的读改写;慢SQL单独做数据汇总,而不是实时扫大表。这几个动作做完,连接池高峰活跃数从100降到了30以内。
6.3 排障路径三:时区、SSL、驱动版本这类隐藏坑
我最后总结几个容易忽略的配置问题,它们的报错方式都很迷惑。
- 时区问题:MySQL 8驱动连接时如果URL不带
serverTimezone,可能报“The server time zone value 'CST' is unrecognized”。解决办法是在URL上加serverTimezone=Asia/Shanghai。 - SSL告警:MySQL 5.7和8默认开启SSL,连接时会打印一大段告警日志,不算报错,但看着烦。如果不强制加密,就加
useSSL=false。 - 驱动与JDK版本不匹配:
mysql-connector-java8.x要求JDK 1.8以上,旧JDK强行用高版本驱动可能直接失败。 - classpath中存在多个版本的驱动jar:不同依赖传递进来的driver版本冲突,会出现“No suitable driver”或者驱动类内部方法找不到的怪异异常。解决方式就是先查依赖树,锁定一个指定版本。
这些坑单个看都不深,但它们往往和“连接池打满”“定时任务突然OOM”混在一起出现,增加了排障难度。所以我在看任何数据源报错时,都会强制先检查JDBC URL——它离问题最近,也最容易被忽略。
最后说一下我的体感。我自己的项目选型很“原始”:Spring Boot +spring-boot-starter-jdbc+ JdbcTemplate + 手写SQL。不引入ORM全家桶,配置少,行为透明,出问题查起来非常直接。但我也不是让大家抛弃MyBatis,而是建议每个人都应该花一晚上把原生JDBC的六步流程完整走一遍。走完之后你会明白,那些XML映射、注解驱动的特性,本质上都是在帮你生成从Connection到ResultSet的样板代码。一个实用小技巧:在本地开发时把spring.datasource.hikari.connection-timeout调小到5000ms,同时打开com.zaxxer.hikari=DEBUG日志,任何连接池的风吹草动都会立刻暴露,很多问题就能在开发阶段被提前发现,而不是等到线上故障才去查。