
简介这是一份面向Java初学者的JDBC实战图解资料围绕使用Java连接MySQL数据库并实现增删改查展开。内容涵盖开发环境准备、数据库及数据表创建、DBUtil数据库连接工具类、实体类设计、DAO数据访问层编写等完整流程并给出基于PreparedStatement的插入、查询、更新、删除代码示例既能防止SQL注入又能帮助读者理解JDBC底层交互原理为后续学习Hibernate、MyBatis等ORM框架打下扎实基础。压缩包内共1个PDF文档体积约326KB文档结构清晰适合按步骤学习或随时查阅。目前已有5619人浏览学习属于轻量实用的入门教程。通过PDF中的详细讲解与代码演示读者可以独立搭建MySQL连接环境掌握使用JDBC完成数据增删改查的核心方法快速积累Java数据库编程实战经验。1. 为什么 2025 年还要手写 JDBC 增删改查框架把 SQL 执行包装得越严实JDBC 里那些“原始”细节反而越值得拆开看。MyBatis 的 Mapper 代理、Hibernate 的实体映射底层最终都会落到 Connection、PreparedStatement、ResultSet 这套 Java Database Connectivity 接口上。平时一个查询接口出问题排查时常要看到底有没有拿到连接、参数绑没绑对、资源关没关这时候手写 JDBC 的肌肉记忆就是最快的定位工具。这个入门工程把完整链路压缩成了四个类DBUtil 管理连接Goddess 映射表结构GoddessDao 实现增删改查GoddessAction 模拟控制层调用。建表、连库、跑 main 一条线走通非常适合刚学完 Java SE 准备进入 J2EE 方向的人也对那些用惯了 MyBatis 但说不清预编译机制的后端开发者有价值。下面先打通最容易被忽略的连接层。2. 连接层 DBUtil 的设计与驱动加载2.1 静态连接工厂的适用边界原工程里的 DBUtil 把所有初始化都放在静态代码块中类加载时完成驱动注册和连接获取对外只暴露一个 getConnection 方法。package com.czgo.db; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://127.0.0.1:3306/imooc; private static final String UNAME root; private static final String PWD root; private static Connection conn null; static { try { Class.forName(com.mysql.jdbc.Driver); conn DriverManager.getConnection(URL, UNAME, PWD); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (SQLException e) { e.printStackTrace(); } } public static Connection getConnection() { return conn; } }这段代码胜在简短。静态块保证驱动只注册一次getConnection 拿到的是同一个 Connection 实例。放在单线程的入门 demo 里没有问题但它有三个明显短板第一物理连接长期不释放MySQL 的 wait_timeout 一到就会被服务端断开第二多个请求共享同一个 Connection事务隔离级别和自动提交状态会被互相干扰第三printStackTrace 只打印堆栈不抛出上层拿到 null 连接后报的错会更难定位。所以我会把这个类定位成“教学基线”真正要复用时至少要改成每次调用都新建连接再往上考虑连接池。改法不复杂抛异常的方式也要收敛。2.2 JDBC URL 参数拆解连接串不是只有地址、端口、库名三段参数不同行为和报错都完全不同。推荐在入门阶段就写全下面几个参数jdbc:mysql://127.0.0.1:3306/imooc?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue参数作用建议useUnicode是否使用 Unicode 字符集配合 characterEncoding 一起写characterEncoding客户端发给服务端的编码utf8 或 utf8mb4serverTimezone指定时区MySQL 8 驱动强校验Asia/ShanghaiuseSSL是否启用 SSL 加密本机学习阶段 false生产按安全要求allowPublicKeyRetrieval允许客户端向服务端获取 RSA 公钥MySQL 8 使用 caching_sha2_password 时常需要参数之间用 分隔不要用分号。要留意的是characterEncodingutf8和utf8mb4的区别在于 emoji 和生僻字表里如果有评论这类文本推荐建表时用 utf8mb4连接串也用 utf8mb4 对应。2.3 驱动类名的版本差异原代码里的Class.forName(com.mysql.jdbc.Driver)在 MySQL Connector/J 5.x 时代是标准写法。从 Connector/J 8.0 开始旧驱动类被移除需要换成com.mysql.cj.jdbc.Driver。// MySQL Connector/J 5.x Class.forName(com.mysql.jdbc.Driver); // MySQL Connector/J 8.x 及以上 Class.forName(com.mysql.cj.jdbc.Driver);Class.forName 做的事情是让 DriverManager 认识这个驱动类JDBC 4.0 之后驱动 jar 包通过META-INF/services/java.sql.Driver自动注册很多场景不写这行也能连上库。我习惯保留因为这样显式声明了使用的驱动协议依赖冲突时看代码就能判断版本方向。2.4 生产写法每次请求获取独立连接把静态共享连接改成每次获取都是新建代码反而更简单public static Connection getConnection() throws SQLException { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new SQLException(MySQL JDBC 驱动未找到请检查依赖, e); } return DriverManager.getConnection(URL, UNAME, PWD); }注意两点一是throws SQLException由调用方处理驱动加载失败这种致命问题要立刻暴露而不是吞掉二是每次调用都建立物理连接开销确实存在所以生产环境需要连接池。理解了这个代价再看 HikariCP、Druid 这类池化方案就会明白它们解决的核心问题是连接复用和存活检测。3. DAO 层 PreparedStatement 与 ResultSet 映射实战3.1 实体字段与表字段的对应关系实体类设计最重要的是字段名要和表结构对齐否则 ResultSet 取值时会报Column not found。package com.czgo.model; import java.io.Serializable; public class Goddess implements Serializable { private static final long serialVersionUID 1L; private Integer id; private String name; private String mobie; private String email; private String address; public Integer getId() { return id; } public void setId(Integer id) { this.id id; } public String getName() { return name; } public void setName(String name) { this.name name; } public String getMobie() { return mobie; } public void setMobie(String mobie) { this.mobie mobie; } public String getEmail() { return email; } public void setEmail(String email) { this.email email; } public String getAddress() { return address; } public void setAddress(String address) { this.address address; } }不建议给这个场景引入一个完整 Lombok手写 getter/setter 反而能让你注意到字段名。一个很现实的问题mobie是原文里的拼写建表时如果写成mobile那实体属性、DAO 里的getString(mobie)、SQL 里的列名三处必须保持一致。我的建议是表结构直接叫mobile代码同步改不要模仿错误拼写。id 用 Integer 而不是 int是因为查询不到数据时能区分“没有值”和“值为 0”。3.2 查询全部ResultSet 遍历与拼接public ListGoddess query() throws SQLException { ListGoddess goddessList new ArrayListGoddess(); Connection conn DBUtil.getConnection(); StringBuilder sb new StringBuilder(); sb.append(select id, name, mobie, email, address from goddess); PreparedStatement ptmt conn.prepareStatement(sb.toString()); ResultSet rs ptmt.executeQuery(); Goddess goddess null; while (rs.next()) { goddess new Goddess(); goddess.setId(rs.getInt(id)); goddess.setName(rs.getString(name)); goddess.setMobie(rs.getString(mobie)); goddess.setEmail(rs.getString(email)); goddess.setAddress(rs.getString(address)); goddessList.add(goddess); } return goddessList; }这段逻辑是 JDBC 查询的标准骨架。StringBuilder在这里不换行拼 SQL主要是避免字符串被 IDE 自动折叠成不好读的一长条真正的动态条件后面会用到。while (rs.next())是移动游标并判断是否有下一行第一次调用时游标位于第一行之前。这里我统一用列名而不是rs.getInt(1)这种下标因为 SQL 列顺序一变下标方案就静默错位列名方案至少会在运行时明确报错。3.3 单条查询占位符与 setXxxpublic Goddess queryById(Integer id) throws SQLException { Goddess g null; Connection conn DBUtil.getConnection(); String sql select id, name, mobie, email, address from goddess where id ?; PreparedStatement ptmt conn.prepareStatement(sql); ptmt.setInt(1, id); ResultSet rs ptmt.executeQuery(); while (rs.next()) { g new Goddess(); g.setId(rs.getInt(id)); g.setName(rs.getString(name)); g.setMobie(rs.getString(mobie)); g.setEmail(rs.getString(email)); g.setAddress(rs.getString(address)); } return g; }?是占位符setInt(1, id)表示把第一个问号绑成 int 类型。PreparedStatement 的预编译有两个直接价值数据库可以复用执行计划参数值在协议层作为数据而不是 SQL 片段传输能挡住大部分注入。setXxx 的方法要和目标列类型匹配id 是 int 就用 setInt是 varchar 就用 setString类型不匹配 MySQL 会报 SQLException。查不到数据时这个方法返回 null。单条查询建议调用方先判空再使用或者改成返回OptionalGoddess后者的好处是强迫调用方处理“不存在”这个分支。3.4 新增与更新executeUpdate 的返回值public void addGoddess(Goddess goddess) throws SQLException { Connection conn DBUtil.getConnection(); String sql insert into goddess(name, mobie, email, address) values(?,?,?,?); PreparedStatement ptmt conn.prepareStatement(sql); ptmt.setString(1, goddess.getName()); ptmt.setString(2, goddess.getMobie()); ptmt.setString(3, goddess.getEmail()); ptmt.setString(4, goddess.getAddress()); ptmt.executeUpdate(); } public void updateGoddess(Goddess goddess) throws SQLException { Connection conn DBUtil.getConnection(); String sql update goddess set name?, mobie?, email?, address? where id?; PreparedStatement ptmt conn.prepareStatement(sql); ptmt.setString(1, goddess.getName()); ptmt.setString(2, goddess.getMobie()); ptmt.setString(3, goddess.getEmail()); ptmt.setString(4, goddess.getAddress()); ptmt.setInt(5, goddess.getId()); ptmt.executeUpdate(); }新增时不用传 id因为建表时已经把它设为自增主键。更新操作的常见坑是容易漏绑定where id?原工程里 updateGoddess 就没有调用setInt(5, goddess.getId())运行时会因为占位符数量与绑定数量不一致直接报错这类问题看异常堆栈往往比看 SQL 更快。executeUpdate 返回的是受影响行数如果更新时传入的 id 不存在返回值是 0可以根据这个值判断业务上是否需要抛出异常。3.5 删除与资源关闭public void deleteGoddess(Integer id) throws SQLException { Connection conn DBUtil.getConnection(); String sql delete from goddess where id?; PreparedStatement ptmt conn.prepareStatement(sql); ptmt.setInt(1, id); ptmt.executeUpdate(); }删除语句的结构和更新几乎一样本质是带条件的 DML。很多初学者在这里不关资源长期跑下来会看到数据库连接被占满。使用连接池时conn.close()不一定是物理断开而是把连接还给池所以更要坚持关闭。try (Connection conn DBUtil.getConnection(); PreparedStatement ptmt conn.prepareStatement(sql)) { ptmt.setInt(1, id); ptmt.executeUpdate(); }try-with-resources 会在代码块结束后自动关闭 PreparedStatement 和 Connection不需要手动写 finally。要注意 ResultSet 如果还在使用也需要声明在 try 资源列表里。下面是这个 DAO 的完整方法清单方法SQL 类型说明query()select查询全部queryById(Integer)select按主键查询addGoddess(Goddess)insert新增updateGoddess(Goddess)update更新注意绑定 iddeleteGoddess(Integer)delete删除4. 控制层调用、事务边界与资源泄漏排查4.1 控制层为何要隔离 DAOGoddessAction 在这里充当控制层它不写 SQL只做参数组装和业务编排。真实 Web 项目里 Action 层接收请求参数把它们封装成 Goddess 对象再调用 DAO 方法。这样做的收益是替换数据库实现时只改 DAO控制层和表现层不需要感知 SQL 细节。public void add(Goddess goddess) throws Exception { GoddessDao dao new GoddessDao(); goddess.setName(Lucy); goddess.setMobie(13800000000); goddess.setEmail(lucyexample.com); goddess.setAddress(Beijing); dao.addGoddess(goddess); }这里故意写死了数据是为了让第一次跑通的读者不用构造参数。换成 Web 请求后name、mobie 这些值应该来自请求体控制层只负责把参数塞进实体。测试时直接调用这个无参构造即可。4.2 组装一个 main 冒烟流程public static void main(String[] args) throws Exception { GoddessAction action new GoddessAction(); Goddess g new Goddess(); g.setName(Alice); g.setMobie(13911112222); g.setEmail(aliceexample.com); g.setAddress(Shanghai); action.add(g); ListGoddess list action.query(); for (Goddess item : list) { System.out.println(item.getId() , item.getName()); } Goddess target action.get(1); if (target ! null) { target.setEmail(newexample.com); action.edit(target); } action.del(2); }冒烟流程覆盖了增、查、改、删四条链路。比较容易忽略的是action.edit(target)之前必须查一遍拿到完整字段否则 target 里 id 是 null更新 SQL 绑定 id 时会抛异常。这也是前面强调 update 要绑定 id 的原因之一。4.3 事务边界多条 DML 必须同时成功或回滚DAO 方法各自获取连接天然没有事务边界。比如新增女神记录时还要往操作日志表插一条数据如果日志插入失败用户信息入库了日志却没有数据就不一致。public void addGoddessWithLog(Goddess goddess) throws SQLException { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); String sql1 insert into goddess(name, mobie, email, address) values(?,?,?,?); PreparedStatement ps1 conn.prepareStatement(sql1); ps1.setString(1, goddess.getName()); ps1.setString(2, goddess.getMobie()); ps1.setString(3, goddess.getEmail()); ps1.setString(4, goddess.getAddress()); ps1.executeUpdate(); String sql2 insert into operate_log(action, target_id) values(?,?); PreparedStatement ps2 conn.prepareStatement(sql2); ps2.setString(1, add); ps2.setInt(2, 99); ps2.executeUpdate(); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } }setAutoCommit(false) 之后所有 DML 都在同一个事务中直到 commit 才真正落库。任何一个 SQLException 都会触发 rollback把前面已经执行的 insert 撤销。finally 里恢复自动提交是为了连接归还连接池后下一个使用者拿到的还是默认状态。项目里涉及多个 DAO 的跨表事务通常用 Spring 的 Transactional 管理控制原理仍然是共享同一个连接只是由框架把连接绑定到了当前线程。4.4 用连接池和 SHOW PROCESSLIST 找资源泄漏如果这个工程要往 Spring Boot 迁移第一个动作就是把 DBUtil 换成连接池。HikariCP 配置中最关键的是 maximumPoolSize 和 connectionTimeout。HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/imooc?serverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(root); config.setMaximumPoolSize(10); config.setConnectionTimeout(30000);连接池内部会维护一部分空闲连接调用 close 时连接归还给池而不是真正关闭因此 DAO 层不关连接的话池会被慢慢耗尽。排查时先在 MySQL 里执行SHOW PROCESSLIST;如果看到大量 Sleep 状态的连接堆积且 Command 列不是 Quit说明应用侧没有正确关闭连接。5. 进阶自增主键回填、异常对照与验证技巧5.1 新增后立刻拿到自增 id业务上经常需要插入后马上用主键去写关联表JDBC 提供getGeneratedKeys来完成主键回填。public int addGoddessReturnId(Goddess goddess) throws SQLException { String sql insert into goddess(name, mobie, email, address) values(?,?,?,?); try (Connection conn DBUtil.getConnection(); PreparedStatement ptmt conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ptmt.setString(1, goddess.getName()); ptmt.setString(2, goddess.getMobie()); ptmt.setString(3, goddess.getEmail()); ptmt.setString(4, goddess.getAddress()); ptmt.executeUpdate(); try (ResultSet rs ptmt.getGeneratedKeys()) { if (rs.next()) { return rs.getInt(1); } } } return -1; }prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)告诉驱动需要回传数据库生成的主键然后在同一批编译对象上执行 getGeneratedKeys。这里不需要手动拼接SELECT LAST_INSERT_ID()也更安全。5.2 高频异常对照表异常现象原因处理方向ClassNotFoundException驱动 jar 没引入检查 pom 或 build pathNo suitable driver foundURL 前缀错误或驱动未注册确认jdbc:mysql://写法Communications link failureMySQL 服务未启动、端口错误检查服务状态和 3306 端口Access denied for user用户名或密码错误核对 DBUtil 中的账号Unknown database imooc库名不存在检查 Navicat 中实际库名Public Key Retrieval is not allowedMySQL 8 认证插件问题连接串加allowPublicKeyRetrievaltrueColumn mobie not found表字段和代码列名不一致统一实体、SQL、表中字段名只要增删改查跑不通先看异常属于连接阶段还是 SQL 执行阶段。连接阶段多数是环境问题SQL 执行阶段多数是列名、占位符绑定、类型匹配问题。5.3 用 MySQL general_log 验证真实 SQL想确认 PreparedStatement 发给数据库的到底是什么不一定要抓包打开 MySQL 通用日志更直接。SET GLOBAL log_output TABLE; SET GLOBAL general_log ON;然后重新运行 main查询日志表SELECT event_time, argument FROM mysql.general_log ORDER BY event_time DESC LIMIT 10;如果看到 insert 语句的参数值已经由驱动转义并且与传入值一致说明增删改查链路完整跑通。验证完后记得关闭日志SET GLOBAL general_log OFF;把日志输出到表里比写文件更直观尤其适合 Windows 本机开发环境。所有增删改查操作是否生效最终都以这张日志表和执行后的 goddess 表数据为准。本文还有配套的精品资源点击获取