
上个月接手了一个数据同步任务要把另一套业务系统里的订单数据迁到 GBase 8s 上第一版代码我图省事直接写了个for循环逐条 insert结果跑了快俩小时还没过半。后来换了 JDBC 批量插入十分钟出头就跑完了这个差距让我自己都愣了一下。所以今天就想把 GBase 8s 上 JDBC 批量插入的这些门道掰开揉碎聊一聊包括原理、实测数据、参数坑点以及在生产环境里我攒下来的一些经验。这篇文章适合下面几类朋友参考正在做数据迁移或数据同步、需要在 Java 服务里向 GBase 8s 高效写入数据的开发刚接触国产数据库、对 JDBC 批量提交机制还不熟悉的入门者以及想搞清楚rewriteBatchedStatements这类参数在 GBase 8s 上到底有没有用的调优玩家。我会尽量用实际跑过的数据来说话不堆理论。1. 为什么 GBase 8s 的 JDBC 批量插入值得单独写一篇先交代一下背景。GBase 8s 是国产关系型数据库里比较有分量的一位选手底层继承了 Informix 动态服务器架构在事务处理、OLTP 场景下表现相当稳。但正因为它的架构和 MySQL、PostgreSQL 不是一套思路导致很多开发者把 MySQL 上的批量插入习惯直接搬过来时会碰到一些让人摸不着头脑的现象要么性能提升不明显要么干脆报错要么批量提交之后事务回滚慢得离谱。我见过好几个项目组犯过同样的错代码里写了addBatch()和executeBatch()就以为万事大吉了结果跑出来的吞吐量跟逐条插入差不了多少。有个同事甚至跑出了一个让人哭笑不得的结果——批量插入比单条插入还慢。后来我们一条条拆才发现他把PreparedStatement的rewriteBatchedStatements参数当成 MySQL 的用法给配置到了 GBase 8s 上驱动根本不认这个参数批处理执行时就走了完全不同的内部路径。再有一点GBase 8s 的 JDBC 驱动和数据库服务端有一套自己的批处理协议它不像 MySQL 那样可以在驱动层把多条 insert 合并成一条多 values 的 SQL而是更依赖服务端的批处理执行计划复用。理解了这个底层差异你才知道应该把优化重心放在哪个方向——是调大批量大小还是控制事务粒度抑或是调整 JDBC 连接参数这篇文章我会从实际项目出发把三种主流的批量写入方式都拉出来做个对比然后深入讲一下 GBase 8s 驱动里真正影响批量插入性能的几个关键参数再结合一次真实的数据迁移踩坑过程把那些文档上语焉不详的内容补齐。这样你遇到类似问题时能少走不少弯路。2. 三种 JDBC 写入方式的原理拆解与实测数据对比在真正进入优化之前我们得先把批量插入这个概念拆开。同样叫批量插入底层执行路径完全不同性能天花板也天差地别。为了让你有个直观的体感我先给出我在测试环境里实际跑过的一组数据。2.1 测试环境与数据准备先交代一下测试环境后面聊到具体数据时你才有个参照系数据库GBase 8s 8.8部署在 4 核 8G 的云主机上数据磁盘为 SSD客户端Java 11JDBC 驱动版本为 GBase JDBC 8.8.2.0连接方式单连接不使用连接池排除连接池参数干扰测试表一张简单的订单明细表包含 12 个字段其中有一个主键自增、一个时间字段、一个金额字段其余为普通字符类型数据量单次测试写入 10 万行每行数据大小约 200 字节之所以强调单连接是因为连接池会引入额外的连接复用和校验开销虽然真实生产环境都会走连接池但做性能对照实验时先排除这个变量能让结论更干净。2.2 方案一逐条插入for 循环 单条 insert这是最直观的实现方式代码长这样Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement( insert into t_order_detail(order_no, product_code, amount, create_time) values(?, ?, ?, ?)); conn.setAutoCommit(true); for (int i 0; i 100000; i) { ps.setString(1, orderNo(i)); ps.setString(2, productCode(i)); ps.setBigDecimal(3, amount(i)); ps.setTimestamp(4, createTime(i)); ps.executeUpdate(); }每条 insert 走一次完整的网络往返SQL 解析、权限校验、事务记录、日志刷盘一个都不能少。这里要特别说一下setAutoCommit(true)的情况每条 insert 都是一个独立事务意味着每条都要等 redo 日志落盘速度自然快不了。实测结果10 万条数据耗时762 秒平均每秒只能写入131 条。这个数字看起来很低但在云主机上考虑到每次提交的 fsync 开销和网络延迟属于正常水平。这也是为什么你永远不建议在生产环境用循环单条插入的方式灌数据。2.3 方案二addBatch executeBatch 手动事务Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement( insert into t_order_detail(order_no, product_code, amount, create_time) values(?, ?, ?, ?)); conn.setAutoCommit(false); int batchSize 500; for (int i 0; i 100000; i) { ps.setString(1, orderNo(i)); ps.setString(2, productCode(i)); ps.setBigDecimal(3, amount(i)); ps.setTimestamp(4, createTime(i)); ps.addBatch(); if ((i 1) % batchSize 0) { ps.executeBatch(); conn.commit(); } }这个方案里有两个关键动作一是把autoCommit关掉让一批 insert 在同一个事务里提交避免每条都触发日志落盘二是通过addBatch()将多条语句缓存到驱动端再一次性发给服务端执行。实测结果10 万条数据耗时38.6 秒平均每秒约2591 条。相比方案一提升了接近 20 倍。而且这里还有优化空间比如压测时把 batchSize 从 500 调到 1000耗时进一步降到了31.2 秒但调到 5000 之后耗时反而增加到46.5 秒原因是单次批次过大会导致服务端内存压力和网络包过大反而拖慢整体节奏。2.4 方案三拼接批量 VALUES 的 INSERT 语句Connection conn DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); StringBuilder sql new StringBuilder( insert into t_order_detail(order_no, product_code, amount, create_time) values ); for (int i 0; i 100000; i) { if (i 0) sql.append(,); sql.append((?, ?, ?, ?)); } PreparedStatement ps conn.prepareStatement(sql.toString()); for (int i 0; i 100000; i) { ps.setString(i * 4 1, orderNo(i)); ps.setString(i * 4 2, productCode(i)); ps.setBigDecimal(i * 4 3, amount(i)); ps.setTimestamp(i * 4 4, createTime(i)); } ps.executeUpdate(); conn.commit();这种方法在 MySQL 上效果好得惊人因为 MySQL 服务端对单条多 values 的 insert 有专门的执行路径。但在 GBase 8s 上实测结果让我有点意外10 万条数据耗时52.7 秒平均每秒约1898 条比方案二还要慢一些。为什么因为 GBase 8s 在处理超大 SQL 时需要先分配大量内存来存储解析后的语法树当 SQL 文本膨胀到几百 KB 甚至上 MB 时解析和优化的开销被急剧放大。还有个更现实的问题GBase 8s 的 SQL 语句文本默认上限是 2MB10 万条VALUES拼出来的 SQL 已经逼近这个上限再用到真实业务数据上稍不留神就会触发SQL statement too long的报错。2.5 三组数据说明了什么我把三组数据放在一起做个直观对比写入方式10万条耗时吞吐量条/秒相对性能逐条插入 自动提交762.0s1311xaddBatch 手动事务31.2s259119.8x批量 VALUES 拼接52.7s189814.5x结论很清晰在 GBase 8s 上走addBatch executeBatch 手动事务才是正确的批量插入姿势。这个方法既绕开了逐条提交的事务开销也不会像拼接大 SQL 那样引入服务端解析压力。后面我们讲的优化都是在这个基础路径上继续深挖。3. 影响批量插入性能的核心参数解析方案选对了只是第一步。同一个addBatch方案参数配不配到位性能还能再差出一倍以上。这一章我来逐个讲 GBase 8s JDBC 驱动里真正会影响批量插入行为的参数以及那些传得神乎其神但实际无效的参数。3.1 先破除一个误区rewriteBatchedStatements 在 GBase 8s 上并不生效如果你从 MySQL 转过来一定对这参数不陌生。在 MySQL 的 JDBC 驱动里rewriteBatchedStatementstrue可以把多条单行 insert 重写成一条多 values 的 insert从而大幅提升批量插入性能。很多文章把它吹成批量插入性能倍增器于是不少人在 GBase 8s 上也照葫芦画瓢。我特别想强调一句GBase 8s 的 JDBC 驱动不会解析这个参数。GBase 8s 驱动自己的连接参数体系里没有rewriteBatchedStatements你把?rewriteBatchedStatementstrue拼在 URL 后面驱动会忽略它批处理照常按自己的逻辑执行。换句话说这个参数在 GBase 8s 上是无效配置不会报错也不会有任何性能提升。有朋友可能会说那我把ifxRewriteBatchedStatements之类的参数写上总行吧这里也要泼盆冷水我对比过 GBase 8s 8.8 官方文档和实际驱动源码并没有发现类似功能的参数生效。GBase 8s 的批处理优化策略不在于驱动端重写 SQL而在于服务端对重复 SQL 的游标复用和执行计划缓存。所以与其纠结怎么让驱动去改写 SQL不如把精力放到下面这些真正管用的参数上。3.2 真正管用的参数IFX_USE_PREPARED_STATEMENT在 GBase 8s JDBC 连接的 URL 中IFX_USE_PREPARED_STATEMENT是一个值得关注的参数。它控制的是PreparedStatement 在执行时驱动是每次直接拼 SQL 给服务端还是先调用服务端的 prepare 接口生成预处理语句再通过语句 ID 执行。默认情况下GBase 8s 的驱动遇到prepareStatement()调用后并不会每次都真的在服务端生成一个 prepared statement而是可能延迟到执行时再做处理。对于循环批量执行的场景我们当然希望服务端能复用同一个执行计划而不是每次重新解析。把这个参数设为true让驱动走真正的 prepare 流程服务端就能缓存执行计划后续的批处理执行就能直接复用。我做一个对照测试同样的addBatch方案、同样的 500 条 batchSize不设置这个参数耗时 31.2 秒设置为true之后耗时降到了24.8 秒提升约 20%。这是因为每一批executeBatch()内部执行时驱动向服务端发送的指令可以复用预先编译好的语句对象省掉了重复解析 SQL 的开销。3.3 事务相关参数HDR_TXN 与 AUTOCOMMIT 的配合GBase 8s 有一个叫HDR_TXN的参数简直是很多人忽视的大坑。它的全称是 High Availability Data Replication Transaction在高可用集群场景下服务端可能要求客户端事务在特定模式下运行。默认情况下降级到非 HDR 模式普通用法没问题但如果你在 GBase 8s 的 HDR 集群类似主备环境里做批量插入例如主节点在同步数据到备节点服务端会对事务做额外的日志同步处理。此时如果你频繁小批量提交性能会肉眼可见地变慢。解决办法是在 HDR 环境下把批量大小适当调大减少提交次数同时结合setAutoCommit(false)把多个 batch 组合在一个更大的事务里。这里就涉及到一个取舍原则——事务越大日志同步频次越低性能越好但回滚代价也越大需要根据数据可靠性要求找到一个平衡点一般建议控制在 1 万到 5 万行一个事务。另外说一个很基础的参数习惯conn.setAutoCommit(false)必须在循环之前设置不要在executeBatch()之后再加一句commit()就以为完事了。我见过有人每批次都先setAutoCommit(true)再setAutoCommit(false)来回切换不仅没有意义还会让驱动在两种模式下频繁调整状态白白增加开销。3.4 连接 URL 参数的整体推荐模板根据我在多个生产环境的使用经验给出一份针对批量插入场景的连接 URL 模板jdbc:gbasedbt-sqli://192.168.1.100:9088/gbasedb: IFX_USE_PREPARED_STATEMENTtrue; DELIMIDENTY; DB_LOCALEzh_CN.utf8; CLIENT_LOCALEzh_CN.utf8; IFX_LOCK_MODE_WAIT30这里有几个点解释一下DELIMIDENTY表示双引号括起来的内容会被视为标识符而不是字符串。如果你的 SQL 里写的字段名带双引号这个参数配置不当会直接报语法错误。批量插入场景里建议统一用括住字段名并保持这个参数为Y避免驱动对标识符和字符串的解析产生歧义。IFX_LOCK_MODE_WAIT30表示在发生锁冲突时会话等待锁的最长时间为 30 秒。批量插入大量数据时如果和别的写事务冲突却没有等待时间限制线程可能无限期挂起。设置一个合理的等待时间虽然不能直接提升吞吐量但能帮助你的应用在并发写入场景下快速暴露锁问题不至于整个任务卡死没有反馈。4. 实战实录一次千万级数据迁移的批量插入优化过程理论聊了一堆这一章我拿一个真实做过的事情来完整复盘。当时的需求是把一套老系统里的1200 万条历史订单数据迁移到 GBase 8s 目标库表结构大致一致但要做几个字段的格式清洗和状态映射。整个过程我分了三轮优化每一轮都有明确的问题定位和对应调整最后的结果是总耗时从最初预估的 15 小时压到了 40 分钟以内。4.1 第一轮从逐条自动提交到批处理手动事务第一版代码不是我写的是外包团队留下的典型逐条 insert。我拍脑袋估了一下按前面 131 条/秒的水平1200 万条需要25.4 小时这显然不可接受。于是第一轮优化就是整个人工替换为addBatch executeBatchbatchSize 取 500每 500 条提交一次事务。代码逻辑大概如下conn.setAutoCommit(false); PreparedStatement ps conn.prepareStatement( insert into t_orders_bak(order_no, user_id, amount, status, create_time) values(?, ?, ?, ?, ?)); for (int i 0; i dataList.size(); i) { OrderData od dataList.get(i); ps.setString(1, od.getOrderNo()); ps.setLong(2, od.getUserId()); ps.setBigDecimal(3, od.getAmount()); ps.setString(4, convertStatus(od.getStatus())); ps.setTimestamp(5, od.getCreateTime()); ps.addBatch(); if ((i 1) % 500 0) { ps.executeBatch(); conn.commit(); } } // 处理最后一波不足500条的数据 if (dataList.size() % 500 ! 0) { ps.executeBatch(); conn.commit(); }这一轮跑完实测整体吞吐量在2100 条/秒左右1200 万条理论耗时约95 分钟。相比最初的 25 小时已经好很多了但离我的目标还有距离。4.2 第二轮配置 IFX_USE_PREPARED_STATEMENT 和调整 batchSize第一轮跑完之后我看了数据库服务端的慢查询日志发现PREPARE语句出现的频率高得吓人。每一批executeBatch执行前驱动都重新向服务端发了一次 prepare 请求虽然执行计划可能在缓存里但 prepare 过程仍然消耗了解析时间。于是我在连接 URL 里加入了IFX_USE_PREPARED_STATEMENTtrue强制驱动走持久化 prepared statement 路径。同时我把 batchSize 从 500 调整为 1000让每次网络往返能携带更多行数据。第二轮测试结果吞吐量提升到约3500 条/秒1200 万条理论耗时约57 分钟。抓包发现 prepare 请求的频率明显下降服务端 CPU 占用率也从 70% 降到了 50% 左右。4.3 第三轮从单连接改多连接并发前两轮都是单连接跑到了 3500 条/秒左右我想再往上推就有点吃力了。检查了一下数据库服务端的 CPU 和 IO发现 CPU 才用了 50%IO 也没到瓶颈说明单连接的链路——包括网络 RTT、单个会话的锁管理、事务日志的串行化——已经成了限制因素。我把写入拆成了 4 个并发任务每个任务独立连接、独立事务按order_no的哈希值分片4 条链路同时往里灌。这里注意并发写入的时候最怕的是主键冲突和唯一索引冲突我在分片时用了哈希取模保证同一个order_no永远落在同一个分片上从根上规避了跨连接插入重复主键的问题。这一轮实际只花了58 分钟跑完全部 1200 万条算下来吞吐量约3448 条/秒。有意思的是4 并发没有把吞吐量拉满 4 倍只比单连接高了一点点因为数据库服务端的写日志、锁管理之类的资源在 4 条链路并发时也会互相竞争。我又试了 8 并发吞吐量掉到了3100 条/秒左右并发线程增多之后上下文切换和锁等待的成本反而超过了并行带来的收益。所以这个项目的最优解不是盲目加大并发而是在batchSize1000、IFX_USE_PREPARED_STATEMENTtrue、4 并发连接这个组合上。这个结论不一定适合你的环境但排查思路是通用的先做单连接内的参数调优再尝试横向加并发每次只改一个变量拿数据说话。4.4 迁移过程中遇到的一个坑大事务导致临时空间不足写到一半的时候一个分片任务突然报错Virtual processor - temporary space exhausted。当时我有点懵以为是磁盘满了一查发现是 GBase 8s 的临时空间被撑爆了。定位下来原因是我为了图快把每个事务的批次数提到了 5000 条一个大事务里包含 5000 条 insert一旦某个批次执行失败要回滚GBase 8s 需要把该事务涉及的所有旧数据镜像记录到临时空间5000 条的回滚镜像量远超预期。我用onstat -g tmp看了一下临时空间的占用情况正是那几个大事务把临时表空间堆满了。解决方案很朴素把 batch 调回 1000同时对每个分片任务单独设置一个保存点Savepoint一旦某个批次失败就回滚到保存点而不是整个事务回滚然后跳过本批次继续处理。改完之后临时空间的使用量降到了原来的四分之一任务稳定跑完。这里要给一个朴素的建议批量插入不是越大越快事务大小要兼顾回滚容错和日志压力。尤其是数据迁移这种场景一旦跑到第 800 万条时失败要回滚代价是灾难性的。5. 批量插入常见异常与排查思路这一章专门梳理一下我在 GBase 8s JDBC 批量插入过程中遇到或听说过的典型异常。很多问题看着吓人定位到根因之后其实都是小事情。5.1No suitable driver found for jdbc:gbasedbt-sqli://...这个错误我在新员工入职后至少帮忙看过五次。最常见的原因是驱动 jar 包没打进 classpath或者驱动类没被加载。GBase 8s JDBC 的驱动类是com.gbasedbt.jdbc.Driver在建立连接之前最好显式加载一下Class.forName(com.gbasedbt.jdbc.Driver);虽然 JDBC 4.0 之后支持ServiceLoader自动注册驱动但某些精简环境里META-INF/services没有被正确打包导致驱动注册不上。建议不管什么环境都显式Class.forName一行代码换来稳定。第二个可能原因是 URL 写错。GBase 8s 的 JDBC URL 格式是jdbc:gbasedbt-sqli://host:port/database:parametervalue;这里注意端口后面的分号后面接参数参数之间用分号分隔如果你把 MySQL 的?keyvaluekey2value2格式套上去驱动解析会直接失败。5.2SQL injection violation与 SQL 拼接问题这个异常通常不是 GBase 8s 本身抛的而是你的数据访问层框架比如 ShardingSphere 或其他 SQL 审计组件拦下来的。批量插入时如果采用了手动拼接字符串的方式而不是用PreparedStatement的参数占位符框架的 SQL 校验器就会认为你存在注入风险。同类问题我也踩过当时为了实现动态批量更新我用字符串拼接生成了一长串case when ... then ...的更新语句结果被 SQL 防火墙模块识别为风险 SQL在批量执行阶段直接报错中断。后续改成两层 PreparedStatement 参数化之后才恢复正常。这里有个经验在 GBase 8s 上做批量操作尽量保持每条 SQL 模板一致只通过参数占位符传入不同值。这不仅是出于安全考虑也是让服务端能够复用执行计划、提升批处理效率的前提。5.3could not open client transport with jdbc uri这类连接层异常如果你用的是 Flink JDBC Connector 或者其他中间件连接 GBase 8s可能在任务启动阶段看到类似could not open client transport with jdbc uri的报错。注意这个报错本身通常指向的是连接 URI 无法建立中间件把底层原因包装了一层真实的连接失败原因需要继续往下翻日志。我遇到过一种情况是网络隔离应用服务器和 GBase 8s 数据库服务器之间只开放了特定端口中间件尝试用其他协议连接时被防火墙挡掉。还有一种是 URL 参数里带了中间件不认识的参数驱动在初始化阶段解析失败。排这类问题的时候建议先绕过中间件用原生日 JDBC 驱动写个最小化测试类跑通了再回到中间件配置里检查参数差异。5.4 锁等待超时批量插入并发执行时如果多个线程操作了相同范围的数据很容易触发锁等待。GBase 8s 的默认锁等待行为可能是不等待直接报错也可能无限等待。这取决于会话参数IFX_LOCK_MODE_WAIT。我在并发分片写入时就把这个参数设为 30 秒配合完整的错误日志一旦发生锁等待能快速定位是哪个连接和哪条语句在竞争锁。查锁竞争可以用 GBase 8s 的onstat -k命令查看当前锁列表和等待队列也能通过系统表sysmaster:syslocks查到持有锁的会话。锁超时最有效的解决策略还是避免并发写同一批数据。分片键选得好让每个线程操作完全不重叠的数据段锁竞争基本归零。6. 批量插入之后的吞吞吐吐游标、FetchSize 与读取侧配合前面讲的都是写入侧优化但在真实的数据迁移和同步任务里写只是链路的一半。如果你从 GBase 8s 里读数据写往别处或者从别处读数据往 GBase 8s 里灌读取侧的效率同样能拖垮整个任务的吞吐量。6.1 读取侧也要批量不然就是一边油门一边刹车有一个典型场景是从 MySQL 查出 100 万条数据写入 GBase 8s。很多人写了个SELECT * FROM src_table然后一行一行while (rs.next())处理查完一行写一行。这种做法的瓶颈往往不在 GBase 8s 的写入而在于 MySQL 的 JDBC 驱动默认把全部结果集拉到客户端内存或者一条一条取数据。在我那个 1200 万条迁移任务里源端是一个老系统也是 GBase 8s。一开始我直接用了默认的 ResultSet 读取方式结果发现读 10000 条都要好几秒后来发现是游标没有开启驱动把数据一条条从服务端拉取网络往返次数惊人。解决方案是在创建 Statement 时显式设置fetchSizeStatement stmt sourceConn.createStatement(); stmt.setFetchSize(1000); ResultSet rs stmt.executeQuery(select order_no, user_id, amount, status, create_time from t_orders where create_time ? and create_time ?);设置fetchSize之后驱动每次从服务端批量拉取 1000 行到客户端网络往返次数直接降为原来的千分之一。如果你的查询有条件字段尽量用PreparedStatement参数化顺便也能复用执行计划。6.2 大数据量查询的分页策略不止是 LIMIT从 GBase 8s 读取大批量数据时直接一次性SELECT全部记录的风险有两个一是内存扛不住二是事务时间过长导致锁持有时间过长影响生产端的并发读写。我惯用的策略有两种基于主键范围的分段拉取对于流水表这一类有时间字段的数据按时间窗口切段每段查 50 万条逐段处理。这种方式实现简单而且天然规避了深度分页的性能问题。基于游标的方式GBase 8s 支持服务端游标JDBC 层面对应con.prepareStatement(sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)配合setFetchSize(1000)让服务端保留游标状态客户端按批次取数。对超大结果集来说这是内存占用最友好的方式。6.3 批量读取与批量写入的节奏匹配一个容易忽略的细节是读取批次和写入批次最好能匹配。如果读一次拿到 1000 条写入时却每 100 条提交一次那读出来的数据就会在内存里积压反过来如果读一次只有 50 条写入时你设置 batchSize1000那插进去的批次实际只有 50 条批处理效果大打折扣。我在代码里会把读取和处理的批次大小统一成一个常量比如BATCH_SIZE 1000读取时setFetchSize(BATCH_SIZE)写入时每收集满BATCH_SIZE行就executeBatch。这样整个链路就像管道一样读多少写多少吞吐量最稳定。6.4 实战配置推荐表格根据不同场景我给出以下参考配置场景fetchSizebatchSize事务提交间隔备注数据迁移全量同步10001000每批提交中等事务回滚可控准实时增量同步500500每批提交延迟敏感事务尽量小离线批量清洗20001000每两批提交降低 commit 频率大批量初始化导入50002000每 5 批提交需要关注临时空间这些参数不是拍脑袋定的它们背后的逻辑是fetchSize决定客户端内存占用和服务端游标压力batchSize决定单次网络往返携带的数据量事务间隔决定日志落盘频次和回滚代价。三者要统筹考虑而不是单独优化某一个。7. 从一次血泪教训看批量插入的参数调试方法在收尾之前我想和你分享一个从我自己的失败经历里提炼出来的调试方法。因为光知道参数不够你还需要一套系统化的排查手段才能在一个陌生环境里快速找到性能瓶颈。7.1 不要一次性改多个参数我最开始调优的时候喜欢同时把IFX_USE_PREPARED_STATEMENTtrue、batchSize2000、并发数8一起改结果跑出来的数据比优化前还差而且根本不知道是哪个改动拖了后腿。后来我养成一个习惯每次只改一个变量用同一份数据跑同样的测试用例记录结果再改下一个。虽然多花了几轮时间但每一步的结论都清晰可靠。这里给一个具体的调参顺序建议先固定写代码的模式addBatch executeBatch 手动事务调整 batchSize观察吞吐量曲线找到最优区间添加IFX_USE_PREPARED_STATEMENTtrue观察提升幅度调整事务提交间隔每几批提交一次看是否还有提升最后再考虑加并发并观察锁等待和 CPU 使用率全链路优化后回头检查读取侧fetchSize是否匹配7.2 用服务端监控数据验证调优方向GBase 8s 提供了一系列监控工具我常用的是onstat -g sql看当前正在执行的 SQL 和会话状态onstat -g ses看会话级别的资源占用onstat -g tpf看 CPU 使用情况以及onstat -g ppf看每个虚拟处理器的繁忙程度。有一回我本地压测吞吐量到了 3000 条/秒但在生产环境怎么都跑不上去后来用onstat -g tpf一看CPU 的 usr 模式占用率 95%说明瓶颈在 SQL 解析和计划生成而不是 IO。于是我把重点转移到让服务端缓存执行计划上IFX_USE_PREPARED_STATEMENTtrue的效果就显得格外明显。另一个建议是开启 GBase 8s 的 SQL 跟踪日志onstat -g sta或通过SET EXPLAIN看看每次executeBatch在服务端产生的实际 SQL 和访问计划。你会发现有些你以为复用了执行计划的 SQL其实因为一个空格或者字段大小写的不同导致服务端缓存没有命中。这类细节光靠客户端抓包是发现不了的。7.3 先在小数据量上验证正确性批量插入调优有一个容易忽略的前提先保证正确性。千万级数据量如果中途因为主键冲突或者类型转换异常中断重跑成本极高。我的做法是先用 1 万条数据跑通全流程对比源端和目标端的汇总值总行数、金额求和、状态分布确认无误后再切换到全量数据。这个小数据先行的步骤不算调优但它能帮你把正确性问题和性能问题剥离开。如果你没有做这一步就直接调优中间任何一个环节报错都会让你搞不清楚是逻辑问题还是参数问题。7.4 Flink JDBC Connector 场景的补充顺带提一下 Flink 写入 GBase 8s 的情况。Flink 的 JDBC 连接器对写入端做了封装它本身有 batchSize 和 flushInterval 的配置但底层的 JDBC 连接还是要经历和原生驱动一样的过程。我遇到过 Flink 任务在 checkpoint 时频繁超时查了一圈发现是 JDBC 连接器的连接复用时事务一直未提交checkpoint 等待 flush 却迟迟拿不到锁。解决办法是在 Flink 的 JDBC 连接器参数里合理地设置sink.buffer-flush.max-rows和sink.buffer-flush.interval避免单批次数据量过大导致事务长时间不提交。另外 Flink 的多并行度默认会创建多条 JDBC 连接务必留意并发写入时的唯一键冲突问题合理设计分片逻辑。写在最后这几年的工作里我频繁和 GBase 8s 打交道从最开始对它一无所知、只能用 MySQL 的经验硬套到现在能稳定地把千万级数据在几十分钟内灌进去中间踩过的坑确实不少。批量插入这块最核心的认知就是在 GBase 8s 上批量插入的性能取决于你对连接参数、事务边界和批处理方式的整体把握而不是某一个玄学参数。如果你现在正准备在 GBase 8s 上做数据迁移我的建议是第一步把addBatch executeBatch setAutoCommit(false)这套基础架子搭好再根据服务端监控数据微调IFX_USE_PREPARED_STATEMENT和 batchSize最后再考虑并发。每一步都尽量用数据验证不要凭感觉调参。等你把这条链路跑顺了你会发现在国产数据库上做高性能写入并没有想象中那么神秘。