JDBC 系列写到第 04 篇,我默认你已经能独立写出一个从 Connection、PreparedStatement 到 ResultSet 的完整 DAO 了。如果前面的篇目你是跳着看的,那也没关系,这一篇的每一块都能单独当排查手册来用。
这篇文章主要聊五个在真实项目里被反复问到、反复踩的细节点:MySQL 驱动到底去哪下载、哪个版本合适、连接串参数是什么意思;批量插入怎么从一条一条插变成真正的批量写;Flink 的 JDBC 连接器在长时间运行时为什么容易报连接异常;DBeaver 开源版怎么用 JDBC 连上 MongoDB;以及一个非常常见的报错 name jdbc is not bound in this context 到底在说什么。我尽量把"为什么会这样"讲清楚,再给可直接照搬的解决办法。
1. MySQL JDBC 驱动下载与版本匹配:先解决"连不上"的第一道坎
1.1 官方下载渠道与 Maven 坐标
不推荐从第三方网站下载驱动压缩包,渠道乱七八糟很容易下到带广告捆绑的版本。第一选择是 MySQL 官方下载页 dev.mysql.com/downloads/connector/j/,里面提供 Platform Independent 的 zip 包和 tar.gz 包;第二选择是 GitHub 上 mysql/mysql-connector-j 的 Releases 页面,或者直接用 Maven Central。如果项目是 Maven 或 Gradle 管理,最省事的做法是直接声明坐标:
xml复制<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
</dependency>
注意 artifactId 从 8.x 开始变成了 mysql-connector-j,早期版本是 mysql-connector-java。老项目里常见的 mysql-connector-java:5.1.49 也能继续用,但新项目我建议直接用 8.0.33 或更高版本,因为连 MySQL 8.0 服务端时,老驱动碰到认证插件问题的概率大得多。
1.2 版本对应关系与驱动类名选择
整理了一张对照表,按"服务端版本 + 推荐驱动版本 + 驱动类名"来选,这样最少踩坑:
| 服务端版本 | 推荐驱动版本 | 驱动类名 | 说明 |
|---|---|---|---|
| MySQL 5.6 / 5.7 | 5.1.49 或 8.0.x | com.mysql.jdbc.Driver / com.mysql.cj.jdbc.Driver | 8.0 驱动能连 5.x,注意时区和认证参数 |
| MySQL 8.0 | 8.0.33 / 8.4 | com.mysql.cj.jdbc.Driver | 默认 caching_sha2_password,要配合 allowPublicKeyRetrieval |
| MySQL 8.0 以后的新版本 | 9.x | com.mysql.cj.jdbc.Driver | 新协议,遇到旧服务端要先验证兼容性 |
从 8.0 开始官方推荐的驱动类名是 com.mysql.cj.jdbc.Driver。老的 com.mysql.jdbc.Driver 在 8.0 里还保留着,但官方已经标记废弃,新代码建议直接改用新的类名。
URL 里几个高频参数也顺手解释一下。serverTimezone 控制时区处理,不设的话连接 MySQL 8 经常报 "The server time zone value" 之类的错误;useSSL=false 是本地开发关闭加密,生产环境按需开启;allowPublicKeyRetrieval=true 在 MySQL 8 的 caching_sha2_password 认证下基本是必需的,否则会报 "Public Key Retrieval is not allowed"。这些参数看起来不起眼,但恰恰是"驱动下载对了还是连不上"的高发原因。
1.3 从 Class.forName 到 SPI 自动注册
老教材都会教你先写 Class.forName("com.mysql.jdbc.Driver") 手动加载驱动,这一步在 Java 6(JDBC 4.0)之后其实已经不用写了。原因是驱动 jar 里的 META-INF/services/java.sql.Driver 文件做了 SPI 注册,只要 jar 在 classpath 里,DriverManager 启动时就会自动发现并注册。所以很多现代项目里你既看不到 Class.forName,也看不到显式配置 driver-class-name,照样能跑通。
那为什么很多人还会写?一方面是从老项目带过来的习惯,另一方面是某些 Web 容器有类加载器隔离,确实存在 SPI 发现失败的情况。写了这行代码不会报错,但也没必要纠结要不要删。真正需要检查的是驱动版本、URL 参数和 classpath 里是否有多个版本的驱动 jar 冲突。想看驱动到底有没有被加载,可以打印一行 DriverManager.getDrivers(),这种方式比猜要快得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JDBC 批量插入性能:从"一条条插"到"一个参数快一个量级"
2.1 慢的本质:网络往返与逐条提交
一条条 insert 慢,不是数据库执行 insert 本身慢,而是程序到数据库之间的网络往返太多。每执行一次 executeUpdate 就是一次完整的 TCP 交互,同时默认 autocommit=true 意味着每一条都自动提交一次事务。事务提交不只是"写个日志"这么简单,它涉及 redo log、undo log、锁释放,这些开销叠加起来才是慢的根源。
还有一个容易被忽略的成本:SQL 文本本身。每条 insert SQL 都要在服务端做一次解析、权限检查、优化、执行,单个来看确实很便宜,但乘以 1 万次就不一样了。批量改写之后,一次解析一条大 SQL,解析次数也同步降下来。
用个生活类比来理解:你有一万份文件要从办公室送到仓库,每趟只送一份,就要来回跑一万趟;批量模式是把一百份打包成一个大包裹,总共只需要送一百趟。网络延迟只要 1 毫秒,单条模式一万次就是 10 秒,批量模式可能只需要 0.2 秒。
2.2 executeBatch 的正确打开方式
先看一个可以直接抄的写法:
java复制String sql = "INSERT INTO user_info (name, age, city) VALUES (?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false);
int batchSize = 500;
int count = 0;
for (User user : userList) {
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.setString(3, user.getCity());
ps.addBatch();
if (++count % batchSize == 0) {
ps.executeBatch();
conn.commit();
ps.clearBatch();
}
}
if (count % batchSize != 0) {
ps.executeBatch();
conn.commit();
}
}
几个细节需要说明。addBatch 只是把参数攒在客户端,真正发到数据库是 executeBatch 触发的。中途 commit 是为了避免一个超大事务长期占用数据库的锁和 undo 空间。最后记得 clearBatch,否则下一次循环时上次的参数可能还残留在 batch 里,这也是我见过最多的问题之一。
常见误用有三个:忘记 setAutoCommit(false),导致批量性能完全发挥不出来;把所有数据一次性 addBatch 完再 executeBatch,内存里攒了几十万条,GC 压力很大;把 executeBatch 写在循环内每次执行,等于没批量,网络往返次数丝毫没降。
2.3 rewriteBatchedStatements=true 为什么这么关键
很多人以为 executeBatch 已经把多条 insert 打包发送了,其实 MySQL 的 Connector/J 默认并不这样做。默认行为是:executeBatch 接受一批参数,但执行时还是一行一行发到服务端,只是把 autocommit 关掉了。也就是说,省掉的是事务提交开销,网络往返一点没省。
打开 rewriteBatchedStatements=true 之后,驱动会把整批参数改写成一条多行 INSERT:
sql复制INSERT INTO user_info (name, age, city) VALUES (?, ?, ?), (?, ?, ?), ...
这样一次网络往返就把这个大 SQL 发给 MySQL,服务端执行效率自然上来了。我本地压测一万行、每行 3 个字段的典型数据如下,不同机器和网络环境下会有差异,看量级就好:
| 方案 | 耗时(毫秒级) | 说明 |
|---|---|---|
| 单条 executeUpdate + autocommit=true | 6000 以上 | 一万次网络往返 + 一万次事务提交 |
| addBatch / executeBatch 默认参数 | 1500 左右 | 省了事务提交,但仍有大量网络往返 |
| 加 rewriteBatchedStatements=true | 150~300 | 多行 SQL 一次发出,网络次数接近 1 |
这个参数直接加在 JDBC URL 末尾:
text复制jdbc:mysql://localhost:3306/appdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
有两个点要提醒。一是如果你依赖 getGeneratedKeys 拿自增主键,开启 rewrite 之后不同驱动版本返回 keys 的行为有差异,建议专门写个小测试确认一下。二是批次不要无脑设得很大,MySQL 有 max_allowed_packet 限制,一条被改写的多行 SQL 总字节数超过这个值会直接报错,500 到 2000 行一般比较稳。
2.4 批次大小与事务边界的经验值
batch size 太小,比如 50,会把网络往返的次数又提上去,性能提升有限;太大,比如 5 万,协议包大小、客户端内存、服务端缓存都容易出问题。一般情况下 500 到 2000 是稳妥区间。建议先按 500 起步,再用真实数据量压一遍,观察耗时的变化再调整。
事务边界和 batch 保持一致就行:每一批提交一次,而不是最后一次性提交。这样即使程序中途崩溃,已提交的批次不会被回滚,重跑时也更容易定位断点。如果你用的是 Spring 事务,记得把批量操作放进一个事务方法里,别让框架的代理把每次 executeBatch 都变成单独事务。
3. Flink JDBC 连接器异常排查:生产环境最容易翻车的三个点
3.1 连接超时或连接池耗尽:先看并行度和连接池配置
Flink 作业里用 JDBC 连接器做 sink 时,最常报的是 Communications link failure 和 Connection is not available, request timed out。这两种异常表面上看起来是网络问题,实际经常是:sink 并行度太高,每个 task 持有的连接数乘起来超过了数据库 max_connections,或者数据库前的防火墙、负载均衡把空闲连接断掉了。
定位思路是先从异常堆栈的 Caused by 往下看。如果是 java.net.ConnectException,直接检查端口、白名单、安全组;如果是连接池超时,去数据库看当前连接数是否打满,顺手查一下 Flink 运行日志里的并发连接数。另外要注意,Flink 的 JDBC sink 默认有连接重试机制,但重试并不能解决"连接数已经满"的问题,只会掩盖症状。常规的调优方向是降低 sink 并行度、调整 JDBC 连接池最大连接数、把数据库侧的 max_connections 和 wait_timeout 调到一个合理值。
3.2 主键冲突和唯一索引导致的作业持续失败
另一种高频异常是 BatchUpdateException,堆栈指向主键冲突或者唯一索引重复。这类问题最坑的地方在于:Flink 基于 checkpoint 的重启机制会把同一个 batch 原样重放,只要上游有重复数据或者下游表结构不变,作业就会陷入"失败-重启-再失败"的循环。
遇到这种情况不能只盯着 SQL 看,要回到数据本身。第一步确认重复数据来自哪个上游;第二步确认业务建表是否真的需要唯一索引;第三步如果必须保留唯一索引,要么在 SQL 里改成 INSERT ... ON DUPLICATE KEY UPDATE 语义,要么在上游做去重。用 flink-connector-jdbc 的原生 sink 时,执行的是普通 INSERT,本身不支持这种 upsert 语义,需要的话得自己扩展,或者用 Table API 的 upsert-kafka 组件链先做一次去重。
3.3 从异常信息反向定位问题的检查表
把常见的异常片段和处置方向整理成了一张速查表,排查时可以直接对照:
| 异常信息片段 | 大概率原因 | 处理方向 |
|---|---|---|
| Communications link failure | 网络不通、数据库重启、空闲连接被 kill |
