☰
JDBC连接MySQL增删改查全解:从PreparedStatement到事务与连接池
2026/10/2 10:21:52 网站建设 项目流程

简介:面向Java初学者、正在准备数据库编程练习或希望补强JDBC基础的中级开发者,这份教程PDF基于MySQL数据库详细演示Java程序完成增删改查(CRUD)的完整流程。全包仅含1个PDF文档,文件大小326KB,内容轻量精炼,目前已有5619人学习下载。教程从开发环境准备讲起,涵盖Eclipse、MySQL与Navicat的搭配使用,指导读者创建imooc数据库和Goddess表,设计id、name、mobile、email、address等字段并准备测试数据;随后逐步拆解DBUtil连接工具类的静态初始化、Goddess实体类封装,以及DAO层查询全部、按ID查询、新增、修改、删除等典型方法,并演示ResultSet结果集处理,使用PreparedStatement参数化语句有效防治SQL注入。同时点出JDBC与Hibernate、MyBatis等ORM框架的底层关系,帮助读者不仅学会入门写法,还能为后续理解框架原理和实际项目开发打下基础;整体结构清晰、代码示例可直接对照练习,适合自学或作为Java Web课程的辅助资料。

1. 从 JDBC 到 MySQL 增删改查:这是 Java 后端绕不开的第一道门槛

很多人在简历里写着“熟悉 Java”,但伸手写一段 JDBC 连接 MySQL 的代码却会卡壳——Class.forName 到底要不要写、mysql-connector-java 和 mysql-connector-j 有什么区别、getConnection 的 URL 里那串参数到底是什么意思。这个标题看起来是基础中的基础,却正好卡在所有 Java 后端入行者的必经之路上:JDBC 是 Java 访问关系型数据库的原始接口,MyBatis、Hibernate 这些 ORM 框架底层全都是它。把增删改查这四件事用 JDBC 亲手实现一遍,你对连接管理、SQL 注入、事务边界、资源释放的理解,会比直接上手框架扎实得多。这篇笔记不会只给你一段能跑的代码,而是把从驱动选择到参数配置、从增删改查到批量操作和事务提交的完整方案拆开讲,最后用几条踩坑记录帮你在面试和实际项目里少走弯路。

2. JDBC 为什么值得手写一遍:六步连接模型与 Statement 家族的选择

2.1 JDBC 在 Java 后端技术栈里的真实位置

JDBC(Java Database Connectivity)是 JDK 自带的数据库访问规范,它定义了一套统一的接口:java.sql.Driver、java.sql.Connection、java.sql.Statement、java.sql.ResultSet。MySQL 官方提供的驱动 jar 包,比如mysql-connector-j,就是这套接口在 MySQL 协议上的具体实现。你在代码里看到的大部分数据库操作工具,本质上做的事情都是:拿到一个 Connection,构造一条 SQL,执行后处理 ResultSet,最后把资源关掉。

为什么在 MyBatis 满天飞的今天还要手写 JDBC?因为框架帮你封装了太多细节,一旦遇到慢 SQL、连接泄漏、批量插入性能上不去这类问题,不懂底层你连排查方向都没有。MyBatis 的SqlSession底层就是Connection,Mapper接口的动态代理最终也是拼 SQL 交给PreparedStatement执行。把 JDBC 的六步模型写熟,你再看框架源码会轻松很多。

2.2 六步模型:从加载驱动到关闭连接

一次完整的 JDBC 调用包含六个步骤,缺一步都可能运行时报错或产生资源泄漏:

  1. 加载驱动(JDBC 4.0 之后可以省略,但写上没坏处)
  2. 建立连接(DriverManager.getConnection)
  3. 创建 Statement(普通 Statement 或 PreparedStatement)
  4. 执行 SQL 并拿到结果集(executeQuery / executeUpdate)
  5. 遍历 ResultSet 提取数据
  6. 按逆序关闭 ResultSet、Statement、Connection
// 完整六步:查询 users 表所有记录 import java.sql.*; public class JdbcDemo { public static void main(String[] args) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { // 1. 加载驱动,mysql-connector-j 8.x 可以省略,但保留可避免老版本兼容问题 Class.forName("com.mysql.cj.jdbc.Driver"); // 2. 建立连接,jdbc:mysql:// 是协议头,localhost:3306 是地址和端口 String url = "jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai"; conn = DriverManager.getConnection(url, "root", "123456"); // 3. 预编译 SQL,? 是占位符 String sql = "SELECT id, name, age FROM users WHERE age > ?"; ps = conn.prepareStatement(sql); ps.setInt(1, 18); // 4. 执行查询,返回结果集 rs = ps.executeQuery(); // 5. 遍历结果 while (rs.next()) { int id = rs.getInt("id"); String name = rs.getString("name"); System.out.println("id=" + id + ", name=" + name); } } catch (Exception e) { e.printStackTrace(); } finally { // 6. 逆序关闭资源,从最内层开始关 try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

这段代码里最值得注意的两个点是占位符和资源关闭。ps.setInt(1, 18)这种写法是 PreparedStatement 的核心价值:SQL 结构和数据分离,MySQL 服务端做预编译,既避免了拼接字符串带来的 SQL 注入风险,又在重复执行同一条 SQL 时省去重新解析的开销。资源关闭的顺序必须是 ResultSet 先关、Connection 最后关,因为 Connection 是底层物理连接的抽象,提前关掉会导致 Statement 和 ResultSet 全部失效。

2.3 为什么优先用 PreparedStatement 而不是 Statement

很多新手会问:既然 Statement 也能执行 SQL,为什么非要用 PreparedStatement?我给你的答案很简单——安全性和性能。用 Statement 拼字符串是这样的:

// 反例:字符串拼接 SQL,存在注入风险 String userName = "tom'; DROP TABLE users; --"; Statement st = conn.createStatement(); st.execute("SELECT * FROM users WHERE name = '" + userName + "'");

这段代码在真实环境里跑一次,users 表就没了。PreparedStatement 的做法是用?占位,由驱动把参数转义后传给服务端,用户输入永远不会被当成 SQL 指令执行。性能方面,MySQL 对prepareStatement执行的 SQL 会做服务端预编译(预编译开关默认开启,8.0 版本行为略有差异),执行一万次同构 SQL 时效率差距非常明显。还有一点容易被忽略:PreparedStatement 的setObject方法能自动处理 Java 类型和 MySQL 类型的映射,比如java.util.Date传到TIMESTAMP字段、BigDecimal传到DECIMAL字段,省去手动格式化的麻烦。

2.4 executeQuery 与 executeUpdate 的返回值语义

增删改查四类操作里,只有查询用executeQuery(),返回的是 ResultSet;插入、更新、删除统一用executeUpdate(),返回的是 int 类型的受影响行数。这个返回值在业务代码里有实际用途:插入后检查返回值是否为 1 判断是否成功;批量删除时如果返回值小于传入的 ID 数量,说明有记录已不存在。还有一种极少见的execute()方法,它既能执行查询也能执行更新,返回 boolean 表示第一个结果是否为 ResultSet,日常开发基本用不到,但面试偶尔会问。

// 更新操作:受影响行数的处理 String updateSql = "UPDATE users SET age = ? WHERE name = ?"; ps = conn.prepareStatement(updateSql); ps.setInt(1, 20); ps.setString(2, "tom"); int affected = ps.executeUpdate(); if (affected == 0) { System.out.println("没有匹配到名为 tom 的用户,可能是名字拼写错误"); } else { System.out.println("成功更新 " + affected + " 行"); }

受影响行数是 JDBC 操作中最直观的成功与否信号。注意 MySQL 默认驱动配置下,更新前后数据没变化时受影响行数可能返回 0(useAffectedRows 参数会改变这个行为),这是很多人调试时遇到的第一个“玄学”,后面避坑章节会详细说。

3. 把增删改查跑通:建表、驱动引入与一个完整可复用的 DAO

3.1 先造一张表和一个测试库:MySQL 侧的准备工作

写代码之前,MySQL 端需要先准备库和表。建议你创建一个独立的测试库,不要在生产库里练手。用 MySQL 8.0 的默认存储引擎 InnoDB,字符集统一 utf8mb4——这个字符集能存表情符号和生僻字,是 5.7 之后的主流选择。

-- 创建测试库,指定字符集和排序规则 CREATE DATABASE IF NOT EXISTS test_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE test_db; -- 创建用户表:id 自增主键,name 唯一索引,age 加默认值 CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, age INT NOT NULL DEFAULT 0, email VARCHAR(100) DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段建表 SQL 有几个细节值得留意。id INT AUTO_INCREMENT PRIMARY KEY是单机场景下最常见的自增主键写法,AUTO_INCREMENT 从 1 开始递增,删除记录后不会回退。name VARCHAR(50) NOT NULL UNIQUE给名字加了唯一约束,后面插入重复数据时可以直接看到 SQLIntegrityConstraintViolationException 的效果。DEFAULT 0对应热搜词里的“mysql设置默认值为0”,这个默认值在批量插入场景下能避免 NOT NULL 字段报错。created_at和updated_at用 TIMESTAMP 自动维护时间,让 JDBC 代码不用手动管理这两个字段。

3.2 Maven 工程引入 MySQL 驱动:版本选择是关键

JDBC 代码运行的前提是 classpath 里有 MySQL 驱动。Maven 项目在pom.xml里加依赖,注意 MySQL 官方 8.0 之后把 artifact 名称从mysql-connector-java改成了mysql-connector-j。

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.4.0</version> </dependency>

版本选择是这里唯一需要你决策的事。如果你连接的是 MySQL 5.7,用 8.x 驱动完全没问题,驱动向后兼容;如果连接的是 MySQL 8.0,千万不要用 5.1.x 的老驱动,会直接报CommunicationsException: Communications link failure。8.x 驱动对应com.mysql.cj.jdbc.Driver,5.x 驱动对应com.mysql.jdbc.Driver。这两个类名在 Class.forName 时写错是最常见的启动报错之一。如果你的项目用的还是 Spring Boot 2.x,它会默认管理一个 8.0.x 版本的驱动,通常不需要自己显式声明版本。

3.3 写一个 JDBC 工具类:把连接管理收敛到一个类里

增删改查的每个方法都要拿连接、关连接,重复代码太多。常见做法是封装一个JdbcUtil,把 URL、用户名、密码放到静态常量里,提供getConnection()和close()两个静态方法。

import java.sql.*; public class JdbcUtil { // 三个静态常量集中管理连接信息,正式项目通常从配置文件读取 private static final String URL = "jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { // 静态代码块确保驱动只加载一次 Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动加载失败,检查 maven 依赖"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(ResultSet rs, Statement st, Connection conn) { // 逆序关闭,每个资源独立 try-catch 避免一个关闭失败影响其他资源 if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (st != null) { try { st.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

这个工具类的设计有几个隐蔽但重要的点。static代码块里的Class.forName在整个 JVM 生命周期内只执行一次,避免了每次 getConnection 都重复加载驱动的开销。close方法接收三个资源参数,调用方可以传入 null(比如执行 update 操作时没有 ResultSet),工具类内部做了判空。URL 里的characterEncoding=utf8mb4参数保证中文和表情符号在传输过程中不会乱码——Java 侧默认使用 UTF-8 编码,但 MySQL 驱动需要明确告诉它连接用的字符集,否则可能回退到 ISO-8859-1。

3.4 UserDAO 的四个方法:增删改查的标准写法

工具类写完,增删改查的逻辑放在 DAO 层。这里明确一下 DAO 的职责边界:只负责数据库操作,不包含业务判断。每个方法都遵循“拿连接 → 预编译 → 设参数 → 执行 → 处理结果 → 关资源”的模式,我逐个写出来。

import java.sql.*; public class UserDAO { // 新增:插入一条用户记录,返回自增主键 public int insert(String name, int age, String email) { String sql = "INSERT INTO users(name, age, email) VALUES(?, ?, ?)"; // RETURN_GENERATED_KEYS 让驱动把自增主键带回来 try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, name); ps.setInt(2, age); ps.setString(3, email); int affected = ps.executeUpdate(); if (affected == 0) { return -1; } // 从结果集里取自增主键,注意它是 ResultSet 不是普通查询结果 try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { return keys.getInt(1); } } return -1; } catch (SQLException e) { e.printStackTrace(); return -1; } } // 删除:按 ID 删除,返回受影响行数 public int deleteById(int id) { String sql = "DELETE FROM users WHERE id = ?"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 更新:按 ID 更新邮箱,返回受影响行数 public int updateEmail(int id, String email) { String sql = "UPDATE users SET email = ? WHERE id = ?"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, email); ps.setInt(2, id); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 查询:按 ID 查询,返回 User 对象或 null public User findById(int id) { String sql = "SELECT id, name, age, email FROM users WHERE id = ?"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getInt("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); user.setEmail(rs.getString("email")); return user; } } return null; } catch (SQLException e) { e.printStackTrace(); return null; } } }

注意这里用到了 try-with-resources 语法,这是 JDK 7 引入的资源管理方式,Connection、PreparedStatement、ResultSet 都实现了 AutoCloseable 接口,语法会自动按声明的逆序关闭,代码里不再需要手写 finally。getGeneratedKeys()是插入场景的常规操作,Statement.RETURN_GENERATED_KEYS这个参数告诉驱动,执行完插入后把数据库生成的自增 ID 返回给 Java 侧,否则你插入完还要再查一次才知道 ID 是多少。调用方拿到返回的 ID 后可以继续做关联操作,比如往订单表插入一条关联记录。

// 实体类 User,字段与数据库表列一一对应 public class User { private int id; private String name; private int age; private String email; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } @Override public String toString() { return "User{id=" + id + ", name='" + name + "', age=" + age + ", email='" + email + "'}"; } }

实体类的字段类型映射遵循 JDBC 规范:MySQL 的 INT 对应 Java 的 int,VARCHAR/TEXT 对应 String。这里没有用复杂类型,是因为增删改查的基础场景不涉及 DATE、DECIMAL 这种需要额外处理的类型。如果你的表里有DECIMAL(10,2)类型的字段,Java 侧用BigDecimal接收,用 double 接收会有精度丢失风险——这是另一个高频踩坑点。

3.5 验证一下:从插入到查询的调用链

代码写完怎么验证?main 方法里跑一遍调用链是最直接的方式,我建议你按“插入 → 查询 → 更新 → 再查询 → 删除”的顺序走一遍。

public class Main { public static void main(String[] args) { UserDAO dao = new UserDAO(); // 插入一条记录 int id = dao.insert("张伟", 25, "zhangwei@example.com"); System.out.println("插入成功,自增 ID = " + id); // 按 ID 查询验证插入结果 User user = dao.findById(id); System.out.println("查询结果: " + user); // 更新邮箱 int updated = dao.updateEmail(id, "new-email@example.com"); System.out.println("更新行数 = " + updated); // 再次查询确认更新生效 User user2 = dao.findById(id); System.out.println("更新后查询: " + user2); // 删除 int deleted = dao.deleteById(id); System.out.println("删除行数 = " + deleted); } }

这段调用链的目的不是展示业务逻辑,而是验证每个 DAO 方法在真实数据库上是否按预期工作。insert返回的自增 ID 可以作为后续操作的输入,这样每次运行都不依赖表里已有数据,测试结果是可重复的。注意插入一条重复 name 时会抛 SQLIntegrityConstraintViolationException,这是唯一约束生效的表现,不是代码 bug,DAO 方法里 catch 住 SQLException 后打印堆栈,后续开发替换成自己的日志框架即可。

4. 连接参数、事务边界与批量插入:JDBC 里最影响线上行为的细节

4.1 连接 URL 参数逐个拆解:characterEncoding、useSSL 与 serverTimezone

jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4这串 URL 里每个参数都有明确用途,也是面试官最爱追问的细节。

useSSL=false的意思是关闭 SSL 加密。本地开发和测试环境用 false 可以避免 SSL 握手带来的连接延迟和证书配置烦恼;生产环境如果数据库和应用程序在同一内网且网络隔离良好,false 也能接受,但跨公网连接时必须开启 SSL(useSSL=true并配置证书),否则用户名密码和业务数据都是明文传输。需要注意的是 MySQL 8.0 驱动对 SSL 的策略有变化,高版本驱动默认会尝试 SSL 连接,没有证书时连接会失败,所以本地开发显式写useSSL=false是止损最干净的方案。

serverTimezone=Asia/Shanghai是 8.0 驱动的必填参数之一。旧版驱动默认取 JVM 时区,当服务器时区和数据库时区不一致时,TIMESTAMP类型的读写会出现几个小时到几十个小时的偏移。设置这个参数后,驱动会在连接时向 MySQL 发送时区信息,保证日期时间传输的一致性。更稳妥的做法是让 MySQL 服务端也配置成default-time-zone='+08:00',两边都明确为什么错乱就不会发生。

characterEncoding=utf8mb4前面提过,它控制的是字符集传输编码。MySQL 5.7 以上推荐 utf8mb4 而不是 utf8——utf8mb4 是真正的“完整 UTF-8”,utf8 在 MySQL 里是 utf8mb3 的别名,碰到 emoji 或者生僻汉字直接抛Incorrect string value异常。如果建表时字符集已经用了 utf8mb4,连接串里这个参数也必须保持一致,否则连接字符集和表字符集不一致会导致存储时做转换,中文内容可能变问号。

另外还有两个参数你在排查时可能会用到。rewriteBatchedStatements=true能大幅提升批量插入性能,后面专门讲;connectTimeout=5000&socketTimeout=60000分别控制 TCP 连接超时和读取超时,默认值是 0(永不超时),生产环境不设置就会出现在线问题排查时数据库无响应、应用线程全部卡死的局面。

4.2 事务边界:为什么单条 SQL 不需要手动事务,批量更新必须用

MySQL 默认的 autocommit 模式下,每一条 SQL 执行完自动提交,这也是前面 DAO 代码里没写事务的原因。但当你需要“要么全部成功、要么全部失败”的业务操作时,比如转账的扣款和入账,必须手动开启事务。

// 事务场景:模拟转账,扣款和入账要么同时成功要么同时回滚 public void transfer(int fromId, int toId, int amount) { Connection conn = null; try { conn = JdbcUtil.getConnection(); // 关闭自动提交,事务从这里开始 conn.setAutoCommit(false); String deductSql = "UPDATE users SET age = age - ? WHERE id = ?"; PreparedStatement ps1 = conn.prepareStatement(deductSql); ps1.setInt(1, amount); ps1.setInt(2, fromId); ps1.executeUpdate(); String addSql = "UPDATE users SET age = age + ? WHERE id = ?"; PreparedStatement ps2 = conn.prepareStatement(addSql); ps2.setInt(1, amount); ps2.setInt(2, toId); ps2.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.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

事务的 API 很简单,但有几个关键点必须理解。第一,setAutoCommit(false)必须是同一个 Connection 上所有 SQL 执行之前调用,事务边界就是这条语句和 commit/rollback 之间的所有操作。第二,rollback()不是只有在 catch 块里才能调用,业务判断不满足也可以主动回滚。第三,setAutoCommit(true)在 close 前重置是必须的——如果你用连接池,连接归还后可能被下一个调用方复用,如果继承了 false 状态,下一个人执行的第一条 SQL 就会在一个未预期的事务里,导致数据不一致。这正是连接池场景里最经典的隐蔽 bug,很多人查了半天才发现是忘记重置。

4.3 批量插入的两种姿势:循环单条与 addBatch

批量插入是增删改查里最容易被做坏的操作。新手最常见的写法是 for 循环里逐条 executeUpdate 插入一万条数据,结果跑了一分多钟。JDBC 提供的addBatch()/executeBatch()机制就是解决这个问题的,但只用它还不够,关键还得配合rewriteBatchedStatements=true参数。

// 批量插入 10000 条用户数据,优先用批处理 + rewriteBatchedStatements public void batchInsert(List<User> userList) { String sql = "INSERT INTO users(name, age, email) VALUES(?, ?, ?)"; String url = "jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true"; try (Connection conn = DriverManager.getConnection(url, "root", "123456"); PreparedStatement ps = conn.prepareStatement(sql)) { // 关闭自动提交,等全部执行完一次提交 conn.setAutoCommit(false); for (User user : userList) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setString(3, user.getEmail()); // addBatch 只做参数收集,不真正发给 MySQL ps.addBatch(); } // executeBatch 统一发给 MySQL 执行 int[] results = ps.executeBatch(); conn.commit(); System.out.println("批量插入完成,影响行数数组长度 = " + results.length); } catch (SQLException e) { e.printStackTrace(); } }

这个写法比 for 循环单条插入快了几十倍,核心原因是addBatch把多条 SQL 攒在内存里,executeBatch()一次通信把整个批次发给 MySQL 执行(前提是驱动参数rewriteBatchedStatements=true开启)。没有这个参数,MySQL 驱动会退化成逐条发送,批处理只是形式上像批,实际性能毫无提升。这个参数在 MySQL 驱动的文档里不是默认开启的,很多“为什么大批量插入还是慢”的翻车现场,都是栽在这上面。

批量操作还有两个边界要注意。executeBatch()返回的 int[] 数组里,每个元素对应一条 SQL 的受影响行数,如果某条执行失败,默认会抛 BatchUpdateException,已经执行的部分不会自动回滚——需要靠你在 catch 块里手动 rollback。批次大小也不是越大越好,JVM 内存和 MySQL 的 max_allowed_packet 参数都有限制,常见做法是每 500 或 1000 条分一批,执行一次 executeBatch 再 commit,避免内存溢出。

4.4 查询大数据量的游标方式:setFetchSize 的真实作用

查一万条数据一次全塞进内存会导致 OOM,JDBC 提供了游标式读取的解决方案——setFetchSize()控制每次从数据库拉取的行数,配合 MySQL 驱动还需要useCursorFetch=true参数。这个参数组合在 MySQL 上不是默认启用的,很多人只知道 setFetchSize 却不知道开启开关。

// 大结果集分批次读取,避免一次性加载到内存 String url = "jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&useCursorFetch=true"; String sql = "SELECT id, name, age FROM users WHERE age > ?"; try (Connection conn = DriverManager.getConnection(url, "root", "123456"); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 0); // 每次从服务端取 1000 行,而不是一次性全量 ps.setFetchSize(1000); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 逐行处理业务逻辑,内存中只保留当前这一批 } } }

useCursorFetch会让 MySQL 服务端为这个连接创建一个游标,setFetchSize(1000)告诉驱动每次拉取 1000 行。但没有useCursorFetch=true时 setFetchSize 被驱动忽略,结果集还是会被一次全量拉回内存——这是 JDBC 里一个典型的“参数没生效”案例。生产环境做报表导出或全表扫描时,这个配置能有效降低应用内存压力。注意游标模式下,ResultSet 保持打开期间,Connection 不能关闭,否则游标失效,所以代码结构上务必保证整个遍历过程都在 try-with-resources 的范围内。

5. JDBC 避坑指南:五个让新手原地翻车的典型场景

5.1 驱动类找不到:ClassNotFoundException 与 NoClassDefFoundError

现象:运行时代码执行到Class.forName("com.mysql.cj.jdbc.Driver")或首次DriverManager.getConnection时,抛出java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。

原因:classpath 里没有 MySQL 驱动的 jar 包。可能是 Maven 依赖没刷新、jar 包冲突被排除、或者用的是 fat jar 打包但驱动没被包含进去。另一个常见关联错误是 NoClassDefFoundError,发生在驱动 jar 存在但其中某个依赖类缺失的场合,通常是驱动的传递依赖没有被引入。

解决:先检查 Maven 依赖树,执行mvn dependency:tree看 mysql-connector-j 是否在列表里;确认版本是 8.x 且类名用的是com.mysql.cj.jdbc.Driver。如果用的是 5.x 驱动,类名是com.mysql.jdbc.Driver。Spring Boot 项目里如果通过spring.datasource.driver-class-name配置驱动类,同样检查这个值是否正确。还有一个隐蔽场景:非 Maven 项目手动导入 jar 包时,jar 包没有最终打进构建产物,记得检查 target 目录下的实际内容。

5.2 Communications link failure:URL 写错与网络不可达

现象:com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure,后面往往跟着The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.

原因:应用连不上 MySQL 服务。最常见是 URL 里的 IP 或端口不对、MySQL 服务没启动、防火墙拦截了 3306 端口、或者是连接串里写了localhost但实际 MySQL 只监听了特定网卡地址。MySQL 8.0 之后还有一个隐蔽问题:驱动版本和 MySQL 服务端版本差异过大,协议协商失败也会被包装成这个异常。

解决:先确认 MySQL 服务在跑:systemctl status mysqld或service mysql status。再用telnet 你的IP 3306验证端口是否可达。排查时间最短的一条命令是mysql -h127.0.0.1 -uroot -p在应用所在机器上直接连一次,能连说明网络和账号没问题,问题在 URL 或驱动版本。另外 MySQL 的 bind-address 配置如果绑定了 127.0.0.1,外部机器会连接失败,生产环境要做区分。

5.3 Unknown database 与 Access denied:库名和权限不匹配

现象:java.sql.SQLException: Unknown database 'test_db'或Access denied for user 'root'@'localhost'。

原因:前者是连接串里的库名在 MySQL 实例上不存在,后者是账号密码错误或该账号没有从当前 IP 访问的权限。很多新手在 MySQL 里执行过CREATE DATABASE但在不同的实例上操作的,比如本地连的是 Docker 里的 MySQL 而建库建在了宿主机上。Access denied 还经常发生在 MySQL 用户的 host 字段限制上,默认创建的'root'@'localhost'只能从本机连,从应用服务器连需要创建'app'@'%'或'app'@'具体IP'的用户。

解决:SHOW DATABASES;看库是否存在,SELECT user, host FROM mysql.user;看账号的 host 授权范围。连接串里库名和实际库名严格一致,大小写敏感。权限问题执行CREATE USER 'app'@'%' IDENTIFIED BY 'password'; GRANT ALL PRIVILEGES ON test_db.* TO 'app'@'%';授权后需要FLUSH PRIVILEGES;生效。

5.4 TIME ZONE 引发的日期偏移 8 小时

现象:Java 侧插入一条2024-01-01 08:00:00的时间,在 MySQL 里查出来是2024-01-01 00:00:00,或者反过来。

原因:MySQL 驱动使用 JVM 默认时区解析 TIMESTAMP 类型的数据,数据库服务端是 UTC 时区,应用服务器是东八区,两边时区不一致导致驱动在做本地时间转换时偏移了 8 小时。这是serverTimezone参数没配置时最容易出现的魔幻场景。

解决:连接串加serverTimezone=Asia/Shanghai或serverTimezone=GMT%2B8(URL 编码里 + 号要转成 %2B)。同时检查 MySQL 服务端时区:SELECT @@global.time_zone, @@session.time_zone;如果是 SYSTEM 则看操作系统时区。统一成+08:00后写入和读取的时间就对齐了。一个更彻底的方案是表中用DATETIME类型替代TIMESTAMP——DATETIME 不依赖数据库时区,存的是什么读出来就是什么,适合业务层统一管理时区的场景。

5.5 更新没有报错但受影响行数为 0

现象:UPDATE语句执行不报错,但executeUpdate()返回 0,实际上数据确实更新成功了。

原因:MySQL 驱动的默认行为里,如果更新操作没有改变行内容(旧值和新值完全相同),服务端返回的受影响行数是 0。这是“没有实际修改”和“没有匹配到记录”两种语义在 JDBC 返回值上的混淆。在 Java 侧依赖受影响行数判断“是否更新成功”的业务代码,会得到错误的失败信号。

解决:确认数据库连接串是否配置了useAffectedRows=true。默认 false 时,MySQL 驱动把 affected rows 语义改成 matched rows(匹配即算数),加了useAffectedRows=true后才返回真正的受影响行数。注意这个参数的取舍和 MySQL JDBC 驱动的文档说明,需要确认实际行为再改,避免全局调整影响其他业务流程。业务侧不要把“影响行数是否大于 0”作为唯一成功判断,配合查询或用 updated_at 字段的变化来判断更稳妥。

6. 进阶实战:用连接池重构连接管理,把 DAO 改造成可上线的代码

手写 DriverManager.getConnection 的方式适合学习和测试,但一旦进入生产环境,频繁创建和销毁物理连接会成为瓶颈——每个连接都要走 MySQL 的握手认证,高并发下线程会卡在建立连接上。连接池的方案是主流选择,HikariCP 是目前 Java 生态里最可靠的实现,Spring Boot 2.x 之后内置默认连接池就是它。

// 引入 HikariCP 依赖后,用配置类创建连接池 import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; public class DataSourceUtil { private static HikariDataSource dataSource; static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/test_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"); config.setUsername("root"); config.setPassword("123456"); // 连接池的核心参数:最大连接数、最小空闲连接数、连接超时时间 config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); dataSource = new HikariDataSource(config); } public static Connection getConnection() throws SQLException { // HikariCP 的 getConnection 返回的是池化连接的代理对象 return dataSource.getConnection(); } }

连接池的配置不是随便拍脑袋填的。maximumPoolSize的值取决于你的数据库实例能承受的最大并发连接数,MySQL 默认max_connections是 151,池子设太大反而会把数据库拖垮。常见做法是应用所在机器的核数乘 2 再加 1,上限不超过数据库 max_connections 的 80%。connectionTimeout=30000意味着如果池子里没有空闲连接且等待超过 30 秒,直接抛 SQLException——这个超时能快速暴露连接泄漏问题,不会让请求无限挂起。池化连接关闭时不是真正断开 TCP,而是把连接状态重置后归还到池里。

// 改造 JdbcUtil:把 DriverManager.getConnection 替换成 DataSourceUtil.getConnection public class JdbcUtil { public static Connection getConnection() throws SQLException { return DataSourceUtil.getConnection(); } public static void close(ResultSet rs, Statement st, Connection conn) { // 关闭逻辑不变,但 conn.close() 现在是归还连接到池中 if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (st != null) { try { st.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

从 DriverManager 换成连接池,对 DAO 代码的侵入几乎为零——只要 JdbcUtil 的 getConnection 返回类型不变,调用方一行都不用改。这也是连接池封装的价值:它在适配层替换了连接获取的实现,而不是把池化逻辑散落在每个 DAO 方法里。注意一个细节:从池里拿到的连接,如果你在代码里手动开启了事务(setAutoCommit(false)),用完归还前一定记得 commit/rollback 并恢复 true,否则连接池的复用会把这些状态带到下一次请求里——这是连接池场景下最容易出问题的状态残留。

验证连接池是否正常工作,可以从两个角度测。第一,观察日志里连接建立的时间:第一次请求会创建连接池初始连接,后续请求延迟大幅下降。第二,压测或高并发循环调用 DAO 的查询方法,同时监控 MySQL 的SHOW PROCESSLIST;,连接数应该稳定在池配置的范围内而不是随请求数暴涨。

如果手头的项目已经用了 MyBatis 或 Spring JDBC Template,底层默认接的就是连接池,但上面的配置参数仍然是通用的。JDBC 本身的这份功课,在框架的包围下会显得“多余”,但当你遇到连接耗尽、事务不生效、批量插入慢这些线上事故时,能帮你的恰恰是今天对这些基础细节的理解。HikariCP 的池参数调优,我个人的习惯是从最小空闲 5、最大 20 起步,压测后根据数据库连接数的实际水位逐步调整,而不是一开始就把最大连接设到 100——多出来的不是性能余量,是隐患。希望这些经验能帮你少踩几个坑,把基本功打扎实,后续上框架时才不至于两眼一抹黑。

对了,这也解释了一个面试高频问题:为什么 MySQL 的 JDBC URL 里要写serverTimezone——不写也能连上,但时间数据会错乱;写上了,才算真正理解了驱动和服务端之间的时区协议。这个细节别当成死记硬背的配置项,它是理解 JDBC 连接模型的一把钥匙。希望今天这份笔记能帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询