做过 Java 后端的同学应该都有体会,数据库交互这块几乎是绕不过去的坎。不管是刚学 Java 基础,还是准备面试八股文,“Java 连接 MySQL 数据库并实现数据交互”都是最常被拿出来考的场景。今天这篇博文,我就把从零开始用 JDBC 操作 MySQL 的完整链路拆开,从驱动选型到代码落地,从连接工具类到增删改查,从常见报错到排查思路,一次性讲清楚。
写这篇文章的初衷很直接:网上的教程要么只贴代码不给解释,要么把 JDBC 讲成天书。我这些年带新人、自己踩坑,积累了不少行之有效的实操套路,今天全部整理出来。无论你是刚接触 Java 的初学者,还是在准备 Java 面试、需要手写 JDBC 代码的求职者,亦或是做数据库课程设计需要快速落地项目的学生,这篇文章都能直接帮你省掉大把试错时间。
1. 整体设计与方案选型
1.1 先理清楚:Java 操作 MySQL 到底有哪些路
先别急着写代码,搞明白几个选择。Java 生态里操作数据库的方案,我粗略分成三层:
- 最底层:JDBC,Java Database Connectivity,这是 Java 访问数据库的官方标准接口,也是所有上层框架的基石。
- 中间层:Spring JDBC(
JdbcTemplate)、Apache DBUtils 这类轻量封装,极大减少样板代码,但仍然要手动处理连接、结果集到对象的映射。 - 上层:MyBatis、MyBatis-Plus、Hibernate 这类 ORM 框架,把数据库表和 Java 对象映射起来,开发者写 SQL 或 HQL 即可,连接管理、结果映射都交给框架。
很多初学者一上来就学 MyBatis,这是走偏了。
先说我的观点:不管你用不用框架,JDBC 都值得亲手写一遍。 原因有三:
- 你迟早会遇到底层报错。
ClassNotFoundException、SQLException、驱动版本不兼容这类问题,不懂 JDBC 的连接机制,你连排查方向都没有。 - 面试官最爱抠细节。Java 面试题里十有八九会问到 JDBC 的六步流程、
Statement和PreparedStatement的区别、事务的提交与回滚,这些全是 JDBC 层面的东西。 - 自己写的代码出问题时,你会发现所有 ORM 框架本质都是「包装好的 JDBC」,翻到框架源码最底层,依然是
DriverManager拿连接、PreparedStatement执行 SQL、ResultSet遍历结果。
所以本文以原生 JDBC 为核心,后续如果有需要,再单独写框架整合篇。
1.2 JDBC 的底层逻辑:连接到底在连接什么
JDBC 的连接本质,是 Java 进程通过 TCP/IP 协议,和 MySQL 服务端的 3306 端口建立一条可通信的通道。MySQL 在这条通道上完成身份认证、字符集协商,之后你就可以通过这条通道发送 SQL 指令、接收结果集。
流程简化之后就是一套固定模板:
- 加载并注册数据库驱动。
- 通过
DriverManager获取数据库连接Connection。 - 通过连接创建
Statement/PreparedStatement。 - 执行 SQL 并返回结果。
- 遍历
ResultSet处理数据。 - 依次关闭
ResultSet、Statement、Connection。
这个六步流程,就是 Java 面试题里常考的「JDBC 操作数据库步骤」,也是整个数据交互的核心原理。
但这里有个关键点:真正干活的是 MySQL 的 Connector/J 驱动包,而不是 JDK 本身。JDK 只定义了接口(java.sql.*),驱动厂商负责实现。所以第一步「加载驱动」的意思是,把 mysql-connector-java 这个 jar 包引入项目,然后通过 Class.forName("com.mysql.cj.jdbc.Driver") 把驱动类加载进 JVM。
在较新的 JDBC 4.0 规范下,驱动可以自动注册,Class.forName 这一步都不写也能连上,只要 classpath 里有驱动 jar 包即可。但为了兼容性和可读性,我仍然建议显式写出来,这能体现你对连接机制的理解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与驱动选型
2.1 MySQL 版本和驱动版本怎么搭才不踩坑
很多报错的根源,都在版本不匹配上。如果让我推荐一个稳妥组合,现阶段我倾向于:
| 组件 | 推荐版本 |
|---|---|
| MySQL Server | 5.7 或 8.0(8.0 为主流) |
| mysql-connector-java | 8.0.x(与 MySQL 8.x 强匹配) |
| JDK | 8 / 11 / 17 均兼容 |
| Maven | 3.6+ |
为什么 MySQL 5.7 和 8.0 要区分对待?关键在于 8.0 的默认认证插件换成了 caching_sha2_password,老版本的驱动(5.1.x)不支持这个协议,会直接报出类似 Unable to load authentication plugin 'caching_sha2_password' 的错误。解决方式有两种:
- 升级驱动到 8.0.20 以上。
- 在 MySQL 里把用户的认证插件改回
mysql_native_password(不推荐长期用,但这确实能救急)。
我在实际项目中遇到过 FireDAC 连接 MySQL 时也报握手协议不支持的问题,这时候去检查 MySQL 版本和客户端库是否配套,方向基本没错。顺手记一下:Windows 下安装 MySQL 后,把 bin 目录加到系统 PATH,不然命令行老是要切路径。
2.2 Maven 依赖与本地 jar 的两种引入方式
不管你是用 IntelliJ IDEA 还是 Eclipse,引入驱动有两种场景。
场景一:Maven 项目
在 pom.xml 中添加依赖:
xml复制<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
这里有个小坑:新版 Maven 坐标里有时用 com.mysql:mysql-connector-j,这是官方后续改的产物名。如果你在公司老项目里看到 <artifactId>mysql-connector-java</artifactId>,不用慌,两套坐标都能用,只是不同发行版本的差异。
场景二:非 Maven 项目
去 MySQL 官网下载 mysql-connector-j 的 jar 包,放到项目根目录下建一个 lib 文件夹,然后右键 Add as Library。之后把这个 jar 包和项目一起打包发布,否则部署到服务器上会报找不到驱动类。
驱动类名也注意一下:MySQL 5.x 的驱动类名是 com.mysql.jdbc.Driver,MySQL 8.x 是 com.mysql.cj.jdbc.Driver。 驱动类名写错,第一行就报 ClassNotFoundException。
3. 核心代码实现:连接与增删改查
3.1 项目结构怎么摆
我建议不要一上来就把所有代码塞进一个 main 方法。实际开发和课程设计里,一个清晰的分层会让你后续改起来舒服很多。下面是我常用的最小结构:
code复制src/
├── jdbc/
│ ├── util/
│ │ └── DBUtils.java // 连接工具类
│ ├── dao/
│ │ ├── UserDao.java // 数据访问接口
│ │ └── impl/
│ │ └── UserDaoImpl.java // JDBC 实现
│ ├── entity/
│ │ └── User.java // 实体类
│ └── test/
│ └── Main.java // 测试入口
实体类其实就是普通 JavaBean,对应一张表的结构。字段命名尽量做到和表字段一一对应,下面以 user 表为例。
3.2 数据库连接工具类:一定要独立出来
连接数据库的代码不应该散落在每个方法里,正确做法是抽一个工具类,统一管理。
java复制package jdbc.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class DBUtils {
private static final String URL = "jdbc:mysql://localhost:3306/testdb?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
static {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USERNAME, PASSWORD);
}
public static void close(AutoCloseable... resources) {
for (AutoCloseable resource : resources) {
if (resource != null) {
try {
resource.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
这个类干了两件事:
- 静态代码块里加载驱动,保证整个 JVM 生命周期只加载一次。
- 提供获取连接和释放资源的工具方法。其中
close(AutoCloseable...)可以一次关掉多个资源,Connection、Statement、ResultSet都实现了AutoCloseable接口,直接往里传即可。
3.3 增删改查完整实现:PreparedStatement 是绝对主角
很多人写第一个 JDBC 程序时会用 Statement,但我强烈建议直接用 PreparedStatement,原因后面 3.4 详细说。这里先看完整的增删改查实现。
查询单条记录
java复制public User findById(int id) {
String sql = "SELECT id, name, email FROM user WHERE id = ?";
try (Connection conn = DBUtils.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.setEmail(rs.getString("email"));
return user;
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
这里用到了 try-with-resources 语法,Java 7 开始支持。它最大的好处是自动关闭资源,省去手动 finally 的繁琐,代码也干净很多。唯一要留意的是,rs 和 ps 的声明顺序与关闭顺序需要分层嵌套,因为 ResultSet 依赖 Statement,不能先关 Statement 再读 ResultSet。
查询列表
java复制public List<User> findAll() {
List<User> list = new ArrayList<>();
String sql = "SELECT id, name, email FROM user";
try (Connection conn = DBUtils.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
User user = new User();
user.setId(rs.getInt("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
list.add(user);
}
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
注意 executeQuery() 是用于查询的,返回 ResultSet;增删改则调用 executeUpdate(),返回的是受影响行数。这两个别混了,混了也不会报什么明显错误,但你会拿不到预期结果。
插入
java复制public int insert(User user) {
String sql = "INSERT INTO user(name, email) VALUES (?, ?)";
try (Connection conn = DBUtils.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
return ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
更新与删除
java复制public int update(User user) {
String sql = "UPDATE user SET name = ?, email = ? WHERE id = ?";
try (Connection conn = DBUtils.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.setInt(3, user.getId());
return ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
public int delete(int id) {
String sql = "DELETE FROM user WHERE id = ?";
try (Connection conn = DBUtils.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
return ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
3.4 为什么不用 Statement,而要死磕 PreparedStatement
其实开发中基本没人用 Statement,但面试里这个问题几乎是必考题。两者核心区别在于:
Statement是直接拼接 SQL 字符串执行,存在 SQL 注入风险。PreparedStatement是预编译的,SQL 结构先在数据库端编译好,参数通过占位符?传入,值不参与 SQL 结构拼接,从机制上杜绝了注入问题。
举个例子,模拟用户登录时拼接 SQL:
java复制// 危险写法
String sql = "SELECT * FROM user WHERE name = '" + name + "' AND password = '" + pwd + "'";
如果 name 传入 ' OR '1'='1,SQL 就变成了:
sql复制SELECT * FROM user WHERE name = '' OR '1'='1' AND password = ''
所有用户都能被查出来,这是经典注入方式。而用 PreparedStatement 时,就算参数里有 OR 1=1,它也只是被当成普通字符串处理。
除了安全,性能上 PreparedStatement 也可能更优,因为数据库端可以复用执行计划。但在单条 SQL 测试里差距不明显,面试时可以说「有预编译优化,但实际收益要看场景」。另外,PreparedStatement 对 Java 类型和 SQL 类型的转换更友好,比如日期、时间戳、BigDecimal 都有对应的 set 方法。
3.5 事务处理的正确姿势
JDBC 事务默认是自动提交,也就是说每执行一条 SQL,就自动 commit 一次。但在真实业务里,比如转账场景,扣钱和加钱必须放在同一个事务里,要么都成功,要么都回滚。
手动事务必须保证用的是同一个 Connection,不能用上面工具类里各自 getConnection 的方式。
java复制public boolean transfer(int fromId, int toId, double amount) {
Connection conn = null;
try {
conn = DBUtils.getConnection();
conn.setAutoCommit(false); // 关闭自动提交
String sql1 = "UPDATE account SET balance = balance - ? WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql1)) {
ps.setDouble(1, amount);
ps.setInt(2, fromId);
ps.executeUpdate();
}
// 模拟异常:如果这里抛异常,那么上面扣钱的操作应该回滚
// if (true) throw new RuntimeException("发生意外");
String sql2 = "UPDATE account SET balance = balance + ? WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql2)) {
ps.setDouble(1, amount);
ps.setInt(2, toId);
ps.executeUpdate();
}
conn.commit(); // 提交事务
System.out.println("转账成功");
return true;
} catch (SQLException e) {
e.printStackTrace();
if (conn != null) {
try {
conn.rollback();
System.out.println("转账失败,已回滚");
} catch (SQLException ex) {
ex.printStackTrace();
}
}
return false;
} finally {
DBUtils.close(conn);
}
}
这里有个细节:try-with-resources 只适合不需要手控事务的场景,一旦需要把多个 PreparedStatement 绑在同一个事务上,就必须手动管理 Connection,然后在 finally 里关闭。
另外一个坑:如果并发量上来,每次请求都新建 Connection,数据库连接会很快耗尽。生产环境必须用连接池,比如 HikariCP、Druid 或 C3P0。连接池的原理理解起来不难——它提前创建一批连接放在池子里,程序需要时借一个,用完放回,而不是销毁重建。我自己实测下来,连接池比每次新建连接快很多,数据库的压力也小很多。这块后面可以单独写一篇。
4. 常见问题与排查技巧实录
4.1 连不上数据库:几个高频报错及处理
我在实际带人的过程中,几乎每天都能遇到这几类报错,这里整理成速查表。
| 报错信息 | 可能原因 | 解决方案 |
|---|---|---|
Access denied for user 'root'@'localhost' (using password: YES) |
用户名或密码错误 | 检查连接串里的账号密码,注意密码有没有转义字符 |
Unknown database 'xxx' |
数据库名不存在 | 先 CREATE DATABASE xxx,注意大小写 |
Communications link failure |
MySQL 服务未启动、IP 地址错误、端口不对、防火墙拦截 | 用 telnet 127.0.0.1 3306 测试端口;Windows 服务管理里启动 MySQL |
Public Key Retrieval is not allowed |
MySQL 8.0 使用 caching_sha2_password 时,JDBC 默认不允许自动获取公钥 | 连接串加上 allowPublicKeyRetrieval=true |
Server returns invalid timezone. Go to the 'Advanced' tab' |
时区问题 | 连接串加上 serverTimezone=Asia/Shanghai 或使用上海的别名 |
The server time zone value 'XXX' is unrecognized |
服务器时区与 JVM 时区不一致 | 同上 |
这里要特别提一下 Communications link failure,新手最容易在这卡住。遇到这个报错一定要分层排查:
- 第一步,确认 MySQL 服务有没有起来。Windows 下按
Win + R,输入services.msc,看 MySQL 服务是否在运行;Linux 下执行systemctl status mysql。 - 第二步,确认端口通不通。命令行执行
telnet 127.0.0.1 3306,如果不通,检查是否被防火墙拦截。 - 第三步,确认连接串里的
localhost是否是目标地址。有时候你连的是远程数据库,但 IP 写错了,就是死路一条。
4.2 编码问题:中文乱码的根源
中文乱码是初学者必修课,背后其实是三个位置的字符集没有对齐:客户端、连接层、服务端。
你在 Java 代码里写死的中文字符,是 UTF-8 编码;MySQL 服务端表默认可能是 utf8mb4;而连接层如果没指定,默认可能是 latin1。三个地方不一致,就出现乱码。
解决办法有两个层面:
- 连接串上追加:
useUnicode=true&characterEncoding=utf8 - 建表时显式指定字符集:
CREATE TABLE user (...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
utf8mb4 是 utf8 的超集,能存表情符号,推荐直接用 utf8mb4。如果你的表已经建好了,可以用 ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4; 来调整。
4.3 驱动版本与 MySQL 版本不匹配
之前说过,caching_sha2_password 是 MySQL 8.0 默认认证插件,老驱动 5.1.49 会直接报 Authentication plugin 'caching_sha2_password' cannot be loaded。最稳的解法是把驱动升到 8.x。
有些时候你会发现,明明项目里已经用了 MySQL 8 的驱动,却还是报这个错,那可能是 mysql-connector-java 和 mysql-connector-j 两个坐标混用导致的冲突。在 Maven 里执行 mvn dependency:tree,排查一下有没有历史依赖把旧版本驱动带进来。
顺带一提,有一种做法是登录 MySQL 后执行:
sql复制ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';
这能把认证插件改成老协议,但这不是长期方案,因为安全性和性能都不如默认的新协议。做课程设计或者本地测试临时用没问题,生产环境不推荐。
4.4 数据库连接泄漏:一个隐蔽但致命的坑
JDBC 程序常见的毛病是:数据库连接、PreparedStatement、ResultSet 用完后没有关闭。这个问题很隐蔽,因为程序短时间跑起来没感觉,但跑几天之后会发现连接数不断上涨,直到数据库报 Too many connections,服务直接不可用。
排查思路:连接泄漏的时候,用 MySQL 命令行执行:
sql复制SHOW PROCESSLIST;
查看有多少 Sleep 状态的连接堆积在 testdb 库上,数量异常多,基本就是代码里没关连接。
解决方式很简单:
- 有
finally的,在finally里统一关闭。 - 用 try-with-resources 的,多个资源声明顺序要正确。
- 生产环境用连接池,并配置连接最大存活时间和空闲回收策略。
我个人经验里最有效的做法,是把资源关闭统一收敛到工具类的 close(AutoCloseable...) 方法里,而不是在每个方法的 finally 块里写三行 if (rs != null) rs.close()。后者写多了容易忘。
4.5 如何快速定位 JDBC 代码里的问题
我经常跟团队里的小伙伴说,遇到 JDBC 问题不要拍脑袋,要按「连接 → 驱动 → SQL → 结果集」四层定位:
- 第一步,确认连接能拿到。如果
getConnection()就报错,那是 URL、账号、密码、端口、服务状态的问题。 - 第二步,确认 SQL 语法。如果是
executeUpdate阶段报错,把 SQL 打印出来,放到 Navicat 或 MySQL 命令行里跑一遍,看是不是SQLSyntaxErrorException。 - 第三步,确认参数绑定。
PreparedStatement的setXxx方法顺序、类型、索引是新手重灾区,注意索引是从 1 开始,不是 0。 - 第四步,确认结果集读取。
rs.getString("列名")里的列名要和 SQL 里查询出来的列名一致,别名没设置就得用原始列名。
举个常见的例子,SQL 里写了聚合函数 SELECT COUNT(*) FROM user,然后想用 rs.getInt("count"),其实应该是 rs.getInt(1),或者给别名:SELECT COUNT(*) AS cnt FROM user,再用 rs.getInt("cnt")。
4.6 性能调优的最小建议
虽然这篇文章重点在入门,但我觉得哪怕你刚开始写 JDBC,也可以顺手养成两个好习惯:
- 尽量一次查出需要的字段,不要
SELECT *。 - 批量操作时用
addBatch()+executeBatch(),比循环单条插入快很多倍。
批量插入示例:
java复制String sql = "INSERT INTO user(name, email) VALUES (?, ?)";
try (Connection conn = DBUtils.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false);
for (int i = 0; i < 10000; i++) {
ps.setString(1, "user_" + i);
ps.setString(2, "email_" + i + "@test.com");
ps.addBatch();
if (i % 500 == 0) {
ps.executeBatch(); // 每500条批量执行一次
}
}
ps.executeBatch();
conn.commit();
System.out.println("批量插入完成");
} catch (SQLException e) {
e.printStackTrace();
}
addBatch 只是把参数塞进批处理队列,executeBatch 才是真正发到数据库执行。批处理配合关闭自动提交,插入几万条数据都是秒级完成,如果一条一条插,速度差距可能达到几十倍。
5. 手写 JDBC 后的一些经验体会
JDBC 这套东西,说难也难,说简单也简单。难在你必须理解连接、驱动、预编译这些概念,简单在这套流程非常固定,只要亲手写过几遍,后面再接触各种 ORM 框架,会发现它们绕来绕去都脱离不了 JDBC 的骨架。
我在实际项目里其实很少直接写 JDBC,更多是用 MyBatis-Plus 或者 Spring Data JPA。但每次遇到诡异的环境问题,比如数据库换版本、字符集不对、驱动冲突,最终都是靠 JDBC 层面的知识去定位和解决的。这是我坚持劝程序员一定要搞懂 JDBC 的原因——它不是过时的技术,而是整个 Java 数据库体系的基石。
如果你正准备面试,不管你面的是初级还是中级,JDBC 六步、PreparedStatement 防注入、事务处理、连接池原理,这四个点一定要能脱稿讲出来。如果你在做课程设计,照着这篇文章搭一个工具类加一个 DAO 层,再连一个测试入口,基本就能跑通整个数据流了。后面如果再有时间,我打算继续写一篇「从 JDBC 到 MyBatis-Plus 的平滑过渡」,把这几个方案放在同一张表对比一下,把选择逻辑也讲透。
