☰
JDBC从零到实战:连接MySQL实现增删改查与事务连接池
2026/10/11 15:02:47 网站建设 项目流程

接到这个标题的时候我就想起自己刚入行那会儿,第一次拿到“Java连接数据库实现增删改查”这个任务,心里是真的发怵。那时候我连JDBC和ODBC都分不清,更别说PreparedStatement为什么比Statement安全、连接串里的参数到底哪个漏不得。后来在项目里踩了无数次坑,回头再看这块内容,其实就六个步骤、四个SQL动作、一堆细枝末节的规矩。这篇就把我从零到能写明白的整个路子捋出来,从建表到工具类,从CRUD到事务和连接池,再到那些你在编译器里不一定看得出来的坑,一次讲透。零基础能照着做出来,有点基础的也能补上几个一直模糊的知识点。

1. JDBC整体思路拆解:为什么所有Java框架都绕不开它

1.1 JDBC到底干了什么事

Java要操作数据库,靠的是JDBC(Java Database Connectivity),它是一套接口规范。真正干活的驱动是各数据库厂商实现的,比如MySQL的驱动就是mysql-connector-java这个包。你在代码里写的Class.forName、DriverManager.getConnection、PreparedStatement,全部是在跟这套接口打交道。

JDBC的完整流程拆开看是六个步骤:

  • 加载驱动,让Java知道我要连什么数据库;
  • 获取连接,建立Java程序与数据库之间的会话通道;
  • 创建语句对象,准备要执行的SQL;
  • 执行SQL,增删改用executeUpdate,查询用executeQuery;
  • 处理结果,查出来的数据装进ResultSet,增删改看受影响行数;
  • 释放资源,连接、语句、结果集都要关闭。

这六个步骤就像你去银行办事:带着身份证进门是加载驱动;取号排队是建立连接;填业务单是编写SQL;柜员受理是执行语句;签收回执是处理结果;办完离开是释放资源。哪一步断了,整个流程就卡住。

1.2 为什么要先学JDBC,而不是一上来用框架

现在干活基本都用MyBatis、Hibernate、Spring Data JPA这些框架,有人就觉得JDBC过时了。但框架再漂亮,底层还是JDBC,只是把重复代码封装掉了。我见过同事在MyBatis里配不好DataSource,报错后一脸懵,因为他不知道连接串参数的含义、不知道驱动类名为什么会变。这些知识恰恰是JDBC这一层才能给你的。

所以这篇的定位很明确:面向零基础入门的人,也面向那些会用框架但总感觉根基不稳的人。你只需要一个能跑起来的Java环境、一个MySQL数据库,不用引入任何框架,先把最原生的JDBC吃透。后面再看框架文档,很多原本死记的东西自然就通了。

1.3 环境与版本怎么选

我建议的本地环境组合:

  • JDK 1.8或以上,太旧的版本有些语法和驱动行为对不上;
  • MySQL 5.7或8.0,两个版本在驱动类名和连接串上有一点差异,会在后面单独讲;
  • Maven作为依赖管理工具,不是必须,但强烈推荐;
  • IDEA或Eclipse都行,选一个趁手的就好,关键是能看debug。
组件推荐版本说明
JDK1.8+学习期1.8完全够用
MySQL5.7 / 8.08.0需要用新驱动类名
mysql-connector-java5.1.49(配5.7旧库) / 8.0.33(配8.0库)版本别乱配

我用的是MySQL 8.0配8.0.33驱动,后面所有示例都按这个组合来讲。如果你还是5.7,把驱动类名换成com.mysql.jdbc.Driver就行。

2. 数据库准备与工程初始化

2.1 建库建表,从一条DDL开始

先在你本地的MySQL里建一个库和一张用户表。不用太复杂,能体现增删改查就行。

CREATE DATABASE IF NOT EXISTS db_user_demo DEFAULT CHARACTER SET utf8mb4; USE db_user_demo; CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键自增', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一', password VARCHAR(100) NOT NULL COMMENT '密码', email VARCHAR(100) DEFAULT NULL COMMENT '邮箱', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

有两个细节说一下。字符集选utf8mb4不是utf8,因为utf8在MySQL里最多存3字节,遇到emoji这种特殊字符会报错,utf8mb4是完整的UTF-8编码。存储引擎用InnoDB,因为它支持事务和行级锁,后面试事务控制的时候还要靠它。

2.2 Maven工程与依赖坐标

如果你用Maven,在pom.xml里加依赖:

<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

不习惯Maven的人也没关系,去下载对应版本的jar包,放到项目的lib目录下,然后在IDE里把它Add as Library。两种方式的本质是一样的:让Java在编译和运行时能找到驱动类。区别只是一个交给Maven管理,一个自己手动管。

2.3 数据库连接配置,参数含义必须搞清楚

我不建议把URL、用户名、密码直接硬编码在Java类里,因为换个环境就要改代码,容易误改。用一个配置文件db.properties,放在src/main/resources下:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/db_user_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=你的密码

这段URL是新手最容易抄错的地方,每个参数我都解释一下:

  • useUnicode=true和characterEncoding=utf8,保证中文写入读取不乱码;
  • useSSL=false,本地开发不开SSL,省去证书的麻烦;
  • serverTimezone=Asia/Shanghai,MySQL 8.0对时区要求变严了,不设会报服务器时区错误;
  • allowPublicKeyRetrieval=true,解决8.0驱动连接时的公钥检索限制问题,后面排查章节细说。

2.4 编写数据库工具类

JDBC的样板代码很多,所以第一步就是把获取连接和释放资源封装成一个工具类,后面每个操作都复用。

public class DBUtils { private static String driver; private static String url; private static String username; private static String password; static { try { InputStream in = DBUtils.class.getClassLoader() .getResourceAsStream("db.properties"); Properties props = new Properties(); props.load(in); driver = props.getProperty("jdbc.driver"); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); Class.forName(driver); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

这里用的是静态代码块,类第一次加载的时候就会读取配置并注册驱动,整个生命周期里只做一次。资源关闭顺序必须先关ResultSet,再关Statement,最后关Connection。反过来的话,先关了连接,语句和结果集可能因为这个连接已经被归还或断开而无法正常关闭,还会留下内存泄漏。

3. 核心增删改查实战:从SQL到代码逐行落地

3.1 新增用户,第一次接触PreparedStatement

先写一个最原始的版本,让大家知道为什么不能这么写:

String sql = "INSERT INTO t_user(username, password, email) VALUES('" + username + "', '" + password + "', '" + email + "')"; Statement stmt = conn.createStatement(); stmt.executeUpdate(sql);

字符串拼接SQL最大问题是SQL注入。比如password传一个123' OR '1'='1,拼出来的SQL就变成了:

INSERT INTO t_user(username, password, email) VALUES('admin', '123' OR '1'='1', 'x@x.com')

条件恒真,这条SQL就能做很多预期外的事。所以实际开发一律用PreparedStatement,用?占位符先把SQL骨架发给数据库,再把参数单独传进去,数据库自己会处理转义和边界。

public int insertUser(User user) { String sql = "INSERT INTO t_user(username, password, email) VALUES(?,?,?)"; try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }

这里我用的是try-with-resources,JDK 1.7以后不用手动close的写法,代码简洁很多,而且即使发生异常也会自动关资源。executeUpdate返回的是受影响的行数,插入成功一般是1。

3.2 删除用户:单条删除加批量删除

单条删除,逻辑最简单:

public int deleteById(int id) { String sql = "DELETE FROM t_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; } }

返回0说明没有这条记录,调用方可以根据这个值判断删除是否真的生效。

批量删除有两种姿势。一种是用循环调单条删除方法,好处是代码简单,坏处是每删一条都要走一次网络往返,数据量大时性能差。另一种是用addBatch和executeBatch:

public int batchDelete(List<Integer> ids) { String sql = "DELETE FROM t_user WHERE id=?"; try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (Integer id : ids) { ps.setInt(1, id); ps.addBatch(); } int[] rows = ps.executeBatch(); // rows数组里每项对应一条SQL的执行结果,通常大于等于0表示成功 return rows.length; } catch (SQLException e) { e.printStackTrace(); return 0; } }

真正大批量执行时,把多条SQL攒在一起发给数据库,能省掉大量的交互时间。我第一次用executeBatch是在一个数据导入需求里,几千条记录一条一条插要几分钟,改成批处理直接降到十几秒。

3.3 修改用户:动态SQL拼接的坑

修改用户有个经典场景:前端可能只传了某几个字段,不是每次都有完整数据。比如只改邮箱,不要因为username和password没传就把它俩更新成空字符串。所以SQL要按条件拼接。

public int updateUser(User user) { StringBuilder sql = new StringBuilder("UPDATE t_user SET "); List<Object> params = new ArrayList<>(); if (user.getUsername() != null && !user.getUsername().isEmpty()) { sql.append("username=?, "); params.add(user.getUsername()); } if (user.getPassword() != null && !user.getPassword().isEmpty()) { sql.append("password=?, "); params.add(user.getPassword()); } if (user.getEmail() != null && !user.getEmail().isEmpty()) { sql.append("email=?, "); params.add(user.getEmail()); } // 如果所有字段都为空,直接返回或者抛异常 if (params.isEmpty()) { return 0; } // 去掉最后一个逗号和空格 sql.setLength(sql.length() - 2); sql.append(" WHERE id=?"); try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } ps.setInt(params.size() + 1, user.getId()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }

这地方我栽过一次:一开始忘了用setLength把尾巴的逗号去掉,拼出来的SQL是UPDATE t_user SET username=?, password=?,直接语法报错。另外参数索引要认真数,前面有几个实体参数,ID就排在后面几位,这个是动态SQL最容易对不齐的地方。我把所有参数先放List里,再统一用setObject填充,索引错乱的问题少多了。

3.4 查询用户:单条、列表、模糊搜索

先看按ID查单条:

public User getUserById(int id) { String sql = "SELECT id, username, password, email, create_time FROM t_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()) { return mapRowToUser(rs); } } } catch (SQLException e) { e.printStackTrace(); } return null; }

查列表就是把while循环走完,把所有行封装成对象放进集合:

public List<User> listAll() { String sql = "SELECT id, username, password, email, create_time FROM t_user"; List<User> list = new ArrayList<>(); try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRowToUser(rs)); } } catch (SQLException e) { e.printStackTrace(); } return list; }

封装成一个映射方法,避免每个查询里都get一遍:

private User mapRowToUser(ResultSet rs) throws SQLException { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setEmail(rs.getString("email")); u.setCreateTime(rs.getTimestamp("create_time")); return u; }

模糊搜索和前面稍微不一样,参数本身要加上%:

public List<User> searchByKeyword(String keyword) { String sql = "SELECT id, username, password, email, create_time FROM t_user WHERE username LIKE ? OR email LIKE ?"; List<User> list = new ArrayList<>(); try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + keyword + "%"; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRowToUser(rs)); } } } catch (SQLException e) { e.printStackTrace(); } return list; }

这里有个很多新手会犯的错误:用Statement加LIKE '%" + keyword + "%'"来拼,一是不安全,二是keyword里带单引号就崩了。PreparedStatement帮你在传参的时候处理掉了这些边界问题。

3.5 实体类User怎么写

public class User { private Integer id; private String username; private String password; private String email; private LocalDateTime createTime; // getter/setter省略,toString建议加上,方便打印调试 }

字段和表字段一一对应,Java里的驼峰createTime对应数据库里的下划线create_time。如果你自己写映射,记住这个对应规律;如果以后用MyBatis,它的map-underscore-to-camel-case会自动帮你在配置里做转换,但要清楚现在这一层里是你手动处理的。

4. 进阶必看:事务、连接池与分层设计

4.1 事务控制:转账失败这个经典场景

CRUD单条都成功了,但合在一起可能出事故。最常见例子是转账:A扣100,B加100。如果第一步执行成功,第二步因为网络中断失败,那钱就凭空消失了。解决办法就是事务。

Java里控制事务三步:

conn.setAutoCommit(false); // 关闭自动提交 try { // 执行多条update conn.commit(); } catch (Exception e) { conn.rollback(); } finally { conn.setAutoCommit(true); // 恢复自动提交,方便连接复用 // 关闭资源 }

关键点在于:一个事务里的所有SQL必须使用同一个Connection对象,不能每条SQL都调一次DBUtils.getConnection()。因为每条连接默认是独立会话,各自自动提交,事务边界根本管不住。所以写事务的时候,通常是把conn作为参数往DAO方法里传,或者直接用ThreadLocal把连接绑到当前线程,这是很多框架底层干的事情。

4.2 连接池:生产环境为什么要换掉DriverManager

每次getConnection都意味着一次TCP握手、认证、建立会话,用完再断开。如果业务量稍微大一点,频繁创建连接的成本会拖垮应用。这时候就需要连接池:提前创建一批连接放着,用完不销毁,由池子统一管理和分配。

连接池有很多选择,老牌的Druid、HikariCP、C3P0。Spring Boot 2.x默认是HikariCP,因为它快、轻量。示例如下:

HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/db_user_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("你的密码"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); HikariDataSource dataSource = new HikariDataSource(config); Connection conn = dataSource.getConnection();

为什么学JDBC阶段还要手写DriverManager?因为你要通过手写知道连接是怎么来的、资源为什么要还、URL里哪些参数是干什么的。理解了这些,用连接池不过是换个获取连接的方式,心智负担小很多。如果直接跳框架,遇到连接超时、连接泄漏问题,你根本无从下手。我现在面试新人,问连接池参数含义,能答清楚的寥寥无几,就是因为他们只用了框架的一行配置。

4.3 代码分层:别把SQL裸写在入口方法里

小demo可以一个类写完,但后续需求一多就难受了。合理的做法是拆成三层:

  • entity:实体类,对应表结构;
  • dao:数据访问层,只做SQL操作;
  • service:业务逻辑层,处理校验、事务、组合多个DAO操作;
  • 入口:main方法或后续的Controller,只负责调用service。

给你一个简化的service示例:

public class UserService { private UserDao userDao = new UserDao(); public boolean register(User user) { // 业务校验:用户名不能重复 if (userDao.getByUsername(user.getUsername()) != null) { return false; } // 密码加密这类处理放这里,不放DAO里 return userDao.insertUser(user) > 0; } }

这样拆分的好处是:改SQL去DAO里看,改业务规则只在Service里动,入口处不会堆一堆SQL。我见过有些项目把几十行JDBC代码写在main方法里,后来要加个业务字段,改起来像在雷区里走路。

5. 常见问题与排查技巧实录

这一节是真的能帮你省时间的,都是我实际遇到或者帮同事排查过的问题。

报错现象根因解决办法
ClassNotFoundException: com.mysql.cj.jdbc.Driver缺驱动jar包,或类名写错确认pom里有依赖,确认类名是cj后缀还是老版Driver
ClassNotFoundException: com.mysql.jdbc.Driver用的是MySQL 8驱动,但类名写成老版8.0驱动类名为com.mysql.cj.jdbc.Driver,或者干脆删掉Class.forName靠SPI自动注册
报错serverTimezoneMySQL 8.0对时区敏感连接串加serverTimezone=Asia/Shanghai
Access denied for user 'root'@'localhost'用户名或密码错,或者没远程访问权限检查properties里的用户名密码;确认数据库账号有权限
Communications link failure数据库没启动、端口不对、网络不通先ping一下端口,mysql -u root -p能否本地连上
中文插入后变成问号连接串没有characterEncoding=utf8,或库表字符集不对连接串、数据库、表、列,四层的字符集统一utf8mb4
Public Key Retrieval is not allowedMySQL 8密码插件为caching_sha2_password连接串加allowPublicKeyRetrieval=true
You have an error in your SQL syntax动态SQL拼接多了逗号、少了空格先打印SQL,在Navicat里验证,确认无误再放回代码
ResultSet closed关闭连接后又取ResultSet数据遍历完ResultSet再关连接,或把数据先取出放到对象里

5.1 三个踩中的坑和对应经验

第一个坑是版本匹配。我刚开始学的时候下载了一个老版驱动,连MySQL 8直接报认证插件不兼容。记住:驱动版本尽量和数据库大版本匹配,别拿5.x驱动连8.x库。

第二个坑是动态SQL的调试。拼SQL这种东西,肉眼很难看出来哪里多了空格或逗号。我的习惯是在执行前打印最终SQL:

System.out.println("Final SQL: " + sql.toString()); System.out.println("Params: " + params);

然后把SQL原文粘到Navicat里跑一遍。SQL能跑通,问题就在参数传递;SQL跑不通,毫无疑问是拼接逻辑有错。

第三个坑是连接资源泄漏。忘了关闭Connection,测试环境跑一个下午就报连接数耗尽。用try-with-resources能杜绝一半问题,另一半在于工具类的close方法要写得健壮,判空加上,保证异常时也能关。

5.2 一个调试顺序的总结

我的调试顺序永远是:先确认SQL本身有没有问题,再怀疑JDBC代码。在数据库客户端里执行一遍同样的SQL,确认结果正常,再回来看代码里的占位符、参数类型、索引。很多新手一报错就开debug,结果看了半天发现是SQL少了一个空格。

另外printStackTrace只是方便控制台看,正式项目请换成日志框架,否则问题只能在你本地复现时才能定位。

一点个人体会,留在这篇的结尾

写这篇的时候,我回想自己第一次独立完成用户表CRUD的那个下午。代码写了两百多行,把六个步骤背得滚瓜烂熟,但真正理解还是在被各种报错折磨之后。如果你现在也正在为某个驱动包、某个时区参数、某个动态SQL头疼,别急,这些东西踩过一次就成肌肉记忆了。JDBC是整个Java数据访问体系的地基,扎实学一遍,后面学MyBatis、Spring Data都会顺畅很多。最后,建议你把文中每个例子都亲手敲一遍,尤其动态更新和批处理,只看不练等于白看,代码这种东西,手上有感觉了才算是自己的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询