
做 Java 开发早晚要面对一个绕不开的大问题怎么让代码和数据库顺畅对话。JDBCJava Database Connectivity就是 Java 官方给出的一套访问关系型数据库的标准接口。它不是什么高深玄学本质就是写代码的人拿着同一套 API去操作不同的数据库产品。不管底层是 MySQL、PostgreSQL、Oracle 还是 SQL Server只要厂商提供了对应的驱动你的 Java 代码就能用几乎一样的方式完成增删改查。这篇内容写给三类人刚学完 Java 基础、想在项目里连数据库的新手用了一个学期 JDBC 但只会照抄模板的初级开发以及用框架久了、想回头搞清楚数据访问底层原理的进阶同学。读完这篇文章你不仅能跑通一个完整的 JDBC 程序还能理解它为什么这么设计、遇到报错怎么排查、生产环境该怎么配置连接池。JDBC 这门手艺值得认真花一周时间吃透。1. JDBC 到底解决的是什么问题1.1 统一接口把“方言”翻译成“标准话”先问一个问题如果没有 JDBC你要连接不同数据库会是什么体验答案很残酷。在 JDBC 出现之前Java 程序访问数据库往往要靠各家数据库厂商自己的客户端库。你用 A 数据库时调的是 A 的 API切到 B 数据库时所有代码推倒重来。这就像你到一个陌生城市每家餐厅都用自己的语言报菜名一顿饭都吃不安稳。JDBC 的聪明之处是定义了java.sql这个包下一整套抽象接口。接口替你把连接、执行 SQL、处理结果、事务这些事全部声明好但具体怎么实现交由各数据库厂商去完成。厂商把实现打包成一个 jar 文件这就是我们常说的“驱动”。所以你在 Maven 里引入那个mysql-connector-java本质上就是把 MySQL 厂商对 JDBC 接口的实现引进项目。你的业务代码面向接口编程而不是面向某一个数据库。换一个数据库调整 URL、驱动类、方言 SQL就能平移大部分代码。这是 JDBC 为 Java 生态解决的第一个大问题让数据访问层具备可移植性。1.2 连接 URL数据库的“门牌号与钥匙包”连接数据库第一行要面对的就是 URL。它的格式长得像一串编码拆开看其实非常直白jdbc:mysql://localhost:3306/school?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8依次拆解jdbc:mysql是协议前缀告诉 DriverManager 用哪个驱动去处理。localhost:3306是数据库所在的主机和端口默认端口可以省略但显式写出来更清晰。school是数据库名。?后面是连接参数参数之间用分隔。三个参数是我个人强烈建议配置的useSSLfalse本地开发和大部分内网场景不需要 SSL不设置的话 MySQL 8 会打印一大段警告日志看着心烦性能也有微小损耗。serverTimezoneAsia/ShanghaiMySQL 8.x 之后对时区校验变严格不指定会直接报错或者时间偏差 8 小时。别看只是一个小参数踩过这个坑的人绝对不少。characterEncodingutf8保证客户端和数据库之间传输中文不乱码。注意如果你的项目要存 emoji 表情这类四字节字符utf8是不够的要写成utf8mb4并且数据库表本身的字符集也要对应设置。2. 核心 API 逐个拆解连接、语句、结果集2.1 DriverManager 与 Connection会话从哪儿来Class.forName(com.mysql.cj.jdbc.Driver)这一行在 JDBC 4.0 之前是必须写的之后引入了 SPI 机制驱动会被自动加载写不写都不影响功能。不过很多老项目的代码里还留着一行新代码里可以直接省略少一行是一行。真正重要的是DriverManager.getConnection()返回的Connection对象。这个对象代表一条真实的数据库会话后续所有操作都围绕它展开。它默认处于自动提交模式——每执行一条 SQL 就立即提交这个特性极其重要后面讲事务的时候我会重点说。Connection 对象还是线程不安全的同一个 Connection 不能同时被多个线程共享和并发使用。这个问题在业务代码里不常见一旦出现通常是用了全局静态 Connection属于非常典型的反面教材。2.2 Statement 与 PreparedStatement为什么强烈推荐后者很多入门教程里会先讲Statement因为它最简单直接拼字符串就能执行。但在我实际工作和面试别人的经历里只要看到业务代码还在用Statement拼 SQL基本可以直接判定这段代码没过质量关。两者的核心差异先列个表对比项StatementPreparedStatementSQL 执行方式每次执行都拼接完整 SQL数据库每次都要解析编译预编译一次后续只用参数填充可复用执行计划安全性存在严重 SQL 注入风险参数化查询结构固定有效防注入性能高并发下解析开销明显重复执行时性能更优可读性SQL 字符串里全是拼接符维护痛苦用?占位结构清晰SQL 注入是怎么回事假设有一条查询String sql SELECT * FROM user WHERE name name ;如果name传的是zhangsan OR 11拼出来就是SELECT * FROM user WHERE name zhangsan OR 11这条 SQL 会无条件查出user表里所有行如果攻击者再加一句UNION SELECT ...整个表都能被拖走。用PreparedStatement之后SQL 结构被预编译固定参数只作为值传入注入代码被当成普通字符串彻底失去拼接能力String sql SELECT * FROM user WHERE name ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, name);铁律任何 Java 代码里禁止用字符串拼接 SQL。这条没有例外包括排序字段和表名——它们确实不好用占位符替代但白名单校验后再拼接是必须的。2.3 ResultSet把关系表搬到 Java 里ResultSet是执行查询后返回的结果集合你可以把它想象成一沓按行叠放的纸。rs.next()的作用就是问问“还有下一张吗有就翻过去”返回false就说明到底了。while (rs.next()) { int id rs.getInt(id); String name rs.getString(name); }取值的方法对应数据库类型整数用getInt字符串用getString小数用getBigDecimal时间用getTimestamp。参数可以用列的索引从 1 开始也可以用列名我建议一律用列名可读性更好也不怕 SELECT 的列顺序调整。ResultSet 默认的游标只能向前移动如果需要前后滚动创建 Statement 时要指定类型Statement stmt conn.createStatement( ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY );还有一种很好用的能力是拿元数据。比如你要做动态表格导出不提前知道列名和列数可以这样ResultSetMetaData meta rs.getMetaData(); int columnCount meta.getColumnCount(); for (int i 1; i columnCount; i) { String columnName meta.getColumnName(i); }3. JDBC CRUD 全流程实战3.1 环境准备依赖、驱动与建表理论讲再多不如双手敲代码。先准备一个工程Maven 依赖如下dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency再准备一张简单的学生表CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, score DECIMAL(5,2) );3.2 完整 CRUD 示例代码先写一个连接工具类把重复代码收拢起来public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/school?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD root123; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }查询操作public void queryStudentByScore(BigDecimal minScore) { String sql SELECT id, name, score FROM student WHERE score ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setBigDecimal(1, minScore); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { int id rs.getInt(id); String name rs.getString(name); System.out.println(id - name); } } } catch (SQLException e) { e.printStackTrace(); } }新增、更新、删除都是executeUpdate()返回的是受影响行数public int insertStudent(String name, int age, BigDecimal score) { String sql INSERT INTO student(name, age, score) VALUES (?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, name); ps.setInt(2, age); ps.setBigDecimal(3, score); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }更新就是把 SQL 换成UPDATE student SET score ? WHERE id ?删除换成DELETE FROM student WHERE id ?套路完全一致。代码里的try-with-resources会在语句执行完后自动关闭资源这是 JDK 7 以后的标准写法。3.3 资源关闭顺序与 try-with-resources先明确一条原则谁打开谁关闭后打开的先关闭。正确的关闭顺序是ResultSet→Statement→Connection。一旦Connection关闭它创建的Statement和ResultSet也会自动失效但最稳妥的方式还是按顺序手动逐个关闭。在没有 try-with-resources 的旧代码里经常看到有人只在 finally 里关了 ConnectionStatement 和 ResultSet 丢在那里不管。短期看不出问题时间一长数据库端的游标资源耗尽就会出现莫名其妙的“Too many open cursors”之类的报错。try-with-resources 会让代码优雅很多所有实现了AutoCloseable的对象都会在 try 块结束后按逆序自动close()。但这里有个细节如果你把三个对象都写在 try 的声明里它们之间是有依赖关系的——Connection 创建 StatementStatement 执行后产生 ResultSet那么 ResultSet 会先被关闭然后是 Statement最后是 Connection。顺序刚刚好。经验之谈Connection 持有时间要尽量短。我曾经接手过一个项目把 Connection 放在类成员变量里一个实例用一整天不关闭上线当天连接数直接打满数据库拒绝服务。连接这个东西用完就放回去比什么都强。4. 事务控制高并发场景下的安全底线4.1 手动提交模型与回滚上一节提到 Connection 默认是自动提交也就是每执行一条 SQL 就立刻持久化。这在单条语句场景下没问题但遇到多步操作必须保证原子性时就麻烦了。举一个经典的转账场景A 扣 100B 加 100。如果两条 UPDATE 之间程序崩了或者数据库报错就会出现 A 扣了钱但 B 没到账的情况。这时候必须引入手动事务Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); String deduct UPDATE account SET balance balance - 100 WHERE user_id ?; try (PreparedStatement ps conn.prepareStatement(deduct)) { ps.setInt(1, 1001); ps.executeUpdate(); } String add UPDATE account SET balance balance 100 WHERE user_id ?; try (PreparedStatement ps conn.prepareStatement(add)) { ps.setInt(1, 1002); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }这里有三个非常关键的细节setAutoCommit(false)必须在执行第一条业务 SQL 之前调用。commit()和rollback()必须在 finally 之前明确调用不能依赖 Connection 关闭时自动提交。这里不适合把 Connection 放在 try-with-resources 里因为你需要手动控制提交和回滚的时机自动关闭会干扰这个流程。4.2 隔离级别与保存点事务隔离级别解决的是并发事务互相干扰的问题。四个级别从宽松到严格隔离级别脏读不可重复读幻读READ_UNCOMMITTED可能可能可能READ_COMMITTED不可能可能可能REPEATABLE_READ不可能不可能可能InnoDB 下基本避免SERIALIZABLE不可能不可能不可能MySQL InnoDB 默认是REPEATABLE_READ通过 MVCC 机制已经基本解决了幻读问题。设置方式是conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ)。一个事务里如果有多步操作偶尔需要只回滚其中一部分。Java 提供了保存点机制Savepoint sp conn.setSavepoint(afterInsert); // 执行一些操作 conn.rollback(sp); // 保存点之前的操作仍然有效之后的操作被撤销注意Savepoint 依赖存储引擎支持MySQL 下 InnoDB 支持MyISAM 不支持事务也就没有保存点的概念。建表时选错存储引擎事务代码全部白写。5. 连接池与生产级配置思路5.1 为什么不能每次都新建连接我见过不少从入门阶段走出来的开发者写完一个能跑的 CRUD 就认为大功告成完全没想过连接本身的成本。每次DriverManager.getConnection()背后发生了什么客户端与数据库服务器建立 TCP 连接至少一次网络往返。数据库端做身份认证、权限校验。数据库为该会话分配资源包括内存缓冲、会话状态。换句话说创建连接不是一件便宜的事。拿生活中的例子类比如果你开了一家面馆每个顾客进门你都要现场砌一个灶台那生意根本做不下去。连接池就是那排提前备好的灶台——提前创建一批连接放在池子里业务来了取一个用用完归还不够了再创建新的。5.2 HikariCP 配置与参数解析市面上常见的连接池有 HikariCP、Druid、dbcp、c3p0我个人的选择是 HikariCP。无它就是快、轻Spring Boot 2.x 默认内置了它很多云厂商的基准测试里它表现也稳。一个基础配置示例HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/school?useSSLfalseserverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(root123); config.setMaximumPoolSize(10); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); HikariDataSource dataSource new HikariDataSource(config);核心参数逐个说参数默认值含义与建议maximumPoolSize10连接池最大连接数不是越大越好200 个连接可能直接拖垮数据库minimumIdle与 max 相同池中保持的最小空闲连接数固定链接请求量的系统可设置得与 max 一致connectionTimeout30000 ms获取连接的超时时间超过就抛异常避免线程无限等待idleTimeout600000 ms空闲连接存活时间只在 minimumIdle maximumPoolSize 时生效maxLifetime1800000 ms连接最大存活时间要小于数据库的 wait_timeout否则连接被数据库端回收后池里全是坏连接关于 maximumPoolSize 怎么定我给一个保守的起步思路参考公式“连接数 ((核心线程数 * 2) 有效并发数)”再通过压测去调整。实际上多数中小业务10 到 20 个连接就绰绰有余了。连接池的配置宁可偏低再扩容也不要一上来就设置几百个连接数据库资源是有限的。6. 高频报错排查与避坑实录6.1 驱动加载与连接异常JDBC 的报错信息其实非常规整看准了就好处理。我把高频异常整理成速查表异常信息原因解决思路ClassNotFoundException: com.mysql.jdbc.Driver驱动 jar 没引入或 MySQL 8 里类名写错MySQL 8 的驱动类是com.mysql.cj.jdbc.Driver或者不用手写 Class.forNameCommunications link failure数据库没启动、端口不通、主机名写错先在本机命令行测ping端口再用数据库客户端连一次确认Access denied for user rootlocalhost密码错误或账号没有远程登录权限校验密码远程连接需要授权账号Unknown database school数据库名写错或库不存在登录数据库执行SHOW DATABASES;确认Connection is closed连接已经被关闭却还在使用检查代码里是否有提前 close 的逻辑或连接已被连接池判定失效最后一种报错在连接池场景下很经典连接空闲时间超过了数据库端的wait_timeout数据库已经把连接断掉但连接池不知情把坏连接借给了业务。解决方法是确保maxLifetime小于数据库的wait_timeout同时连接池开启连接有效性检测。6.2 SQL 乱码与数据丢失中文乱码几乎都是字符集不统一造成的。排查时从三条链路依次确认数据库连接 URL 是否带了characterEncodingutf8。数据库表本身的字符集是不是utf8mb4。应用服务器的默认字符集设置。插进去的数据变成“问号”问题大概率出在连接 URL从数据库查出来是正常的但 HTTP 接口返回乱码就要检查 Web 层的编码过滤器了。另一个容易忽略的点MySQL 8 的utf8实际是utf8mb3不支持四字节字符。如果想存 emoji连接参数和表字符集都得改成utf8mb4字段类型对应的VARCHAR长度也需要重新评估。6.3 批量操作与性能隐患JDBC 的性能坑有一个非常隐蔽但危害极大的典型N1 查询。场景是这样先查出一批学生然后在循环里逐个查每个学生的成绩。如果有 1000 个学生就会产生 1 条主查询加 1000 条子查询一共 1001 次数据库往返。正确做法是把成绩表也一次性查出来在内存里做关联。批量插入场景下有一个直接提升性能的配置String sql INSERT INTO student(name, age, score) VALUES (?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i 0; i 10000; i) { ps.setString(1, 学生 i); ps.setInt(2, 20); ps.setBigDecimal(3, new BigDecimal(80)); ps.addBatch(); if (i % 500 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit(); }这里有个容易被忽略的点MySQL 连接 URL 上加rewriteBatchedStatementstrue驱动的批处理才会真正被重写成多值 INSERT 一次性发送性能可以提升好几倍。不加这个参数addBatch也只会逐条发送批处理名存实亡。这趟走下来你会发现 JDBC 其实特别朴素一套接口、一个驱动、几个对象剩下的都是规范。可正是这套朴素的规范支撑起了整个 Java 数据访问生态。Hibernate、MyBatis、Spring Data 层层封装剥开皮到底层还是 Connection、Statement、ResultSet 老三样。我个人在实际项目中最大的体会是把 JDBC 的细节吃透再去用任何 ORM 框架都会有降维打击般的清晰感。别人在百度报错的时候你已经能从“驱动加载”“连接池参数”“事务边界”几个维度直接定位问题根源。所以别急着跳过 JDBC 去学花哨框架把文章里的代码亲手敲一遍试着在不查资料的情况下写一个带事务的转账程序你会感谢这段练习。最后给一条实在的建议写 JDBC 代码时永远默认使用 PreparedStatement、永远手动管理事务边界、永远不留连接不关闭。这三个“永远”能帮你避开 90% 的数据库访问坑。