JDBC(MySQL)这条线,第一天大家多半已经跑通了“加载驱动、拿到Connection、对着控制台证明自己能连上库”的流程。但说句实在话,那个阶段你手里的东西还撑不起一个哪怕最简单的用户管理模块。DAY02的核心任务,就是把“能连上”变成“真的会把数据弄进去再弄出来”,也就是完整走一遍增删改查,同时把Statement、PreparedStatement、ResultSet、事务边界这几个天天要碰的东西一次性捋清楚。这篇文章我会按照自己带项目时习惯的顺序来讲,从连接参数里的坑开始,到插入用户数据这种最典型的场景收尾,顺便把这两天新手必然踩的异常也一并列出来。不管你是在交实验课的“第1关:JDBC插入用户数据”,还是正在给自己写的JavaWeb项目搭数据访问层,今天的内容都够你直接抄作业了。
1. 先聊会话建立:JDBC连接MySQL的参数里到底藏着多少细节
很多教程会把“建立连接”三行代码一带而过,但我见过太多人卡在这一步,而且卡得莫名其妙。不是因为代码写错了,是因为JDBC的URL、驱动版本和MySQL服务端配置这三者之间存在着微妙的兼容关系。
1.1 class.forName到底在干什么,为什么有的版本不写也能跑
先说个常见的困惑:为什么我现在用MySQL Connector/J 8.x时,不写Class.forName("com.mysql.cj.jdbc.Driver")也能正常获取连接?因为JDBC 4.0之后引入了SPI机制,驱动JAR包里的META-INF/services/java.sql.Driver文件会被DriverManager自动扫描。你只要把JAR放在classpath里,DriverManager.getConnection()时会自动加载这些驱动类。所以那个Class.forName在大部分现代项目里其实是可选的。
但问题来了——如果你用的还是老版的com.mysql.jdbc.Driver(比如MySQL Connector/J 5.x的JAR),那就必须显式加载,因为老版本没有SPI配置文件。更麻烦的是,如果你在项目里同时引入了多个版本的驱动JAR,DriverManager可能加载到旧版,然后给你报一个“Table not found”或者“Unknown database”这种误导性极强的错误。所以DAY02第一课,我建议你去IDEA里看一眼项目的依赖树(右键项目 -> Maven -> Show Dependencies),确认手里只有一份mysql-connector-j,而且版本是8.0.x。这一步能让你少怀疑人生三天。
顺带说一句,如果你用的是IDEA 2023之后的新版本,新建Spring Boot项目时它会自动帮你把JDBC驱动下载好;但如果你手动往lib目录里塞JAR,有时会触发“download from maven failed”这类提示。这个多半不是网络问题,而是IDEA的Maven仓库索引没刷新,强制Reload All Maven Projects就能解决。
1.2 URL参数:时区、SSL、字符集这三个老演员
连接MySQL的URL里有几个参数,几乎每天都会在技术群里看到有人被坑。以我常用的写法为例:
jdbc:mysql://localhost:3306/user_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true逐个解释一下为什么要有这些玩意儿:
serverTimezone=Asia/Shanghai:MySQL 8.0之后服务端默认时区是UTC,而JDBC驱动在解析DATETIME类型时会拿本地时区和服务器时区做转换。不指定的话,你存的2025-06-01 10:00:00读出来可能变成2025-06-01 18:00:00。这种错位非常隐蔽,因为所有代码看起来都没问题。useSSL=false:MySQL 8.0默认开启了SSL连接,但多数本地开发环境并没有配证书。不关掉,某些驱动版本会直接抛“SSL connection error”或者“Communications link failure”。开发环境直接关掉,生产环境再按运维给的链接串配置。characterEncoding=utf8:确保写入的是UTF-8编码,防止中文乱码。这里要注意,MySQL的utf8其实只是utf8mb3,存不了Emoji和生僻字。如果你表结构用的是utf8mb4,连接串最好也写characterEncoding=utf8mb4,同时不要在上游JVM里做重复的转码。allowPublicKeyRetrieval=true:MySQL 8.0的默认认证插件是caching_sha2_password,用这种插件连接时,如果服务器没有提前缓存你的公钥,驱动会要求你允许它直接检索公钥。不设这个参数,你会看到“Public Key Retrieval is not allowed”的异常。本地开发直接放行,生产环境建议走SSL或让DBA统一处理。
还有一个参数是useUnicode=true,这个其实被characterEncoding隐含了,老教程里常见,现在写不写都行,写了也没坏处。
1.3 连接管理类的正确打开方式:不建工厂的项目迟早要跪
DAY02我不建议你还是把连接代码写在main方法里。至少写一个DBUtils工具类,把URL、用户名、密码抽成final String常量,提供一个静态方法getConnection(),再提供一个closeAll()方法统一关连接和结果集。这样做的好处不是“代码规范”这种虚头巴脑的理由,而是你马上要做的增删改查会疯狂重复这一段代码,不抽出来就是给自己找麻烦。
public class DBUtils { private static final String URL = "jdbc:mysql://localhost:3306/user_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void closeAll(Connection conn, Statement stmt, ResultSet rs) { // 注意这里有个坑:ResultSet关闭后,你再调Statement的close会抛NullPointerException // 所以判断顺序要小心,先关rs,再关stmt,最后关conn if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这里的关闭顺序是有讲究的:ResultSet依赖于Statement,Statement依赖于Connection,所以关闭时反向操作。如果中间某个环节抛异常,要确保其他环节仍然能关闭,所以每个close都包了独立的try-catch,别用逗号分隔的rs.close(); stmt.close();这种写法——万一第一个就抛了,后面的全部不执行。
注意:连接池那套东西DAY02先不碰,HikariCP、Druid这些配置和应用服务器相关的内容,等你把原生JDBC的手感和调试思路练熟了再上,否则出了问题你根本分不清是驱动的问题还是池子的问题。
2. Statement还是PreparedStatement:这一步选错,后面全是窟窿
说实话,现在如果你去写新代码还用Statement拼接SQL字符串,基本等于给自己挖坑。DAY02我会直接按PreparedStatement为主来教,但会把对比讲透,让你知道为什么面试官总爱问这个。
2.1 SQL注入是个什么样的东西,占位符为什么能防住
先看一段反面教材:
String name = "abc' OR '1'='1"; String sql = "SELECT * FROM t_user WHERE username = '" + name + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);当name被拼进SQL之后,实际上执行的是:
SELECT * FROM t_user WHERE username = 'abc' OR '1'='1'OR '1'='1'永远为真,这条语句会把整个表查出来。如果是DELETE或UPDATE语句,被注入的后果就更酸爽了。PreparedStatement解决这个问题的思路不是“过滤特殊字符”,而是把SQL模板和数据分离,把用户输入永远当作“数据值”而不是“SQL片段”。驱动层会用参数化的方式把?替换成值的“传输格式”,服务端会严格区分SQL语法和参数值,所以注入没有立足之地。
还有一种面试常问的:为什么PreparedStatement在MySQL里“预编译”有时候不生效?因为MySQL Connector/J默认可能走的是客户端模拟参数,真正的服务端预编译需要你在URL里加useServerPrepStmts=true,而且还要结合连接池的配置。不过对于业务层来说,我们需要的安全性和可读性都拿到了,性能差异在大多数场景下可以忽略——真正的性能瓶颈通常不在这一层。
2.2 PreparedStatement的占位符使用规范
写PreparedStatement最容易翻车的点就是占位符的下标从1开始,不是0。这个我第二次写就栽过,当时查了半天以为是SQL写错,结果是setString(0, name)直接抛Parameter index out of range。
参数设置时要对应类型:
pstmt.setString(1, username); pstmt.setInt(2, age); pstmt.setDate(3, new java.sql.Date(System.currentTimeMillis())); // 注意不是java.util.Date pstmt.setObject(4, someUntypedValue);有一个小技巧:当你处理不确定类型的值,或者一段代码里要支持多张表的通用写入时,用setObject()会更省事,驱动会根据数据库字段的类型自动适配。但它也有坑——当你传进去的是java.util.Date时,很多驱动会报错或者存成奇怪的时间值。所以日期这种特殊类型,最好还是手动转成java.sql.Date、java.sql.Timestamp或java.time.LocalDateTime,别偷懒。
还有一点,占位符只能用于“值”,不能用于表名、列名、排序字段。比如:
String sql = "SELECT * FROM ? WHERE id = ?"; // 错误,第一个?不能被参数化如果你确实需要一个动态的表名,要么白名单校验,要么写死多个分支,不要想着靠占位符省事。
2.3 增删改查的标准范式,这里给一套能复用的模板
Day02你要是能把这套模板背下来并理解每一行的意义,后面用MyBatis之前,你的手工JDBC基本不会出大问题。
public int insertUser(String username, String password, String email) { String sql = "INSERT INTO t_user (username, password, email, create_time) VALUES (?, ?, ?, NOW())"; try (Connection conn = DBUtils.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); pstmt.setString(2, password); pstmt.setString(3, email); return pstmt.executeUpdate(); // 返回受影响的行数 } catch (SQLException e) { e.printStackTrace(); return 0; } }这里有几个值得注意的细节。第一,我用的是JDK 7之后的try-with-resources写法,Connection、PreparedStatement实现了AutoCloseable,它们会自动关闭,会自动调用close方法,省去了我手动在finally里写的麻烦。这也是DAY02推荐的做法,它比传统的finally块更安全,因为finally里如果又抛个异常,会把原始的SQLException吞掉。第二,executeUpdate()返回的是受影响行数,插入成功通常返回1,你用它来判断成功与否,比boolean更明确。第三,SQL里的NOW()是数据库函数,它不走占位符,写在模板里没有问题。
注意,密码字段这只是示例。生产环境里密码肯定要加盐哈希,绝不能明文入库,但那是另一篇文章的话题,别在学习阶段图省事直接把明文存进去。
3. DAY02核心实战:JDBC插入用户数据的完整姿势
热词里那个“第1关:JDBC插入用户数据”应该是很多学校实训平台上的一道关卡。它考察的核心其实就是三件事:预编译SQL、设置参数、判断结果。但我要在这道题之上多走两步,把获取自增主键、批量插入和事务手动提交都讲全了,这才是DAY02真正的含金量。
3.1 单条插入的完整代码拆解,从建表到验证
先保证你有这么一张表:
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, email VARCHAR(100), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );然后是最常见的单条插入:
public boolean addUser(User user) { String sql = "INSERT INTO t_user (username, password, email) VALUES (?, ?, ?)"; try (Connection conn = DBUtils.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, user.getUsername()); pstmt.setString(2, user.getPassword()); pstmt.setString(3, user.getEmail()); int rows = pstmt.executeUpdate(); return rows == 1; } catch (SQLException e) { // 这里要重点判断:如果是DuplicateKeyException,说明用户名重复 // 但在原生JDBC层面,你需要通过异常码来识别 e.printStackTrace(); return false; } }在MySQL里,如果插入了一条重复的唯一键数据,不会返回值“0”,而是直接抛SQLException,错误码是1062。所以你在catch里可以根据getErrorCode()判断具体原因,给用户返回“用户名已存在”这样的友好提示。同理,数据太长时错误码是1406,非空字段不填是1048。把这些错误码整理成一张表,是后面做统一异常处理的底气。
3.2 拿到自增主键:这个操作比你想象的更常见
你注册一个用户,完了下一步通常要拿到这个用户的id,去初始化他的购物车、收藏夹或者默认设置。如果插完再去查一遍“SELECT MAX(id)”,在多线程环境下可能拿到别人的id。正确做法是在prepareStatement时指定要返回的键:
String sql = "INSERT INTO t_user (username, password, email) VALUES (?, ?, ?)"; PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); pstmt.setString(1, user.getUsername()); pstmt.setString(2, user.getPassword()); pstmt.setString(3, user.getEmail()); pstmt.executeUpdate(); ResultSet keyRs = pstmt.getGeneratedKeys(); if (keyRs.next()) { long newId = keyRs.getLong(1); user.setId(newId); // 回填id }注意,这里getGeneratedKeys返回的是一个ResultSet,它同样需要关闭。我又要提一遍try-with-resources,它同样适用于这个ResultSet,你可以在try的小括号里一块写进去,这样它也会自动关闭。还有一个坑:如果你调用getGeneratedKeys()的时机太晚,比如先执行了另一条SQL,某些驱动会把之前的键集合冲掉,所以要在executeUpdate()之后立刻拿。
3.3 批量插入10000条数据:这就要谈手动事务了
你有没有试过用for循环插一万条用户数据,跑了半天?因为每条executeUpdate()默认都会自动提交一次事务,而每提交一次,MySQL都要做一次磁盘同步,那个开销是非常大的。DAY02我会直接教你把自动提交关掉,攒够一批再提交。
public void batchInsert(List<User> users) { String sql = "INSERT INTO t_user (username, password, email) VALUES (?, ?, ?)"; try (Connection conn = DBUtils.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { conn.setAutoCommit(false); // 关掉自动提交 for (int i = 0; i < users.size(); i++) { User u = users.get(i); pstmt.setString(1, u.getUsername()); pstmt.setString(2, u.getPassword()); pstmt.setString(3, u.getEmail()); pstmt.addBatch(); // 加入批处理 if (i % 500 == 0) { // 每500条执行一次批处理,防止PreparedStatement积累过多数据 pstmt.executeBatch(); } } pstmt.executeBatch(); // 执行剩余批次 conn.commit(); // 统一提交 } catch (SQLException e) { // 批处理中如果某一条失败,通常需要在catch里rollback // MySQL默认执行executeBatch时如果有一条出错,并不代表全部失败, // 真正要不要回滚,取决于你的业务规则 e.printStackTrace(); try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } }这里面最核心的是conn.setAutoCommit(false)。一旦关了自动提交,你做的所有SQL都不会真正落库,直到你调用commit()。如果中间抛了异常而你既不commit()也不rollback(),连接关闭时数据会被静默丢弃,这就可能造成“明明没报错但数据没了”的诡异现象。所以catch里务必rollback()。
还有一点,批量插入时,URL里最好也加上rewriteBatchedStatements=true,这个参数会让驱动把多条INSERT重写成一条多值INSERT,性能提升一倍不止。不上这个参数,你说的“批处理”其实还是逐条在服务端执行。
注意:
addBatch()的批处理与conn.setAutoCommit(false)是两件独立的事。前者是驱动层面的批量提交API,后者是JDBC事务层面的自动提交开关。只有两者结合,批量插入的性能优化才完整。
4. ResultSet结果集处理:查出来只是开始,带走才是目的
查询比增删改麻烦的地方在于,你不仅要把SQL写对,还要把数据库返回的表格数据转成Java里好用的东西。这个转换过程,就是JDBC里最体现基本功的部分。
4.1 next()到底在做什么,getObject和getXxx怎么选
ResultSet内部是一个指向表格行数据的光标。初始化时,它停在第一行之前,rs.next()会把光标向下移动一行并返回true,如果没有下一行了就返回false。所以标准的遍历写法是:
while (rs.next()) { int id = rs.getInt("id"); String userName = rs.getString("username"); LocalDateTime createTime = rs.getTimestamp("create_time").toLocalDateTime(); }这里的getString("username")可以用列名,也可以用下标getString(2)。我强烈建议用列名,因为列名的可读性和容错性都更好,尤其你的SQL里如果用了别名,比如SELECT COUNT(*) AS cnt ...,直接用rs.getInt("cnt"),别人一看就懂。用下标的话,一旦SQL里字段顺序变了,你这里全得跟着改,而且改漏了也不会报错,只会读到错位的数据。
关于getObject():有的教程推崇写一个通用的结果集转实体工具类,里面全靠getObject()然后手动强转。我觉得对这种学习阶段的项目来说,getObject可以作为兜底,但你依然要知道每个字段的实际类型。特别是DATE、TIME、DATETIME这些,很多新手用rs.getObject("create_time")得到的是一个java.sql.Timestamp,转成java.util.Date没问题,但要转成LocalDateTime就不是一句instanceof能搞定的。建议起始阶段就用getTimestamp()或者getDate(),不给自己埋雷。
4.2 手动封装User对象:离DTO和ORM还有一步之遥
任何时候,都不要在业务代码里直接拿着ResultSet到处传。至少做一个Dao层,把ResultSet到对象的转换放在一个不需要让业务代码看到的地方:
public User findUserByUsername(String username) { String sql = "SELECT id, username, password, email, create_time FROM t_user WHERE username = ?"; try (Connection conn = DBUtils.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); try (ResultSet rs = pstmt.executeQuery()) { if (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setEmail(rs.getString("email")); u.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime()); return u; } return null; } } catch (SQLException e) { e.printStackTrace(); return null; } }我在这里专门把ResultSet也放进了内部try-with-resources里,因为如果这个查询在连接池里被复用,上一次操作遗留的ResultSet没有被关闭,会对连接池造成隐患。虽然在这个代码里Connection关闭后一切都被释放了,但当你以后使用连接池,连接并不会真正关闭,而是回到池里继续给下一个线程用——如果ResultSet这种衍生资源没关干净,池里的连接状态就会混乱。这个习惯早养成早受益。
从手写这种rs.getXxx开始,你就会慢慢理解为什么Hibernate、MyBatis这些框架会出现,也能理解为什么很多人说“MyBatis不过是把ResultSet映射这个活自动化了”。
5. 连接管理、异常排查与资源释放:不崩就是赢
DAY02这个阶段,你在IDEA控制台看到的异常,十个里面有八个都可以归结到三个大方向:驱动没加载、连接没成功、SQL写错了。我把这两天最常遇到的坑整理成一张速查表,排障的时候对着看就行。
| 异常信息片段 | 大概率原因 | 排查动作 |
|---|---|---|
| ClassNotFoundException: com.mysql.cj.jdbc.Driver | 驱动JAR没放进去 | 检查Maven依赖或lib目录,确认JAR存在 |
| Communications link failure | 端口不通、MySQL没启动、防火墙拦截 | telnet localhost 3306,看服务是否监听 |
| Access denied for user 'root'@'localhost' | 用户名密码错误或host限制 | 确认密码,或单独建一个给应用用的低权限账号 |
| Unknown database 'xxx' | 数据库不存在,或者URL里库名拼错 | SQL客户端里连一下,看库名是否一致 |
| Public Key Retrieval is not allowed | 8.0驱动 + caching_sha2_password | 连接串加allowPublicKeyRetrieval=true |
| Server returns invalid timezone. Go to 'Advanced' tab | 时区没指定 | 连接串加serverTimezone=Asia/Shanghai |
| Duplicate entry 'xxx' for key 'username' | 插入的数据撞了唯一索引 | 错误码1062,按业务提示用户或跳更新 |
| Parameter index out of range (x > number of parameters...) | 占位符下标从1开始,数错了 ? 的数量 | 把SQL语句拿出来数一遍?个数,再核对代码里setXxx的顺序 |
| Cannot load driver class: com.mysql.jdbc.Driver | Connector/J 8.x之后老驱动类名被删了 | 类名改成com.mysql.cj.jdbc.Driver,或者升级驱动 |
| Unknown column 'create_time' in 'field list' | 表结构里字段和SQL对不上 | 用DESC t_user看表结构,核对字段名 |
5.1 为什么你看到的是“download from maven failed”而不是编译错误
很多人把IDEA里Maven报的“下载失败”和代码本身的编译错误混在一起。其实Maven下载失败,表现为依赖包名的旁边有红色波浪线,代码里一堆Cannot resolve symbol。这种情况大多数是因为Maven仓库源在国外,或者本地仓库里缓存了坏的JAR。解决办法是去settings.xml里加阿里云或者腾讯云的镜像,然后在IDEA里File -> Invalidate Caches and Restart。等你解决了这个基础环境问题,今天的代码才能顺利编译运行。
5.2 一张表记住资源释放的三个原则
资源释放这件事,我说再多不如给你三个直接的原则:
- 创建了谁,谁就要负责关闭。你在
main方法里开的Connection,就要在main方法里关,不要把关闭责任丢给框架。 - 关闭顺序和创建顺序相反。先关ResultSet,再关Statement,最后关Connection。
- 能交回尽量交回,不能交回就释放。使用连接池时,
conn.close()并不关闭物理连接,而是把连接交回池里复用,所以close的调用地点和方法一定要统一。
有的同学图省事,只在finally里写了一个conn.close()。如果此时Statement或者ResultSet没有被关闭,在某些极端情况下会导致数据库连接占满,程序越来越卡。JDBC的API设计是父资源关闭会级联关闭子资源,但那是JDBC规范给出的默认行为,很多驱动并不是按这个规范来保证的,所以还是靠我们显式关闭最稳妥。
5.3 顺带提一句同步远程库表结构的问题
如果你遇到的是“把远程库的这张表同步到本地”这种需求,我的建议是不要手工去建表,风险太大。MySQL的mysqldump可以按结构导出:
mysqldump -h 远程IP -u 用户名 -p --no-data 库名 表名 > table_schema.sql然后在本地执行:
mysql -u root -p 目标库名 < table_schema.sql--no-data这个参数是关键,它只导出建表语句,不会带数据。如果你的目的是数据也要同步,去掉--no-data即可,但要注意导出的SQL文件里可能包含LOCK TABLES、DROP TABLE这些操作,在本地执行时要看清内容再跑。如果是团队协作,更推荐用Flyway或Liquibase这类迁移工具,它们能把库表结构的变化纳入版本管理,比从命令行手工倒腾要靠谱得多。
6. 结束前最好再看一眼:DAY02之后你该练什么
按照我的经验,出师的标准不是把今天这段代码跑通,而是你闭上眼睛能画出这样一张图:外层业务代码需要数据,于是通过Dao层的方法,把参数传给PreparedStatement,驱动把SQL发到MySQL,MySQL经过优化器、执行计划、存储引擎,把结果倒腾出来,再通过协议传回驱动,驱动填进ResultSet,DAO把这些行映射成Java对象,返回到业务层。这张链路图里的每一个环节,都会在以后排查问题、做性能优化时用到。
DAY02真正留给你的家庭作业应该是:把今天实现的增删改查封装成一个完整的UserDao,尝试引入一个简单的Druid或HikariCP连接池,再给自己出一道“判断用户名是否存在,存在则更新,不存在则插入”的业务题。这道题练完了,你对PreparedStatement、事务边界、重复键处理的感受会完全不一样。我在实操中最深的体会是:JDBC这关看起来枯燥,但它是唯一能让你在出问题时不慌的底气。后面用框架再顺滑,底层逻辑不清,遇到诡异Bug还是只能干瞪眼。所以今天这份东西,值得你反复多跑几遍。