
1. 内容整体设计与思路拆解1.1 为什么Day4必须啃下JDBC这块硬骨头很多人在Java Web学习路上走到第四天第一次真正接触到程序连着数据库这件事。前三天学的Servlet、JSP、HTTP协议这些说到底都在处理请求怎么来、页面怎么响应但一个Web系统真正值钱的地方在于它能把用户提交的数据存下来、查出来、改掉、删掉。这个存查改删的动作在Java世界里最基础、最绕不开的技术就是JDBCJava Database Connectivity。JDBC不是一个框架它是Java官方提供的一套操作数据库的标准接口。你可以把它理解为数据库驱动的规范说明书——各个数据库厂商比如MySQL、Oracle按照这套说明书实现自己的驱动Java程序员只需要面向JDBC接口写代码底层换成哪个数据库都不影响上层逻辑。这个设计思路当年是非常超前的类似于家里的电器都遵循统一的插座标准你不需要关心电从哪个电厂来。Day4的核心目标很纯粹让Java代码能够通过JDBC完成对MySQL数据库的增删改查操作。听起来简单但里面埋着不少细节——驱动加载、连接获取、SQL语句执行、结果集处理、资源释放每一步都有讲究。这篇文章我会按照实际学习的推进顺序把每一个环节掰开揉碎地讲同时穿插我在实际项目中踩过的坑和总结的经验保证你看完能独立写出一套可用的JDBC操作代码。1.2 学完这一天的能力边界我需要先把话说清楚Day4不是让你成为数据库专家而是帮你打通Java程序 ↔ 数据库这条通路。学完这一天你应该能实现以下事情在IntelliJ IDEA中配置好MySQL驱动依赖编写代码完成对一张表的增、删、改、查操作理解为什么推荐用PreparedStatement而不用Statement学会正确关闭数据库资源避免连接泄漏初步了解连接池存在的意义知道为什么企业项目不用DriverManager直接获取连接这套能力是后面学习MyBatis、Spring Data JPA等框架的基础。如果你直接跳过JDBC去学MyBatis你会发现框架帮你封装的那些神奇操作背后其实都是JDBC那套流程的简化包装。地基不打牢上层建筑迟早要出事。2. 核心细节解析与实操要点2.1 JDBC操作的标准七步曲任何JDBC操作不管复杂还是简单都离不开下面七个步骤。我把这七个步骤比作去银行办业务先找到银行加载驱动、取号排队获取连接、填写单据创建Statement、柜台办理执行SQL、拿到回执处理ResultSet、离开柜台关闭资源。// 第一步加载驱动MySQL 8.x版本 Class.forName(com.mysql.cj.jdbc.Driver); // 第二步获取数据库连接 String url jdbc:mysql://localhost:3306/day4_demo?useSSLfalseserverTimezoneAsia/Shanghai; String username root; String password 你的密码; Connection conn DriverManager.getConnection(url, username, password); // 第三步创建Statement对象 Statement stmt conn.createStatement(); // 第四步执行SQL语句 String sql SELECT * FROM student; ResultSet rs stmt.executeQuery(sql); // 第五步处理结果集 while (rs.next()) { System.out.println(rs.getInt(id) - rs.getString(name)); } // 第六步关闭结果集 rs.close(); // 第七步关闭Statement和Connection stmt.close(); conn.close();这七个步骤里第一步到第四步是最容易出问题的环节。Class.forName反射加载驱动这件事在MySQL 8.x版本其实可以省略——因为SPI机制会自动注册驱动。但为了代码的兼容性和可读性我建议还是保留万一哪天你要切换到旧版本的MySQL驱动呢。第五步的ResultSet处理值得多讲两句。ResultSet可以理解为一个只有前进指针的表格初始状态下指针指向第一行之前你必须调用next()方法让指针下移才能取到第一行数据。这就好比你翻一本相册一开始手放在封面外面的位置每翻一页next()才能看到一张照片。这个机制初学者经常误解以为rs.getInt(id)能直接拿到第一行实际上不调用next()会直接报错或者返回空值。2.2 Statement和PreparedStatement的本质区别很多教程都会告诉你要用PreparedStatement因为可以防止SQL注入但很少讲透它为什么能防。我在教学过程中发现理解这个为什么比记住结论重要得多。Statement拼接SQL是这样的String name 张三; String sql SELECT * FROM student WHERE name name ;假如name的值变成了张三 OR 11SQL就变成了SELECT * FROM student WHERE name 张三 OR 11这个条件永远为真整张表的数据都会被查出来。如果更进一步把name设成; DROP TABLE student;--后果不堪设想。这就是SQL注入的本质用户输入被当成了SQL代码的一部分执行。PreparedStatement的做法是先把SQL骨架发送给数据库预编译再用参数占位符?的方式传入数据。因为SQL结构已经定死传入的值只会被当作数据而非代码来处理注入自然就失效了。String sql SELECT * FROM student WHERE name ?; PreparedStatement pstmt conn.prepareStatement(sql); pstmt.setString(1, 张三); ResultSet rs pstmt.executeQuery();还有一个隐藏的好处PreparedStatement的预编译机制在多次执行同一条SQL时性能更高。MySQL的prepared statement虽然默认只在服务端缓存会话内的语句但比起每次拼接字符串让数据库重新解析SQL开销依然更低。注意哪怕你只写一次性的查询SQL也请养成用PreparedStatement的习惯。这不是为了炫技而是为了让你在真正写业务系统时不会写出漏洞满天的代码。2.3 资源释放最容易忽略但最致命的一环上述代码中我特意把资源释放的步骤放在最后因为它太重要了值得单独拿出来讲。数据库连接是珍贵的资源一个MySQL实例默认的最大连接数是151个。如果每次操作都create一个连接不关闭很快连接数就会被耗尽后续所有请求都会卡在获取连接这一步——这不是危言耸听我在帮某公司排查线上问题时遇到过很多次服务无响应最终定位到是连接泄漏。JDBC的资源释放遵循后开先关的原则先关ResultSet再关Statement最后关Connection。用传统写法每个close()都要套一层try-catch因为close()方法本身会抛SQLException代码会变得非常冗长。Java 7之后提供了try-with-resources语法糖这个必须用上try (Connection conn DriverManager.getConnection(url, username, password); PreparedStatement pstmt conn.prepareStatement(sql)) { pstmt.setString(1, 张三); try (ResultSet rs pstmt.executeQuery()) { while (rs.next()) { System.out.println(rs.getString(name)); } } } catch (SQLException e) { e.printStackTrace(); }这里有个细节ResultSet需要单独嵌套一层try-with-resources因为ResultSet的生命周期短于Connection和Statement——先查完数据、处理完结果就可以立刻释放ResultSet再等Statement和Connection的自动关闭。如果你把所有资源都塞进同一个try的声明列表里关闭顺序也符合后开先关的原则但ResultSet单独嵌套的好处是代码语义更清晰且能提前释放结果集占用的内存。3. 实操过程与核心环节实现3.1 环境准备与开表建库我假设你的电脑上已经装好了MySQL 8.x和IntelliJ IDEA Community版没装的话先去搞定这两样。创建一个Java项目后在pom.xml如果是Maven项目里加上MySQL驱动依赖dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.3.0/version /dependency如果你用的是非Maven项目需要手动下载mysql-connector-j的jar包放到lib目录然后在Project Structure里把它加入项目的依赖。这里有一个容易踩的坑网上很多老教程让你依赖com.mysql:mysql-connector-java这个坐标在MySQL 8.0.31之后已经停止更新新版本统一改成了com.mysql:mysql-connector-j别搞混了。连上MySQL之后创建Day4的练习库和一张学生表CREATE DATABASE IF NOT EXISTS day4_demo DEFAULT CHARSET utf8mb4; USE day4_demo; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, age INT, score DECIMAL(5,2), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO student (name, age, score) VALUES (张三, 20, 88.5), (李四, 21, 76.0), (王五, 19, 92.5);为什么表结构要加上created_at字段因为实际业务中绝大部分表都有创建时间这个字段练习时加上它能让你的代码更贴近真实场景。DECIMAL(5,2)表示最高支持9999.99学生的分数用这个类型就够了。3.2 封装一个JDBC工具类学完基础操作之后我强烈建议你顺手封装一个JdbcUtil工具类。这个类的目标是把获取连接和关闭资源这两件事抽出来复用避免每个DAO方法里都重复一遍DriverManager.getConnection那几行代码。public class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/day4_demo?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USERNAME root; private static final String PASSWORD 你的密码; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); throw new RuntimeException(MySQL驱动加载失败); } } 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(); } } } } }这个工具类的设计思路是静态代码块负责加载驱动getConnection方法封装连接参数close方法用可变参数接收任意数量的资源统一关闭。可变参数这里用了AutoCloseable接口因为Connection、Statement、ResultSet都实现了这个接口。URL这几个参数我逐个解释一下useSSLfalseMySQL 8.x默认会尝试建立SSL连接本地开发环境没配SSL证书设成false避免报警告serverTimezoneAsia/Shanghai指定服务器时区不设的话可能出现8小时的时间偏差characterEncodingutf8确保中文不乱码和数据库的utf8mb4字符集配合使用注意不要把数据库用户名和密码硬编码在工具类里这只是学习阶段的写法。真实项目中要用配置文件加jasypt加密的方式放在application.yml或独立properties文件里。3.3 增删改查完整实战封装好工具类下面实现一个完整的CRUD。我以Student实体为例写一个StudentDao类Data Access Object数据访问对象这是Java Web分层架构里最基础的一环。public class StudentDao { // 查询所有学生 public ListStudent findAll() { ListStudent list new ArrayList(); String sql SELECT id, name, age, score FROM student; try (Connection conn JdbcUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql); ResultSet rs pstmt.executeQuery()) { while (rs.next()) { Student stu new Student(); stu.setId(rs.getInt(id)); stu.setName(rs.getString(name)); stu.setAge(rs.getInt(age)); stu.setScore(rs.getBigDecimal(score)); list.add(stu); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 根据ID查询 public Student findById(int id) { String sql SELECT id, name, age, score FROM student WHERE id ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql)) { pstmt.setInt(1, id); try (ResultSet rs pstmt.executeQuery()) { if (rs.next()) { Student stu new Student(); stu.setId(rs.getInt(id)); stu.setName(rs.getString(name)); stu.setAge(rs.getInt(age)); stu.setScore(rs.getBigDecimal(score)); return stu; } } } catch (SQLException e) { e.printStackTrace(); } return null; } }这段代码的重点在ResultSet映射rs.getInt(id)是按下标名取值也可以写成rs.getInt(1)按下标位置取值。建议用列名方式因为SQL里SELECT的列顺序一旦调整顺序方式就会出错而列名方式不受影响。ORDER BY的使用场景先不讲Day4保持简单即可。插入和更新操作与查询最大的不同是使用executeUpdate()方法这个方法返回受影响的行数// 新增学生 public boolean insert(Student stu) { String sql INSERT INTO student(name, age, score) VALUES(?, ?, ?); try (Connection conn JdbcUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql)) { pstmt.setString(1, stu.getName()); pstmt.setInt(2, stu.getAge()); pstmt.setBigDecimal(3, stu.getScore()); int rows pstmt.executeUpdate(); return rows 0; } catch (SQLException e) { e.printStackTrace(); return false; } }executeUpdate()的返回值用来判断是否执行成功这是一个很实用的经验不要写完SQL就默认执行成功一定要校验影响行数。删除操作也是同理唯一需要注意的是age参数对应表里的age INT类型Java端的Integer和MySQL的INT是可以直接对应的。3.4 事务控制一个字段引发的连锁问题Day4的基础CRUD完成后我建议你额外加餐学习一下事务控制。为什么因为转账这类涉及多个SQL语句的操作如果中间某一步失败数据就会出现不一致。MySQL默认的自动提交模式是每个SQL语句执行完立刻提交。如果需要多条SQL作为一个整体要么全部成功要么全部失败就必须关闭自动提交手动控制事务边界。Connection conn null; try { conn JdbcUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 模拟转账扣减A的余额 String sqlA UPDATE account SET balance balance - 100 WHERE id 1; // 模拟转账增加B的余额 String sqlB UPDATE account SET balance balance 100 WHERE id 2; try (PreparedStatement pstmtA conn.prepareStatement(sqlA); PreparedStatement pstmtB conn.prepareStatement(sqlB)) { pstmtA.executeUpdate(); // 模拟中间抛出异常 // int x 1 / 0; pstmtB.executeUpdate(); } conn.commit(); // 手动提交 } catch (SQLException e) { conn.rollback(); // 回滚 e.printStackTrace(); } finally { if (conn ! null) { conn.setAutoCommit(true); // 恢复自动提交 JdbcUtil.close(conn); } }事务控制的三个关键点第一Connection必须是同一个对象不能两个操作各拿一个连接那样事务就失效了第二commit和rollback必须在finally块外面操作确保异常发生后走回滚逻辑第三事务提交完成后要恢复自动提交模式因为连接可能被连接池复用这个状态会保留。3.5 连接池企业级的连接管理方案学到这里你可能会想我每次操作用JdbcUtil.getConnection()拿连接用完再close不是挺好的吗这个思路在练习阶段没问题但如果在高并发生产环境每次请求都创建一个物理TCP连接到MySQL开销是非常大的——光握手认证就要花几十毫秒连接用完又立刻断开这种穿脱衣服的节奏会让数据库和应用的性能双双拉胯。连接池的思路是预先创建一批连接放在池子里用的时候借出来用完放回去连接的创建和销毁只发生在池子初始化时。这就好比一家餐厅提前准备好了所有餐具放在消毒柜里客人来了直接拿出来用而不是客人到了才现去买餐具。Java生态里常用的连接池有Druid和HikariCP两个。我个人在中小型项目里更倾向Druid因为它的监控面板非常强大——可以看到活跃连接数、SQL执行时长、慢查询统计等指标。HikariCP的优势是性能极快Spring Boot 2.x之后把它作为默认连接池配置更简洁。两者的核心参数差异不大参数含义Druid建议值HikariCP建议值initialSize初始连接数5-HikariCP用minimumIdle替代maxActive / maximumPoolSize最大连接数2020maxWait / connectionTimeout获取连接超时时间10000ms30000msminIdle / minimumIdle最小空闲连接数55如果你只是想学原理Druid是更好的学习工具因为它把连接池的每项指标都展示在监控页面上你能直观看到连接是怎么被借出和归还的。Day4能理解连接池是干什么的、为什么需要连接池、连接池有哪些关键参数就足够了等到学Spring Boot再实践连接池的集成。4. 常见问题与排查技巧实录4.1 驱动相关的错误No suitable driver found for jdbc:mysql://localhost:3306/day4_demo这个错误十个人里有八个遇到过。原因基本上是三类一是没有导入mysql-connector-j依赖二是连接URL的格式写错了——注意是jdbc:mysql://主机名:端口/数据库名主机名和端口之间是一个冒号三是驱动类没有加载成功检查一下Class.forName(com.mysql.cj.jdbc.Driver)的字符串是否完整MySQL 8.x驱动类名是com.mysql.cj.jdbc.Driver老版本是com.mysql.jdbc.Driver少了一个cj就会报这个错。Loading class com.mysql.jdbc.Driver. This is deprecated.这个警告表示代码里用了旧版驱动类名MySQL 8.x兼容了旧类名但会提示废弃。把Class.forName里的类名换成com.mysql.cj.jdbc.Driver即可或者干脆删掉Class.forName这行SPI机制会自动加载。4.2 中文乱码问题查询结果里中文变成???或者乱码字符这个问题的排查方向是三层字符集数据库表的字符集、连接URL的characterEncoding参数、Java代码的文件编码。你的库表在建库时指定了utf8mb4连接URL加了characterEncodingutf8IDEA的默认编码在Settings Editor File Encodings里设置成UTF-8这三层都对齐了就不会乱码。有一个容易忽略的点如果你是通过cmd黑窗口执行SQL插入中文数据Windows默认的GBK编码可能会让插入的数据本身就乱码这是源头问题和Java代码无关。练习时建议用Navicat或DBeaver这类图形化客户端操作数据库。4.3 连接超时与连接泄漏Connection is not available, request timed out出现这个报错先检查连接池的最大连接数配了多少。如果同时并发量超过最大连接数后来者就会开始等待。但更常见的原因是连接泄漏——代码里拿了连接没有close连接池里的连接被借走不还最终池子被掏空。排查连接泄漏的一个技巧是启用Druid的removeAbandoned配置removeAbandonedtrue removeAbandonedTimeout180 logAbandonedtrue这个配置会在连接被借用超过180秒且未归还时强制回收并打印日志。你在日志里看到某个方法的连接长时间未关闭就能精准定位到泄漏代码。这项配置只能在开发环境用生产环境要谨慎开启因为强制回收可能会打断正在执行的长事务。4.4 SQL语法错误的快速定位执行SQL时报SQLSyntaxErrorException很多人第一反应是看SQL语句本身。我的经验是先看异常信息里数据库返回的错误码和位置提示比如MySQL的1064错误会精确告诉你语法错误发生的位置。把SQL拿到Navicat里手动执行一遍排除Java端的问题往往能更快定位。还有一种隐蔽的坑SQL语句末尾的分号。SQL字符串里带不带分号在Navicat里都能跑通但JDBC执行时如果SQL带了分号MySQL 8.x的驱动会处理但有些老版本驱动和某些中间件会报错。所以我推荐的规范是Java代码里的SQL字符串一律不带分号。4.5 主键自增值的获取插入一条新记录后业务代码经常需要拿到这条记录的自增主键值——比如新增学生后马上需要给他的成绩表创建关联记录。JDBC的prepareStatement可以传入RETURN_GENERATED_KEYS参数拿到自增主键try (Connection conn JdbcUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { pstmt.setString(1, stu.getName()); pstmt.setInt(2, stu.getAge()); pstmt.setBigDecimal(3, stu.getScore()); pstmt.executeUpdate(); try (ResultSet rs pstmt.getGeneratedKeys()) { if (rs.next()) { stu.setId(rs.getInt(1)); } } }这个应用场景很常见报表统计、日志关联、外键引用。在ORM框架里这个动作被封装成了useGeneratedKeystrue之类的配置但理解它的底层机制你在遇到框架不支持的场景时才能自己手写。5. 从JDBC到框架这一步为什么不能跳在Day4的最后我想聊聊一个很多初学者纠结的问题既然MyBatis、Spring Data JPA这些框架用起来那么方便为什么还要花精力学原始JDBC我的观点是框架是JDBC的封装但不是JDBC的替代。用一个框架你至少要理解它帮你做了什么出现问题时才知道从哪里排查。MyBatis的Mapper接口最终执行的就是JDBC的PreparedStatementSpring Data JPA底层用的是JDBC连接池管理连接事务注解的本质就是JDBC的connection.commit()和rollback()。这些底层机制都出自Day4学习的内容。在实际工作的排查现场我见过太多只会用框架不懂JDBC的开发者SQL报错看不懂驱动日志、事务不生效不知道是连接对象不一致、慢查询分不清是SQL问题还是连接池配置问题。那些能快速定位问题的老手靠的往往不是花哨的框架技巧而是扎实的JDBC功底。Day4的练习内容虽然基础但值得你花时间把CRUD完完整整敲一遍每一种操作都亲手执行把上面那些异常都亲自触发一次。踩过坑、看过报错比看十篇教程都有用。等JDBC这张底图清晰了再往上搭框架你会觉得一切理所当然——这正是学习Java Web最舒服的节奏。我自己的一个习惯是每学一个新技术都在本地建一个demo项目记录完整代码和常见报错截图。这个问题库到了真正做项目时会成为非常有价值的排查手册。Day4的JDBC内容同样适用建议你也能保留一份这样的记录。