JDBC连接MySQL增删改查全解:从PreparedStatement到事务与连接池
发布时间:2026/10/2 10:21:58来源:尧图网络
简介面向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 后端技术栈里的真实位置JDBCJava 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底层就是ConnectionMapper接口的动态代理最终也是拼 SQL 交给PreparedStatement执行。把 JDBC 的六步模型写熟你再看框架源码会轻松很多。2.2 六步模型从加载驱动到关闭连接一次完整的 JDBC 调用包含六个步骤缺一步都可能运行时报错或产生资源泄漏加载驱动JDBC 4.0 之后可以省略但写上没坏处建立连接DriverManager.getConnection创建 Statement普通 Statement 或 PreparedStatement执行 SQL 并拿到结果集executeQuery / executeUpdate遍历 ResultSet 提取数据按逆序关闭 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?useSSLfalseserverTimezoneAsia/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 默认驱动配置下更新前后数据没变化时受影响行数可能返回 0useAffectedRows 参数会改变这个行为这是很多人调试时遇到的第一个“玄学”后面避坑章节会详细说。3. 把增删改查跑通建表、驱动引入与一个完整可复用的 DAO3.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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表 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 groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.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.Driver5.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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4; 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 里的characterEncodingutf8mb4参数保证中文和表情符号在传输过程中不会乱码——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 的 intVARCHAR/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, zhangweiexample.com); System.out.println(插入成功自增 ID id); // 按 ID 查询验证插入结果 User user dao.findById(id); System.out.println(查询结果: user); // 更新邮箱 int updated dao.updateEmail(id, new-emailexample.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这是唯一约束生效的表现不是代码 bugDAO 方法里 catch 住 SQLException 后打印堆栈后续开发替换成自己的日志框架即可。4. 连接参数、事务边界与批量插入JDBC 里最影响线上行为的细节4.1 连接 URL 参数逐个拆解characterEncoding、useSSL 与 serverTimezonejdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4这串 URL 里每个参数都有明确用途也是面试官最爱追问的细节。useSSLfalse的意思是关闭 SSL 加密。本地开发和测试环境用 false 可以避免 SSL 握手带来的连接延迟和证书配置烦恼生产环境如果数据库和应用程序在同一内网且网络隔离良好false 也能接受但跨公网连接时必须开启 SSLuseSSLtrue并配置证书否则用户名密码和业务数据都是明文传输。需要注意的是 MySQL 8.0 驱动对 SSL 的策略有变化高版本驱动默认会尝试 SSL 连接没有证书时连接会失败所以本地开发显式写useSSLfalse是止损最干净的方案。serverTimezoneAsia/Shanghai是 8.0 驱动的必填参数之一。旧版驱动默认取 JVM 时区当服务器时区和数据库时区不一致时TIMESTAMP类型的读写会出现几个小时到几十个小时的偏移。设置这个参数后驱动会在连接时向 MySQL 发送时区信息保证日期时间传输的一致性。更稳妥的做法是让 MySQL 服务端也配置成default-time-zone08:00两边都明确为什么错乱就不会发生。characterEncodingutf8mb4前面提过它控制的是字符集传输编码。MySQL 5.7 以上推荐 utf8mb4 而不是 utf8——utf8mb4 是真正的“完整 UTF-8”utf8 在 MySQL 里是 utf8mb3 的别名碰到 emoji 或者生僻汉字直接抛Incorrect string value异常。如果建表时字符集已经用了 utf8mb4连接串里这个参数也必须保持一致否则连接字符集和表字符集不一致会导致存储时做转换中文内容可能变问号。另外还有两个参数你在排查时可能会用到。rewriteBatchedStatementstrue能大幅提升批量插入性能后面专门讲connectTimeout5000socketTimeout60000分别控制 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()机制就是解决这个问题的但只用它还不够关键还得配合rewriteBatchedStatementstrue参数。// 批量插入 10000 条用户数据优先用批处理 rewriteBatchedStatements public void batchInsert(ListUser userList) { String sql INSERT INTO users(name, age, email) VALUES(?, ?, ?); String url jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue; 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 执行前提是驱动参数rewriteBatchedStatementstrue开启。没有这个参数MySQL 驱动会退化成逐条发送批处理只是形式上像批实际性能毫无提升。这个参数在 MySQL 驱动的文档里不是默认开启的很多“为什么大批量插入还是慢”的翻车现场都是栽在这上面。批量操作还有两个边界要注意。executeBatch()返回的 int[] 数组里每个元素对应一条 SQL 的受影响行数如果某条执行失败默认会抛 BatchUpdateException已经执行的部分不会自动回滚——需要靠你在 catch 块里手动 rollback。批次大小也不是越大越好JVM 内存和 MySQL 的 max_allowed_packet 参数都有限制常见做法是每 500 或 1000 条分一批执行一次 executeBatch 再 commit避免内存溢出。4.4 查询大数据量的游标方式setFetchSize 的真实作用查一万条数据一次全塞进内存会导致 OOMJDBC 提供了游标式读取的解决方案——setFetchSize()控制每次从数据库拉取的行数配合 MySQL 驱动还需要useCursorFetchtrue参数。这个参数组合在 MySQL 上不是默认启用的很多人只知道 setFetchSize 却不知道开启开关。// 大结果集分批次读取避免一次性加载到内存 String url jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaiuseCursorFetchtrue; 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 行。但没有useCursorFetchtrue时 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 failureURL 写错与网络不可达现象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 rootlocalhost。原因前者是连接串里的库名在 MySQL 实例上不存在后者是账号密码错误或该账号没有从当前 IP 访问的权限。很多新手在 MySQL 里执行过CREATE DATABASE但在不同的实例上操作的比如本地连的是 Docker 里的 MySQL 而建库建在了宿主机上。Access denied 还经常发生在 MySQL 用户的 host 字段限制上默认创建的rootlocalhost只能从本机连从应用服务器连需要创建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参数没配置时最容易出现的魔幻场景。解决连接串加serverTimezoneAsia/Shanghai或serverTimezoneGMT%2B8URL 编码里 号要转成 %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 侧依赖受影响行数判断“是否更新成功”的业务代码会得到错误的失败信号。解决确认数据库连接串是否配置了useAffectedRowstrue。默认 false 时MySQL 驱动把 affected rows 语义改成 matched rows匹配即算数加了useAffectedRowstrue后才返回真正的受影响行数。注意这个参数的取舍和 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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4); 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%。connectionTimeout30000意味着如果池子里没有空闲连接且等待超过 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 连接模型的一把钥匙。希望今天这份笔记能帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网