
做Java开发这些年JDBC算是我打交道最多的基础组件之一。上周帮同事排查一个数据迁移任务他拿着报错日志问我我写了好几段SQL用分号拼在一起扔给JDBC为什么就是执行不了这个看似基础的问题其实牵扯出JDBC执行多条语句的几种完全不同的方式很多人分不清循环执行、批量执行和一次性执行多条之间的区别导致要么性能很慢要么写出来的代码有严重的安全隐患。我干脆把这部分内容完整整理一遍从底层机制到完整示例再到常见坑写成一篇文章给需要用Java JDBC执行多条语句的读者一份可以直接照抄的实操笔记。1. JDBC执行多条语句的三种常见方式1.1 需求从哪里来什么场景需要一次执行多条SQL先搞清楚为什么要关心多条语句这件事。我在实际项目里遇到最多的场景有三类第一类是批量初始化比如新环境部署时要在库里建一批表、插入一批基础配置数据。既然建表和插入是固定的一组操作自然会想一次性把这几条SQL都发出去而不是写几十次Statement调用。第二类是批量数据变更比如从外部接口同步几千条用户信息或者把一张大表的数据按规则刷一遍。这类任务的特点是SQL结构相同、参数不同只差在具体的值上。此时纠结的不是怎么拼多句SQL而是怎么高效地把几千次更新送进数据库。第三类是脚本化执行类似于在Java程序里跑一个批处理脚本SQL文件里本身就有几百行含多条DDL和DML。直接用JDBC去解析并逐条执行不是不行但会很啰嗦于是大家开始研究能不能一条连接一口气全跑完。三类场景对应的是完全不同的技术路线。如果只是简单回答JDBC能不能执行多条SQL答案其实很模糊因为JDBC本身提供了多条路可以走每一条的机制、限制和适用条件都不一样。把这层逻辑梳理清楚比背几个API方法重要得多。1.2 三条技术路线到底该选哪条针对上面的场景JDBC层面有这三种主流方案逐条执行用Statement或PreparedStatement在循环里一调一执行或者一次调用只处理一条SQL。这是最基础、最通用、兼容性最好的方式几乎任何数据库驱动都支持。批量执行用PreparedStatement.addBatch()把多条同结构SQL攒在一起最后通过executeBatch()一次性发给数据库。注意这里的一次发给数据库指的是驱动层合并发送SQL本身还是多条独立的语句。多语句执行通过连接参数开启特定模式如MySQL的allowMultiQueriestrue让驱动允许一条SQL字符串里包含多条分号分隔的语句再用execute()跑完。用一个表格对比它们的核心差异会更直观方式是否一条调用可执行多条SQL是否需要参数占位符主要适用场景典型风险逐条执行循环调用否推荐使用逻辑复杂、各条SQL差异大的场景循环内频繁网络往返性能差批量执行Batch否但对驱动来说是一次发送支持防注入效果好大量同构INSERT/UPDATE批量过大会造成内存压力多语句执行allowMultiQueries等是受限脚本化执行、多条固定SQL易踩SQL注入与XML解析的坑这三条路没有绝对的优劣选型完全取决于业务场景。后续我把每条路的关键点、代码怎么写、参数怎么配全部拆开来讲。2. 最稳的基础线Statement与PreparedStatement逐条执行2.1 基础用法execute、executeUpdate与executeQuery三者怎么分工很多新手搞不清楚Statement里那三个执行方法有什么区别这直接影响多条语句怎么处理。简单说executeQuery(String sql)只能执行SELECT返回ResultSet。如果往里塞一条INSERT驱动会在运行时报错。executeUpdate(String sql)执行INSERT、UPDATE、DELETE以及DDL语句返回受影响的行数。对DDL语句部分数据库返回0。execute(String sql)能执行任意SQL。返回一个boolean值true表示结果是ResultSetfalse表示没有结果集可能有更新计数。所以我平时判断这句SQL能不能用execute跑的标准就是不清楚语句类型的场景下用execute先行判断明确是查询就用executeQuery明确是更新就用executeUpdate。那逐条执行多条语句怎么写其实就是按顺序在同一个Statement上多次调用try (Connection conn DriverManager.getConnection(url, user, password); Statement stmt conn.createStatement()) { stmt.executeUpdate(INSERT INTO t_user(name, age) VALUES(张三, 20)); stmt.executeUpdate(INSERT INTO t_user(name, age) VALUES(李四, 21)); stmt.executeUpdate(UPDATE t_user SET age age 1 WHERE name 张三); }这段代码没有任何花哨操作但它是理解后续所有方案的基础。这里要提醒一个细节同一个Statement实例可以反复执行不同SQL但如果在执行过程中某个操作抛出SQLException这个Statement的状态是不值得信任的最稳妥的做法是关闭当前Statement重新创建一个。我在生产环境里见过因为复用异常Statement导致数据重复插入的案例不要图省事。2.2 PreparedStatement的参绑定与批量增强逐条执行也有更好的形态就是PreparedStatement。它比Statement多了一个关键能力SQL结构和参数值分离。对于结构相同、参数不同的多条SQL业务代码可以先建好SQL骨架再用?占位符逐条填充参数String sql INSERT INTO t_user(name, age) VALUES(?, ?); try (Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, 王五); ps.setInt(2, 25); ps.executeUpdate(); ps.setString(1, 赵六); ps.setInt(2, 26); ps.executeUpdate(); }每次调用executeUpdate()时PreparedStatement会把当前的参数值绑定到SQL中发送给数据库执行。这套机制有两大好处一是由驱动处理参数转义能有效防止SQL注入二是对于预编译语句数据库可以复用执行计划减少解析开销。用PrepExpense的方式逐条执行时特别注意参数的索引是从1开始的不是0。很多人在这里栽过跟头明明SQL没问题执行却报Parameter index out of range。2.3 批量提交的关键优化参数rewriteBatchedStatements纯循环逐条执行最大的问题在于网络往返。假设要插入1万条数据每发一条SQL要等数据库返回结果这个等的过程累计起来非常可观。解决思路就是批量执行把多条SQL攒在客户端缓冲区一次网络请求发给数据库。最基本的批量执行写法是按PreparedStatement.addBatch()来做的String sql INSERT INTO t_user(name, age) VALUES(?, ?); try (Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i 0; i 10000; i) { ps.setString(1, user_ i); ps.setInt(2, 20 i % 50); ps.addBatch(); if (i % 1000 999) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); }这套写法里有几个点要解释一下。首先addBatch()并不是真正执行只是把当前参数组合放进一个批处理队列队列里的条目会在executeBatch()时统一发送。因此必须及时分批提交否则一万条全攒着客户端内存先顶不住。其次MySQL驱动有个非常关键的隐藏参数rewriteBatchedStatements默认是false。在默认情况下executeBatch()会把队列里的语句逐条发给服务器批量执行没有真正减少网络往返性能提升十分有限。只有把这个参数设为true驱动才会把一批同结构的INSERT语句重写成一条多VALUES语句比如把100条INSERT INTO t_user(name,age) VALUES(?,?)合并成一条包含100组VALUES的语句。这一步重写才是批量插入从能跑变成飞快的核心。连接串类似这样jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue实测下来同样的两万条插入不开启重写时耗时是秒级到十几秒开启后通常能压到原来的十分之一甚至更低。强烈建议所有做MySQL批量写入的人检查一下自己连接串里有没有这个参数。还要提醒两点一是批量执行期间务必关闭自动提交setAutoCommit(false)否则每条SQL可能独立提交事务原子性根本无法保证二是executeBatch()返回的是int[]数组里每个元素对应一条语句的影响行数部分驱动可能返回Statement.SUCCESS_NO_INFO值-2可别把-2当成异常去处理。3. 一条道走到黑开启allowMultiQueries一次跑多句3.1 allowMultiQueries参数的作用与配置方式有朋友会问既然批量执行这么好为什么还有人非要一条调用执行多条SQL原因在于批量执行有个天然限制——它要求SQL结构必须一致。如果手里是一段混合了建表、插入、更新、删除的脚本长度上百行就没法用Batch模板化了。MySQL的JDBC驱动提供了allowMultiQueries参数默认false。把它设为true之后Statement.execute()可以接收分号分隔的多个SQL驱动会把整段字符串发给MySQL服务器由服务器逐条执行jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaiallowMultiQueriestrue配置生效后代码可以这样写String multiSql DELETE FROM t_user_temp; INSERT INTO t_user_temp(name, age) VALUES(甲, 1); UPDATE t_user SET age age 1 WHERE id 100;; try (Statement stmt conn.createStatement()) { boolean hasResult stmt.execute(multiSql); // 遍历所有返回结果 while (true) { if (hasResult) { try (ResultSet rs stmt.getResultSet()) { // 处理结果集 } } else { int updateCount stmt.getUpdateCount(); if (updateCount -1) { break; } System.out.println(影响行数: updateCount); } hasResult stmt.getMoreResults(); } }注意我特意用了execute()而不是executeUpdate()。executeUpdate()虽然也能接收多条语句并返回最后一条语句的影响行数但它会丢弃中间SELECT查询的结果集而且部分驱动对多语句模式下的executeUpdate()处理并不一致。用execute()配合getMoreResults()遍历各段结果是兼容性最稳妥的处理方式。3.2 多语句模式下execute与executeUpdate的表现差异多语句模式下一个容易踩的坑是把多条INSERT写进executeUpdate()还指望着拿到所有语句影响行数的总和。事实上executeUpdate()只返回多语句中被执行的最后一条语句的更新计数。比如stmt.executeUpdate(INSERT INTO t1(a) VALUES(1); INSERT INTO t2(b) VALUES(2););返回的值实际上对应的是第二条INSERT的更新计数跟第一条无关。想要每条语句的影响数必须走getMoreResults()循环读取。如果第一条语句是SELECT第二条是INSERT那么executeUpdate()返回的值会是INSERT的计数还是查询结果答案是executeUpdate()根本返回不了ResultSet如果第一条语句产生结果集会直接影响迭代位置。这也是我推荐用execute()统一处理多条语句的原因。3.3 实际效果什么时候该用什么时候千万别用从实用角度说allowMultiQueries最适合执行的场景是部署类脚本、一次性数据订正SQL。这类SQL本身就是从SQL文件里读出来的内容相对固定不会有用户输入拼进去。把它们一次发给数据库省去解析和循环调用的麻烦。但它也隐藏着两个大坑。第一个坑是驱动行为不一致。allowMultiQueries是MySQL特有的参数PostgreSQL的JDBC驱动根本没有这个选项Oracle驱动是否支持一次性执行多条语句取决于驱动版本与setDefaultRowPrefetch之类设置并不能一概而论。项目里如果需要兼容多种数据库多语句方案的可移植性会很差。我在设计公共数据访问层时核心逻辑一律走逐条或批量路线多语句只在特定数据库、特定场景下作为加速手段使用并且用开关和配置隔离。第二个坑是安全边界模糊。多语句模式意味着代码里的一段字符串中间可能藏着另一段完整SQL一旦这段字符串里有未经严格校验的用户输入攻击者完全可以利用分号插入自己的语句。比如把正常查询变成查询后接着DELETE全表。很多安全扫描工具也会直接警告开启allowMultiQueries的存在。生产环境如果确实需要开必须确保执行多语句的入口不接收任何外部参数而且二进制层面禁止拼接用户输入。4. 完整实操一个从建表到批量写入的JDBC示例4.1 环境准备与数据库连接串光讲原理不过瘾我搭一个能直接跑通的小例子。环境用的是MySQL 8.0、JDK 17、mysql-connector-java 8.0.33。先建表CREATE DATABASE IF NOT EXISTS multi_stmt_demo DEFAULT CHARSET utf8mb4; USE multi_stmt_demo; CREATE TABLE IF NOT EXISTS t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB;连接串用这样一组参数jdbc:mysql://127.0.0.1:3306/multi_stmt_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowMultiQueriestruerewriteBatchedStatementstrue同时开了allowMultiQueries和rewriteBatchedStatements目的是让下面三种执行模式都能同一套连接串下对比。这里额外提醒allowMultiQueries和rewriteBatchedStatements这两个参数并不冲突一个影响多语句解析一个影响批量重写凑在一起没有任何排斥关系。但要注意执行Batch时驱动不会把多语句拼进批量队列Batch机制本身只处理单语句模板。4.2 完整代码逐段说明完整示例我整理成一个简单的Java类核心逻辑分三块一是用allowMultiQueries跑一组DDLDML二是用PreparedStatement批量插入数据三是查询验证结果。第一部分多语句执行脚本化SQLpublic void executeMultiStatements(Connection conn) throws SQLException { String multiScript INSERT INTO t_order(order_no, user_id, amount, status) VALUES(NO001, 1001, 88.50, 0); INSERT INTO t_order(order_no, user_id, amount, status) VALUES(NO002, 1002, 12.00, 1); UPDATE t_order SET amount amount 10 WHERE order_no NO001;; try (Statement stmt conn.createStatement()) { boolean hasResultSet stmt.execute(multiScript); int stmtIndex 0; while (true) { stmtIndex; if (hasResultSet) { try (ResultSet rs stmt.getResultSet()) { while (rs.next()) { System.out.println(结果集 stmtIndex 行了: rs.getInt(1)); } } } else { int affected stmt.getUpdateCount(); if (affected -1) { break; } System.out.println(第 stmtIndex 条语句影响行数: affected); } hasResultSet stmt.getMoreResults(); } } }这段代码输出之后你会看到第1、2条语句各自影响行数是1第3条UPDATE也是1。每条语句的状态是独立的。第二部分批量写入两万条订单数据public int batchInsertOrders(Connection conn, int total) throws SQLException { String sql INSERT INTO t_order(order_no, user_id, amount, status) VALUES(?, ?, ?, ?); int batchSize 1000; int affected 0; conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i total; i) { ps.setString(1, String.format(BNO%05d, i)); ps.setLong(2, 2000L i); ps.setBigDecimal(3, new BigDecimal(100.00).add(new BigDecimal(i))); ps.setInt(4, 0); ps.addBatch(); if ((i 1) % batchSize 0) { int[] rows ps.executeBatch(); for (int row : rows) { affected Math.max(row, 0); } conn.commit(); } } int[] rows ps.executeBatch(); for (int row : rows) { affected Math.max(row, 0); } conn.commit(); } finally { conn.setAutoCommit(true); } return affected; }注意Math.max(row, 0)的处理因为驱动在批量执行时可能对部分语句返回SUCCESS_NO_INFO-2如果直接把row累加进影响数会把-2也加进去数据统计就不准了。取max(row, 0)是为了忽略特殊标记。第三部分查询验证public int countOrders(Connection conn) throws SQLException { try (Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(SELECT COUNT(*) FROM t_order)) { rs.next(); return rs.getInt(1); } }4.3 实测对比与性能参考我本机跑了三组测试每组都是两万条订单插入得到的数据大致是这样执行方式耗时参考说明循环逐条executeUpdate12秒左右每次请求等待网络往返耗时与网络延迟强相关PreparedStatement addBatch未开rewriteBatchedStatements2秒左右有合并发送但SQL未重写性能提升有限PreparedStatement addBatch开启rewriteBatchedStatements0.8秒左右驱动重写为多VALUES语句网络往返大幅减少allowMultiQueries execute如果是脚本化多句读取和发送都在一次往返内适合固定脚本不适合动态参数要强调的是上面数据是在我的本机环境跑出来的不同的数据库版本、网络环境、缓存状态都会导致数字波动但趋势不变同构大批量数据用Batch重写参数异构脚本SQL用多语句模式。不要反过来把动态参数拼进多语句里执行那是性能和安全的双重灾难。我在实测中还发现一个有意思的现象当rewriteBatchedStatementstrue且批量里的SQL带ON DUPLICATE KEY UPDATE时重写效果会退化某些驱动版本甚至完全停止重写退化为逐条发送。如果你在这种场景下有性能瓶颈先确认驱动版本再考虑是否要拆成先UPDATE再INSERT的逻辑这种实际经验在官方文档里通常找不到。5. 常见问题与排查技巧实录5.1 报错check the manual that corresponds to your MySQL server version这是多语句执行最常见的报错完整信息类似You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near xxx at line 1。遇到这报错先别急着检查SQL语法优先排查是不是没开启allowMultiQueriesMySQL驱动默认只允许一条语句当你把用分号拼接的SQL传给它时驱动不会替你去拆分成多次执行而是直接把包含分号的整段SQL发给服务器服务器又不认识这种写法于是抛出语法错误。我的排查顺序是固定的确认连接串里有没有allowMultiQueriestrue。确认不是PreparedStatement配合多语句使用多语句模式在PreparedStatement下部分驱动版本行为不一致能不用就不用。用数据库客户端工具直接粘贴SQL执行一遍排除SQL本身语法问题。检查SQL字符串中是否分割符不是分号而是其他符号比如有些人用了中文分号肉眼看不出来。5.2 批量执行时的内存溢出与通信超时批量执行最常见的两类非功能性故障第一类是内存问题第二类是超时问题。批量队列是存在客户端的如果addBatch()了几万条而迟迟不执行PreparedStatement内部的缓冲区会持续膨胀最终触发OutOfMemoryError。这种故障在测试环境很难发现因为测试数据量小生产环境一上量就炸。我的经验是批量大小不宜超过1000条每满一个批次立即executeBatch()并commit()同时把自动提交关掉批次控制权完全交给代码而不是依赖驱动默认的逐条自动提交。第二类是通信链路超时现象是执行长时间没有响应然后抛Communications link failure。这往往不是SQL本身慢而是连接读写超时设置太短。可以在连接串里显式设置socketTimeout和connectTimeout例如socketTimeout60000单位是毫秒。如果SQL确实比较重还应该从数据库层面去查慢查询日志判断SQL执行本身的时间。针对这两种故障最实用的应对手段是记录批次的执行进度不要等全部跑完才输出日志。用日志每间隔几个批次输出当前已处理行数能快速定位到卡在哪一段为排查节省大量时间。5.3 多语句执行的安全底线SQL注入与事务控制最后必须把安全和事务这两件事单独拎出来强调。先说SQL注入。allowMultiQueriestrue模式下最大的危险性在于分号成为语句边界恶意输入可以截断原SQL并追加任意操作。比如代码里写了String name userInput; stmt.execute(SELECT * FROM t_user WHERE name name );如果userInput是 OR 11; DELETE FROM t_user; --那在开启多语句的数据库上DELETE就会跟着查询一起执行。这比单语句模式下的注入危害大得多。所以开启多语句后涉及任何外部输入的部分一律改用PreparedStatement且不要把外部输入拼进多语句字符串实在需要动态值宁可折中一下把动态部分单独用PreparedStatement执行静态脚本部分放在多语句里。再说事务控制。多语句执行时MySQL服务器会在无显式事务的情况下自动提交每一条语句。比如下面这段conn.setAutoCommit(true); stmt.execute(INSERT INTO a ...; UPDATE b ...;);一旦第二条UPDATE执行失败第一条INSERT已经提交数据库处于半完成状态。如果你要求两条语句具备原子性必须先setAutoCommit(false)多语句执行完后显式commit()异常时rollback()。另外要注意MySQL多语句执行碰到中间任一语句失败时默认会停止后续语句的执行而前面已经执行的语句在事务回滚之前是保持打开状态的。所以把整个执行过程包进事务再设置合适的回滚逻辑是保证数据一致性的前提。我给团队定的规矩很简单生产环境默认不开allowMultiQueries确需开时多语句入口只接收内部脚本并且强制关闭自动提交。这条规矩在多个项目里都避免过实际事故值得沿用。6. 一点经验补充多写几条JDBC之后我最大的体会是不要为了追求一行代码执行多条SQL的炫技感忽略了数据库驱动在底层做的事情。JDBC从来不是把SQL文本扔过去就完事这么简单它涉及到连接管理、事务边界、网络往返、结果集迭代以及最为关键的防注入设计。如果你现在手头的数据变更任务还停留在循环里一条条执行的阶段优先把rewriteBatchedStatementstrue和Batch批量提交用好这一步对性能的提升立竿见影。如果手里有固定脚本需要一次性执行再考虑allowMultiQueriestrue但务必管好入口、关掉自动提交、做好异常回滚。逐条执行、批量执行、多语句执行三者各有用武之地摸清各自边界写出来的代码才能真正扛得住生产环境的压力。