只要你的 Java 项目叫“业务系统”,就逃不开 MySQL。这句话我说了快十年,带过的新人没有一个能反驳。很多人第一次连数据库,都是在搜索引擎里复制一段三行代码,跑通了就丢到生产环境,然后被时区报错、中文乱码、连接超时、数据库连接数被打满这些事按在地上反复摩擦。这篇我把“Java 连接 MySQL 并实现数据交互”从头到尾拆开讲,不只有能跑的 demo,还包括驱动选型、连接 URL 参数、CRUD 实操、事务与批处理、连接池配置、高频报错排查、性能优化。新手看完能独立接库里最简单的读写需求,写过一阵子但一直靠复制粘贴的人,也能把那些“为啥别人不出错就我出错”的疑惑一次性理清。1. JDBC 核心原理与驱动选型1.1 JDBC 到底解决了一个什么问题Java 要操作 MySQL,绕不开一个标准:JDBC(Java 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.Driver,8.0 之后统一改成com.mysql.cj.jdbc.Driver。网上大量老文章还在用老路径,直接复制到新工程就会报ClassNotFoundException。所以看到任何教程,先确认它用的驱动版本和包路径是否匹配你项目里的 jar。另外,Maven 坐标也有变化。较新的驱动坐标是这样:<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.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_password,Java 驱动连接时如果没在 URL 里配置allowPublicKeyRetrieval=true,可能会在握手阶段报错,这个我放在后面的问题排查部分详细讲。数据库里顺手建一张测试表,我们后面所有演示都基于它: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 ) ENGINE=InnoDB;选择InnoDB引擎不用犹豫,它是目前 MySQL 默认且最成熟的存储引擎,支持事务、行级锁和崩溃恢复。除非你做全文检索或临时缓存,否则别动引擎。2.2 Maven 工程与依赖配置项目骨架我会用一个最精简的 Maven 工程,不用 Spring Boot,先把 JDBC 底层的原理摸透。这很重要——一旦你理解了原生 JDBC,再去看 Spring 的 JdbcTemplate、MyBatis,会发现它们不过是对这套流程的封装。pom.xml里除了驱动依赖,我还会加一个日志依赖用来演示排查思路:<dependencies> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.36</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true逐个解读参数:参数作用说明useUnicode=true&characterEncoding=utf8保证客户端与服务端之间的字符集一致缺失会导致中文乱码useSSL=false关闭 SSL 连接本地开发与内网环境建议关闭,省去证书配置成本;公网传输再按需开启serverTimezone=Asia/Shanghai指定服务器时区高版本驱动必须显式设置,否则可能报时区错误或时间差 8 小时allowPublicKeyRetrieval=true允许客户端自动获取服务端公钥MySQL 8 默认认证插件下常见需要此参数rewriteBatchedStatements=true开启批处理语句重写批量插入性能提升的关键,后面会讲第一次连库我建议先不加任何参数,看看驱动默认行为,再加上这些参数逐个体会差异。很多时候你只知道“加参数解决报错”,但不知道为什么,换台机器换个环境照样抓瞎。获取连接的代码写出来非常简单:public class DBConfig { private static final String URL = "jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; 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(List<User> 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 要加rewriteBatchedStatements=true。不加这个参数,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替换掉原来的DriverManager:<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.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 'xxx'@'localhost'用户名、密码错误,或账号没有对目标库的访问权限用客户端工具登录验证;检查 GRANT 语句Communications link failure网络不通、端口未开放、MySQL 服务未启动ping主机,telnet 主机 3306测试端口,查看 MySQL 进程The server time zone value '...' is unrecognized驱动版本与服务器时区不匹配URL 加serverTimezone=Asia/ShanghaiPublic Key Retrieval is not allowedMySQL 8 的 caching_sha2_password 认证机制URL 加allowPublicKeyRetrieval=trueUnknown character set: 'utf8mb4'或中文乱码数据库、表、连接三侧字符集不一致统一为 utf8mb4,URL 加characterEncoding=utf8这里要单独展开讲一下时区问题和Public Key Retrieval。时区报错的完整信息往往带着一堆时区候选值,新手看了就懵。本质是:MySQL 8.0 驱动默认要求客户端提供准确的时区,用来把数据库的TIMESTAMP类型换算成本地时间。如果你不指定,服务端时区信息又不明确,驱动就直接抛异常。就算你运气好没报错,连接参数不规范也容易导致时间字段差 8 小时——那是另一个隐蔽坑,调了它连接让你怀疑人生。Public Key Retrieval is not allowed只要出现在 MySQL 8.0 以上版本。原因是 MySQL 8 默认认证插件caching_sha2_password在首次认证时要求交换 RSA 公钥,客户端默认出于安全考虑不允许自动获取,于是握手失败。内网开发环境加allowPublicKeyRetrieval=true是最省事的解法,但生产环境如果对安全要求高,可以考虑先通过 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 条一组,每组一个事务,配合rewriteBatchedStatements=true,同样一万条数据,实测可以压到 1 秒内到 2 秒左右。批处理还有一个隐藏价值:它天然限制了事务粒度。每批 500 条单独成一事务,中途失败了,最多回滚这 500 条,不会影响前面已经成功提交的数据。这在数据导入场景非常实用,你不需要手动写断点续传,只要每批独立提交,失败后从失败批次重跑即可。事务粒度上还有一个反向陷阱:有人在批处理时把所有数据放在一个事务里,想着“反正分批插入,失败就全部回滚”。结果到了千万级数据导入,单事务持锁时间过长,锁等待和回滚日志膨胀会带来更大问题。事务做小一点,风险边界也就小一点。最后忠告:连接池参数、批次大小这些数值,不要在网上抄完就完事。参数是跟业务特征绑定的,读多写少的系统、批量导入的系统、实时交易系统,最优配置完全不同。上线前做一轮简单的压测,把连接池最大值、最小空闲从当前值上下浮动几个档位,观察响应时间和服务端连接数,再定最终值。数据交互这块没有银弹,只有不停地测量和调整。我个人这几年带项目的体会是:JDBC 这块内容虽然“老”,但它是一切 ORM 框架的地基。你懂了连接 URL 每个参数的意义,再看 MyBatis 的dataSource配置就是透明的;你懂了事务提交和连接归还的生命周期,再复杂的分布式事务方案也能分辨它到底在哪个环节做补偿。地基打不牢,上面盖什么都慌。
只要你的 Java 项目叫“业务系统”,就逃不开 MySQL。这句话我说了快十年,带过的新人没有一个能反驳。很多人第一次连数据库,都是在搜索引擎里复制一段三行代码,跑通了就丢到生产环境,然后被时区报错、中文乱码、连接超时、数据库连接数被打满这些事按在地上反复摩擦。这篇我把“Java 连接 MySQL 并实现数据交互”从头到尾拆开讲,不只有能跑的 demo,还包括驱动选型、连接 URL 参数、CRUD 实操、事务与批处理、连接池配置、高频报错排查、性能优化。新手看完能独立接库里最简单的读写需求,写过一阵子但一直靠复制粘贴的人,也能把那些“为啥别人不出错就我出错”的疑惑一次性理清。1. JDBC 核心原理与驱动选型1.1 JDBC 到底解决了一个什么问题Java 要操作 MySQL,绕不开一个标准:JDBC(Java 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.Driver,8.0 之后统一改成com.mysql.cj.jdbc.Driver。网上大量老文章还在用老路径,直接复制到新工程就会报ClassNotFoundException。所以看到任何教程,先确认它用的驱动版本和包路径是否匹配你项目里的 jar。另外,Maven 坐标也有变化。较新的驱动坐标是这样:<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.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_password,Java 驱动连接时如果没在 URL 里配置allowPublicKeyRetrieval=true,可能会在握手阶段报错,这个我放在后面的问题排查部分详细讲。数据库里顺手建一张测试表,我们后面所有演示都基于它: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 ) ENGINE=InnoDB;选择InnoDB引擎不用犹豫,它是目前 MySQL 默认且最成熟的存储引擎,支持事务、行级锁和崩溃恢复。除非你做全文检索或临时缓存,否则别动引擎。2.2 Maven 工程与依赖配置项目骨架我会用一个最精简的 Maven 工程,不用 Spring Boot,先把 JDBC 底层的原理摸透。这很重要——一旦你理解了原生 JDBC,再去看 Spring 的 JdbcTemplate、MyBatis,会发现它们不过是对这套流程的封装。pom.xml里除了驱动依赖,我还会加一个日志依赖用来演示排查思路:<dependencies> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.36</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true逐个解读参数:参数作用说明useUnicode=true&characterEncoding=utf8保证客户端与服务端之间的字符集一致缺失会导致中文乱码useSSL=false关闭 SSL 连接本地开发与内网环境建议关闭,省去证书配置成本;公网传输再按需开启serverTimezone=Asia/Shanghai指定服务器时区高版本驱动必须显式设置,否则可能报时区错误或时间差 8 小时allowPublicKeyRetrieval=true允许客户端自动获取服务端公钥MySQL 8 默认认证插件下常见需要此参数rewriteBatchedStatements=true开启批处理语句重写批量插入性能提升的关键,后面会讲第一次连库我建议先不加任何参数,看看驱动默认行为,再加上这些参数逐个体会差异。很多时候你只知道“加参数解决报错”,但不知道为什么,换台机器换个环境照样抓瞎。获取连接的代码写出来非常简单:public class DBConfig { private static final String URL = "jdbc:mysql://localhost:3306/demo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; 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(List<User> 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 要加rewriteBatchedStatements=true。不加这个参数,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替换掉原来的DriverManager:<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.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 'xxx'@'localhost'用户名、密码错误,或账号没有对目标库的访问权限用客户端工具登录验证;检查 GRANT 语句Communications link failure网络不通、端口未开放、MySQL 服务未启动ping主机,telnet 主机 3306测试端口,查看 MySQL 进程The server time zone value '...' is unrecognized驱动版本与服务器时区不匹配URL 加serverTimezone=Asia/ShanghaiPublic Key Retrieval is not allowedMySQL 8 的 caching_sha2_password 认证机制URL 加allowPublicKeyRetrieval=trueUnknown character set: 'utf8mb4'或中文乱码数据库、表、连接三侧字符集不一致统一为 utf8mb4,URL 加characterEncoding=utf8这里要单独展开讲一下时区问题和Public Key Retrieval。时区报错的完整信息往往带着一堆时区候选值,新手看了就懵。本质是:MySQL 8.0 驱动默认要求客户端提供准确的时区,用来把数据库的TIMESTAMP类型换算成本地时间。如果你不指定,服务端时区信息又不明确,驱动就直接抛异常。就算你运气好没报错,连接参数不规范也容易导致时间字段差 8 小时——那是另一个隐蔽坑,调了它连接让你怀疑人生。Public Key Retrieval is not allowed只要出现在 MySQL 8.0 以上版本。原因是 MySQL 8 默认认证插件caching_sha2_password在首次认证时要求交换 RSA 公钥,客户端默认出于安全考虑不允许自动获取,于是握手失败。内网开发环境加allowPublicKeyRetrieval=true是最省事的解法,但生产环境如果对安全要求高,可以考虑先通过 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 条一组,每组一个事务,配合rewriteBatchedStatements=true,同样一万条数据,实测可以压到 1 秒内到 2 秒左右。批处理还有一个隐藏价值:它天然限制了事务粒度。每批 500 条单独成一事务,中途失败了,最多回滚这 500 条,不会影响前面已经成功提交的数据。这在数据导入场景非常实用,你不需要手动写断点续传,只要每批独立提交,失败后从失败批次重跑即可。事务粒度上还有一个反向陷阱:有人在批处理时把所有数据放在一个事务里,想着“反正分批插入,失败就全部回滚”。结果到了千万级数据导入,单事务持锁时间过长,锁等待和回滚日志膨胀会带来更大问题。事务做小一点,风险边界也就小一点。最后忠告:连接池参数、批次大小这些数值,不要在网上抄完就完事。参数是跟业务特征绑定的,读多写少的系统、批量导入的系统、实时交易系统,最优配置完全不同。上线前做一轮简单的压测,把连接池最大值、最小空闲从当前值上下浮动几个档位,观察响应时间和服务端连接数,再定最终值。数据交互这块没有银弹,只有不停地测量和调整。我个人这几年带项目的体会是:JDBC 这块内容虽然“老”,但它是一切 ORM 框架的地基。你懂了连接 URL 每个参数的意义,再看 MyBatis 的dataSource配置就是透明的;你懂了事务提交和连接归还的生命周期,再复杂的分布式事务方案也能分辨它到底在哪个环节做补偿。地基打不牢,上面盖什么都慌。