接到这个标题的时候我就想起自己刚入行那会儿,第一次拿到“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。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8+ | 学习期1.8完全够用 |
| MySQL | 5.7 / 8.0 | 8.0需要用新驱动类名 |
| mysql-connector-java | 5.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自动注册 |
| 报错serverTimezone | MySQL 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 allowed | MySQL 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都会顺畅很多。最后,建议你把文中每个例子都亲手敲一遍,尤其动态更新和批处理,只看不练等于白看,代码这种东西,手上有感觉了才算是自己的。