资讯动态

JDBC实战:从连接参数到增删改查,彻底搞定MySQL数据库操作

发布时间:2026/9/28 13:18:07 来源:尧图企业网站定制
JDBCMySQL这条线第一天大家多半已经跑通了“加载驱动、拿到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配置文件。更麻烦的是如果你在项目里同时引入了多个版本的驱动JARDriverManager可能加载到旧版然后给你报一个“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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue逐个解释一下为什么要有这些玩意儿serverTimezoneAsia/ShanghaiMySQL 8.0之后服务端默认时区是UTC而JDBC驱动在解析DATETIME类型时会拿本地时区和服务器时区做转换。不指定的话你存的2025-06-01 10:00:00读出来可能变成2025-06-01 18:00:00。这种错位非常隐蔽因为所有代码看起来都没问题。useSSLfalseMySQL 8.0默认开启了SSL连接但多数本地开发环境并没有配证书。不关掉某些驱动版本会直接抛“SSL connection error”或者“Communications link failure”。开发环境直接关掉生产环境再按运维给的链接串配置。characterEncodingutf8确保写入的是UTF-8编码防止中文乱码。这里要注意MySQL的utf8其实只是utf8mb3存不了Emoji和生僻字。如果你表结构用的是utf8mb4连接串最好也写characterEncodingutf8mb4同时不要在上游JVM里做重复的转码。allowPublicKeyRetrievaltrueMySQL 8.0的默认认证插件是caching_sha2_password用这种插件连接时如果服务器没有提前缓存你的公钥驱动会要求你允许它直接检索公钥。不设这个参数你会看到“Public Key Retrieval is not allowed”的异常。本地开发直接放行生产环境建议走SSL或让DBA统一处理。还有一个参数是useUnicodetrue这个其实被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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue; 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依赖于StatementStatement依赖于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 11; 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 11OR 11永远为真这条语句会把整个表查出来。如果是DELETE或UPDATE语句被注入的后果就更酸爽了。PreparedStatement解决这个问题的思路不是“过滤特殊字符”而是把SQL模板和数据分离把用户输入永远当作“数据值”而不是“SQL片段”。驱动层会用参数化的方式把?替换成值的“传输格式”服务端会严格区分SQL语法和参数值所以注入没有立足之地。还有一种面试常问的为什么PreparedStatement在MySQL里“预编译”有时候不生效因为MySQL Connector/J默认可能走的是客户端模拟参数真正的服务端预编译需要你在URL里加useServerPrepStmtstrue而且还要结合连接池的配置。不过对于业务层来说我们需要的安全性和可读性都拿到了性能差异在大多数场景下可以忽略——真正的性能瓶颈通常不在这一层。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(ListUser 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里最好也加上rewriteBatchedStatementstrue这个参数会让驱动把多条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 rootlocalhost用户名密码错误或host限制确认密码或单独建一个给应用用的低权限账号Unknown database xxx数据库不存在或者URL里库名拼错SQL客户端里连一下看库名是否一致Public Key Retrieval is not allowed8.0驱动 caching_sha2_password连接串加allowPublicKeyRetrievaltrueServer returns invalid timezone. Go to Advanced tab时区没指定连接串加serverTimezoneAsia/ShanghaiDuplicate 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.DriverConnector/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发到MySQLMySQL经过优化器、执行计划、存储引擎把结果倒腾出来再通过协议传回驱动驱动填进ResultSetDAO把这些行映射成Java对象返回到业务层。这张链路图里的每一个环节都会在以后排查问题、做性能优化时用到。DAY02真正留给你的家庭作业应该是把今天实现的增删改查封装成一个完整的UserDao尝试引入一个简单的Druid或HikariCP连接池再给自己出一道“判断用户名是否存在存在则更新不存在则插入”的业务题。这道题练完了你对PreparedStatement、事务边界、重复键处理的感受会完全不一样。我在实操中最深的体会是JDBC这关看起来枯燥但它是唯一能让你在出问题时不慌的底气。后面用框架再顺滑底层逻辑不清遇到诡异Bug还是只能干瞪眼。所以今天这份东西值得你反复多跑几遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑