简介:这份PDF资料面向Java初学者与需要巩固数据库基础的开发者,系统讲解如何通过JDBC连接MySQL并完成增删改查操作。内容从环境准备入手,涵盖JDK、Eclipse、MySQL与Navicat的配置,逐步演示建库建表、编写DBUtil工具类加载驱动并获取连接、定义Goddess实体类,以及基于DAO层实现插入、查询、更新、删除的完整流程,并强调PreparedStatement防注入的实践要点。资源包共1个PDF文件,大小约326KB,篇幅紧凑、代码示例完整,便于按章节对照练习。目前已有5619人学习下载,适合希望理解Hibernate、MyBatis等ORM框架底层原理、打牢JDBC基础的读者参考,也可作为课程实验与课程设计的辅助材料。
1. JDBC 连 MySQL 做增删改查:为什么你写的 CRUD 一上并发就翻车
很多人第一次用 Java 连 MySQL,都是照着一段模板代码抄:Class.forName加载驱动、DriverManager.getConnection拿连接、Statement拼 SQL、ResultSet遍历结果。本地跑一遍,增删改查全绿,于是以为这块已经拿下了。可一旦放到真实业务里,几十个请求同时进来,连接数暴涨、Too many connections报错、SQL 注入被扫、事务回滚不干净,问题全冒出来。JDBC 连接 MySQL 实现增删改查这件事,难点从来不在「能不能跑通」,而在「怎么写得扛得住、查得安全、改得干净」。这篇笔记面向正在做 Java 后端、需要手写数据访问层或准备面试的工程师,把驱动加载、连接管理、预编译、事务、批处理、连接池这几件事按落地顺序讲透,每一步都给能直接抄的命令和代码,也把血泪踩过的坑标出来。看完你应该能自己判断:什么时候该用原生 JDBC,什么时候该上连接池,什么时候干脆交给 MyBatis-Plus 这类通用 CRUD 框架。
2. 从驱动到连接:JDBC 打通 MySQL 的最小可运行链路
2.1 驱动依赖与 MySQL 版本对齐
动手之前先把依赖和数据库版本对齐,这一步翻车的人比想象中多。MySQL 5.7 和 8.0 的驱动类名、连接串参数、时区处理都不一样。常见做法是用 Maven 引入驱动,版本跟服务端大版本匹配。
<!-- pom.xml:MySQL 8.x 用 8.x 驱动,5.7 用 5.1.x 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>逻辑说明:mysql-connector-java是官方 JDBC 驱动实现,它把 Java 的java.sql.*接口翻译成 MySQL 的通信协议。参数说明:groupId和artifactId固定,version必须和服务端匹配——8.0 服务端配 8.x 驱动,5.7 服务端配 5.1.x 驱动,混用最常见的后果是Unknown system variable 'query_cache_size'或时区报错。如果你用 Gradle,把上面换成implementation 'mysql:mysql-connector-java:8.0.33'即可。
提示:MySQL 8.0 之后驱动类名从
com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver,老代码里写死旧类名会直接抛ClassNotFoundException。
2.2 连接串参数怎么设才不踩时区和 SSL 的坑
连接串是 JDBC 里最容易被忽视、又最容易出问题的地方。下面这条是我在 8.0 环境里常用的最小可用串:
// 8.0 推荐连接串:显式指定时区、关闭不必要的 SSL 告警 String url = "jdbc:mysql://127.0.0.1:3306/demo" + "?useUnicode=true" + "&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai" + "&useSSL=false" + "&allowPublicKeyRetrieval=true";逻辑说明:useUnicode和characterEncoding保证中文不乱码;serverTimezone解决 8.0 驱动默认时区导致的The server time zone value is unrecognized报错;useSSL=false在本地和内网环境关掉 SSL 握手,避免WARN: Establishing SSL connection without server's identity verification这类噪音;allowPublicKeyRetrieval=true是 8.0 默认加密插件caching_sha2_password在非 SSL 连接下必须开的开关,不开会报Public Key Retrieval is not allowed。
参数怎么改:生产环境如果走内网且没配证书,useSSL=false可以保留;如果强制 SSL,把它改成true并配好trustCertificateKeyStoreUrl。serverTimezone按服务器实际时区填,别照抄Asia/Shanghai到 UTC 机器上。
2.3 用 DriverManager 跑通第一条查询
先把最小链路跑通,再谈封装。下面这段是纯 JDBC 查询,不依赖任何框架:
import java.sql.*; public class JdbcDemo { public static void main(String[] args) throws Exception { String url = "jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true" + "&characterEncoding=utf8&serverTimezone=Asia/Shanghai" + "&useSSL=false&allowPublicKeyRetrieval=true"; // 8.0 驱动可省略 Class.forName,SPI 会自动加载;显式写更稳 Class.forName("com.mysql.cj.jdbc.Driver"); try (Connection conn = DriverManager.getConnection(url, "root", "123456"); PreparedStatement ps = conn.prepareStatement( "SELECT id, name, age FROM user WHERE age > ?")) { ps.setInt(1, 18); // 参数下标从 1 开始 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println(rs.getLong("id") + " " + rs.getString("name") + " " + rs.getInt("age")); } } } } }逻辑说明:Class.forName触发驱动类静态块注册到DriverManager;getConnection建立物理连接;PreparedStatement预编译 SQL 并绑定参数;ResultSet是游标,next()逐行推进。参数说明:ps.setInt(1, 18)的下标从 1 开始,不是 0,这是新手最常见的越界来源;rs.getLong("id")用列名取值比用下标更抗表结构变更。try-with-resources保证连接、语句、结果集按逆序自动关闭,漏掉它就会连接泄漏。
注意:
DriverManager.getConnection每次调用都新建一条物理连接,用完关闭。它没有池化,单机压测几十并发就会打满 MySQL 的max_connections,所以它只适合脚本和验证,不适合线上服务。
3. 增删改查四类操作:预编译、批处理与主键回填
3.1 插入:拿到自增主键和批量插入的正确姿势
单条插入要拿回自增 ID,靠的是Statement.RETURN_GENERATED_KEYS:
String sql = "INSERT INTO user(name, age) VALUES(?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, "张三"); ps.setInt(2, 25); int rows = ps.executeUpdate(); // 影响行数 try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { long id = keys.getLong(1); // 自增主键 System.out.println("新记录 id=" + id); } } }逻辑说明:executeUpdate返回受影响行数,插入成功通常是 1;getGeneratedKeys拿回数据库生成的主键。参数说明:第二个参数RETURN_GENERATED_KEYS必须显式传,否则getGeneratedKeys返回空结果集。
批量插入不要循环单条执行,用addBatch+executeBatch:
String sql = "INSERT INTO user(name, age) VALUES(?, ?)"; conn.setAutoCommit(false); // 关自动提交,攒批 try (PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < 1000; i++) { ps.setString(1, "user" + i); ps.setInt(2, 20 + i % 30); ps.addBatch(); if (i % 500 == 0) { // 每 500 条刷一次,防内存膨胀 ps.executeBatch(); ps.clearBatch(); } } ps.executeBatch(); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; }逻辑说明:addBatch把参数攒在客户端,executeBatch一次性发给服务端,减少网络往返。参数说明:setAutoCommit(false)让整批要么全成要么全滚;每 500 条刷一次是经验值,太大内存吃紧,太小失去批处理意义。连接串上加rewriteBatchedStatements=true能让驱动把多条 INSERT 合并成一条多值语句,吞吐能再上一个台阶。
3.2 删除与更新:影响行数是你唯一的后悔药
删除和更新写法几乎一样,核心是永远带 WHERE 条件,并且检查影响行数:
String sql = "UPDATE user SET age = ? WHERE id = ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 30); ps.setLong(2, 1001L); int rows = ps.executeUpdate(); if (rows == 0) { // 没有匹配行:可能是 id 不存在,也可能是并发下已被别人改走 throw new IllegalStateException("更新失败,目标记录不存在"); } }逻辑说明:executeUpdate对 UPDATE/DELETE 返回实际改动行数。参数说明:rows == 0不代表报错,但业务上往往意味着数据状态和预期不符,必须显式处理,否则会出现「接口返回成功但数据没变」的玄学问题。删除同理,DELETE FROM user WHERE id = ?,绝不要写没有 WHERE 的 DELETE——那是删库跑路的标准开场。
提示:MySQL 的 UPDATE 如果新值和旧值完全相同,默认返回的影响行数是 0(除非连接串加
useAffectedRows=true改成匹配行数语义)。排查「更新成功却返回 0」时先想到这一点。
3.3 查询:结果集映射与分页的正确写法
查询的坑集中在结果集映射和分页。先看映射:
String sql = "SELECT id, name, age, created_at FROM user WHERE age BETWEEN ? AND ? ORDER BY id DESC LIMIT ?, ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 18); ps.setInt(2, 60); ps.setInt(3, 0); // offset ps.setInt(4, 20); // pageSize try (ResultSet rs = ps.executeQuery()) { List<User> list = new ArrayList<>(); while (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setName(rs.getString("name")); u.setAge(rs.getInt("age")); u.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime()); list.add(u); } } }逻辑说明:ORDER BY必须和分页一起用,否则LIMIT返回的顺序不确定;rs.getTimestamp(...).toLocalDateTime()把 JDBC 时间类型转成 Java 8 时间 API。参数说明:LIMIT ?, ?第一个是偏移量、第二个是条数,深分页(offset 很大)性能会急剧下降,常见优化是用「上一页最大 id」做游标:WHERE id < ? ORDER BY id DESC LIMIT 20。
排序相关的热搜词这里也能对上:ORDER BY后面跟的列如果有索引,排序就走索引;没索引会触发Using filesort,数据量大时是性能杀手。用EXPLAIN看执行计划,Extra列出现Using filesort就要考虑加索引或改排序字段。
3.4 事务:手动提交、回滚与隔离级别的边界
多条写操作要么全成要么全滚,靠事务:
Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 开启事务 conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); // MySQL 默认 // ... 多条 update / insert conn.commit(); } catch (SQLException e) { if (conn != null) conn.rollback(); // 出错回滚 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 归还池前恢复 conn.close(); } }逻辑说明:setAutoCommit(false)后所有语句在同一事务里,commit落盘、rollback撤销。参数说明:TRANSACTION_REPEATABLE_READ是 MySQL InnoDB 默认隔离级别,能防脏读和不可重复读,但防不住幻读(要靠间隙锁或串行化)。事务里不要做远程调用、不要等用户输入,否则长事务会锁住行、拖垮并发。
4. 连接池与通用 CRUD:从能跑到扛得住的最后一公里
4.1 为什么必须上连接池,HikariCP 怎么配
DriverManager每次新建物理连接,TCP 握手 + 认证开销在毫秒级,高并发下直接压垮数据库。连接池预先建好一批连接循环复用,这是线上服务的标配。HikariCP 是当前主流选择,配置如下:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true" + "&characterEncoding=utf8&serverTimezone=Asia/Shanghai" + "&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(20); // 最大连接数 config.setMinimumIdle(5); // 最小空闲连接 config.setConnectionTimeout(30000); // 拿连接超时 30s config.setIdleTimeout(600000); // 空闲回收 10min config.setMaxLifetime(1800000); // 连接寿命 30min,小于 MySQL wait_timeout HikariDataSource ds = new HikariDataSource(config);逻辑说明:maximumPoolSize是并发上限,不是越大越好——连接数超过数据库 CPU 核数太多反而因上下文切换变慢,经验值是CPU 核数 * 2 + 磁盘数。参数说明:maxLifetime必须小于 MySQL 的wait_timeout(默认 28800 秒),否则连接被服务端单方面掐断,客户端拿到的是失效连接,报Communications link failure。connectionTimeout是拿不到连接时的等待上限,设太小高峰期直接抛异常,设太大请求全堵住。
注意:连接池的
close()不是真关闭物理连接,而是归还池中。所以业务代码里conn.close()照常写,别因为「怕关掉」而漏关,漏关才是真的把池耗干。
4.2 用 MyBatis-Plus 的通用 CRUD 省掉重复代码
手写 JDBC 的增删改查在表多、字段多时是纯体力活。通用 CRUD 服务(基于 MyBatis-Plus 的Db工具类或BaseMapper)能把单表操作压到一行:
// 基于 MyBatis-Plus 的 Db 静态工具类,无需注入 Mapper User user = new User(); user.setName("李四"); user.setAge(28); Db.save(user); // 插入 User loaded = Db.getById(1001L); // 按主键查 loaded.setAge(29); Db.updateById(loaded); // 按主键更新 Db.removeById(1001L); // 按主键删除 List<User> list = Db.lambdaQuery(User.class) .gt(User::getAge, 18) .orderByDesc(User::getId) .last("LIMIT 20") .list(); // 条件查询逻辑说明:Db是无状态工具类,底层复用 MyBatis-Plus 的SqlSession,省去 Mapper 注入;lambdaQuery用方法引用代替字符串字段名,编译期就能发现字段写错。参数说明:last("LIMIT 20")直接拼 SQL 片段,只用于确定安全的场景,别把用户输入拼进去。这套方案适合单表 CRUD 占多数的业务,复杂多表关联还是老老实实写 XML 或手写 SQL。
4.3 原生 JDBC、MyBatis-Plus、JPA 怎么选
| 方案 | 适用场景 | 上手成本 | 灵活度 | 主要代价 |
|---|---|---|---|---|
| 原生 JDBC | 脚本、批处理、极致性能调优 | 低 | 最高 | 样板代码多,易漏关资源 |
| MyBatis-Plus | 单表 CRUD 为主的后台系统 | 中 | 高 | 复杂 SQL 仍需手写 |
| Spring Data JPA | 领域模型清晰、以对象为中心 | 中高 | 中 | 复杂查询易生成低效 SQL |
选型逻辑:如果项目里 80% 是单表增删改查,MyBatis-Plus 的通用 CRUD 能省掉大量重复代码;如果对 SQL 有极致控制需求(比如批量导入、复杂报表),原生 JDBC 或 MyBatis XML 更合适;JPA 适合领域驱动设计、实体关系复杂的场景,但要对它生成的 SQL 保持警惕,必要时用@Query接管。
5. 避坑与排查:JDBC 连 MySQL 最常见的 5 个翻车现场
5.1 时区报错:The server time zone value is unrecognized
现象:启动或首次查询时抛java.sql.SQLException: The server time zone value '?D1ú±ê×?ê±??' is unrecognized。原因:MySQL 8.0 驱动要求明确时区,而服务端time_zone是SYSTEM且系统时区名驱动识别不了。解决:连接串加serverTimezone=Asia/Shanghai(按实际时区填),或登录 MySQL 执行SET GLOBAL time_zone = '+8:00'后重启连接。
5.2 连接泄漏:Too many connections 越跑越多
现象:服务跑一段时间后报Too many connections,SHOW PROCESSLIST看到大量Sleep连接。原因:某处Connection/Statement/ResultSet没关,或异常路径跳过了close。解决:全部改用try-with-resources;上连接池并设maxLifetime;用SHOW STATUS LIKE 'Threads_connected'监控连接数趋势,持续上涨就是泄漏。
5.3 SQL 注入:字符串拼接的 Statement 是重灾区
现象:登录接口输入' OR '1'='1直接绕过校验。原因:用Statement拼接用户输入,SQL 结构被篡改。解决:一律用PreparedStatement加?占位符,参数通过setXxx绑定。预编译不仅防注入,还能让数据库缓存执行计划,重复执行更快。排序字段、表名这类不能参数化的位置,用白名单校验,别直接拼。
5.4 中文乱码:characterEncoding 没设或库表字符集不一致
现象:插入中文后查出来是???或乱码。原因:连接串没设characterEncoding=utf8,或库/表/列的字符集是latin1。解决:连接串加useUnicode=true&characterEncoding=utf8;建库时用CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,utf8mb4才能存 emoji 和生僻字。用SHOW VARIABLES LIKE 'character%'核对服务端字符集。
5.5 批处理失效:executeBatch 比循环单条还慢
现象:写了addBatch/executeBatch,性能却没提升。原因:连接串没开rewriteBatchedStatements=true,驱动仍逐条发送;或批太大导致内存溢出。解决:连接串加rewriteBatchedStatements=true;每批控制在 500~1000 条;executeBatch后调clearBatch释放参数缓存。用SHOW GLOBAL STATUS LIKE 'Com_insert'对比批处理前后的语句数验证效果。
6. 进阶技巧:用 EXPLAIN 和慢查询日志把 CRUD 性能钉死
写到能跑只是及格,能定位慢在哪才算过关。我一般用两个工具配合:EXPLAIN看单条 SQL 的执行计划,慢查询日志抓整体瓶颈。
先看EXPLAIN怎么读:
EXPLAIN SELECT id, name, age FROM user WHERE age > 18 ORDER BY id DESC LIMIT 20;重点看四列:type越靠左越好(system > const > eq_ref > ref > range > index > ALL),出现ALL就是全表扫描;key是实际用的索引,为NULL说明没走索引;rows是预估扫描行数,越大越慢;Extra里Using filesort表示额外排序、Using temporary表示用了临时表,这两个都是优化信号。给age加索引后type从ALL变成range,rows从几十万降到几千,效果立竿见影。
慢查询日志用来抓漏网的:
-- 开启慢查询日志,超过 1 秒的记录 SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';参数说明:long_query_time按业务容忍度设,线上一般 0.5~1 秒;日志文件路径要有写权限。开完之后用mysqldumpslow -s t /var/log/mysql/slow.log按耗时排序,找出最拖后腿的几条 SQL,再回到EXPLAIN逐条分析。
还有一个容易被忽略的点:连接池参数和数据库参数要联动调。HikariCP 的maxLifetime必须小于 MySQL 的wait_timeout,maximumPoolSize乘以应用实例数不能超过 MySQL 的max_connections。我踩过一次坑:三个服务实例各配 20 连接,MySQLmax_connections默认 151,加上其他客户端直接打满,报Too many connections。后来把单实例池降到 15,并调大max_connections,才稳住。
最后说个验证方法:写完一套 CRUD,别只看功能通不通,用SHOW STATUS LIKE 'Com_select'、Com_insert、Com_update、Com_delete看语句计数是否符合预期,用SHOW STATUS LIKE 'Threads_connected'看连接数是否稳定。功能测试只能证明「能跑」,这些计数才能证明「跑得干净」。我自己现在的习惯是:任何一段 JDBC 代码合入前,先过一遍try-with-resources有没有漏、PreparedStatement有没有用、影响行数有没有判、连接池参数有没有和数据库对齐。这四条守住,增删改查基本不会出大问题。希望帮到你。
本文还有配套的精品资源,点击获取