我接手这个优化任务的时候,第一反应是查代码,结果被一条批量插入给气笑了。线上一个数据同步接口,用MyBatis往MySQL里灌数据,单次插入一千两百条,接口响应时间稳定在4分50秒左右,偶尔还飙到6分钟。业务方天天催,说这个同步任务再这么跑下去,下游报表就别出数了。
当时我盯着日志里那行Preparing: insert into table_x (col1, col2, ...) values (?, ?, ...), (?, ?, ...), ...,后面的问号数量多到屏幕都放不下。我把这条SQL复制出来数了一下,一千两百条数据,一条SQL里拼了三万多个参数占位符。这种写法,MySQL解析起来不慢才怪。5分钟到3秒,不是靠某一个魔法参数实现的,而是靠三个层面的优化叠加出来的。这篇文章我把完整的排查链路和优化步骤拆开讲,每一步都给到可以直接抄作业的配置和代码。
1. 5分钟卡在哪:先把慢查询的账算明白
动手优化之前,我先做了两件最基础的事:打开MySQL的慢查询日志,确认这条大SQL的真实执行耗时;然后用SHOW PROFILE和EXPLAIN把执行链路拆开。结果很有意思,数据库执行这条插入SQL本身只用了不到200毫秒,真正的耗时大头在应用层——MyBatis在循环里逐条解析XML、拼装SQL、设置参数,以及JDBC驱动一条条执行时产生的网络往返。
1.1 逐条Insert的隐性开销
任何一条SQL从应用发到数据库,都要经过"应用组装 → 网络传输 → 数据库解析 → 执行 → 返回结果"这条链路。如果是一千两百条独立的小SQL逐条发送,这条链路就要走一千两百遍。即使每次网络往返只要2毫秒,光是网络延迟就吃掉了2.4秒,再加上每条SQL的解析、事务日志写入、行锁竞争,5分钟就这么堆出来了。
我用lsof和tcpdump简单抓了一下包,发现在优化前,程序确实是在一条一条地发Insert语句。JDBC驱动默认的rewriteBatchedStatements是false,即使你在MyBatis的Mapper里写了batch模式,驱动也会把批处理拆成单条执行,这是很多团队踩过却没意识到的坑。
1.2 大SQL还有一个隐藏的地雷:undo log和redo log
把一千两百条数据拼成一条大SQL,虽然执行时MySQL解析SQL的成本还算能接受,但InnoDB在一条事务里写入大量行时,会产生大量的redo log刷盘,同时undo log要记录旧版本用于回滚。如果事务提交间隔太短、或者每次批量太大导致单个事务的undo log膨胀,数据库会出现明显的抖动。这个场景我在优化前的压测里亲眼见过,CPU使用率波动很厉害,偶尔还触发磁盘IO瓶颈。
换句话说,优化方向不是去死磕某一条SQL的写法,而是要降低真正引起耗时的那部分开销——逐条执行的次数和单事务的数据量。想明白这一点,后面的优化方案就有了清晰的取舍依据。
2. 优化一:把JDBC驱动最强的批量能力打开
这一层是整个优化里ROI最高的一步,改一个连接参数就行,但绝大多数项目根本不知道它的存在。
2.1 rewriteBatchedStatements这个参数
MySQL的JDBC驱动,有一个默认关闭的参数叫rewriteBatchedStatements。它的作用是:当开启时,驱动会把连续的、结构相同的批量Insert语句,重写成一条多VALUES的复合Insert语句发送到数据库。没开启的时候,JDBC的executeBatch()实际上是拆成一条条单语句逐条发送的——这一点坑了无数人。
我是在数据库连接串上做的改动,运行环境是Spring Boot项目,数据库连接池用的Druid。修改后的jdbc-url是这样的:
jdbc.url=jdbc:mysql://127.0.0.1:3306/app_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true就这一个参数,配合代码里真正调用批量API,实测单批次插入一千两百条的耗时直接从4分50秒降到了35秒左右。这个参数对Insert、Update、Delete的批处理都有效,建议只要使用JDBC批量操作的场景,都无脑开启。它的副作用几乎可以忽略,唯一需要注意的是,开启后MyBatis会把多个Insert拼接成一条大SQL,SQL文本会变长,但只要不超过max_allowed_packet就没事。
2.2 调大max_allowed_packet,别让SQL被拦腰截断
拼成一条大SQL后,第一个暴露的问题就是包体超限。MySQL默认的max_allowed_packet是4MB,我这边一千二百行、每行20多个字段,拼完的SQL文本已经超过4MB了,直接报PacketTooBigException。
我当时的处理方式:
# 在MySQL实例上执行,session级别立即生效 SET GLOBAL max_allowed_packet = 64*1024*1024; SET SESSION max_allowed_packet = 64*1024*1024;然后在配置文件my.cnf里持久化:
[mysqld] max_allowed_packet=64M设置完要重启数据库实例才彻底生效,生产环境重启前记得先评估连接池和正在跑的任务。这一步不做,rewriteBatchedStatements等于白开。我建议按数据量的十倍预估SQL文本大小,宁可给足也别让线上偶发报错。
3. 优化二:放弃XML里拼foreach,换成真正的Batch API
当时代码里的Mapper方法是用<foreach>标签在XML里拼接values,这种方式本身就是批量插入性能杀手。它的问题在于:MyBatis做动态SQL解析时,会为每一条记录执行一次OGNL表达式求值,然后把所有记录拼接成一整条SQL。数据量一大,解析和内存copy的开销会滚雪球。
3.1 为什么foreach拼SQL在小数据量下好用,大数据量下必炸
如果你的业务是一次插入几十条,foreach这种方式完全没问题,代码简单、直观,一眼看懂。但到了几百上千条的规模,它有三个硬伤:
- XML解析开销:MyBatis需要为每条记录解析
<foreach>集合表达式,生成对应的参数映射,一千条记录就有上千次解析。 - 参数对象占用:所有插入的值都会放到一个巨大的参数数组中,占用的堆内存不小,触发GC时还会STW卡顿。
- SQL文本过长:拼出来的SQL可能是几十万字符,网络传输和数据库解析都不轻松。
所以我的第二层优化,是把数据插入改成彻底走SqlSessionTemplate的批量模式,绕开XML拼接。核心代码如下:
@Autowired private SqlSessionTemplate sqlSessionTemplate; public void batchInsertWithBatchMode(List<MyData> dataList, int batchSize) { // 关键点:用BATCH执行器,这个执行器会把多条SQL累积起来,最后统一提交 SqlSession session = sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH, false); try { MyDataMapper mapper = session.getMapper(MyDataMapper.class); for (int i = 0; i < dataList.size(); i++) { mapper.insert(dataList.get(i)); if (i % batchSize == 0 && i > 0) { // 每攒够一批就刷一次,避免内存里积压太多未提交SQL session.flushStatements(); } } session.commit(); } catch (Exception e) { session.rollback(); throw e; } finally { session.close(); } }这里有个细节必须解释:SqlSessionTemplate默认替我们管理SqlSession,但当我们要使用ExecutorType.BATCH时,不能直接拿template的Mapper调insert,那样不会生效。必须openSession(ExecutorType.BATCH)手动开一个session,拿到BATCH执行器。BATCH模式下,Mapper里的单条insert方法调用并不会真正执行SQL,而是被JDBC驱动累积到批量队列里,最后由flushStatements()或commit()一次性发送。这一步配合第一节的rewriteBatchedStatements,才真正发挥批量威力。
3.2 BATCH模式的正确姿势:批大小和刷新时机
用过BATCH模式的同学应该知道一个坑:flushStatements()太频繁,批量就失去意义;完全不flush,内存又扛不住。我这边压测后确定了一个经验值——每一批500到1000条之间是收益最高的区间。
我用的是每500条flush一次,完整处理一千两百条也就flush两三次,效果很平稳。刚开始我尝试每100条flush一次,耗时反而涨了一点,因为flush太频繁,JDBC驱动的批量队列还没攒够就被强制发送了。批大小、flush频率、事务边界,这三角关系建议大家在测试环境用自己的数据量压一遍,别直接抄网上的数字。
改完这一层,单批一千两百条数据的插入耗时从35秒降到了7秒左右。接下来还有最后一步,把最后一层时间挤出来。
4. 优化三:拆事务、控批次、让数据库少做点事
到了这一步,单次请求的SQL数量和体积都已经优化得差不多了,但还有一个始终拖着后腿的地方:一条事务塞下太多数据,InnoDB在刷redo log和做行锁管理时的成本会成倍上升。我把数据量进一步分级:超过一千条就拆分批次,每个批次一个独立事务,避免单事务数据量过大。
4.1 分批插入和事务边界的权衡
这里不是简简单单把List切个片就完事。事务边界的选取必须结合业务语义。比如我这个同步任务,允许单批次失败重试,那么每个批次独立提交就是合适的。如果一个业务要求全部成功或全部失败,那就不应该拆事务,而是在事务内分批flush。
我当时给每个批次定为800条,块与块之间独立提交,最终代码如下:
@Transactional(rollbackFor = Exception.class) public void syncBatch(List<MyData> fullList) { int batchSize = 800; for (int i = 0; i < fullList.size(); i += batchSize) { List<MyData> batchList = fullList.subList(i, Math.min(i + batchSize, fullList.size())); batchInsertWithBatchMode(batchList, batchList.size()); } }但注意一个细节:外层方法加了@Transactional,会开启一个大事务,内层每个批次的独立提交就不生效了,真正生效的是一个超大事务。所以要么外层不加事务,要么把事务边界直接下放到批次方法内部。我在最终版本里把事务注解移除了,改成在batchInsertWithBatchMode方法内部自行commit或rollback,这样每个批次的事务边界才清晰可控。
为什么批次定为800而不是更大的5000?我实测过三组数据,列在下面:
| 批次大小 | 总耗时 | 事务日志情况 | 我的结论 |
|---|---|---|---|
| 200 | 9秒 | 事务提交频繁,redo log小刷多次 | 太碎,网络和日志刷写开销分摊不低 |
| 800 | 3.2秒 | 事务提交适中,整体平滑 | 当前场景最优 |
| 5000 | 3.1秒 | 单事务太大,undo膨胀,有抖动风险 | 收益不明显且有隐患 |
批次越大,批量SQL数量越少,理论上DB执行越快,但单个事务的undo log、行锁竞争会增加。800这个数值在当前表结构和数据量下收益最高,再往上边际收益几乎为零,风险反而上升。这个结论不一定适合所有场景,但思路是可复用的:在生产数据量下画出耗时曲线,用数据说话,而不是凭感觉定批大小。
4.2 小心连接池的maxActive限制
还有一个小坑,大批量数据操作时连接池状态会变得很微妙。我当时的Druid连接池配置了maxActive=50,批量插入的单连接占用时间并不长,但连续大批量插入时,如果其他接口也在占用连接,连接池很容易被打满,表现为wait_time异常升高。后来我把Druid的连接泄漏检测打开,并让批量任务的连接使用时间控制在1秒以内,情况立刻缓解。
给一段可以参考的连接池核心配置:
spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 50 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true time-between-eviction-runs-millis: 60000如果批量任务很多,建议给批量任务单独的DataSource,避免跟在线接口抢连接,这是大型项目的常见做法。
5. 最终验证:3秒是从哪一秒开始算的
优化做完了,得给出让业务方信服的对比数据。我在同样的环境、同样的机器配置、同样的数据量下做了五轮前后对比,平均结果如下:
| 优化阶段 | 单批1000条耗时 | 说明 |
|---|---|---|
| 原始foreach逐条 | 约290秒 | 大部分耗时在网络往返和逐条SQL执行 |
| 开启rewriteBatchedStatements | 约35秒 | 驱动把批量语句重写为多VALUES大SQL |
| 改走BATCH模式+flush | 约7秒 | 去掉了XML拼接和OGNL解析开销 |
| 设定合理批大小+事务边界 | 约3秒 | 数据库日志写入和锁竞争显著下降 |
有一个容易被忽略的细节:3秒这个数字,一开始我认为是纯批量插入的耗时,但后来看一下监控,发现有一小部分是日志打印和对象转换。也就是说,如果你的代码里还在循环里打log、做一堆字段convert,这些时间也算进了接口响应里。优化SQL的同时,把循环里无谓的logger.info和重复的Bean复制也处理一下,耗时能再往下降。
我当时顺手做了一件事:把数据对象的字段映射从反射拷贝改成了手动getter/setter赋值,对象转换的耗时又少了大约400毫秒。反射在单条数据上看不出毛病,但套上几千上万条数据,开销会成倍放大。
6. 优化之后,还需要注意的几个稳定性问题
优化不是改完配置就结束了。上线前我对几个隐藏问题做了充分验证,这里也分享出来。
6.1 数据一致性与自增主键回填
用BATCH模式执行插入时,自增主键的回填行为跟普通单条插入不一样。如果你的业务需要插入后马上拿到自增ID,使用BATCH模式会拿不到或者拿到的是错误的ID。这个场景下建议继续用foreach拼接的方式,或者插入完成后重新按业务唯一键查询ID。
我当时这个同步任务不需要回填主键,所以BATCH模式完全没问题。如果你的代码里有useGeneratedKeys,切换到BATCH模式之前一定要在上线前测一下,这个坑踩到的话,影响面非常大。
6.2 大事务回滚风险
批量插入最大的风险不是慢,而是做到一半失败后回滚。优化前5分钟的插入,如果在最后一步失败,MySQL回滚的耗时跟正向插入差不多,也是接近5分钟。这5分钟里,事务锁住的那些行,其他线程全部阻塞。我见过一个生产事故,一个批量回滚拖垮了整个订单服务。
所以不要以为批量任务慢才需要优化,批量任务更应该控事务大小,就是为了让回滚时间可控。800条一个事务,单事务回滚时间实测在50毫秒左右,业务完全无感。这一点我认为比缩短插入耗时本身更有价值。
6.3 动静分离:批量任务单独线程池
优化完以后,我把这个同步接口从业务主线程里拆了出来,扔到了独立的线程池里,交给一个调度任务每天固定时间跑。原因很简单:批量操作即使优化到3秒,也还是会占用数据库连接和CPU资源,如果用户点击一下就要等这3秒,体验很差。把核心插入逻辑异步化,主线程立刻返回,再配合一个独立的批量任务线程池,彻底避免批量任务拖垮在线接口。
线程池的参数是根据生产环境压测定的:
ThreadPoolExecutor batchPool = new ThreadPoolExecutor( 2, // 核心线程 4, // 最大线程 30L, TimeUnit.SECONDS, // 空闲回收 new ArrayBlockingQueue<>(100), // 有界队列 new ThreadPoolExecutor.CallerRunsPolicy() // 满了之后用调用线程执行,防止拒绝 );批处理任务一般IO密集度很高,线程数不需要开太多,2到4个足够。如果开8个,反而会把数据库连接和磁盘IO打满,影响其他在线服务。这块建议根据实际压测来调整,不要盲目调大。
6.4 排查工具怎么选
整个排查过程,我用的工具其实非常基础:MySQL的SHOW FULL PROCESSLIST抓慢SQL,用自带的information_schema看事务和锁等待。定位网络往返问题,用的是Wireshark简单抓包看MySQL报文。这些工具不需要专门装什么平台,但能让你很清楚看到瓶颈在网络层还是数据库执行层。这里也提醒一句:遇到批量插入慢的问题,先判断瓶颈是"网络往返次数太多"还是"单条SQL执行太慢",判断错了,优化方向就全偏了。
7. 这一趟优化下来,我沉淀的几个经验
整个优化做完,我不只是拿到了一组好看的数字,更重要的是总结出了几条复用价值很高的经验,以后再遇到类似的批量插入慢的问题,基本都能按这个思路快速定位。
先区分是应用层慢还是数据库层慢。这个看似废话,但太多人一上来就调MySQL参数,最后发现SQL根本没执行几次。我当时先用SHOW FULL PROCESSLIST观察了20秒,发现SQL一条条轮番出现、数据库没闲着,但每一条都极短——这是典型的应用层逐条发送问题。先看现象再定位,别急着改配置。
rewriteBatchedStatements是性价比最高的开关。我用了这么多年MySQL,一直到最近才认真研究这个参数,之前一直以为JDBC的executeBatch就是真正的批量。这个参数默认关闭,很多教程里提都没提,但这个开关一打开,批量插入性能是天壤之别。建议所有用JDBC做批处理的工程,先检查这一个参数再谈其他优化。
批大小需要"压测后确定",不是越大越好。很多人都以为分批时批越大越快,实际结果从800到5000几乎没有差别,但风险却大了很多。对大多数业务场景来说,500到1000是一个兼顾性能和稳定的甜点区间。盲目追求"一锅端"反而是很多生产事故的温床。
我自己现在做批量任务设计时,默认的三条准则是:每条SQL都不要超过一千行,每个事务都不要超过一千条记录,每个批量队列flush前都要确认对端可以接受大包。想明白这三点,批量插入其实没有那么神秘,做到3秒也并不是什么极限操作,无非就是把每一层该省的开销都省掉罢了。