
只要你的 Java 项目叫“业务系统”就逃不开 MySQL。这句话我说了快十年带过的新人没有一个能反驳。很多人第一次连数据库都是在搜索引擎里复制一段三行代码跑通了就丢到生产环境然后被时区报错、中文乱码、连接超时、数据库连接数被打满这些事按在地上反复摩擦。这篇我把“Java 连接 MySQL 并实现数据交互”从头到尾拆开讲不只有能跑的 demo还包括驱动选型、连接 URL 参数、CRUD 实操、事务与批处理、连接池配置、高频报错排查、性能优化。新手看完能独立接库里最简单的读写需求写过一阵子但一直靠复制粘贴的人也能把那些“为啥别人不出错就我出错”的疑惑一次性理清。1. JDBC 核心原理与驱动选型1.1 JDBC 到底解决了一个什么问题Java 要操作 MySQL绕不开一个标准JDBCJava Database Connectivity。它本质上是一套接口规范定义了一套统一的操作数据库的方式。你写代码的时候面向这套接口而真正跟 MySQL 方言打交道的是 MySQL 官方提供的驱动实现。你可以把 JDBC 类比成 USB 接口。电脑不关心你插的是 U 盘还是手机只要对方遵守 USB 协议就能通信。JDBC 就是这个协议MySQL 驱动就是那个把 MySQL 特有的网络协议翻译成 JDBC 接口语言的“转接头”。这套设计带来的实际好处是你学会用 JDBC 连 MySQL将来换 PostgreSQL、Oracle代码结构基本不用改只换驱动包和连接 URL 就行。可不要小看这个抽象实际项目里很多团队做多数据库支持靠的就是这一层。从执行流程看一次 JDBC 交互无非五步加载并注册驱动8.0 之后其实可以省略显式加载这一步后面讲。通过 DriverManager 或 DataSource 获取连接 Connection。创建 Statement 或 PreparedStatement携带 SQL。执行 SQL拿到 ResultSet 结果集或影响行数。依次关闭 ResultSet、Statement、Connection按创建顺序的反向关闭。这五步贯穿整篇后文所有实操代码都是这条主线上长出来的。1.2 驱动选择版本和坐标一步都不能错选驱动是第一道坎也是第一个很容易踩坑的地方。MySQL 驱动有两个大版本线5.1.x 系列和 8.x 系列。如果你的 MySQL 是 5.7用 5.1.49 没问题如果是 8.0最好用 8.0 系列驱动因为 8.0 驱动完整支持 MySQL 8 的默认认证插件 caching_sha2_password还会自动协商更优的字符集和功能。这里有个细节值得强调MySQL 5.x 驱动和 8.x 驱动的包路径前缀不一样。老驱动是com.mysql.jdbc.Driver8.0 之后统一改成com.mysql.cj.jdbc.Driver。网上大量老文章还在用老路径直接复制到新工程就会报ClassNotFoundException。所以看到任何教程先确认它用的驱动版本和包路径是否匹配你项目里的 jar。另外Maven 坐标也有变化。较新的驱动坐标是这样dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.4.0/version /dependency注意 artifactId 是mysql-connector-j不是早年常见的mysql-connector-java。后者虽然还在仓库里但新版本已经改名为前者。至于要不要用最新版我的建议是不要盲目追新选一个已经在大规模生产环境验证过的稳定版本比如 8.0.33。数据库驱动这东西稳定压倒一切。提示驱动版本与 MySQL 服务端版本不必完全一致但差距不要跨大版本。用 8.4 驱动连 5.7 库通常没问题反过来用 5.1 驱动连 MySQL 8.0 就可能遇到认证插件不兼容。2. 环境准备与基础工程搭建2.1 数据库侧的准备库、账号、权限很多初学者直接在 root 用户下写代码这个习惯非常不好。生产环境最好不要用 root 跑业务应用权限过大误操作或注入漏洞时影响面不可控。我会单独建一个业务账号只给它需要的最小权限。登录 MySQL 后执行CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER demo_user% IDENTIFIED BY DemoPass123; GRANT SELECT, INSERT, UPDATE, DELETE ON demo_db.* TO demo_user%; FLUSH PRIVILEGES;这里有两个细节很多人踩过第一字符集一定要用utf8mb4不是utf8。MySQL 的utf8最多只能存 3 个字节像 emoji 表情和部分生僻字存不进去utf8mb4是真正的 4 字节 UTF-8兼容性完整。第二如果你用 MySQL 8.0 默认创建用户默认认证插件是caching_sha2_passwordJava 驱动连接时如果没在 URL 里配置allowPublicKeyRetrievaltrue可能会在握手阶段报错这个我放在后面的问题排查部分详细讲。数据库里顺手建一张测试表我们后面所有演示都基于它USE demo_db; CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(128) DEFAULT NULL, age INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB;选择InnoDB引擎不用犹豫它是目前 MySQL 默认且最成熟的存储引擎支持事务、行级锁和崩溃恢复。除非你做全文检索或临时缓存否则别动引擎。2.2 Maven 工程与依赖配置项目骨架我会用一个最精简的 Maven 工程不用 Spring Boot先把 JDBC 底层的原理摸透。这很重要——一旦你理解了原生 JDBC再去看 Spring 的 JdbcTemplate、MyBatis会发现它们不过是对这套流程的封装。pom.xml里除了驱动依赖我还会加一个日志依赖用来演示排查思路dependencies dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency dependency groupIdorg.slf4j/groupId artifactIdslf4j-simple/artifactId version1.7.36/version /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency /dependencies为什么不直接用 Spring Boot因为 Spring Boot 内置的数据源自动配置会帮你把连接池、驱动加载、事务管理都处理好好用的同时也会掩盖很多底层细节。一旦出问题排查成本会更高。先用原生 JDBC 把每一步跑通再上框架是最稳妥的学习路径也是遇到问题能快速定位的基础。工程结构建议按职责分包config放连接配置dao放数据访问代码entity放与表结构对应的实体类util放工具方法。别看业务小从第一天开始就养成分层习惯后面维护会省很多事。3. 核心代码实录连接、CRUD 与事务3.1 连接 URL 的五段式拆解拿到连接的第一步是写对 URL。JDBC URL 的格式是jdbc:mysql://主机地址:端口/数据库名?参数1值1参数2值2逐项拆开看jdbc:mysql://协议头驱动靠它识别这是 MySQL。主机地址可以是localhost、IP也可以是云数据库的内网地址。端口默认3306如果你改过这里必须对应。数据库名就是你要连的那个库比如demo_db。注意 URL 里的库和连接账号的权限是两码事账号必须有该库的权限才能连上。?后的参数核心配置都在这里。一段生产环境可用的 URL 长这样jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruerewriteBatchedStatementstrue逐个解读参数参数作用说明useUnicodetruecharacterEncodingutf8保证客户端与服务端之间的字符集一致缺失会导致中文乱码useSSLfalse关闭 SSL 连接本地开发与内网环境建议关闭省去证书配置成本公网传输再按需开启serverTimezoneAsia/Shanghai指定服务器时区高版本驱动必须显式设置否则可能报时区错误或时间差 8 小时allowPublicKeyRetrievaltrue允许客户端自动获取服务端公钥MySQL 8 默认认证插件下常见需要此参数rewriteBatchedStatementstrue开启批处理语句重写批量插入性能提升的关键后面会讲第一次连库我建议先不加任何参数看看驱动默认行为再加上这些参数逐个体会差异。很多时候你只知道“加参数解决报错”但不知道为什么换台机器换个环境照样抓瞎。获取连接的代码写出来非常简单public class DBConfig { private static final String URL jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; private static final String USER demo_user; private static final String PASSWORD DemoPass123; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }注意 8.0 驱动里Class.forName(com.mysql.cj.jdbc.Driver)可以省略因为 JDBC 4.0 之后驱动通过META-INF/services自动注册。但很多老项目还保留这行代码保留也不会错。你只需要确保引入的 jar 包路径正确即可。3.2 Statement 与 PreparedStatement老生常谈但值得再聊一次获取连接之后实战里第一个分歧点来了用Statement还是PreparedStatement很多初学者因为Statement写起来短就顺手用了。但Statement有两个致命问题SQL 注入风险和 SQL 编译开销。SQL 注入的原理是拼接字符串。假设登录 SQL 是这样的String sql SELECT * FROM t_user WHERE username name AND password pwd ;如果name传入admin --拼出来的 SQL 就变成了SELECT * FROM t_user WHERE username admin -- AND password ...--是 MySQL 的注释符它后面的校验条件全部失效攻击者直接就拿到了 admin 账号。这不是危言耸听这是真实世界里反复出现的攻击手法。PreparedStatement用占位符预编译 SQL参数作为单独数据传递数据库层会做元数据解析和类型校验从机制上杜绝了拼接注入。同时它还有缓存执行计划的好处同一句 SQL 反复执行时性能更好。标准的查询写法public User findByUsername(String username) { String sql SELECT id, username, email, age, created_at FROM t_user WHERE username ?; try (Connection conn DBConfig.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User u new User(); u.setId(rs.getLong(id)); u.setUsername(rs.getString(username)); u.setEmail(rs.getString(email)); u.setAge(rs.getInt(age)); u.setCreatedAt(rs.getTimestamp(created_at)); return u; } } } catch (SQLException e) { log.error(query user failed, username{}, username, e); } return null; }这段代码里有两个细节要说第一try-with-resources。这是 Java 7 引入的语法糖能自动关闭实现了AutoCloseable的资源。比起在 finally 里手动关闭漏写一行这个方式又安全又干净。注意内部打开的 ResultSet 也会在 try 结束时被关闭。第二setXxx 方法必须与列类型对应。setString对应字符串列setInt对应整数列如果用错数据库可能隐式转换性能下降不说还可能出现不可预期的结果。ResultSet读取时也一样getTimestamp拿时间列getLong拿 BIGINT 列。插入数据的写法public int insertUser(User u) { String sql INSERT INTO t_user(username, email, age) VALUES (?, ?, ?); try (Connection conn DBConfig.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, u.getUsername()); ps.setString(2, u.getEmail()); ps.setInt(3, u.getAge()); int rows ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { u.setId(keys.getLong(1)); } } return rows; } catch (SQLException e) { log.error(insert user failed, e); } return 0; }这里第二个参数Statement.RETURN_GENERATED_KEYS是关键。你在 MySQL 里自增的 id执行完 insert 后数据库才生成通过getGeneratedKeys()才能拿到。不想要这个值可以不传但实际业务里拿回主键的场景非常多。3.3 事务提交与批处理写入默认情况下连接处于自动提交模式每执行一条 SQL 就立即提交。这在单条 SQL 写入时没问题但遇到“多个步骤必须一起成功一起失败”的场景就不行了。最常见的例子是转账扣减 A 账户余额 增加 B 账户余额两步必须绑定成一个原子操作。中间任何一步失败整个操作都要回滚否则账就平不了。手动事务的标准写法public boolean transfer(Long fromId, Long toId, BigDecimal amount) { String updateSql UPDATE t_account SET balance balance - ? WHERE id ?; String addSql UPDATE t_account SET balance balance ? WHERE id ?; Connection conn null; try { conn DBConfig.getConnection(); conn.setAutoCommit(false); try (PreparedStatement psFrom conn.prepareStatement(updateSql); PreparedStatement psTo conn.prepareStatement(addSql)) { psFrom.setBigDecimal(1, amount); psFrom.setLong(2, fromId); psFrom.executeUpdate(); // 模拟中间异常 // if (true) throw new RuntimeException(mock error); psTo.setBigDecimal(1, amount); psTo.setLong(2, toId); psTo.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); log.error(transfer failed, rollback, e); return false; } } catch (SQLException e) { log.error(get connection failed, e); return false; } finally { if (conn ! null) { conn.setAutoCommit(true); // 恢复自动提交避免连接归还连接池时状态异常 conn.close(); } } }事务的几个要点setAutoCommit(false)必须在拿到连接后、执行业务 SQL 前调用。只要出现SQLException或业务异常就调用rollback()把已执行但未提交的操作全部回滚。finally 里必须把连接状态恢复为自动提交再归还连接池。这是很多踩坑点连接池里的连接对象是复用的如果上一次事务没恢复自动提交下一个人拿到同一个连接就全部失效了。事务尽量短别在事务里执行网络请求、文件读写等耗时的外部操作。锁持有得越久并发冲突概率越高。批处理是另一个能明显提升性能的关键。它解决的问题是一次循环插入一万条数据如果每条都走一次网络往返慢到你怀疑人生。批处理把这些 SQL 攒到一定量再发到数据库网络开销直接从一万次变成几十次。public void batchInsert(ListUser userList) { String sql INSERT INTO t_user(username, email, age) VALUES (?, ?, ?); try (Connection conn DBConfig.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); int count 0; for (User u : userList) { ps.setString(1, u.getUsername()); ps.setString(2, u.getEmail()); ps.setInt(3, u.getAge()); ps.addBatch(); count; if (count % 500 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); } catch (SQLException e) { log.error(batch insert failed, e); } }这里有三点经验送给你第一批次大小建议控制在 500 到 1000 条左右。太小的话批处理优势不明显太大会导致单次提交的 SQL 包过大数据库解析压力陡增甚至内存溢出。第二URL 要加rewriteBatchedStatementstrue。不加这个参数MySQL 驱动只是简单地把多条 SQL 依次发给服务端并没有真正合并。加了之后驱动会把同一条 SQL 的多组参数重写为 MySQL 原生的VALUES (...),(...)多值语法实测下来批量插入性能提升 5 到 10 倍是常事。第三批量操作也建议走手动事务一批一提交。既可以避免中途失败导致全部回滚又可以减少提交次数。4. 连接池从 demo 走向生产的必修课4.1 DriverManager 直连为什么撑不住真实场景前文所有示例都在用DriverManager.getConnection()。这种方式在学习的 demo 里没问题但它有个致命问题每次调用都会创建一个全新的物理连接。而 MySQL 建立连接的过程涉及 TCP 握手、认证、权限校验耗时往往在几十毫秒到几百毫秒之间。高并发场景下大量请求同时创建连接数据库服务器很快就会被拖垮。打个比方直连模式就像每次吃饭都从零开始搭灶台、生火、做饭。连接池就好比一个常年开火的餐厅后厨灶台提前备好客人点菜直接炒吃完把锅放回炉边下个客人继续用。生产环境必须使用连接池。连接池的核心思路是初始化时创建一批连接放在池子里复用应用需要时从池里借用完归还池子空了就排队等待而不是无限新建。连接池选型上我会优先推荐 HikariCP。知名连接池 DBCP、C3P0 各有拥趸但 HikariCP 性能指标几乎全面领先字节码优化做到极致Spring Boot 2.x 以后也把它作为默认连接池。另一个选择是阿里巴巴的 Druid它的监控面板更丰富适合需要可视化运维和数据源统计的业务场景。如果对监控没有特别需求就用 HikariCP轻量而且够快。4.2 HikariCP 参数配置实录在 Maven 里加入依赖后用HikariDataSource替换掉原来的DriverManagerdependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.1.0/version /dependency配置代码public class DataSourceConfig { public static HikariDataSource createDataSource() { HikariConfig config new HikariConfig(); config.setJdbcUrl(URL); config.setUsername(demo_user); config.setPassword(DemoPass123); config.setDriverClassName(com.mysql.cj.jdbc.Driver); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setIdleTimeout(600000); config.setConnectionTimeout(30000); config.setMaxLifetime(1800000); config.setPoolName(DemoHikariPool); return new HikariDataSource(config); } }这些参数有一个基本逻辑maximumPoolSize池子能容纳的最大连接数。不是越大越好。每个连接背后都是一个 TCP 连接和一个 MySQL 服务端线程连接数超过数据库极限反而会引发宕机。一个常用的估算口径是并发峰值 × (单请求平均耗时 / 1000)再乘以一个余量系数。假设峰值并发 100平均请求耗时 200ms那么池子大小约为100 × 0.2 20。minimumIdle池子至少保留的空闲连接数。设置 0 也可以但通常保留几个能降低冷启动延迟。connectionTimeout应用从池子拿连接的等待超时。30 秒是相对保守的数值具体要看你的最大请求耗时。如果业务 SQL 平均只需几十毫秒却要等 30 秒才报错那对用户体验来说太久了。maxLifetime连接的最大存活时间。MySQL 服务端默认的wait_timeout是 8 小时池子里长期不用的连接可能被服务端断开客户端却不知道下次调用时就会报“连接失效”。把 maxLifetime 设得小于服务端超时时间就能规避。配置好之后业务代码里获取连接只需要dataSource.getConnection()用完close()归还连接池而不是真正关闭。注意前面说的事务恢复问题在这里同样适用连接归还前必须把autoCommit恢复成 true否则污染连接状态。5. 高频问题与排查思路实录5.1 六个现场报错速查表下面这六个错误是我在不同项目里见过最频繁的每一个都能单独写一篇事故复盘。报错关键信息根本原因解决方案ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动 jar 未引入或包路径不对检查 pom.xml 依赖确认驱动版本对应的路径Access denied for user xxxlocalhost用户名、密码错误或账号没有对目标库的访问权限用客户端工具登录验证检查 GRANT 语句Communications link failure网络不通、端口未开放、MySQL 服务未启动ping主机telnet 主机 3306测试端口查看 MySQL 进程The server time zone value ... is unrecognized驱动版本与服务器时区不匹配URL 加serverTimezoneAsia/ShanghaiPublic Key Retrieval is not allowedMySQL 8 的 caching_sha2_password 认证机制URL 加allowPublicKeyRetrievaltrueUnknown character set: utf8mb4或中文乱码数据库、表、连接三侧字符集不一致统一为 utf8mb4URL 加characterEncodingutf8这里要单独展开讲一下时区问题和Public Key Retrieval。时区报错的完整信息往往带着一堆时区候选值新手看了就懵。本质是MySQL 8.0 驱动默认要求客户端提供准确的时区用来把数据库的TIMESTAMP类型换算成本地时间。如果你不指定服务端时区信息又不明确驱动就直接抛异常。就算你运气好没报错连接参数不规范也容易导致时间字段差 8 小时——那是另一个隐蔽坑调了它连接让你怀疑人生。Public Key Retrieval is not allowed只要出现在 MySQL 8.0 以上版本。原因是 MySQL 8 默认认证插件caching_sha2_password在首次认证时要求交换 RSA 公钥客户端默认出于安全考虑不允许自动获取于是握手失败。内网开发环境加allowPublicKeyRetrievaltrue是最省事的解法但生产环境如果对安全要求高可以考虑先通过 SSL 连接或者预置公钥。5.2 排查工具与方法遇到连接问题我的排查序列是固定的不会盲目改代码第一步用数据库客户端验证账号和密码本身是否可用。很多所谓“代码连接失败”其实是密码里带了特殊字符被 URL 解析搞坏或者账号只在某个 host 下授权了。用客户端能连上再看代码。第二步检查网络层。ping一下主机看通不通telnet 主机 3306看端口是否可达。云数据库尤其容易在安全组层面挡掉端口这是本地环境根本复现不出来的问题。第三步打开驱动日志。JDBC 驱动本身支持调试日志Java 端可以通过-Djava.util.logging.config.file或接入 SLF4J 查看驱动输出的连接过程能看到它在哪个阶段出错。比如 SSL 握手失败、认证类型不支持这些日志里都会留下明确线索。第四步测试最小可复现用例。排除框架干扰写一个只有 JDBC 连接的 Java 类直接跑。很多时候问题出在 Spring 配置叠加了一层或者 MyBatis 的拦截器搞了鬼。最小用例能让问题迅速归因。6. 数据交互层面的性能优化6.1 永远先检查 SQL 与索引连接池优化解决的是“连接”层面的瓶颈。但真实系统里大多数慢查询的根因其实在 SQL 本身。一次全表扫描和一次索引命中的差距在百万行数据上可能是几十毫秒和几秒的差别。排查慢 SQL 最直接的命令EXPLAIN SELECT * FROM t_user WHERE username demo;看type那一列如果出现ALL就是全表扫描数据量大时会铺开读磁盘如果是ref、const这类索引命中的类型说明查询用上了索引。username如果不在任何索引上就要考虑加索引ALTER TABLE t_user ADD INDEX idx_username (username);注意加索引不是无脑加。索引会占用额外空间每次插入、更新都要维护索引结构写多读少的表加太多索引反而拖慢写入。一个经验法则是索引优先加在 WHERE 条件频繁出现的列、JOIN 关联列和 ORDER BY 排序列上。另一个常见问题是SELECT *。在真实业务里一个表几十个字段你只需要其中三个结果集全拉回来传输和内存开销全部浪费。写查询时只选择需要的列这个习惯带来的收益常被低估。在数据量大的场景下从SELECT *改成指定列响应时间往往肉眼可见地下降。6.2 写入侧优化批处理与事务粒度写入方向上最大的优化空间在批处理和事务的合理组合。前面演示过addBatch这里再补充一个更常见的现实问题一条一条地 insert 一万行数据。不优化版一万次网络往返 一万次事务提交耗时可能几十秒甚至几分钟。优化版分批 500 条一组每组一个事务配合rewriteBatchedStatementstrue同样一万条数据实测可以压到 1 秒内到 2 秒左右。批处理还有一个隐藏价值它天然限制了事务粒度。每批 500 条单独成一事务中途失败了最多回滚这 500 条不会影响前面已经成功提交的数据。这在数据导入场景非常实用你不需要手动写断点续传只要每批独立提交失败后从失败批次重跑即可。事务粒度上还有一个反向陷阱有人在批处理时把所有数据放在一个事务里想着“反正分批插入失败就全部回滚”。结果到了千万级数据导入单事务持锁时间过长锁等待和回滚日志膨胀会带来更大问题。事务做小一点风险边界也就小一点。最后忠告连接池参数、批次大小这些数值不要在网上抄完就完事。参数是跟业务特征绑定的读多写少的系统、批量导入的系统、实时交易系统最优配置完全不同。上线前做一轮简单的压测把连接池最大值、最小空闲从当前值上下浮动几个档位观察响应时间和服务端连接数再定最终值。数据交互这块没有银弹只有不停地测量和调整。我个人这几年带项目的体会是JDBC 这块内容虽然“老”但它是一切 ORM 框架的地基。你懂了连接 URL 每个参数的意义再看 MyBatis 的dataSource配置就是透明的你懂了事务提交和连接归还的生命周期再复杂的分布式事务方案也能分辨它到底在哪个环节做补偿。地基打不牢上面盖什么都慌。