用 Java 连接 MySQL 数据库,看起来是个老掉牙的话题,但真正自己动手写过一遍的人都知道,这里面的坑比想象中多得多。尤其是刚接触 Java 后端的朋友,第一次调通 JDBC 代码,心情往往不是“终于学会了”,而是“总算跑通了”。我记得自己第一份项目里写 Java 连接 MySQL 时,被 ClassNotFoundException、CommunicationsException、Access denied 轮番轰炸,光一个数据库连接就折腾了一个下午,后来才发现不过是时区参数没写。这篇内容就是想把 Java 连接 MySQL 并实现数据交互这件事彻底讲明白。核心关键词就两个:Java 连接 MySQL、数据交互。我会从环境准备讲到驱动依赖,再到完整的增删改查代码、事务与批量处理,最后把生产环境必用的连接池和调试经验也一并打包。适合刚学 Java Web 的在校生、第一次在公司项目里写数据库操作的初级开发,也适合那些框架用惯了、想回头补一补 JDBC 底层细节的朋友。把这套东西吃透,后面你再去看 MyBatis、JPA,会发现那些框架不过是一层漂亮的壳。1. 为什么还要手写 JDBC:数据访问大厦的第一块砖很多初学者会问,现在写 Java 项目,十个里有九个用 Spring Boot + MyBatis、JPA,直接上手 JDBC 还有必要吗?这个问题的答案,要看你对自己代码的理解深度。1.1 JDBC 在 Java 数据访问体系里的定位JDBC(Java Database Connectivity)是 Java 官方定义的一套数据库访问接口规范。它定义了一套接口,比如 Connection、Statement、ResultSet,数据库厂商负责实现这些接口,提供对应的驱动包。Java 代码通过这套统一接口操作不同的数据库,不需要关心 MySQL 和 Oracle 之间的底层差异。打个比方,JDBC 就像电源插座的标准规格,MySQL 驱动、Oracle 驱动都是按照这个规格生产的插头。你的 Java 代码只对着插座写,不用管背后接的是哪家发电厂的线。明白了这层关系,你就知道 MyBatis、Hibernate 这些框架,本质上是帮你把 JDBC 的样板代码封装了起来。你在 MyBatis 里写一条 SQL,框架底层还是要创建 Connection、准备 PreparedStatement、执行 executeQuery,只不过这些动作被隐藏了。所以框架入门容易,遇到问题排查难。一旦 SQL 报错、连接池满、事务回滚不生效,不懂 JDBC 的人往往不知从何下手,懂的人扫一眼日志就能定位到是哪个环节出了问题。1.2 什么场景适合直接拿起 JDBC现在都推荐用框架,动不动就上 Spring Boot,但有些场景其实直接撸 JDBC 更香。一是小工具和命令行程序。我之前写过一些内部数据整理的极简工具,就数据库连接加查询逻辑,总共几百行。这种项目引入一整套 ORM 框架,光是配置类和依赖就比你业务代码还长,纯粹给自己找麻烦。二是性能敏感的数据批处理。ORM 框架在批量插入、大批量查询时的自动映射、缓存管理都会带来额外开销。JDBC 的 PreparedStatement 配合 addBatch、executeBatch,可以发挥数据库驱动的极限性能,这一点框架很难超越。三是学习框架原理。你只有亲手写过 JDBC 代码,理解了连接从哪来、SQL 怎么执行、结果集怎么映射,才能理解 MyBatis 具体做了什么、JPA 又为何这么“重”。我见过不少开发,迁移个 Spring Boot 版本,MyBatis 报一大堆错,焦虑得不行,其实就是底层 JDBC 驱动的行为变了,不懂底层的就完全抓瞎。所以手写 JDBC 不是过时,而是基本功。这篇文章教的这套东西,是后续所有数据访问技术的基石。2. 准备环境与驱动依赖:光有代码可不够真到了动手阶段,第一步不是写代码,而是核对你的开发环境。环境不匹配,后面写的代码全是空中楼阁。2.1 JDK 与 MySQL 版本怎么配先说 JDK,现在写新代码最低也要用 JDK 8,能上 JDK 11 或者 17 更好。JDK 8 虽然还能用,但 Java 11 之后的 DriverManager 加载机制有些细微变化,加上 Spring Boot 3 已经强制要求 JDK 17,建议新项目直接上 JDK 17,省得以后再迁。MySQL 这边要分两种情况。本地学习、自己调试,用 MySQL 5.7 或者 8.0 都可以,8.0 的默认认证方式是 caching_sha2_password,对驱动版本有要求。公司项目如果是老系统,可能还在 5.6、5.7,那驱动也要对应调整。我建议你本地直接装 MySQL 8.0 系列,毕竟新项目基本都往这个版本走。版本核对具体分三项。第一,MySQL 服务要正常启动,命令行里能用 mysql -u root -p 登录进去。第二,要提前建好数据库和测试表,这些准备动作不要太随意,因为后边代码要用到。第三,记住端口号,MySQL 默认 3306,如果你用的是 XAMPP、宝塔这类集成环境,端口可能被改过,到时候连不上别怀疑代码,先怀疑端口。2.2 驱动依赖怎么选:这里有个大坑Java 连接 MySQL 需要驱动包,最常见的叫 mysql-connector-j。这个名字有个历史坑:老一点的版本叫 mysql-connector-java,从 8.0.31 开始官方把 artifact 的 groupId 改成了 com.mysql,artifactId 改成了 mysql-connector-j。如果你在网上搜到的是老坐标,复制到 Maven 里 configure 时可能有点绕。用 Maven 搭建工程,直接在 pom.xml 里加依赖。这是最标准的写法:<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>如果你是 5.x 的老驱动,对应的依赖坐标是:<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency>版本怎么选?我的经验是,用 MySQL 8.0 数据库就配 8.0.x 的驱动,用 MySQL 5.7 数据库,8.0.x 驱动也能兼容,但有时会碰到连接属性不识别的问题。反过来,5.1.x 驱动连 MySQL 8.0 数据库,会直接报 Unable to load authentication plugin 'caching_sha2_password'。所以最省心的组合是 MySQL 8.0 数据库 + 8.x 驱动,想折腾的可以试试其他组合,但别在版本匹配上浪费太多时间。另外需要注意 class 名称的差异。老驱动是 com.mysql.jdbc.Driver,新驱动是 com.mysql.cj.jdbc.Driver。虽然 JDBC 4.0 以后驱动可以自动注册,不需要手动加载,但很多老项目的代码里仍然写了 Class.forName("com.mysql.jdbc.Driver"),用新驱动时这一行会报 ClassNotFoundException,解决方法是把类名换成新的。3. 完整实操:从连接数据库到增删改查环境备齐,依赖配好,下面写代码。我建议你跟着这个流程走一遍,比单纯读代码清楚得多。3.1 第一步:建立数据库连接我们先解决从 Java 到 MySQL 的“握手”问题。下面这段代码是最基础的连接方式:import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcConnectionDemo { public static void main(String[] args) { String url = "jdbc:mysql://localhost:3306/test_db" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8"; String user = "root"; String password = "你自己的密码"; try (Connection conn = DriverManager.getConnection(url, user, password)) { System.out.println("连接成功,数据库是: " + conn.getCatalog()); } catch (SQLException e) { e.printStackTrace(); } } }这里面的 URL 是连接字符串,必须严格按照固定格式来写。它由几个部分组成:jdbc:mysql:// 是协议头,localhost 是数据库主机地址,3306 是端口,test_db 是数据库名,后面跟着的是连接参数。jdbc:mysql://主机地址:端口/数据库名?参数1=值1&参数2=值2我第一次学的时候最容易漏掉的就是参数部分。三个参数里,serverTimezone 几乎是必写的,因为 MySQL 8.x 驱动默认要求指定时区,不写大概率报 The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,一串乱码直接把人看懵。useSSL=false 是为了避免本地连接时做 SSL 握手,开发环境加上这个参数能节省连接时间。characterEncoding=utf8 是为了保证中文不乱码,如果你没写它,插入中文数据时经常会出现问号或者乱码。这段代码用了 JDK 7 引入的 try-with-resources 语法,Connection 是 AutoCloseable 的子类,try 块结束自动关闭连接。这是一个好习惯,避免忘记写 conn.close()。很多初学者一开始不爱用这个语法,喜欢手工 close,最后要么少了一个关闭,要么在关闭时又抛出空指针,反而不如这样干净。3.2 第二步:使用 Statement 还是 PreparedStatement连接建立了,接下来执行 SQL。这里有两个选择,一个 Statement,一个 PreparedStatement。我直接给结论:写代码一律用 PreparedStatement,除非你的 SQL 字符串里没有一处变量拼接。Statement 的典型写法是把 SQL 字符串拼出来:Statement stmt = conn.createStatement(); String sql = "SELECT * FROM user WHERE username = '" + username + "'"; ResultSet rs = stmt.executeQuery(sql);这种写法在 username 传入一段恶意字符串时,会造成什么结果?比如传入' OR '1'='1,整条 SQL 变成查询全部用户,这就是 SQL 注入的经典攻击方式。我见过不少老系统就是这么被爬走数据的,所以不要心存侥幸。PreparedStatement 的写法和它完全不同:String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery();参数在 SQL 里用问号占位,后续通过 setString、setInt 这些方法把值传进去。驱动会把 SQL 结构和参数值分开处理,参数值经过转义,恶意字符串只是被当成普通字符串,无法改变 SQL 结构。PreparedStatement 还有一个好处:同样的 SQL 模板可以预编译,重复执行时性能更好,尤其适合循环插入数据的场景。3.3 第三步:查询数据并处理结果集连接和 SQL 都就绪了,下面我们写一个完整的查询流程。假设有一张 user 表,包含 id、username、age 三个字段。代码可以这样写:String sql = "SELECT id, username, age FROM user WHERE age > ?"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 20); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { int id = rs.getInt("id"); String username = rs.getString("username"); int age = rs.getInt("age"); System.out.println("ID=" + id + ", 用户名=" + username + ", 年龄=" + age); } } } catch (SQLException e) { e.printStackTrace(); }ResultSet 是查询结果集,可以理解为一个游标式的二维表。rs.next() 让游标移动到下一行,如果有数据就返回 true,没有数据就返回 false。所以 while (rs.next()) 是遍历结果集的标准循环。取列值的时候,可以按下标取 rs.getInt(1),也可以按列名取 rs.getInt("id"),我强烈建议用列名,因为 SQL 一旦调整,按列名还能保持代码可读性,按标号很容易取错列。这里有个细节容易被忽略:ResultSet 一旦关闭,再想获取数据就来不及了。所以处理结果集的逻辑必须在 try 块内完成,或者先把数据复制到一个 List 里,连接关闭后还能继续使用。我通常这么处理:List<User> userList = new ArrayList<>(); while (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setAge(rs.getInt("age")); userList.add(u); } // 出了 try 块,连接关闭,userList 依然可用3.4 第四步:增删改操作与事务管理增删改和查询不同,它们不返回结果集,而是返回一个影响行数。用 executeUpdate() 执行,返回值是 int,代表被修改的行数。举个例子:// 插入 String insertSql = "INSERT INTO user(id, username, age) VALUES (?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, 1); ps.setString(2, "张三"); ps.setInt(3, 25); int rows = ps.executeUpdate(); System.out.println("插入影响行数:" + rows); } // 更新 String updateSql = "UPDATE user SET age = ? WHERE username = ?"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, 26); ps.setString(2, "张三"); ps.executeUpdate(); } // 删除 String deleteSql = "DELETE FROM user WHERE username = ?"; try (PreparedStatement ps = conn.prepareStatement(deleteSql)) { ps.setString(1, "张三"); ps.executeUpdate(); }如果你只有一条 SQL,上面的写法足够了。但实际业务里,经常有两个操作必须同时成功或者同时失败。最典型的例子是转账:A 账户扣钱、B 账户加钱,两条 SQL 只要一条失败,另一条就必须回滚,否则账就不平了。JDBC 的事务控制有三个核心操作。默认情况下,Connection 的 autocommit 是 true,也就是每条 SQL 执行后自动提交,我们要先关掉。然后执行多条 SQL,全部成功后 commit(),任何一个环节出问题就 rollback() 回滚,最后在 finally 里恢复自动提交状态。代码是这样:Connection conn = null; try { conn = DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); String sql1 = "UPDATE account SET balance = balance - 100 WHERE username = 'A'"; String sql2 = "UPDATE account SET balance = balance + 100 WHERE username = 'B'"; try (PreparedStatement ps1 = conn.prepareStatement(sql1)) { ps1.executeUpdate(); } try (PreparedStatement ps2 = conn.prepareStatement(sql2)) { 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(); } } }这里注意,close() 之前一定要先 setAutoCommit(true),否则连接回到连接池后,下一个人拿到这个连接会因为 autocommit 还是 false 而导致行为异常。这种问题极难排查,日志里看不到任何报错,但到处是事务不生效、数据时有时无的诡异现象。批量操作也是生产环境的高频需求。如果要在循环里插入一万条数据,每条都用 executeUpdate(),性能极差。正确姿势是使用 addBatch + executeBatch:String sql = "INSERT INTO user(username, age) VALUES (?, ?)"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i = 0; i < 10000; i++) { ps.setString(1, "用户" + i); ps.setInt(2, 20 + i % 50); ps.addBatch(); if (i % 1000 == 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit(); }每凑够一批就 executeBatch 一次,而不是等全部积累完,是在内存与速度之间取一个平衡。最终数量少时一次性提交没问题,量大的时候分批是必须的。4. 从能跑到跑好:生产环境必须直面的性能与安全问题把上面的代码跑通了,你只是完成了“能连上、能增删改查”这一步。实际项目上线后,还会面临连接管理、SQL 注入、连接耗尽这些新问题。4.1 SQL 注入到底是怎么回事上面提到用 PreparedStatement 代替 Statement,这里展开说说原理。SQL 注入的本质是用户输入被拼进了 SQL 语句里,变成了 SQL 的一部分。看一个例子:String sql = "SELECT * FROM user WHERE username = '" + input + "'";如果用户输入的是admin' --,拼接之后变成:SELECT * FROM user WHERE username = 'admin' -- '在 MySQL 里,-- 后面是注释,所以这条语句完全忽略了后面的单引号,直接以 username = 'admin' 作为条件执行。如果你运气好,数据库里恰好有 admin 用户,数据就泄露了。更狠的攻击是' OR 1=1 --,拼接后变成查询所有用户。PreparedStatement 之所以安全,是因为它把 SQL 语句的结构和执行参数分开了。结构在 prepareStatement 时已经确定,参数被当作了纯数据传给驱动,驱动会做转义处理,单引号变成普通字符,无论如何都成不了 SQL 结构的一部分。这是官方推荐的第一道防线,不需要任何额外配置,只要你在写代码时养成用 PreparedStatement 的习惯就够了。4.2 连接池:高并发下的生命线新手写 JDBC 代码,每执行一次操作就新开一个连接,用完就关,在低并发本地测试没问题。但到了线上,一个请求开一次连接,连接建立和断开的开销非常大,数据库连接数很快就撑不住了。数据库连接池可以简单理解为提前建好了一批连接放在池子里,用的时候从池里取出一个,用完关掉其实是归还到池子,而不是真的断开。这样数据库服务端不用频繁创建和销毁线程,应用端也能快速拿到可用连接。Java 生态里最常见的连接池是 HikariCP,Spring Boot 2.x 之后默认使用它。HikariCP 为什么快?它的字节码被高度优化过,并发获取连接时锁的粒度更细,连接有效性检测也更聪明。如果自己管理连接池,可以这样配:<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency>HikariConfig config = new HikariConfig(); config.setJdbcUrl(url); config.setUsername(user); config.setPassword(password); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); HikariDataSource dataSource = new HikariDataSource(config);配置参数里,maximumPoolSize 不是越大越好,通常和数据库实例的 CPU 核数相关。我见过有人直接配 200,结果数据库连接被打满,应用反而变慢。连接池的大小应该结合数据库性能和业务并发量来调,一开始 10 到 20 个是合理的起点,之后通过压测慢慢往上调。拿到 DataSource 之后,原来的代码要改成从 dataSource.getConnection() 获取连接,这样连接就由连接池来管理。如果你继续 new DriverManager.getConnection(),等于完全绕过了连接池,配置白做了。4.3 关于驱动自动加载的常见误解Java 6 之后,JDBC 驱动支持通过 ServiceProvider 机制自动注册,也就是 DriverManager 会在启动时扫描 classpath 下的 META-INF/services/java.sql.Driver 文件,自动加载驱动类。所以新项目里写不写 Class.forName("com.mysql.cj.jdbc.Driver") 其实都可以。但老一点的项目里,这行代码还是很普遍。为什么?因为旧驱动不是自动注册的,必须手动加载。而且有些容器环境下,DriverManager 的自动发现机制可能失效,写了 Class.forName 反而更稳妥。我的建议是:写的时候不写无所谓,但看到老代码里这行时不要惊讶,更不要删了之后报错又怪别人。5. 常见问题排查与避坑实录:跑一次就会遇到的意外代码写完了,运行报错才是真正的开始。下面这些问题是初学者最容易碰到的,我把它们整理成了一张速查表,方便复盘时对照。报错信息常见原因解决方法Communications link failureMySQL 服务未启动、端口不对、防火墙拦截先确认 MySQL 是否启动,telnet 3306 测试端口连通性Access denied for user 'root'@'localhost'用户名或密码错误,或者用户没有远程访问权限核对密码,确认用户是否有对应库的权限Unknown database 'test_db'数据库不存在先通过命令行 CREATE DATABASE 创建库ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动依赖没加载,或者类名写错检查 Maven 依赖是否生效,确认类名是 cj 开头The server time zone value is unrecognizedURL 里没有指定 serverTimezone增加 &serverTimezone=Asia/ShanghaiSSL connection errorURL 里没有禁用 SSL,或本地 MySQL 不支持 SSL增加 &useSSL=false 仅限开发环境中文数据乱码连接字符集未设置,或表本身字符集不是 utf8mb4URL 增加 characterEncoding=utf8,确认表字符集No operations allowed after connection closed连接已关闭,但代码还在使用 Connection 或 ResultSet检查资源释放逻辑,把取数放在 try 内完成第一类容易踩的坑是连接失败。Communications link failure 是新手最常看到的报错,原因很多,但排查思路是有迹可循的。先在命令行用 mysql -u root -p 连一下本机 MySQL,如果命令行都连不上,说明 MySQL 服务没起来,跟 Java 代码没有任何关系。如果命令行能连上,再用 telnet 127.0.0.1 3306 试一下端口,通说明网络和服务都正常,问题大概率出在代码的 URL、用户名或密码上。第二类坑是驱动版本不匹配。我之前就遇到过,MySQL 8.0 数据库配了 5.1.x 的驱动,项目启动时一直报 Unable to load authentication plugin,其实就是因为新版本 MySQL 默认的加密插件变了,老驱动不认。解决办法有两个,要么把驱动升到 8.x,要么给数据库新建一个 mysql_native_password 的用户,但明显升驱动更简单。第三类坑是连接池配置不当。我见到过把 maximumPoolSize 配得异常大的,结果应用启动时数据库 CPU 直接飙高,因为连接池预热创建了大量连接。也有配置了连接池但代码里还是用 DriverManager 拿连接的,白白空跑连接池。还有前面提到的 autocommit 没恢复,这是最阴间的坑:某条连接执行了事务没提交,归还给连接池后,下一个人拿这个连接继续做操作,数据一直不落库,排查半天才发现是连接状态被污染了。排查这类问题,我的经验是关掉连接池,直接用 DriverManager 裸连,能跑通说明业务代码没问题,剩下的锅基本在连接池配置上。反过来也一样,如果裸连报错,先排除驱动、URL、权限这些基础项。两层分开排查,问题范围立刻缩小一半。6. 封个简易工具类:JDBC 的第二次升华写到这里,你已经有能力完成一次完整的 Java 连接 MySQL 数据交互了。但如果每个业务方法里都重复写获取连接、创建 PreparedStatement、遍历 ResultSet 的代码,你会很快感到繁琐。实际项目中,我们通常会封装一个 JDBC 工具类,把连接的获取和资源的释放统一管理。我提供一个极简版的工具类思路,你可以根据自己的项目再调整:public class JdbcUtil { private static final String URL = "jdbc:mysql://localhost:3306/test_db" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) { /* ignore */ } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { /* ignore */ } } if (conn != null) { try { conn.close(); } catch (SQLException e) { /* ignore */ } } } }注意,工具类里我用 try 块包裹了 close 方法,但忽略了异常。这不是不在乎异常,而是关闭资源时的异常通常不会影响主要业务流程,而且如果在 finally 里再抛出异常,反而会掩盖前面的业务异常。这是一种权衡,但也是线上代码常见的容错方式。有了工具类之后,业务代码可以写成:Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = JdbcUtil.getConnection(); ps = conn.prepareStatement("SELECT * FROM user WHERE age > ?"); ps.setInt(1, 20); rs = ps.executeQuery(); while (rs.next()) { System.out.println(rs.getString("username")); } } finally { JdbcUtil.close(conn, ps, rs); }这套代码已经比较接近公司里真正在用的风格了。如果再进一步,你可以在这个基础上做一个简单的 BaseDao、泛型查询封装,或者直接引入 Spring 的 JdbcTemplate。你会发现,JdbcTemplate 的核心思想就是这个工具类的增强版:连接统一管理、重复代码消除、异常转换。理解了 JDBC 这层,你再看 Spring 的数据访问体系,真的会有一层窗户纸被捅破的感觉。我记得自己第一次完整封装出这个工具类的时候,心里特别踏实,因为我知道哪怕离开框架,我也能独立完成数据库操作。这种底层能力,是面试时聊到数据库性能调优、事务边界、连接管理时能够深入对话的底气。这篇文章的内容到这里就完整了。如果你还在因为框架报错而一头雾水,不妨像我当初一样,找个周末,从 DriverManager 开始,不依赖任何框架,手写一个 Java 连接 MySQL 的小项目,把增删改查、事务、批量操作都过一遍,你会收获一种“尽在掌握”的感觉。过程中遇到任何问题,欢迎回来对照这篇文章,一步一步排查。毕竟,JDBC 踩过的每一个坑,都是数据库编程这条路上的垫脚石。
用 Java 连接 MySQL 数据库,看起来是个老掉牙的话题,但真正自己动手写过一遍的人都知道,这里面的坑比想象中多得多。尤其是刚接触 Java 后端的朋友,第一次调通 JDBC 代码,心情往往不是“终于学会了”,而是“总算跑通了”。我记得自己第一份项目里写 Java 连接 MySQL 时,被 ClassNotFoundException、CommunicationsException、Access denied 轮番轰炸,光一个数据库连接就折腾了一个下午,后来才发现不过是时区参数没写。这篇内容就是想把 Java 连接 MySQL 并实现数据交互这件事彻底讲明白。核心关键词就两个:Java 连接 MySQL、数据交互。我会从环境准备讲到驱动依赖,再到完整的增删改查代码、事务与批量处理,最后把生产环境必用的连接池和调试经验也一并打包。适合刚学 Java Web 的在校生、第一次在公司项目里写数据库操作的初级开发,也适合那些框架用惯了、想回头补一补 JDBC 底层细节的朋友。把这套东西吃透,后面你再去看 MyBatis、JPA,会发现那些框架不过是一层漂亮的壳。1. 为什么还要手写 JDBC:数据访问大厦的第一块砖很多初学者会问,现在写 Java 项目,十个里有九个用 Spring Boot + MyBatis、JPA,直接上手 JDBC 还有必要吗?这个问题的答案,要看你对自己代码的理解深度。1.1 JDBC 在 Java 数据访问体系里的定位JDBC(Java Database Connectivity)是 Java 官方定义的一套数据库访问接口规范。它定义了一套接口,比如 Connection、Statement、ResultSet,数据库厂商负责实现这些接口,提供对应的驱动包。Java 代码通过这套统一接口操作不同的数据库,不需要关心 MySQL 和 Oracle 之间的底层差异。打个比方,JDBC 就像电源插座的标准规格,MySQL 驱动、Oracle 驱动都是按照这个规格生产的插头。你的 Java 代码只对着插座写,不用管背后接的是哪家发电厂的线。明白了这层关系,你就知道 MyBatis、Hibernate 这些框架,本质上是帮你把 JDBC 的样板代码封装了起来。你在 MyBatis 里写一条 SQL,框架底层还是要创建 Connection、准备 PreparedStatement、执行 executeQuery,只不过这些动作被隐藏了。所以框架入门容易,遇到问题排查难。一旦 SQL 报错、连接池满、事务回滚不生效,不懂 JDBC 的人往往不知从何下手,懂的人扫一眼日志就能定位到是哪个环节出了问题。1.2 什么场景适合直接拿起 JDBC现在都推荐用框架,动不动就上 Spring Boot,但有些场景其实直接撸 JDBC 更香。一是小工具和命令行程序。我之前写过一些内部数据整理的极简工具,就数据库连接加查询逻辑,总共几百行。这种项目引入一整套 ORM 框架,光是配置类和依赖就比你业务代码还长,纯粹给自己找麻烦。二是性能敏感的数据批处理。ORM 框架在批量插入、大批量查询时的自动映射、缓存管理都会带来额外开销。JDBC 的 PreparedStatement 配合 addBatch、executeBatch,可以发挥数据库驱动的极限性能,这一点框架很难超越。三是学习框架原理。你只有亲手写过 JDBC 代码,理解了连接从哪来、SQL 怎么执行、结果集怎么映射,才能理解 MyBatis 具体做了什么、JPA 又为何这么“重”。我见过不少开发,迁移个 Spring Boot 版本,MyBatis 报一大堆错,焦虑得不行,其实就是底层 JDBC 驱动的行为变了,不懂底层的就完全抓瞎。所以手写 JDBC 不是过时,而是基本功。这篇文章教的这套东西,是后续所有数据访问技术的基石。2. 准备环境与驱动依赖:光有代码可不够真到了动手阶段,第一步不是写代码,而是核对你的开发环境。环境不匹配,后面写的代码全是空中楼阁。2.1 JDK 与 MySQL 版本怎么配先说 JDK,现在写新代码最低也要用 JDK 8,能上 JDK 11 或者 17 更好。JDK 8 虽然还能用,但 Java 11 之后的 DriverManager 加载机制有些细微变化,加上 Spring Boot 3 已经强制要求 JDK 17,建议新项目直接上 JDK 17,省得以后再迁。MySQL 这边要分两种情况。本地学习、自己调试,用 MySQL 5.7 或者 8.0 都可以,8.0 的默认认证方式是 caching_sha2_password,对驱动版本有要求。公司项目如果是老系统,可能还在 5.6、5.7,那驱动也要对应调整。我建议你本地直接装 MySQL 8.0 系列,毕竟新项目基本都往这个版本走。版本核对具体分三项。第一,MySQL 服务要正常启动,命令行里能用 mysql -u root -p 登录进去。第二,要提前建好数据库和测试表,这些准备动作不要太随意,因为后边代码要用到。第三,记住端口号,MySQL 默认 3306,如果你用的是 XAMPP、宝塔这类集成环境,端口可能被改过,到时候连不上别怀疑代码,先怀疑端口。2.2 驱动依赖怎么选:这里有个大坑Java 连接 MySQL 需要驱动包,最常见的叫 mysql-connector-j。这个名字有个历史坑:老一点的版本叫 mysql-connector-java,从 8.0.31 开始官方把 artifact 的 groupId 改成了 com.mysql,artifactId 改成了 mysql-connector-j。如果你在网上搜到的是老坐标,复制到 Maven 里 configure 时可能有点绕。用 Maven 搭建工程,直接在 pom.xml 里加依赖。这是最标准的写法:<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>如果你是 5.x 的老驱动,对应的依赖坐标是:<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency>版本怎么选?我的经验是,用 MySQL 8.0 数据库就配 8.0.x 的驱动,用 MySQL 5.7 数据库,8.0.x 驱动也能兼容,但有时会碰到连接属性不识别的问题。反过来,5.1.x 驱动连 MySQL 8.0 数据库,会直接报 Unable to load authentication plugin 'caching_sha2_password'。所以最省心的组合是 MySQL 8.0 数据库 + 8.x 驱动,想折腾的可以试试其他组合,但别在版本匹配上浪费太多时间。另外需要注意 class 名称的差异。老驱动是 com.mysql.jdbc.Driver,新驱动是 com.mysql.cj.jdbc.Driver。虽然 JDBC 4.0 以后驱动可以自动注册,不需要手动加载,但很多老项目的代码里仍然写了 Class.forName("com.mysql.jdbc.Driver"),用新驱动时这一行会报 ClassNotFoundException,解决方法是把类名换成新的。3. 完整实操:从连接数据库到增删改查环境备齐,依赖配好,下面写代码。我建议你跟着这个流程走一遍,比单纯读代码清楚得多。3.1 第一步:建立数据库连接我们先解决从 Java 到 MySQL 的“握手”问题。下面这段代码是最基础的连接方式:import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcConnectionDemo { public static void main(String[] args) { String url = "jdbc:mysql://localhost:3306/test_db" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8"; String user = "root"; String password = "你自己的密码"; try (Connection conn = DriverManager.getConnection(url, user, password)) { System.out.println("连接成功,数据库是: " + conn.getCatalog()); } catch (SQLException e) { e.printStackTrace(); } } }这里面的 URL 是连接字符串,必须严格按照固定格式来写。它由几个部分组成:jdbc:mysql:// 是协议头,localhost 是数据库主机地址,3306 是端口,test_db 是数据库名,后面跟着的是连接参数。jdbc:mysql://主机地址:端口/数据库名?参数1=值1&参数2=值2我第一次学的时候最容易漏掉的就是参数部分。三个参数里,serverTimezone 几乎是必写的,因为 MySQL 8.x 驱动默认要求指定时区,不写大概率报 The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,一串乱码直接把人看懵。useSSL=false 是为了避免本地连接时做 SSL 握手,开发环境加上这个参数能节省连接时间。characterEncoding=utf8 是为了保证中文不乱码,如果你没写它,插入中文数据时经常会出现问号或者乱码。这段代码用了 JDK 7 引入的 try-with-resources 语法,Connection 是 AutoCloseable 的子类,try 块结束自动关闭连接。这是一个好习惯,避免忘记写 conn.close()。很多初学者一开始不爱用这个语法,喜欢手工 close,最后要么少了一个关闭,要么在关闭时又抛出空指针,反而不如这样干净。3.2 第二步:使用 Statement 还是 PreparedStatement连接建立了,接下来执行 SQL。这里有两个选择,一个 Statement,一个 PreparedStatement。我直接给结论:写代码一律用 PreparedStatement,除非你的 SQL 字符串里没有一处变量拼接。Statement 的典型写法是把 SQL 字符串拼出来:Statement stmt = conn.createStatement(); String sql = "SELECT * FROM user WHERE username = '" + username + "'"; ResultSet rs = stmt.executeQuery(sql);这种写法在 username 传入一段恶意字符串时,会造成什么结果?比如传入' OR '1'='1,整条 SQL 变成查询全部用户,这就是 SQL 注入的经典攻击方式。我见过不少老系统就是这么被爬走数据的,所以不要心存侥幸。PreparedStatement 的写法和它完全不同:String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery();参数在 SQL 里用问号占位,后续通过 setString、setInt 这些方法把值传进去。驱动会把 SQL 结构和参数值分开处理,参数值经过转义,恶意字符串只是被当成普通字符串,无法改变 SQL 结构。PreparedStatement 还有一个好处:同样的 SQL 模板可以预编译,重复执行时性能更好,尤其适合循环插入数据的场景。3.3 第三步:查询数据并处理结果集连接和 SQL 都就绪了,下面我们写一个完整的查询流程。假设有一张 user 表,包含 id、username、age 三个字段。代码可以这样写:String sql = "SELECT id, username, age FROM user WHERE age > ?"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 20); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { int id = rs.getInt("id"); String username = rs.getString("username"); int age = rs.getInt("age"); System.out.println("ID=" + id + ", 用户名=" + username + ", 年龄=" + age); } } } catch (SQLException e) { e.printStackTrace(); }ResultSet 是查询结果集,可以理解为一个游标式的二维表。rs.next() 让游标移动到下一行,如果有数据就返回 true,没有数据就返回 false。所以 while (rs.next()) 是遍历结果集的标准循环。取列值的时候,可以按下标取 rs.getInt(1),也可以按列名取 rs.getInt("id"),我强烈建议用列名,因为 SQL 一旦调整,按列名还能保持代码可读性,按标号很容易取错列。这里有个细节容易被忽略:ResultSet 一旦关闭,再想获取数据就来不及了。所以处理结果集的逻辑必须在 try 块内完成,或者先把数据复制到一个 List 里,连接关闭后还能继续使用。我通常这么处理:List<User> userList = new ArrayList<>(); while (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setAge(rs.getInt("age")); userList.add(u); } // 出了 try 块,连接关闭,userList 依然可用3.4 第四步:增删改操作与事务管理增删改和查询不同,它们不返回结果集,而是返回一个影响行数。用 executeUpdate() 执行,返回值是 int,代表被修改的行数。举个例子:// 插入 String insertSql = "INSERT INTO user(id, username, age) VALUES (?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, 1); ps.setString(2, "张三"); ps.setInt(3, 25); int rows = ps.executeUpdate(); System.out.println("插入影响行数:" + rows); } // 更新 String updateSql = "UPDATE user SET age = ? WHERE username = ?"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, 26); ps.setString(2, "张三"); ps.executeUpdate(); } // 删除 String deleteSql = "DELETE FROM user WHERE username = ?"; try (PreparedStatement ps = conn.prepareStatement(deleteSql)) { ps.setString(1, "张三"); ps.executeUpdate(); }如果你只有一条 SQL,上面的写法足够了。但实际业务里,经常有两个操作必须同时成功或者同时失败。最典型的例子是转账:A 账户扣钱、B 账户加钱,两条 SQL 只要一条失败,另一条就必须回滚,否则账就不平了。JDBC 的事务控制有三个核心操作。默认情况下,Connection 的 autocommit 是 true,也就是每条 SQL 执行后自动提交,我们要先关掉。然后执行多条 SQL,全部成功后 commit(),任何一个环节出问题就 rollback() 回滚,最后在 finally 里恢复自动提交状态。代码是这样:Connection conn = null; try { conn = DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); String sql1 = "UPDATE account SET balance = balance - 100 WHERE username = 'A'"; String sql2 = "UPDATE account SET balance = balance + 100 WHERE username = 'B'"; try (PreparedStatement ps1 = conn.prepareStatement(sql1)) { ps1.executeUpdate(); } try (PreparedStatement ps2 = conn.prepareStatement(sql2)) { 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(); } } }这里注意,close() 之前一定要先 setAutoCommit(true),否则连接回到连接池后,下一个人拿到这个连接会因为 autocommit 还是 false 而导致行为异常。这种问题极难排查,日志里看不到任何报错,但到处是事务不生效、数据时有时无的诡异现象。批量操作也是生产环境的高频需求。如果要在循环里插入一万条数据,每条都用 executeUpdate(),性能极差。正确姿势是使用 addBatch + executeBatch:String sql = "INSERT INTO user(username, age) VALUES (?, ?)"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i = 0; i < 10000; i++) { ps.setString(1, "用户" + i); ps.setInt(2, 20 + i % 50); ps.addBatch(); if (i % 1000 == 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit(); }每凑够一批就 executeBatch 一次,而不是等全部积累完,是在内存与速度之间取一个平衡。最终数量少时一次性提交没问题,量大的时候分批是必须的。4. 从能跑到跑好:生产环境必须直面的性能与安全问题把上面的代码跑通了,你只是完成了“能连上、能增删改查”这一步。实际项目上线后,还会面临连接管理、SQL 注入、连接耗尽这些新问题。4.1 SQL 注入到底是怎么回事上面提到用 PreparedStatement 代替 Statement,这里展开说说原理。SQL 注入的本质是用户输入被拼进了 SQL 语句里,变成了 SQL 的一部分。看一个例子:String sql = "SELECT * FROM user WHERE username = '" + input + "'";如果用户输入的是admin' --,拼接之后变成:SELECT * FROM user WHERE username = 'admin' -- '在 MySQL 里,-- 后面是注释,所以这条语句完全忽略了后面的单引号,直接以 username = 'admin' 作为条件执行。如果你运气好,数据库里恰好有 admin 用户,数据就泄露了。更狠的攻击是' OR 1=1 --,拼接后变成查询所有用户。PreparedStatement 之所以安全,是因为它把 SQL 语句的结构和执行参数分开了。结构在 prepareStatement 时已经确定,参数被当作了纯数据传给驱动,驱动会做转义处理,单引号变成普通字符,无论如何都成不了 SQL 结构的一部分。这是官方推荐的第一道防线,不需要任何额外配置,只要你在写代码时养成用 PreparedStatement 的习惯就够了。4.2 连接池:高并发下的生命线新手写 JDBC 代码,每执行一次操作就新开一个连接,用完就关,在低并发本地测试没问题。但到了线上,一个请求开一次连接,连接建立和断开的开销非常大,数据库连接数很快就撑不住了。数据库连接池可以简单理解为提前建好了一批连接放在池子里,用的时候从池里取出一个,用完关掉其实是归还到池子,而不是真的断开。这样数据库服务端不用频繁创建和销毁线程,应用端也能快速拿到可用连接。Java 生态里最常见的连接池是 HikariCP,Spring Boot 2.x 之后默认使用它。HikariCP 为什么快?它的字节码被高度优化过,并发获取连接时锁的粒度更细,连接有效性检测也更聪明。如果自己管理连接池,可以这样配:<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.0.1</version> </dependency>HikariConfig config = new HikariConfig(); config.setJdbcUrl(url); config.setUsername(user); config.setPassword(password); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); config.setIdleTimeout(600000); HikariDataSource dataSource = new HikariDataSource(config);配置参数里,maximumPoolSize 不是越大越好,通常和数据库实例的 CPU 核数相关。我见过有人直接配 200,结果数据库连接被打满,应用反而变慢。连接池的大小应该结合数据库性能和业务并发量来调,一开始 10 到 20 个是合理的起点,之后通过压测慢慢往上调。拿到 DataSource 之后,原来的代码要改成从 dataSource.getConnection() 获取连接,这样连接就由连接池来管理。如果你继续 new DriverManager.getConnection(),等于完全绕过了连接池,配置白做了。4.3 关于驱动自动加载的常见误解Java 6 之后,JDBC 驱动支持通过 ServiceProvider 机制自动注册,也就是 DriverManager 会在启动时扫描 classpath 下的 META-INF/services/java.sql.Driver 文件,自动加载驱动类。所以新项目里写不写 Class.forName("com.mysql.cj.jdbc.Driver") 其实都可以。但老一点的项目里,这行代码还是很普遍。为什么?因为旧驱动不是自动注册的,必须手动加载。而且有些容器环境下,DriverManager 的自动发现机制可能失效,写了 Class.forName 反而更稳妥。我的建议是:写的时候不写无所谓,但看到老代码里这行时不要惊讶,更不要删了之后报错又怪别人。5. 常见问题排查与避坑实录:跑一次就会遇到的意外代码写完了,运行报错才是真正的开始。下面这些问题是初学者最容易碰到的,我把它们整理成了一张速查表,方便复盘时对照。报错信息常见原因解决方法Communications link failureMySQL 服务未启动、端口不对、防火墙拦截先确认 MySQL 是否启动,telnet 3306 测试端口连通性Access denied for user 'root'@'localhost'用户名或密码错误,或者用户没有远程访问权限核对密码,确认用户是否有对应库的权限Unknown database 'test_db'数据库不存在先通过命令行 CREATE DATABASE 创建库ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动依赖没加载,或者类名写错检查 Maven 依赖是否生效,确认类名是 cj 开头The server time zone value is unrecognizedURL 里没有指定 serverTimezone增加 &serverTimezone=Asia/ShanghaiSSL connection errorURL 里没有禁用 SSL,或本地 MySQL 不支持 SSL增加 &useSSL=false 仅限开发环境中文数据乱码连接字符集未设置,或表本身字符集不是 utf8mb4URL 增加 characterEncoding=utf8,确认表字符集No operations allowed after connection closed连接已关闭,但代码还在使用 Connection 或 ResultSet检查资源释放逻辑,把取数放在 try 内完成第一类容易踩的坑是连接失败。Communications link failure 是新手最常看到的报错,原因很多,但排查思路是有迹可循的。先在命令行用 mysql -u root -p 连一下本机 MySQL,如果命令行都连不上,说明 MySQL 服务没起来,跟 Java 代码没有任何关系。如果命令行能连上,再用 telnet 127.0.0.1 3306 试一下端口,通说明网络和服务都正常,问题大概率出在代码的 URL、用户名或密码上。第二类坑是驱动版本不匹配。我之前就遇到过,MySQL 8.0 数据库配了 5.1.x 的驱动,项目启动时一直报 Unable to load authentication plugin,其实就是因为新版本 MySQL 默认的加密插件变了,老驱动不认。解决办法有两个,要么把驱动升到 8.x,要么给数据库新建一个 mysql_native_password 的用户,但明显升驱动更简单。第三类坑是连接池配置不当。我见到过把 maximumPoolSize 配得异常大的,结果应用启动时数据库 CPU 直接飙高,因为连接池预热创建了大量连接。也有配置了连接池但代码里还是用 DriverManager 拿连接的,白白空跑连接池。还有前面提到的 autocommit 没恢复,这是最阴间的坑:某条连接执行了事务没提交,归还给连接池后,下一个人拿这个连接继续做操作,数据一直不落库,排查半天才发现是连接状态被污染了。排查这类问题,我的经验是关掉连接池,直接用 DriverManager 裸连,能跑通说明业务代码没问题,剩下的锅基本在连接池配置上。反过来也一样,如果裸连报错,先排除驱动、URL、权限这些基础项。两层分开排查,问题范围立刻缩小一半。6. 封个简易工具类:JDBC 的第二次升华写到这里,你已经有能力完成一次完整的 Java 连接 MySQL 数据交互了。但如果每个业务方法里都重复写获取连接、创建 PreparedStatement、遍历 ResultSet 的代码,你会很快感到繁琐。实际项目中,我们通常会封装一个 JDBC 工具类,把连接的获取和资源的释放统一管理。我提供一个极简版的工具类思路,你可以根据自己的项目再调整:public class JdbcUtil { private static final String URL = "jdbc:mysql://localhost:3306/test_db" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) { /* ignore */ } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { /* ignore */ } } if (conn != null) { try { conn.close(); } catch (SQLException e) { /* ignore */ } } } }注意,工具类里我用 try 块包裹了 close 方法,但忽略了异常。这不是不在乎异常,而是关闭资源时的异常通常不会影响主要业务流程,而且如果在 finally 里再抛出异常,反而会掩盖前面的业务异常。这是一种权衡,但也是线上代码常见的容错方式。有了工具类之后,业务代码可以写成:Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = JdbcUtil.getConnection(); ps = conn.prepareStatement("SELECT * FROM user WHERE age > ?"); ps.setInt(1, 20); rs = ps.executeQuery(); while (rs.next()) { System.out.println(rs.getString("username")); } } finally { JdbcUtil.close(conn, ps, rs); }这套代码已经比较接近公司里真正在用的风格了。如果再进一步,你可以在这个基础上做一个简单的 BaseDao、泛型查询封装,或者直接引入 Spring 的 JdbcTemplate。你会发现,JdbcTemplate 的核心思想就是这个工具类的增强版:连接统一管理、重复代码消除、异常转换。理解了 JDBC 这层,你再看 Spring 的数据访问体系,真的会有一层窗户纸被捅破的感觉。我记得自己第一次完整封装出这个工具类的时候,心里特别踏实,因为我知道哪怕离开框架,我也能独立完成数据库操作。这种底层能力,是面试时聊到数据库性能调优、事务边界、连接管理时能够深入对话的底气。这篇文章的内容到这里就完整了。如果你还在因为框架报错而一头雾水,不妨像我当初一样,找个周末,从 DriverManager 开始,不依赖任何框架,手写一个 Java 连接 MySQL 的小项目,把增删改查、事务、批量操作都过一遍,你会收获一种“尽在掌握”的感觉。过程中遇到任何问题,欢迎回来对照这篇文章,一步一步排查。毕竟,JDBC 踩过的每一个坑,都是数据库编程这条路上的垫脚石。