简介:面向Java初学者的JDBC数据库操作指南,围绕如何连接MySQL并完成增删改查展开,适合正在学习JavaWeb或希望深入理解数据库编程原理的广大开发者。资源以单个PDF文档打包,体积约326KB,内容涵盖数据库建表准备、DBUtil连接类的静态初始化、实体类字段映射以及DAO层增删改查的完整代码示例。教程默认读者已装好JDK、Eclipse、MySQL与Navicat基础工具,并针对imooc数据库中的Goddess表演示代码,特别展示了PreparedStatement参数化SQL防止注入,还对比Hibernate、MyBatis等ORM框架的底层封装逻辑。目前已有5619人学习下载,说明这份实战教程对入门者有较强的参考价值。读者通过这份资源能快速搭建JDBC开发环境,掌握数据库操作的标准分层思路,不仅能够完成基本的增删改查编码,还能为后续学习企业级开发框架筑牢基础。
1. 为什么 JDBC 连接 MySQL 的增删改查值得手写一遍
很多后端开发一上来就用 MyBatis 或 Spring Data JPA,SQL 写在注解里或 XML 里,连接池和事务由框架托管,日子过得很舒服。但面试题里仍然高频出现“jdbc 连接 mysql 数据库实现增删改查操作”,线上排查CommunicationsException、sql injection violation、连接被关闭、大查询撑爆内存时,绕来绕去最后还是落到 JDBC 的底层机制上。手写一遍不是为了造轮子,而是为了看清驱动加载、连接 URL 参数、PreparedStatement 预编译、事务边界和资源释放这些框架替你省掉的步骤。这篇文章给出一条完整的路径:从 MySQL 驱动和连接参数开始,写 CRUD、加事务和批处理,最后给出一个不依赖 Spring 的模板类。适合正在准备 Java 面试的人,也适合在非 Spring 项目里需要手写数据访问层的人。
2. JDBC 连接 MySQL 的驱动引入、URL 参数与最小连接代码
2.1 驱动包引入:mysql-connector-j 与 Class.forName 的真相
JDBC 连接 MySQL 的第一步是让驱动类出现在运行时环境里。当前官方驱动坐标已经改名为mysql-connector-j,旧的mysql-connector-java仍可用但已不是新代码的推荐选择。用 Maven 管理依赖可以这样写:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>这个依赖会把com.mysql.cj.jdbc.Driver打包进来。JDBC 4.0 之后,驱动 jar 包通过META-INF/services/java.sql.Driver完成了自动注册,所以现代 JDK 里不写Class.forName也能通过DriverManager.getConnection建立连接。那为什么教程里还经常看到这行代码?原因有两个:一是老项目、老驱动版本确实需要手动加载;二是某些 Web 容器或类加载器隔离环境里自动注册会失效,写上一句更稳妥。
Class.forName("com.mysql.cj.jdbc.Driver"); String url = "jdbc:mysql://localhost:3306/test"; try (Connection conn = DriverManager.getConnection(url, "root", "123456")) { System.out.println(conn.getCatalog()); } catch (ClassNotFoundException | SQLException e) { e.printStackTrace(); }这段代码里DriverManager.getConnection会从已注册的 Driver 列表中找到一个能处理jdbc:mysql协议的驱动,并返回一个物理连接。conn.getCatalog()只是验证连接可用,正常开发中不会这样用。这里有一个容易被忽略的参数:url中没有带任何连接参数,所以 MySQL 8.0 驱动会按默认行为去认证和处理时区,很容易触发后面要说的时区异常。
2.2 连接 URL 参数:时区、字符编码、SSL 与超时
连接串绝对不是只写jdbc:mysql://localhost:3306/test就完了。MySQL 8.0 驱动对时区、认证插件、SSL 行为都比较敏感,缺了参数轻则警告,重则直接拒绝连接。我通常会在一开始就把下面这些参数带全:
String url = "jdbc:mysql://localhost:3306/test" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8mb4" + "&connectTimeout=3000" + "&socketTimeout=10000" + "&allowPublicKeyRetrieval=true" + "&rewriteBatchedStatements=true";这些参数各管一件事,最重要的几个如下表所示:
| 参数 | 示例值 | 作用 |
|---|---|---|
serverTimezone | Asia/Shanghai | 指定服务器时区,避免驱动无法识别 MySQL 系统时区而抛异常 |
useSSL | false | 本地开发和内网场景关闭 SSL 握手,节省连接时间并避免警告 |
characterEncoding | utf8mb4 | 让中文和 emoji 字符正确传输,不要用utf8 |
connectTimeout | 3000 | 建连超时毫秒数,网络不通时快速失败 |
socketTimeout | 10000 | 读取数据超时毫秒数,SQL 卡住时能事后排查 |
allowPublicKeyRetrieval | true | 配合caching_sha2_password认证插件使用 |
rewriteBatchedStatements | true | 批处理时把多条 INSERT 重写为多值 INSERT,大幅提升批量写入性能 |
serverTimezone是新手最常踩的坑。旧驱动不强制要求,MySQL 8.0 驱动遇到连接串里没有时区时,会尝试读取数据库系统的默认时区,如果宿主机是 Linux 且时区不太标准,就会抛出类似The server time zone value 'CST' is unrecognized的异常。characterEncoding=utf8mb4也是从 8.0 驱动开始更强调的,utf8在 MySQL 里是utf8mb3的别名,存不了四字节 emoji 和一些生僻字。
2.3 连接失败先看异常定位
连接 MySQL 失败时的报错信息看起来很长,但大多对应几个固定原因,可以直接按下表定位:
| 异常现象 | 大概率原因 |
|---|---|
Public Key Retrieval is not allowed | 认证插件是caching_sha2_password,连接串少allowPublicKeyRetrieval=true |
The server time zone value '...' is unrecognized | 连接串少serverTimezone参数 |
Communications link failure | MySQL 未启动、端口不对、防火墙拦截、目标 IP 不可达 |
Access denied for user 'root'@'localhost' | 用户名密码错误,或该用户没有从当前主机连接的权限 |
Unknown database | 数据库名写错 |
ClassNotFoundException: com.mysql.cj.jdbc.Driver | 驱动 jar 没有进入 classpath |
定位时先看是不是连接串参数问题,再看网络端口能不能通,最后看 MySQL 用户授权。mysql -h 127.0.0.1 -P 3306 -uroot -p能通就能排除大多数网络问题,剩下的基本是 JDBC 参数和驱动版本问题。
3. 用 PreparedStatement 实现 MySQL 的增删改查:查询、更新与自增主键
3.1 为什么优先 PreparedStatement
JDBC 里有三种执行 SQL 的方式:Statement、PreparedStatement、CallableStatement。做增删改查时绝大多数场景应该选PreparedStatement。它会把 SQL 中的可变部分用?占位符表达,参数通过setXxx传入,驱动负责把参数转义和类型转换。这样做有实际收益:
- 参数内容不会破坏 SQL 语义,避免字符串拼接导致的注入问题;
- SQL 串相同但参数不同时,驱动可以复用预编译结果;
- 代码可读性远好于
"SELECT * FROM user WHERE name = '" + name + "'"。
一个常见误用是只把 PreparedStatement 当字符串替身,仍然用参数拼接出完整 SQL,再调用prepareStatement(sql),这样预编译失去意义,注入防线也形同虚设。
3.2 查询操作:executeQuery 与 ResultSet 遍历
查询操作是最常用的入口。下面这段代码查出一张user表里指定用户名的记录:
String sql = "SELECT id, name, email FROM user WHERE name = ?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "张三"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { int id = rs.getInt("id"); String name = rs.getString("name"); String email = rs.getString("email"); System.out.println(id + " - " + name + " - " + email); } } }executeQuery()只用于 SELECT,返回ResultSet。rs.next()有两个作用:第一,把游标从当前位置移到下一行;第二,返回布尔值判断是否还有数据。所以while (rs.next())是遍历结果集的标准写法。rs.getInt("id")里的参数可以是列名,也可以是列的索引,列索引从 1 开始。用列名比用索引可读性好,但如果 SQL 里用了SELECT name AS n,要拿别名。try-with-resources在这里同时管理了Connection、PreparedStatement和ResultSet,后面章节会专门说资源释放。
3.3 增删改:executeUpdate 的返回值与自增主键
插入、更新、删除都使用executeUpdate(),它返回受影响的行数。增删改的核心区别只在 SQL 和执行后的处理:
String insertSql = "INSERT INTO user(name, email) VALUES(?, ?)"; try (PreparedStatement ps = conn.prepareStatement( insertSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, "李四"); ps.setString(2, "lisi@example.com"); int rows = ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { System.out.println("新用户 id:" + keys.getInt(1)); } } }注意prepareStatement的第二个参数Statement.RETURN_GENERATED_KEYS。它让驱动在 insert 执行后把数据库生成的字段值取回来,通常联自增主键。如果不加这个参数,getGeneratedKeys()拿不到数据。getGeneratedKeys()同样返回一个ResultSet,但里面只有一列,就是新生成的主键。更新和删除不需要这个机制:
String updateSql = "UPDATE user SET email = ? WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setString(1, "new@example.com"); ps.setInt(2, 1); int rows = ps.executeUpdate(); if (rows == 0) { System.out.println("没有匹配的记录"); } }executeUpdate的返回值对业务判断很有用。更新时返回 0,可能是没有匹配的WHERE条件,也可能是数据更新前后没有变化。这两个场景在 MySQL 默认行为里都返回 0,需要业务自己决定是否区分。
3.4 JDBC 核心方法返回值对照
写实现的时候,先想清楚该用哪个方法,可以减少大量无效试错。下表是增删改查中最常用的方法:
| 方法 | 适用场景 | 返回值 |
|---|---|---|
executeQuery() | 查询 | ResultSet |
executeUpdate() | INSERT / UPDATE / DELETE | int受影响行数 |
execute() | 任意 SQL,一般不用 | boolean,表示是否返回结果集 |
addBatch() | 把当前参数加入批 | 无 |
executeBatch() | 批量执行 | int[],每个元素是一条 SQL 影响的行数 |
getGeneratedKeys() | 获取自增主键 | ResultSet |
execute()很少用到,它要额外判断第一个结果是结果集还是更新计数,代码可读性差。CRUD 里用前面两个就够。另外,MySQL 里executeUpdate也可以执行 DDL,比如CREATE TABLE,返回 0,但业务代码不应该用 JDBC 去跑 DDL。
4. JDBC 事务、批处理与隔离级别:让增删改查不再是单条 SQL 的拼接
4.1 手动事务:从 setAutoCommit 到 commit 和 rollback
JDBC 连接默认是自动提交模式,即每条 SQL 执行完就立即提交,不用写commit。但真实业务里转账、下单这类操作需要多条 SQL 保证原子性,必须手动控制事务。手动事务的固定套路是:
Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement( "UPDATE account SET balance = balance - 100 WHERE id = 1")) { ps1.executeUpdate(); } try (PreparedStatement ps2 = conn.prepareStatement( "UPDATE account SET balance = balance + 100 WHERE id = 2")) { ps2.executeUpdate(); } conn.commit(); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } e.printStackTrace(); } finally { if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } }setAutoCommit(false)之后,当前连接上执行的每一条 SQL 都不会立即落库,而是等commit()统一提交。如果中途抛异常,rollback()会把本连接内所有未提交的改动撤销。这里有几个要点:
conn不能放在 try-with-resources 里再手动提交,因为 try 块结束后连接会先关,未提交事务默认回滚,所以手动事务里连接要用传统 try-catch-finally 管理;PreparedStatement可以放在内层 try 里,它们关闭不会影响连接上的事务状态;rollback()也不是只能全部回滚,可以先Savepoint sp = conn.setSavepoint(),再conn.rollback(sp),实现部分回滚;- 事务要短,尽量不在事务里执行远程调用、文件读写等长耗时操作。
4.2 批处理:addBatch 与 executeBatch 的组合
一万条 INSERT 一条条执行会非常慢,网络往返占了大头。JDBC 的批处理可以把多条 SQL 攒起来一次性发给 MySQL。配合连接串里的rewriteBatchedStatements=true,驱动甚至能把多条单行 INSERT 重写为一条多值 INSERT,写入性能会有量级提升。
String sql = "INSERT INTO user(name, email) VALUES(?, ?)"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i = 0; i < 10000; i++) { ps.setString(1, "user_" + i); ps.setString(2, i + "@test.com"); ps.addBatch(); if (i % 500 == 0) { ps.executeBatch(); ps.clearBatch(); } } int[] result = ps.executeBatch(); conn.commit(); } catch (SQLException e) { conn.rollback(); e.printStackTrace(); }addBatch()只是把当前参数存入批缓冲区,不真正执行。executeBatch()执行批内所有 SQL,返回int[],数组每个元素对应该条 SQL 影响的行数。需要注意三点:
- 不是攒到一万条再一次执行,内存和事务长度都受不了。按 500 条或 1000 条一批,是经验值;
clearBatch()能清空批缓冲区,避免批内 SQL 重复执行;- 批处理与手动事务配合时,如果中间某批失败,想保留之前批次的话就需要按批提交,否则全部回滚。生产环境一般按批提交,但也要接受“部分成功”的后果。
rewriteBatchedStatements参数对批量 INSERT 非常关键。不加它,executeBatch()本质上仍是串行发送每条语句;加了它,MySQL 驱动会把同一条 SQL 的不同参数合并成INSERT INTO user(name, email) VALUES(?,?),(?,?)...,一次网络往返就传过去。
4.3 事务隔离级别:什么时候用 what
JDBC 允许通过conn.setTransactionIsolation(int level)设置当前连接的事务隔离级别。MySQL 支持四档级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 典型场景 |
|---|---|---|---|---|
TRANSACTION_READ_UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用 |
TRANSACTION_READ_COMMITTED | 阻止 | 可能 | 可能 | 大多数业务场景 |
TRANSACTION_REPEATABLE_READ | 阻止 | 阻止 | 可能 | MySQL InnoDB 默认 |
TRANSACTION_SERIALIZABLE | 阻止 | 阻止 | 阻止 | 数据强一致,并发极低 |
MySQL 默认是REPEATABLE_READ,这是 InnoDB 的默认值。JDBC 里想设置就写成:
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);这个设置只对当前连接生效,连接归还给连接池后可能会被重置。使用连接池时,最好在获取连接后确认隔离级别,或者在连接池配置里指定transactionIsolation。注意副作用:隔离级别越高,锁竞争和性能损失越明显,不要为所有事务统一调成SERIALIZABLE,那基本等于把并发线程串行化。
5. JDBC 连接管理与资源释放:fetchSize、try-with-resources 与异常对照
5.1 try-with-resources 的正确姿势与 ResultSet 生命周期
JDBC 的三个核心对象都实现了AutoCloseable,可以直接用 try-with-resources 管理。正确的层次结构是:
String sql = "SELECT id, name FROM user WHERE age > ?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 18); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 处理一行 } } }关闭顺序是ResultSet先关,再关PreparedStatement,最后关Connection,try-with-resources 会自动按声明顺序的逆序关闭,所以这样写没问题。如果ResultSet不需要单独关闭,只声明Connection和PreparedStatement也可以,因为Statement.close()会连带关闭它的结果集。但显式声明ResultSet可以让“用完之后马上释放游标”这件事更清晰。
这里最容易犯的错是把ResultSet传出方法。比如写一个方法执行查询并返回ResultSet:
// 错误演示:连接关闭后结果集不可用 public ResultSet findUser(int id) throws SQLException { Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT ..."); ResultSet rs = ps.executeQuery(); return rs; }方法返回后Connection还没有被显式关闭,但连接池或调用方很难知道什么时候该关。一旦连接关闭或归还连接池,ResultSet关联的游标就失效了。正确做法是把查询逻辑放在同一个 try 块内处理,或者把结果转换为 DTO 列表再返回。
5.2 fetchSize:MySQL JDBC 查询流式输出的关键参数
大结果集查询是 JDBC 里比 CRUD 更考验功底的场景。默认情况下,MySQL Connector/J 会尝试把查询结果全部拉到客户端内存里,几百万行的表直接SELECT *很危险。想让它流式返回,必须正确设置fetchSize:
String url = "jdbc:mysql://localhost:3306/test?useCursorFetch=true"; String sql = "SELECT id, name FROM user"; try (Connection conn = DriverManager.getConnection(url, "root", "123456"); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setFetchSize(200); ps.setFetchDirection(ResultSet.FETCH_FORWARD); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 每次从 MySQL 服务端取 200 行 } } }关键在连接 URL 里的useCursorFetch=true。没有这个参数,ps.setFetchSize(200)会被驱动忽略,数据仍然一次性拉取。开启后,MySQL 服务端会维护游标,客户端读取时再分批取数据。这种方式适合全表扫描导出,但要注意它会让事务或连接的持有时间变长,取数据期间不能释放连接,也不能在同一个连接上执行其他 SQL。如果只是普通分页查询,更可靠的做法是 JDBC SQL 里加LIMIT ? OFFSET ?,而不是依赖流式。
5.3 对照表:连接与执行阶段的典型异常
| 异常信息 | 原因 | 处理方式 |
|---|---|---|
No operations allowed after connection closed | 使用的连接已被关闭或归还连接池后继续借用 | 检查连接池 maxLifetime 配置,使用连接前不要持有过长时间 |
java.sql.SQLSyntaxErrorException | SQL 语法错误,比如表名用了保留字 | 打印完整 SQL,在客户端工具里执行验证 |
java.sql.SQLException: sql injection violation | 请求被安全网关或代理拦截,通常是因为 SQL 里有拼接痕迹 | 改写为参数化查询,关闭对 SQL 文本的拼接 |
Communication link failure后连接断开 | 网络超时或 MySQLwait_timeout将空闲连接断开 | 连接池开启testWhileIdle,适当设置socketTimeout |
The table 'xxx' is full | 磁盘满了或 MySQL 表大小限制 | 清理数据、扩容、分区表 |
网关报sql injection violation这种错误比较特殊:你的代码明明是安全的,但网关注册了敏感规则,看到 SQL 里有疑似OR 1=1或注释符号就拦截。排查时先把真实 SQL 打印出来,确认是网关误判还是手工拼接。
6. 手写一个 JDBC 增删改查模板类,把重复代码留在基类里
6.1 一个不依赖框架的 JdbcTemplate
如果项目只有几张表,不想引入 MyBatis,又不想每个 DAO 都写一遍 try-with-resources,可以把变化的部分收敛成参数。下面这个模板类封装了增删改查中最常用的两个方法,一个执行更新,一个执行查询并把结果映射成对象:
@FunctionalInterface public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; }public class JdbcTemplate { private final Connection conn; public JdbcTemplate(Connection conn) { this.conn = conn; } public int update(String sql, Object... params) throws SQLException { try (PreparedStatement ps = conn.prepareStatement(sql)) { bindParams(ps, params); return ps.executeUpdate(); } } public <T> List<T> query(String sql, RowMapper<T> mapper, Object... params) throws SQLException { try (PreparedStatement ps = conn.prepareStatement(sql)) { bindParams(ps, params); try (ResultSet rs = ps.executeQuery()) { List<T> list = new ArrayList<>(); while (rs.next()) { list.add(mapper.map(rs)); } return list; } } } private void bindParams(PreparedStatement ps, Object... params) throws SQLException { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } } }调用方式很直接:
JdbcTemplate jdbc = new JdbcTemplate(conn); String username = "张三"; User user = jdbc.query("SELECT id, name, email FROM user WHERE name = ?", rs -> new User(rs.getInt("id"), rs.getString("name"), rs.getString("email")), username) .stream().findFirst().orElse(null); int rows = jdbc.update("UPDATE user SET email = ? WHERE id = ?", "new@example.com", 1);ps.setObject会根据参数实际类型自动调用对应的setXxx,减少模板方法数量。它的缺点是很隐晦,如果传入了null,需要根据 SQL 里的列类型决定setNull的类型参数,极端情况下要写ps.setNull(i, Types.VARCHAR)。
6.2 这个模板类的事务归属
注意JdbcTemplate的构造器接收外部传入的Connection,模板类内部不负责关闭连接。这样设计是因为连接归属调用方,调用方可以在同一个连接上开启手动事务:
try (Connection conn = DbUtil.getConnection()) { conn.setAutoCommit(false); JdbcTemplate jdbc = new JdbcTemplate(conn); jdbc.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); jdbc.update("UPDATE account SET balance = balance + 100 WHERE id = 2"); conn.commit(); }如果把关闭连接的逻辑放进模板类,手动事务就没法跨多个方法共用连接。让模板类保持 stateless,是封装 JDBC 工具时最值得记住的边界。需要连接池时,只要把DbUtil.getConnection()换成dataSource.getConnection(),模板代码不用动。这个模板类已经能满足中小工具、课程设计、内部后台这类“数据量不大、表结构明确、不需要动态 SQL”的场景;当查询条件开始频繁变化、需要动态拼接 SQL 时,再考虑引入 MyBatis 或 jOOQ 也不迟。
本文还有配套的精品资源,点击获取