☰
JDBC实战与Spring Boot集成:连接池、事务与排障全攻略
2026/10/9 14:23:07 网站建设 项目流程

先问个问题:你上一次自己写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中的连接
PreparedStatementPreparedStatementSetter/参数数组#{}占位符
ResultSet手动逐行处理RowMapperresultMap / 自动映射
事务边界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: 60000

Spring 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防火墙和敏感操作审计。这两者的选择,我理解是这样的:

对比项HikariCPDruid
性能非常快,社区公认成熟稳定,性能略逊但差距有限
监控提供基础指标,需配合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=true

useServerPrepStmts=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

这个报错说明连接池里的连接全部被占用,新请求等不到连接。我当时做的排查链路是:

  1. 打开HikariCP的日志,确认池大小、活跃连接数。看Actuator的hikaricp.connections.active指标,可以确认是不是真的满了。
  2. 到数据库端执行SHOW PROCESSLIST,发现大量连接长时间处于Query状态,SQL内容高度相似,都是同一张统计报表的查询。
  3. 用EXPLAIN分析那条SQL,发现索引没生效,全表扫描扫了千万行。
  4. 进一步看事务日志,发现该查询所在的@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日志,任何连接池的风吹草动都会立刻暴露,很多问题就能在开发阶段被提前发现,而不是等到线上故障才去查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询