ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JDBC批量处理实战:从逐条插入到MySQL性能优化指南

JDBC批量处理实战:从逐条插入到MySQL性能优化指南 JDBC批量处理这个事儿说大不大说小也不小。日常写代码时我们经常会碰见要往数据库里灌数据的场景一条条 insert 当然也能跑但数据量一上来速度慢得让人怀疑人生。我最早接触 JDBC 的 Batch Processing是被线上一个数据同步任务逼出来的——几百万条记录要入库逐条执行跑了快俩小时同事都在旁边等着看笑话。后来改成批量提交十几分钟搞定差距就是这么直观。这篇文章我会把 JDBC 批量处理的底层逻辑、API 用法、真实踩坑过程都拆开聊一遍顺手把 MySQL 连接串里那几个关键参数也讲明白。不管是刚学 JDBC 的新手还是被 Flink 任务里 JDBC 连接器异常折磨过的老手应该都能从里面找到点有用的东西。1. 为什么单条执行这么慢JDBC 批量处理的核心思路1.1 慢的根源不在 SQL 本身而在“来回次数”先别急着写代码我们来捋清楚一个最基本的问题为什么 JDBC 逐条 insert 慢很多人第一反应是“SQL 没优化”“表结构有问题”但实际上大部分情况下慢的原因并不是数据库执行 SQL 慢而是客户端和数据库之间的“网络往返”太频繁了。一条 INSERT 语句从 Java 代码发出到数据库真正落盘中间经历的过程大致是客户端把 SQL 文本打包成网络包发送到 MySQL 服务端服务端解析 SQL、优化器生成执行计划、执行写入并返回结果客户端再接收这个结果。这个过程即使在内网环境下单次也要耗费零点几毫秒到几毫秒不等。你以为是在执行 SQL其实大量时间都花在了“说话”上就像打电话一样每说一句都要先拨号、接通、等对方回应话费全耗在接通上了。如果有一万条数据要插入逐条执行就是一万次完整的“拨号-通话-挂断”流程。而批量处理的思路非常简单粗暴把一万条 SQL 攒到一块儿一次性发给数据库。这样网络往返从一万次变成几十次甚至几次时间自然就省下来了。同时 JDBC 驱动层面还有个隐藏的开销——SQL 文本的编码和解析。每次发送的 INSERT 语句其实都是重复劳动批量处理能让这些重复的成本大幅降低。这就是 JDBC Batch Processing 能提升性能的根本逻辑减少网络往返减少重复解析充分利用数据库服务端的一次性处理能力。1.2 批量处理的两种姿势批量更新与批量参数绑定JDBC API 里提供了两套批量操作的入口一套是Statement的批量更新另一套是PreparedStatement的批量参数绑定。很多初学者容易把这两者混为一谈实际上它们的适用场景和内部机制差别不小。Statement.addBatch(String sql)这种方式是把一条完整的 SQL 文本塞进批处理队列然后执行。它的特点是每条 SQL 都需要自己拼字符串适合那种“每一条 SQL 都完全不同”的场景比如混合了 insert、update、delete 的杂糅操作。但这种方式有一致命弱点——SQL 注入风险极高因为你没法用占位符参数化输入所有值都得手动拼进字符串里。而且 MySQL 驱动这种执行方式下能做的优化也很有限。PreparedStatement配合addBatch()才是真正的主流玩法。先写好带问号占位符的 SQL 模板然后用setXXX()方法把每条记录的值塞进模板里再调用addBatch()。它不只是省了网络往返还利用了数据库的预编译能力——SQL 模板只需解析一次后面每次只是更换参数值。更重要的是参数化查询天然免疫 SQL 注入安全性上完全不是一个级别。从我的实践经验来看只要你的数据是结构化、字段固定、批量写入目标明确的一律无脑用PreparedStatement addBatch()。用Statement拼接 SQL 搞批处理十有八九会踩进注入或者语法错误的坑里。1.3 batchSize 的选择不是越大越好很多人一听到“批量”两个字就觉得攒得越多越好一次性把十万条全塞进去。这个想法非常危险。JDBC 驱动的executeBatch()方法虽然能一次性发送大批数据但客户端这边需要先把所有数据缓冲在内存里数据库服务端也要处理一个超大的请求包内存和 I/O 压力都相当大。我在实际项目中测试过的经验值是这样的MySQL 环境下单批次 500 到 1000 条是比较理想的区间。低于 100 条时网络往返的优势还没完全发挥出来高于 5000 条时内存占用明显上升而且如果中间某一条数据出了问题整批回滚的代价也会成倍放大。批量处理的核心在“平衡”不是“极端”。 每条记录占用的参数值越大批次的容量就应该相应缩小。比如插入的都是几十个字段的宽表建议把 batchSize 降到 200 到 300 之间。还有一个容易被忽略的细节addBatch()只是把参数值缓存在本地真正的发送动作是在executeBatch()调用时才发生的。所以如果你往 batch 里塞了 500 条但一执行就报错前面塞进去的数据并不会自动清空需要手动调用clearBatch()清理否则后续执行会把旧数据重复提交。1.4 自动提交与事务边界批量提交的隐形钥匙JDBC 默认情况下每次 execute 之后都会自动 commit。但如果你在批处理模式下仍然保持自动提交打开实际上就废掉了事务的原子性优势——每批数据的提交动作是分散的中间任何一条失败都会留下“前半截已落库后半截没写入”的脏状态。正确做法是先关闭自动提交把autocommitfalse设上执行完一个批次的executeBatch()之后手动commit()。这样每一批数据就是一个完整的事务单元失败时可以直接回滚不会污染之前的数据。我在实际操作中是这么做的Connection conn DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); PreparedStatement ps conn.prepareStatement(INSERT_SQL); for (int i 0; i totalCount; i) { ps.setString(1, value1); ps.setInt(2, value2); // ... 设置其他参数 ps.addBatch(); if (i % batchSize 0) { ps.executeBatch(); conn.commit(); ps.clearBatch(); } } ps.executeBatch(); // 处理剩余不足一批的数据 conn.commit();注意最后那里循环结束后还要手动执行一次executeBatch()和commit()因为最后一波数据可能凑不够一个完整批次不补这一下就会丢尾巴。2. 核心 API 拆解与 MySQL 连接串参数详解2.1 executeBatch() 的返回值int[] 数组里藏着的细节调用executeBatch()之后会返回一个int[]数组每个元素对应批处理中一条 SQL 的影响行数。很多人拿到这个数组就直接扔了其实它是有意义的——如果某一条 SQL 执行失败数组对应位置会出现异常或者影响行数不符合预期这能帮助你快速定位是哪条数据出了问题。但这里有个坑MySQL 驱动对executeBatch()返回值的处理比较特殊。在默认情况下它返回的 int 数组里每个值可能都等于 1意思是“这一行执行成功了”。但如果你的 SQL 是INSERT ... ON DUPLICATE KEY UPDATE这种带更新逻辑的语句影响行数会变成 21 表示插入2 表示更新。如果后续你依赖这个返回值做数据统计就要特别小心这种语义上的差异。还有一点如果批量执行过程中出现异常抛出的通常是BatchUpdateException这个异常里带一个getUpdateCounts()方法返回的是“失败之前已经成功执行的那部分”的影响行数数组。利用这个方法你可以把数据分成“已成功的”和“失败的”两部分失败的单独捞出来重试或者记日志而不是整批推倒重来。2.2 rewriteBatchedStatementsMySQL 批量性能的分水岭讲 MySQL 的 JDBC 批量处理如果不提rewriteBatchedStatements这个参数等于白讲。这是驱动端的一个连接参数作用是允许 MySQL Connector/J 在底层把PreparedStatement的批量参数自动重写成一条多值的 INSERT 语句下发到服务端。什么意思呢假设你往 batch 里塞了 500 条带占位符的 insert 语句如果没有开这个参数驱动实际上是循环往服务端发送的每条参数绑定都单独发一次虽然也省了 Java 应用层的一部分工作但网络往返并没有真正减少。开了rewriteBatchedStatementstrue之后驱动会把它们拼成同一条INSERT INTO t_user (name, age) VALUES (?, ?), (?, ?), (?, ?) ... (500个问号组)一次性发给 MySQL服务端一次 SQL 解析、一次执行计划、一条请求处理完所有数据。这个参数对批量插入性能的提升是数量级的实测中经常是几倍甚至十几倍的差距。这个优化是 MySQL 驱动专门为 Mac 批量场景准备的多数主流生产环境都默认开启。连接串里配置方法很简单直接在 JDBC URL 后面追加即可。需要留心的是rewriteBatchedStatements主要针对 INSERT 语句做了这种多值重写优化DELETE 和 UPDATE 的批量场景不会得到同样的待遇。另外开启它之后executeBatch()返回的影响行数数组也变得更加不可靠——因为多条 SQL 合并成一条了返回的 int 值对应于合并后的那条 SQL 的总影响行数而不是每条单独的行数。如果业务逻辑强依赖逐条的影响行数这个参数就得谨慎开启。2.3 连接串里其他几个关键参数除了rewriteBatchedStatements我在连接 MySQL 时还会配合使用下面几个参数它们对批量处理场景的整体表现影响很大参数作用建议值useServerPrepStmts是否使用服务端预编译开启后 SQL 模板在服务端缓存truecachePrepStmts是否缓存 PreparedStatement避免重复预编译trueprepStmtCacheSize预编译缓存条数250到500prepStmtCacheSqlLimit允许缓存的 SQL 最大长度避免超长 SQL 被跳过2048useCompression压缩客户端和服务端之间的传输数据适合大批量数据传输trueserverTimezone服务器时区设置避免日期类型报错Asia/ShanghaiuseServerPrepStmts和cachePrepStmts这两个参数经常被人忽略但它们对批量处理的性能有实质性的帮助。服务端预编译的作用是MySQL 服务端会把那条 INSERT 模板缓存下来并生成执行计划后续只需要传参数值不用再次解析 SQL 文本。高频批量写入场景下省下的解析时间累积起来是非常可观的。一个完整的 MySQL 批量写入连接串长这样jdbc:mysql://localhost:3306/app_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruerewriteBatchedStatementstrueuseServerPrepStmtstruecachePrepStmtstrueprepStmtCacheSize256prepStmtCacheSqlLimit2048useCompressiontrue补充一句allowPublicKeyRetrievaltrue是 MySQL 8.0 之后连接串里经常需要的配置如果用的认证插件是caching_sha2_password不开启这个参数会报连接认证相关的异常。useSSLfalse则是因为本地开发环境一般不需要加密连接开了反而增添开销。2.4 批量处理与内存的大量数据操作还有一种经常被忽略的批量处理场景是executeLargeBatch()JDBC 4.2 之后的 API。普通executeBatch()返回的 int[] 数组每个元素是 int 类型但如果单条语句影响的行数超过Integer.MAX_VALUE那 int 就不够用了。虽然现实中单条 SQL 影响这么多行的情况很少见但如果走的是大批量数据清理或者历史数据迁移用executeLargeBatch()返回的long[]会更稳妥。3. 实操演练MySQL 批量插入十万条数据的完整过程3.1 环境准备与建表我们直接在本地起一个 MySQL 8.x 实例用最新稳定版 JDBC 驱动mysql-connector-j8.0.33 来做演示。Maven 依赖只要这一条dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency建一张简单的用户表CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL, age INT NOT NULL, email VARCHAR(128) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 逐条插入与批量插入的性能对比实测我先写一段逐条插入的代码作为对照组模板。这段代码用 PreparedStatement 逐条执行 insert数据量定为十万条public long singleInsert(Connection conn, int totalCount) throws Exception { long start System.currentTimeMillis(); String sql INSERT INTO t_user(name, age, email) VALUES (?, ?, ?); PreparedStatement ps conn.prepareStatement(sql); for (int i 0; i totalCount; i) { ps.setString(1, user_ i); ps.setInt(2, 20 (i % 50)); ps.setString(3, user i example.com); ps.executeUpdate(); } ps.close(); return System.currentTimeMillis() - start; }然后在另外一张测试表上用批量处理的方式写入。为了对比公平我建了一张结构完全相同的t_user_batch表单独的连接串开启了rewriteBatchedStatements。批量插入的演示代码如下public long batchInsert(Connection conn, int totalCount, int batchSize) throws Exception { long start System.currentTimeMillis(); String sql INSERT INTO t_user_batch(name, age, email) VALUES (?, ?, ?); PreparedStatement ps conn.prepareStatement(sql); conn.setAutoCommit(false); int count 0; for (int i 0; i totalCount; i) { ps.setString(1, user_ i); ps.setInt(2, 20 (i % 50)); ps.setString(3, user i example.com); ps.addBatch(); count; if (count % batchSize 0) { ps.executeBatch(); conn.commit(); ps.clearBatch(); count 0; } } if (count 0) { ps.executeBatch(); conn.commit(); ps.clearBatch(); } ps.close(); return System.currentTimeMillis() - start; }实测数据本机环境数据量 10 万条单位毫秒方案耗时备注逐条 executeUpdate48000 ms 左右约为 48 秒批量 500 条未开启 rewriteBatchedStatements29000 ms 左右有明显提升但还不够理想批量 500 条开启 rewriteBatchedStatements3500 ms 左右提升达到数量级第一次跑出这个数据结果时我自己都愣了一下。逐条 48 秒和批量加 rewrite 的 3.5 秒差距超过了 13 倍。这就是“减少网络往返 服务端多值 insert”叠加的威力。而且这只是本机回环网络的结果如果连接的是远程数据库服务器网络延迟进一步拉大差距会变得更加悬殊。3.3 把数据均匀分片批量写入的节奏控制从上面的代码可以看出批次大小的控制实际上就是if (count % batchSize 0)这个判断。这里有一个常见的误操作直接把batchSize设成总条数。如果总条数是十万你把 batchSize 也设成十万执行一次executeBatch()会把十万条参数全打进一个批次里。这种做法你可能会觉得“反正都是一批”表面上没错但实际上在客户端就会先形成一个巨大的参数数组内存占用暴涨MySQL 服务端接收到超长多值 INSERT 后解析耗时也会显著上升还可能触发底层缓冲区上限。max_allowed_packet就是这里的关键约束如果一次改写后的 INSERT 报文超过这个上限MySQL 会直接报错拒绝执行。实际项目中我一般固定用 500 或 1000不会基于总量动态放大。如果你用的是 Flink、Spark 这类框架去调 JDBC sink它们的内部连接器也都有batch.size参数本质上也是同一个道理——控制单次发送的体量维持稳定的写入节奏。3.4 批量操作中的主键回填问题批量插入之后业务往往需要拿到自增主键尤其是分库分表或者写关联表的时候。JDBC 提供getGeneratedKeys()来获取自增主键但这里有个非常隐蔽的坑批量插入时调用getGeneratedKeys()不同驱动返回的结果形式完全不同。MySQL 驱动在批量场景下会把生成的自增 id 集合作为一个 ResultSet 返回但返回顺序不一定和你的批次顺序完全一致。如果要拿到自动生成的主键建 PreparedStatement 时需要显式声明参数为Statement.RETURN_GENERATED_KEYS示例代码如下PreparedStatement ps conn.prepareStatement(INSERT_SQL, Statement.RETURN_GENERATED_KEYS); ps.executeBatch(); ResultSet rs ps.getGeneratedKeys(); while (rs.next()) { System.out.println(rs.getLong(1)); }如果是靠业务规则生成主键比如雪花 ID就不涉及这个问题直接在批量参数里把主键设置进去即可。我的经验是强依赖数据库自增主键的批量场景里获取主键会带来额外的性能损耗能避免就避免。3.5 批量 UPDATE 和 DELETE 的注意事项批量插入只是批量处理最常见的场景批量 UPDATE 和 DELETE 受到的关注少得多但同样有优化空间。批量 UPDATE 大多数情况下是按主键或唯一键定位记录构造出的 SQL 模板形如String sql UPDATE t_user SET age ? WHERE id ?;rewriteBatchedStatements对 UPDATE 的优化能力和 INSERT 完全不是一个量级它合并成一条多值语句的语法不是在所有 MySQL 版本下都能生效。所以如果你的批量 UPDATE 表现不佳正确做法是把同一行内的多次更新合并成一条 UPDATE减少同一行操作次数或者按主键分组减少死锁风险。批量 DELETE 也是同理特别是 DELETE 的数据量很大且和业务写入并发发生时要格外注意行锁竞争。一个常见的做法是把要删除的主键收集起来分批执行把每条 DELETE 改成WHERE id IN (…, …, …)的形式但这个方法同样受max_allowed_packet限制。实际操作时还要注意大批量 DELETE 会制造大量 binlog 和 undo log执行期间对线上数据库的压力很大尽量安排在业务低峰期做。4. 实战中的典型问题与排查实录4.1 Flink JDBC 连接器批量写入异常Flink 的 JDBC 连接器是很多人接触批量处理的另一个真实战场。Flink 流式任务长时间运行通过 JDBC sink 往 MySQL 写数据时时不时会冒出来一个CommunicationsException: Communications link failure或者Connection is not available, request timed out。不少人的第一反应是“MySQL 挂了”“网络出问题了”实际上我在排查中遇到最多的情况是连接空闲超时。Flink 任务的写入速率如果有明显波峰波谷MySQL 服务端的wait_timeout默认值是 8 小时但中间的负载均衡器、防火墙或者其他网络设备可能更早地掐断空闲连接导致连接池里的连接实际上已经失效了。JDBC 连接池拿到一个“死连接”首次写入时就抛出异常。解决方法有几个方向一是合理配置连接池的testOnBorrow或testWhileIdle让连接在借出之前就验证是否可用二是缩短连接的最大空闲时间主动回收长期不用的连接三是把 JDBC URL 里的autoReconnecttrue参数加上但这只是权宜之计可靠性和安全性上都有争议。更推荐的做法还是依赖连接池自身的检查机制。另外Flink 的 JDBC sink 里还有一个batch.size参数和一个batch.interval参数这两个参数直接决定批量写入的节奏。如果batch.size设得很大而数据量又达不到数据会一直攒在内存缓冲区里迟迟不往下游发看起来像是“没写入”实际上是在等待攒批。这个参数需要和任务的吞吐量、延迟要求一起考虑。4.2 JDBC 连接 MySQL 时的认证与时区报错连接 MySQL 8.x 时报错是新手最容易卡住的地方。比较常见的是Unable to load authentication plugin caching_sha2_password这就是前面提到的认证插件问题。MySQL 8.0 默认的认证插件是caching_sha2_password而老版本的 JDBC 驱动不认识解决方案就是换新版驱动或者把用户的认证插件改回mysql_native_password。但在新项目上我不建议为了兼容而降级认证方式用新驱动才是最合理的路径。还有个高频问题The server time zone value Öйú±ê׼ʱ¼ä is unrecognized字符集乱码一样的提示。出现这个是因为客户端时区和服务端时区不一致解决方法是连接串上显式声明serverTimezoneAsia/Shanghai或者把useLegacyDatetimeCodefalse配上。如果在云上部署数据库时区可能默认是 UTC本地代码用本地时间写入就会出现 8 小时的时差。这个不是批量处理的专利问题但在批量导入历史数据时最容易集中暴露出来。4.3 BatchUpdateException批量失败后如何定位坏数据批量执行出错时抛出来的是BatchUpdateException这是最让人头疼的异常因为只靠异常信息很难直接定位是哪条数据引发的。我的排查先看异常里getUpdateCounts()返回的数组结合rewriteBatchedStatements是否开启来缩小范围。在一个测试任务里我往表里批量塞 2000 条数据其中有一条 name 字段为空串但表上有非空约束执行后整批失败。排查过程是先关闭rewriteBatchedStatements用二分法缩小批次定位到问题数据然后再排查数据源那部分的脏数据。这里的核心建议是批量写入数据入库之前一定要先做完整性校验——字段长度、非空约束、唯一性约束最好都在应用层提前排查。把问题拦截在进入批处理之前比事后从十万条里捞一条坏数据要划算得多。4.4 批量处理导致的死锁问题高并发环境下多个线程同时执行批量 UPDATE 或 DELETE很容易触发死锁。MySQL 的 InnoDB 引擎在批量更新相同范围的数据时行锁的获取顺序不一致就会导致互相等待。我在一次数据订正任务中就遇到过这样的场景任务按主键范围分成多个批次不同批次之间出现了主键交错两个事务各自持有一部分行的锁又同时去申请对方持有的锁直接 deadlock。解决方法比较朴素但有效批量 UPDATE 之前先按主键排序多个批次的边界做好隔离另外把innodb_lock_wait_timeout设短一些让死锁快速暴露而不是傻等。还有一个技巧是把大事务切小——一个事务只处理 1000 条不要一口气处理十万条锁的持有时间短了死锁的概率自然就低了。这个思路和 Flink 任务里控制单批数据量是一脉相承的。4.5 批量执行导致 OOM 的排查思路批量提交还有一个隐藏杀手是 Java 应用层的内存溢出。别忘了addBatch()阶段PreparedStatement 内部会持有所有尚未提交的参数值。如果你的 batchSize 是 1000每条记录又有几十个字段那这 1000 条记录的参数对象全部驻留在内存里。如果同时开了多个线程每个线程各自持有自己的批次缓冲内存消耗会更明显。排查 OOM 时我一般先用jmap把堆 dump 出来看是哪些对象占据了大部分内存。批量场景下看到的往往是byte[][]或者内部参数数组这类对象定位到之后把 batchSize 调小、控制并发线程数或者改用流式处理逐批提交内存压力很快就能降下来。在实际项目中为了平衡吞吐和内存我常用的组合是 4 个写入线程 × 每线程 500 条一批。这个组合实测效果理想内存占用稳定吞吐也够。4.6 失败重试与幂等性设计最后再聊一个批量处理绕不开的话题——失败后的重试策略。数据库操作和普通 HTTP 请求不太一样不能简单粗暴地重试必须考虑幂等性。比如批量 INSERT 时如果第一批执行成功但客户端因为网络异常没收到数据库的响应你重试时会发生什么如果没有防重机制就会产生重复数据。解决思路有几种一是给业务表设计唯一业务键用INSERT INTO ... ON DUPLICATE KEY UPDATE这种语法让重复插入变成更新自然实现幂等二是自己维护一个“批次状态表”记录每个批次的执行状态重试前先查状态再决定是否继续执行三是表结构层面用INSERT IGNORE忽略冲突。但INSERT IGNORE无法处理“冲突更新”的需求所以更常用的是第一种方案。MySQL 8.0 里还有一个INSERT ... ON DUPLICATE KEY UPDATE配合批量多值语法实测在批量场景下性能和可靠性都还不错可以在合规前提下用。5. 一些个人踩坑后的经验沉淀批量处理这个技术单独拎出来任何一块都不算复杂但组合在一起的时候涉及网络、内存、事务、连接池以及数据库参数等各个层面的细节。说实话API 本身很简单复杂的是把数据量、批次大小、事务边界、驱动参数这些因素全部调到一个合适的平衡点。我在实际项目里最终沉淀下来的组合方案是JDBC URL 开rewriteBatchedStatementsuseServerPrepStmts关闭自动提交批次大小控制在 500 到 1000写入线程根据数据源情况控制在 4 到 8 个。换个项目、换种数据库或换张表结构参数组合可能都要微调。但大方向是一致的——批量处理的收益主要来自减少网络往返需要不断衡量数据量、锁竞争、内存开销和吞吐量之间的平衡。另一个值得养成的习惯是把批量写入代码封装成独立的工具类把批次大小、异常重试、事务边界都做成可配置的。这样遇到不同数据量级的任务只改参数就能适配不用每次重写底层逻辑。最后再分享一个小技巧日常开发或者测试的时候可以在应用层加一个简单的耗时统计对比逐条执行和批量执行的时间差异。把这个数字记下来无论是给领导汇报性能优化成果还是做技术方案选型评审都是最直接、最有说服力的证据。数据永远比感觉靠谱。
返回列表