JDBC从入门到实战:连接池、PreparedStatement与事务控制全解析

发布时间:2026/10/21 16:23:49
JDBC从入门到实战:连接池、PreparedStatement与事务控制全解析
写这套笔记的时候我本来只是想记录一下自己从零学Java数据库编程的过程没想到越写越深到第30篇才发现光一个JDBC就能牵扯出这么多东西驱动加载、连接管理、事务边界、预编译、连接池……每一环都是生产环境里真正会遇到的坎。这篇我尽量按照我实际踩坑的顺序来写把JDBC从“能跑通”到“能扛住并发”的过程完整捋一遍适合正在学Java的同学也适合那些用ORM用惯了、想回头补一补底层原理的开发者。1. 先想明白一件事JDBC到底在解决什么问题1.1 没有JDBC的时代连数据库为什么这么痛苦现在大家写Java访问数据库要么直接用MyBatis、Hibernate这种ORM框架要么就用Spring的JdbcTemplate很少有人意识到JDBC本身是这一切的地基。那在没有JDBC之前Java程序要访问数据库是什么体验答案是各家数据库厂商自己出客户端库你连MySQL要用MySQL提供的一套API连Oracle又得学另一套API换个数据库几乎等于把数据访问层整个重写一遍。而且每个厂商的API风格差别很大有的用回调有的用游标有的直接用面向过程的函数全家桶学习成本极高。这就好比你去不同国家出差每个国家都要求你只能用本国的语言和本国的翻译官交流翻译官还不通用。JDBC做的事情就是统一了这一层它在Java应用和数据库之间架了一座标准桥梁定义好“怎么连接”“怎么发SQL”“怎么拿结果”这些动作的统一规范。你写的代码只需要面向JDBC接口底层换成哪个数据库就由对应的驱动去适配业务代码基本上可以做到零改动。1.2 JDBC的底层逻辑面向接口编程的经典教材案例JDBC的全称是Java Database Connectivity从名字就能看出来它强调的是连接能力。但如果你深入研究过它的源码结构就会发现JDBC本质上是一组接口规范真正干活的都是各个数据库厂商实现的驱动类。java.sql包下面躺着四个核心接口Driver、Connection、Statement、ResultSet这四个接口配合DriverManager这个管理类就构成了JDBC的完整工作流。这套设计的精髓在于“约定大于实现”。Sun公司现在叫Oracle只负责定标准比如Connection接口里就该有createStatement()方法Statement接口里就该有executeQuery()方法至于MySQL底层怎么和服务器通信、Oracle底层怎么解析SQL这些细节全部封装在驱动里。调用方根本不需要关心。这种模式放到今天的微服务架构里依然无处不在——你调某个远程接口的时候拿到的也是一个抽象接口底层HTTP还是RPC你并不关心。1.3 JDBC的工作流程其实只有六步很多新手学JDBC容易被各种代码片段绕晕其实整个流程非常固定我总结下来就是六步加载驱动、获取连接、创建Statement、执行SQL、处理结果、关闭资源。你写的任何JDBC代码无论多复杂拆开看都逃不出这六步。加载驱动这一步在老版本的JDBC中必须手动写Class.forName(com.mysql.jdbc.Driver)JDBC 4.0以后驱动可以自动注册这行代码很多时候可以省略了。获取连接就是DriverManager.getConnection(url, username, password)这一步背后其实干了非常多的事解析URL、和数据库服务器建立TCP连接、做身份认证、协商协议版本……所以它也是整个流程中最耗时的一步。后面几步就是纯粹的SQL操作了。最后关闭资源这一步被无数人忽略但恰恰是生产环境出问题最多的地方后面我会专门讲。2. JDBC核心组件逐个拆开看2.1 DriverManager和驱动的加载机制先聊驱动加载。很多人写Class.forName(com.mysql.cj.jdbc.Driver)的时候并不知道这行代码到底干了什么。Class.forName的作用是把指定类加载进JVM并触发静态代码块而MySQL驱动类里恰好有一个静态代码块往DriverManager里注册自己。如果你用的是新版驱动并且依赖是正常引入的那么即使不写Class.forName也能连上数据库因为从JDBC 4.0开始引入了一个叫SPI的自动加载机制。JVM会在启动时扫描classpath下的META-INF/services/java.sql.Driver文件自动把里面声明的驱动类加载并注册。这也是为什么很多教程里代码能跑通但你删掉那句Class.forName也没事。DriverManager.getConnection是获取连接的核心入口。它内部维护了一个CopyOnWriteArrayList存着所有已注册的驱动调用时会遍历每个驱动让它们分别尝试解析这个URL。那个能解析成功并且能建立连接的驱动就会返回一个Connection实例。所以你的classpath里同时存在MySQL驱动和PG驱动也没关系只要URL写得正确DriverManager能找到正确的驱动。驱动类加载这块有一个常见误区有人会把Class.forName放在每次获取连接的方法里反复执行。其实这个注册动作只做一遍就够了重复加载没什么好处。我一般习惯把这段逻辑放在静态代码块里保证类首次加载时执行一次即可。2.2 Connection、Statement、ResultSet三件套Connection代表的是一条物理数据库连接。它不只是用来执行SQL的还承载了事务控制commit、rollback、setAutoCommit、元数据查询getMetaData)、保存点setSavepoint这些能力。可以说Connection是整个JDBC会话的门面。但注意它本身不是线程安全的一个连接同一时刻只能被一个线程使用这也是为什么后面必须引入连接池。Statement家族有三兄弟Statement、PreparedStatement、CallableStatement。最简单的Statement就是直接把SQL字符串扔给数据库执行适合一次性语句。PreparedStatement是预编译语句执行前先把SQL骨架发给数据库编译之后再用参数填充占位符。CallableStatement则是用来调用存储过程的。实际开发中PreparedStatement的使用率远高于另外两个原因后面会说。ResultSet是查询结果的载体。它内部本质是一个游标刚开始指向结果集第一行之前的位置每次调用next()游标下移一行返回boolean表示是否还有下一行。从ResultSet里取数据可以用getString、getInt、getDate等类型化方法也可以用万能的getObject。同时ResultSet还带着ResultSetMetaData元数据信息告诉我们一共有几列、列名叫什么、列的数据类型是什么这也是很多ORM框架能自动做结果映射的基础。2.3 为什么说PreparedStatement是安全底线这个问题值得多说几句。很多初学者贪图方便喜欢用字符串拼接的方式写SQLString sql SELECT * FROM user WHERE name name ; Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql);这段代码在功能上是能跑的但它有一个致命问题SQL注入。如果name的值是 OR 11那拼接出来的SQL就变成了SELECT * FROM user WHERE name OR 11这个查询会把整张表的数据全部查出来。更可怕的是如果传入; DROP TABLE user; --数据库会直接执行删除表的语句。PreparedStatement通过参数占位符从根源上解决了这个问题。SQL骨架和参数值是分开传输的数据库会把参数当作纯数据来处理而不是当作SQL指令去解析。即使参数里包含OR 11它也只是一个普通字符串值不会产生语义变化。除了安全PreparedStatement在性能上也有优势。同一个SQL骨架只要编译一次后续重复执行只需要传新的参数值省掉了重复的SQL解析和优化过程。这在批量插入场景下效果极其明显String sql INSERT INTO user (name, age) VALUES (?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i 10000; i) { ps.setString(1, user i); ps.setInt(2, 20 i % 10); ps.addBatch(); if (i % 500 0) { ps.executeBatch(); } } ps.executeBatch(); }批处理模式加上预编译插入一万条数据的时间从分钟级降到了秒级。这是我在实际项目中对比过多次的结论。3. 实操手写一个能支撑日常开发的JDBC封装3.1 环境准备和依赖引入这部分我按最常用的组合来JDK 8、MySQL 8.x、Maven管理依赖。如果你用的是Oracle或者PG思路完全一致只需要替换驱动类和URL格式。Maven项目里引入MySQL驱动很简单dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency注意旧版坐标是mysql:mysql-connector-java8.0.31之后的版本坐标改成了com.mysql:mysql-connector-j。这个细节很容易被忽略如果你在Maven中央仓库搜不到旧坐标的包或者下载到很老的版本多半是坐标写错了。MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver连接URL也要带上时区参数否则会报Server returns invalid timezone异常jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8useSSLfalse在本地开发时是必要的因为自签名证书会让连接检查失败。生产环境如果数据库配置了SSL要改成useSSLtrue并配上证书相关内容。characterEncodingutf8是解决中文乱码的第一个关键点。3.2 写一个工具类先把连接管理统一一开始我写JDBC代码每个类里都重复写一遍DriverManager.getConnection后来发现维护成本太高URL和账号密码改一处所有类都要跟着改。所以我建议第一步就是抽一个工具类出来。import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这个工具类看着简单但有几个细节值得注意。PASSWORD直接写死在代码里只适合本地开发真实项目一定要放到配置中心或者环境变量里而且要对配置文件做脱敏处理。数据库密码这种敏感信息一旦跟着代码进入Git仓库基本等于裸奔。从Connection的获取频率来看每次都让DriverManager重新建一条连接其实很浪费这个问题等到第4节讲连接池的时候再展开。此处先保持简单把核心逻辑讲透。3.3 增删改查的完整实现注意try-with-resources讲完了工具类我们来看看具体的数据操作。我用最典型的INSERT和SELECT来示范掌握了这两个UPDATE和DELETE就是换换SQL的事。插入数据的写法public int insertUser(String name, int age) { String sql INSERT INTO user (name, age) VALUES (?, ?); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, name); ps.setInt(2, age); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } }这里我用了try-with-resources语法它会在代码块结束时自动调用close()方法省去了手写finally里释放资源的麻烦。PreparedStatement和Connection都实现了AutoCloseable接口所以可以放在try的资源列表里。查询数据的写法稍微长一点因为需要处理ResultSetpublic static ListMapString, Object query(String sql, Object... params) { ListMapString, Object list new ArrayList(); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { ResultSetMetaData metaData rs.getMetaData(); int columnCount metaData.getColumnCount(); while (rs.next()) { MapString, Object row new HashMap(); for (int i 1; i columnCount; i) { row.put(metaData.getColumnLabel(i), rs.getObject(i)); } list.add(row); } } } catch (SQLException e) { throw new RuntimeException(e); } return list; }这段代码里用ResultSetMetaData动态获取列名而不是硬编码rs.getString(name)好处是SQL怎么变结果映射都能自适应。缺点是通过Map拿数据之后还要手动转型类型安全差一些。真实项目中如果有强类型需求可以用BeanPropertyRowMapper之类的工具做自动映射但那已经是JdbcTemplate的领域了。有个细节值得提一下ResultSet的关闭可以单独放在内层try里也可以让外层try一并接管。我习惯把ResultSet放进内层try因为有些驱动要求ResultSet必须先于Statement关闭这个嵌套顺序能保证关闭时序正确。3.4 事务控制别让commit和rollback成为玄学JDBC默认情况下每条SQL执行完都会自动提交setAutoCommit(true)是默认状态。但在真实的业务场景里比如转账操作扣款和加款必须作为一个原子操作要么都成功要么都失败。这时候就必须手动控制事务了。事务控制的标准模板如下Connection conn null; try { conn JdbcUtil.getConnection(); conn.setAutoCommit(false); // 业务操作1扣款 String sql1 UPDATE account SET balance balance - ? WHERE id ?; try (PreparedStatement ps conn.prepareStatement(sql1)) { ps.setBigDecimal(1, amount); ps.setInt(2, fromAccountId); ps.executeUpdate(); } // 业务操作2加款 String sql2 UPDATE account SET balance balance ? WHERE id ?; try (PreparedStatement ps conn.prepareStatement(sql2)) { ps.setBigDecimal(1, amount); ps.setInt(2, toAccountId); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { // 回滚事务保证数据一致性 if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException(e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这个模板有几个关键点。第一setAutoCommit(false)必须在执行第一条SQL之前调用。第二rollback()和commit()只能选一个异常走rollback正常走commit。第三close()之前把setAutoCommit(true)恢复因为连接池复用时如果连接还保持着false的自动提交状态下一次使用的人就莫名其妙地被卷进一个事务里了。还有一个容易被忽略的点事务的隔离级别。MySQL默认是REPEATABLE_READ这在绝大多数业务场景下是够用的。但如果你在事务里先查询再更新并且对一致性要求极高要留意间隙锁导致的死锁问题。这部分内容很深与JDBC本身关系不大属于数据库引擎层面的事情这里先记住一个结论事务越短越好不要把远程调用、慢查询、文件操作塞进事务里。4. 生产环境里的必修课连接池4.1 为什么不能每次都手动建连接如果你是按前文的写法去写生产代码上线第一个星期就可能碰到一个诡异现象数据库连接数飙升响应越来越慢最后直接报Too many connections。原因很简单DriverManager.getConnection每次都会新建一条物理连接而数据库服务端能同时接受的连接数是有上限的。MySQL默认max_connections是151你20个线程池的并发接口每个请求都新建连接瞬间就能把连接数打满。更重要的是创建一条数据库连接要经历TCP握手、身份认证、权限校验等步骤平均耗时在几十毫秒到上百毫秒之间这个开销对于高频接口来说完全不可接受。连接池的解决方案是复用。池子里面先放好一批连接谁用谁借用完归还省掉了反复建连和断连的开销。打个比方自己动手每次要用才去河里挑水连接池就是家里装了个蓄水池提前蓄好水随用随取。4.2 主流连接池该怎么选Java生态里常用的连接池有HikariCP、Druid、C3P0、DBCP。现在新项目基本就是在HikariCP和Druid之间二选一了。HikariCP是目前性能最好的连接池Spring Boot 2.x之后的默认选择就是它。它的设计原则是极致的快字节码层面做了很多优化代码量小且无第三方依赖。如果你只需要一个靠谱的连接池选它准没错。Druid是某国内团队开源的工具最大的亮点是自带监控面板和SQL统计功能可以对慢查询、活跃连接数、SQL执行频率做可视化监控。在需要排查数据库性能问题的场景下Druid的管理后台确实方便。代价是性能略低于HikariCP且依赖更多、更重。C3P0和DBCP属于老前辈了性能和维护活跃度都不如前面两个除非是在维护远古项目否则不建议新项目使用。我的选择逻辑很简单需要监控、喜欢可视化面板的用Druid追求极致性能或者不想引入额外重依赖的用HikariCP。4.3 连接池关键参数背后的逻辑以HikariCP为例核心参数其实就这几个参数建议值说明maximumPoolSize20池中最大连接数不是越大越好minimumIdle5池中最小空闲连接数connectionTimeout30000获取连接的超时时间单位毫秒idleTimeout600000空闲连接最大存活时间单位毫秒maxLifetime1800000连接最大生命周期单位毫秒maximumPoolSize这个参数要重点说。很多人以为连接数配得越大性能越好其实是误区。每条连接都会占用数据库服务端的内存和线程资源连接数一旦超过数据库的承载能力不仅没有加速反而会因为上下文切换和锁竞争拖慢整体速度。我见过一个项目把maximumPoolSize配到200数据库直接被打挂。对于大多数业务系统20到50就足够了具体数值要根据压测结果来定。maxLifetime为什么会存在因为数据库服务器和应用之间隔着网络中间可能有防火墙或负载均衡器它们会把长时间空闲的TCP连接静默回收掉。如果应用不知道这个变化还在用那条“已经死掉的连接”去执行SQL就会报出类似Connection is not available或者底层Socket超时的错误。所以连接池要主动设置一个生命周期在连接失效之前把它关掉换新的。maxLifetime一般建议比数据库wait_timeout短一点。HikariCP的基本用法如下Spring Boot环境下可以直接用配置项定义HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8); config.setUsername(root); config.setPassword(your_password); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); HikariDataSource dataSource new HikariDataSource(config); // 之后直接从这个dataSource取连接 Connection conn dataSource.getConnection();用上了连接池以后之前JdbcUtil里的getConnection()就要改掉了改成从dataSource获取连接。连接池也承担了验证连接是否可用的职责HikariCP默认有connectionTestQuery和validationTimeout机制会在你获取连接时自动做一次轻量校验确保拿到手的连接是能用的。5. 实战中踩过的坑整理成速查表5.1 ClassNotFoundException和数据库连接失败这个错误发生在加载驱动阶段。常见原因有三个依赖没引对、坐标写错、类名写错。MySQL 8对应com.mysql.cj.jdbc.DriverMySQL 5.x对应com.mysql.jdbc.Driver。如果你从网上找老代码看到的是5.x的类名而项目里引的是8.x驱动就会报错。把依赖和类名统一一下就能解决。Communications link failure这类连接失败错误排查思路是这样的先确认网络通不通ping一下数据库IP再确认端口通不通telnet试试再看数据库账号有没有权限从当前IP访问最后确认URL里的地址、端口、库名拼写是否正确。我见过最离谱的一次是同事把URL里的端口从3306打成了3036排查了半天才发现是手滑。5.2 连接泄漏最隐蔽的性能杀手连接泄漏指的是连接用完了没有被关闭数据库连接数一直在涨。这个问题比你想的更隐蔽连接池里的连接被借走还没还池子里的可用连接越来越少直到耗尽。更麻烦的是由于连接池还会不断尝试新建连接前期的症状只是响应变慢不会直接报错等到连接数打满直接雪崩。排查连接泄漏首先看代码里是否所有分支都关闭了连接。用try-with-resources是防止泄漏的最有效手段但要注意try-with-resources的关闭顺序是逆序的后创建的先关Connection最后关这个顺序正好是我们期望的。另一个排查手段是借助连接池监控Druid的可视化面板能看到当前活跃连接数如果某个接口调用后活跃连接数不降基本可以确定泄漏源就在那个接口附近。5.3 中文乱码一个参数引发的惨案中文乱码是JDBC入门时最经典的问题。现象很统一写入数据库的数据在Java端正常读出显示正常但在数据库客户端工具里看却是乱码或者反过来数据库里正常的数据查出来变成问号。排查思路要分两层看连接层和存储层。连接URL上已经加了characterEncodingutf8那就确保数据库表也是utf8或utf8mb4编码。这里插一句存中文强烈建议用utf8mb4因为真正的utf8在MySQL里最多只能存3字节像emoji这种4字节字符会直接插入失败。如果表和连接都确认是utf8客户端工具还是乱码那就看一下工具本身的显示编码设置。写代码的时候最怕这种叠加问题每层单独看都是对的放在一起就不对。5.4 参数绑定setObject和setNull的坑用PreparedStatement传参时如果传进去的Java对象是null直接调用setObject(i, null)会报SQLException——因为驱动无法从null推断出SQL类型。解决方法是调用setNull(i, Types.VARCHAR)显式指定类型。这个细节在业务中很常见新增记录时某些字段允许为空如果直接用setObject传null就会炸。另一个问题是日期时间的处理。java.util.Date和java.sql.Date是两个不同的类型PreparedStatement没有重载setDate来接收java.util.Date的方法所以传日期参数时要先转换成java.sql.Timestamp或者java.sql.Date。从ResultSet里取日期时同理取出来的是java.sql.Timestamp在业务层要再转成用户习惯的java.util.Date。这些问题在用MyBatis的时候都被自动处理了所以才显得JDBC繁琐但正是因为繁琐才值得把原理搞清楚。5.5 SQL注入的另一种形态排序字段和表名参数化能防住值注入但防不住结构注入。比如排序功能客户端传过来一个orderBy字段有人图省事直接拼到SQL里String sql SELECT * FROM product ORDER BY orderBy;orderBy如果传的是id; DELETE FROM product; --照样能执行恶意SQL。问题在于PreparedStatement的参数占位符只能用在值的位置表名、列名、排序关键字这些SQL结构部分是没法参数化的只能通过白名单机制解决提前定义好允许的列名列表客户端传进来的值必须匹配列表中的项否则就拒绝。我处理这类问题时一般会把允许的排序字段做成枚举映射传进来的字符串只在枚举里做匹配匹配不上就使用默认排序。千万不要因为“反正参数化能防注入”就放松对拼接内容的校验。最后分享一个我实战中的小习惯JDBC这个东西用ORM用久了的人会觉得它笨重但我觉得越底层的东西越值得花时间搞明白。一个很实在的训练方法是给自己定个小目标用纯JDBC手写一个Mini版的ORM框架不需要支持复杂的关联查询能搞定单表映射就行。整个过程会让你把反射、动态代理、注解解析、泛型这些Java核心知识点全串起来。我当初做完这个小项目之后再去看MyBatis和Spring的源码理解速度完全不一样了。笔记写到这里其实已经超出“笔记”的范畴了更像是一份从入门到上手的完整踩坑记录。如果你正卡在某个JDBC报错上可以对照上面的速查表逐个排查。遇到解决不了的问题优先怀疑连接没关、URL参数不对、编码不一致这老三项——大多数问题最后都出在这三个地方。