☰
Java后端性能优化实战:批量插入、更新与深分页慢SQL调优
2026/10/21 13:37:19 网站建设 项目流程

那次线上告警来得相当突然。某个订单系统的数据接入接口,平均耗时从80毫秒一路涨到接近两秒,数据库CPU直接冲到90%,慢SQL日志里密密麻麻全是insert和select,偶尔夹杂几条update也是秒级返回。我接手排查之后发现,问题本身并不玄学,核心就是三句话:insert是一条一条插,update是一条一条改,select走了深分页扫了整张表。整个过程涉及Java代码层面、SQL写法层面和数据库结构层面的多重调整,正好覆盖insert、update、select三类高频操作。这篇文章就把这次优化经历完整拆开讲,把思路、代码、参数、坑一次性说完,适合正在做Java后端性能优化的同学,也适合想把手头接口吞吐再往上抬一抬的老手。

  • 慢插入:某个批量导入功能在循环里执行单条INSERT,一万条数据就要调用一万次数据库。
  • 慢更新:状态流转任务逐条UPDATE,频繁提交小事务,锁竞争严重。
  • 慢查询:列表接口使用深度分页,LIMIT 100000, 20直接让 MySQL 扫描十几万行。

排查思路不复杂,先看数据库侧的表现,再回应用侧翻代码,最后才动手改。

1. 乱象初现:慢SQL告警与三层定位思路

1.1 现象:从监控指标到代码入口

当时的告警信息很直观:接口P99耗时从220ms上升到1800ms,数据库连接池活跃连接数打到上限,大量请求在等待获取连接。打开MySQL慢查询日志之后,三类SQL原形毕露。

第一类是插入语句的堆积。一行一行插入订单明细,耗时主要不在SQL本身,而在每次执行都独立提交事务,redo log刷盘、网络往返、SQL解析全部重复一万次。第二类是UPDATE语句,集中在同一个业务表上,明显是某个状态机在批量推进工单状态。第三类是SELECT,典型的列表分页查询,翻到第100页之后就开始大量扫描。

定位到具体方法也不难,通过打印调用栈、结合 arthas trace 观察热点方法的耗时分布,基本把范围锁定在三个入口:导入任务、状态流转任务、列表查询接口。这里我的建议是不要一上来就改代码,先把数据摸清楚。确认是哪条SQL在拖垮数据库,确认它的执行计划是不是出了大问题,确认调用频率到底有多高,然后再决定优化方向。

1.2 优化策略:先榨SQL,再动代码

这一次我给自己定了三条优化原则。

第一,SQL能合并的绝不循环执行。数据库最怕的不是单条SQL复杂,而是应用侧用几千次简单SQL把连接池榨干。能批量拼接的批量拼接,能一次更新的多次合并为一次。

第二,索引设计必须和执行计划对齐。不是说有索引就行,而是要确认查询真的走到了索引,回表次数被控制住,排序字段也在索引覆盖范围内。

第三,事务粒度要重新设计。很多人把“事务”默认等同于“整个方法”,结果一个批量任务开启了一个超长事务,行锁一直不释放,后面的更新全部排队。优化的核心之一就是把大事务拆小,把持锁时间降下来。

下面三个章节分别展开 insert、update、select 的优化细节,每一段都会有代码示例和前后对比。

2. 批量insert优化:从逐条插入到批量提交

2.1 单条insert为什么成为全链路瓶颈

先看最初的写法,典型的逐条插入:

public void insertOrders(List<OrderItem> orderList) { for (OrderItem item : orderList) { jdbcTemplate.update( "INSERT INTO order_record(order_no, item_id, amount, create_time) VALUES(?, ?, ?, ?)", item.getOrderNo(), item.getItemId(), item.getAmount(), item.getCreateTime() ); } }

这段代码表面看不出大问题,但如果orderList有一万条,那就等于向MySQL发送了一万次INSERT语句。每次INSERT都要经历:客户端拼接SQL、网络传输、MySQL解析SQL、打开事务、执行写入、提交事务、刷redo log、网络返回。一整套往返走下来,单条耗时可能只要1ms,但一万次累加就会变成不可忽视的延迟。

我实测过一组数据:在相同配置的MySQL实例上,逐条插入10000条,耗时大约是23秒;改成批量提交后,耗时能压到1.5秒以内,性能提升超过10倍。优化空间完全来自减少网络往返和减少事务提交次数。

改造思路也很明确:把“每条数据独立提交”改成“攒一批一起提交”。

2.2 使用JdbcTemplate批量更新接口

Spring的JdbcTemplate提供了batchUpdate方法,正好用来做批处理。改后代码如下:

public void insertOrders(List<OrderItem> orderList) { jdbcTemplate.batchUpdate( "INSERT INTO order_record(order_no, item_id, amount, create_time) VALUES(?, ?, ?, ?)", new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { OrderItem item = orderList.get(i); ps.setString(1, item.getOrderNo()); ps.setString(2, item.getItemId()); ps.setBigDecimal(3, item.getAmount()); ps.setTimestamp(4, item.getCreateTime()); } @Override public int getBatchSize() { return orderList.size(); } } ); }

batchUpdate方法内部会使用PreparedStatement的addBatch和executeBatch机制,将多条INSERT打包发送给数据库执行。对MySQL来说,JDBC驱动还会把多个INSERT组合成一条多VALUES的INSERT语句,减少了解析次数。

如果在MyBatis环境下,同样可以开启批量执行器:

SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH); try { OrderRecordMapper mapper = sqlSession.getMapper(OrderRecordMapper.class); for (OrderItem item : orderList) { mapper.insert(item); } sqlSession.commit(); } finally { sqlSession.close(); }

这里要注意一个容易被忽略的点:ExecutorType.BATCH不等于自动批量提交,它只是让多次update操作复用同一个PreparedStatement并累积执行,最终必须显式commit才会真正落库。

2.3 分批大小与rewriteBatchedStatements参数

批量插入不是一次性把十万条全塞进去就完事。一次batchUpdate的数量太多,会带来几个问题:SQL文本过长超过max_allowed_packet限制、事务持有过多行锁、内存中对象列表过大。

实际中我一般把单批大小控制在1000条左右,分批循环提交:

public void insertOrdersInBatch(List<OrderItem> orderList) { int batchSize = 1000; for (int from = 0; from < orderList.size(); from += batchSize) { int to = Math.min(from + batchSize, orderList.size()); List<OrderItem> subList = orderList.subList(from, to); jdbcTemplate.batchUpdate(SQL, new BatchPreparedStatementSetter() { // 省略实现 }); } }

还有一个MySQL特有的大坑:JDBC驱动参数rewriteBatchedStatements。默认情况下,MySQL的Connector/J驱动执行addBatch并不会真的将多条语句打包发送,而是仍然逐条执行,看起来用了batch,性能却没提升。必须在JDBC URL上显式开启:

jdbc:mysql://localhost:3306/order_db?rewriteBatchedStatements=true&useServerPrepStmts=true

开启这个参数后,驱动才会把一批INSERT改写成INSERT INTO ... VALUES (...),(...),(...)这种形式真正批量发送给服务端。这个参数是这次优化中性价比最高的一个动作,不花一分钱,改动一行配置,性能直接翻倍。

验证连接参数是否生效,可以通过MySQL通用日志或者观察网络包数量来确定。最简单的办法是打开MySQL的general_log,看同一时间点收到的INSERT语句是不是多VALUES形式。

注意:rewriteBatchedStatements=true对UPDATE语句也有影响,它会把批量UPDATE重写成多条数据一起执行,但前提是每一条的SQL结构必须完全一致。如果业务代码里每条UPDATE的动态条件不同,就不会被重写。

2.4 优化前后对比与事务边界设计

优化前后的数据很直观,我还是用一万条订单明细做基准:

方案耗时说明
循环单条INSERT约23秒网络往返9900+次,事务提交10000次
batchUpdate(1000/批)约1.8秒网络往返10次,事务提交10次
开启rewriteBatchedStatements后约1.1秒INSERT语句重写为多VALUES,减少服务端解析

这里需要额外说明事务边界。如果是真正的一次性批量任务,比如凌晨跑批,允许一个事务包含全部数据,这样出了问题可以整体回滚。但如果是线上实时接口,一个事务里插入一万行、持有一万行的行锁和间隙锁,对高并发写入是很危险的,容易造成锁等待甚至死锁。

我的习惯是:实时接口单个事务控制在1000行以内,跑批任务控制在5000行以内。超过这个量就要考虑拆分为多个事务,每批次提交后单独记录进度,失败时从上次断点继续。这也引出一个更深层的问题,批量插入往往伴随着批量更新,下面进入update场景。

3. update优化:减少行锁持有时间与消息积压

3.1 循环update为什么撑不住

这个订单系统里有一个状态流转任务,每天定时把一批待处理的订单状态从“已支付”推进到“已处理”。原始代码写法如下:

public void processOrders(List<Long> orderIds) { for (Long orderId : orderIds) { jdbcTemplate.update( "UPDATE order_record SET status = 3, update_time = NOW() WHERE id = ?", orderId ); } }

这样的写法缺点和逐条INSERT一样:多次网络往返、多次事务提交。但在高并发场景下,它还有一个更致命的问题——锁的粒度和持有时间。

每次UPDATE都会对目标行加行锁,事务提交时才释放。如果代码在for循环里每执行一条UPDATE就自动提交一次,理论上每次持锁时间很短。但实际上很多团队会把整个循环包在一个大事务里,比如用@Transactional注解了processOrders方法,那就会等到全部订单更新完后才统一提交。假设4000个订单分布在连续的主键范围内,前者更新完不提交,后面同范围的更新全部排队。

这次优化中我先把事务边界从“整个方法”改为“每500条一个事务”,再把语句本身改成可批量执行的写法。

3.2 CASE WHEN批量更新与临时表更新

批量更新最简单的落地方式是使用CASE WHEN语法,一条SQL更新多行:

UPDATE order_record SET status = CASE id WHEN 1001 THEN 3 WHEN 1002 THEN 3 WHEN 1003 THEN 4 END, update_time = NOW() WHERE id IN (1001, 1002, 1003);

对应的Java代码通过字符串拼接生成动态SQL。这里我建议使用MyBatis的foreach标签或者自己构建SQL语句,注意控制单批数量。

这种写法最大的优势是减少SQL执行次数。原本1000条订单需要执行1000次UPDATE,现在只需要1次UPDATE,网络往返减少了999次,MySQL解析也只做一次。同时因为所有行都在一条SQL里更新,InnoDB对目标行的锁虽然会全部加上,但锁等待窗口比多次独立提交小得多,因为提交次数减少了。

不过CASE WHEN批量更新并不是银弹,它有两个明显缺陷。

第一,如果一次更新的行数过多,生成的SQL文本会非常长,可能超过max_allowed_packet限制,同时一条语句占用大量内存。我实测单条UPDATE超过2000行时,SQL文本已经比较可观,解析和锁等待时间也会上升。

第二,CASE WHEN方式无法针对不同行设置不同的WHERE条件。它本质上是先通过WHERE id IN锁定候选行,再用CASE字段做行级赋值,适用于按主键更新,但不适用于复合业务条件的更新。

如果更新条件比较复杂,另一种更稳妥的方案是使用临时表:

CREATE TEMPORARY TABLE tmp_order_update ( id BIGINT PRIMARY KEY, status INT ); INSERT INTO tmp_order_update(id, status) VALUES (1001, 3), (1002, 3), (1003, 4); UPDATE order_record o INNER JOIN tmp_order_update t ON o.id = t.id SET o.status = t.status, o.update_time = NOW();

临时表方案需要额外的一次INSERT,但好处是更新逻辑更清晰,且能配合复杂的JOIN条件使用。跑批场景下我倾向于用临时表,实时接口场景下用CASE WHEN,因为临时表的创建和销毁本身有成本。

3.3 版本号控制与分批防重

批量更新还需要考虑并发场景下的数据覆盖问题。比如状态流转任务在批量更新订单状态,同时用户在前端手动取消订单,如果两个操作没有版本控制,就可能出现任务把已取消的订单重新覆盖为“已处理”。

最简单的解法是乐观锁。每次更新都带上当前状态作为条件:

UPDATE order_record SET status = 3, update_time = NOW() WHERE id = ? AND status = 2

在批量更新场景中,可以改为:

UPDATE order_record SET status = 3, update_time = NOW() WHERE id IN (...) AND status = 2

如果返回的影响行数小于期望数量,说明部分订单状态已经被其他请求修改,需要回读数据找出冲突项。实现时可以按影响行数做校验:

int affected = jdbcTemplate.update(sql, args); if (affected < expectCount) { log.warn("批量更新存在冲突,期望更新{}条,实际更新{}条", expectCount, affected); // 触发冲突处理流程 }

另外还有一点必须提醒,不要把状态判断只放在SQL的WHERE条件里。如果应用侧代码先执行SELECT判断状态,再由UPDATE语句执行更新,两个操作之间可能存在时间差,并发下依然会丢更新。正确的做法是把状态条件直接写进UPDATE语句,数据库的行锁会保证条件判断和更新操作原子发生。

3.4 update优化后的实测效果

我拿1000个订单的状态流转做了对比测试,测试条件是相同表结构、相同数据量、相同MySQL配置:

方案耗时说明
循环单条UPDATE,每次自动提交约6.2秒1000次网络往返,1000次事务提交
循环单条UPDATE,一个大事务约5.5秒1000次网络往返,1次事务提交,但锁持有时间过长
CASE WHEN批量更新,1000条一批约0.4秒1次SQL,1次事务提交,锁持有时间大幅缩短
临时表JOIN更新,1000条一批约0.6秒多一次临时表写入,但条件更灵活

这个结果说明,网络往返次数对UPDATE性能的影响是决定性的。CASE WHEN方案把1000次往返压缩到1次,收益非常直接。

踩过的坑也要提一句。当时我直接在原有@Transactional方法里改成CASE WHEN批量更新,结果方法内部分了三批,每批1000条,但因为方法外层的JdbcTemplate默认自动提交,第一批更新完就自动commit了,中间某批失败后无法整体回滚。后来我改成了编程式事务,手动控制每批提交时机,才能保证正确处理。

4. select优化:慢查询的索引重建与分页改造

4.1 通过EXPLAIN定位慢查询的索引问题

列表查询接口是另一处重灾区。页面上的订单列表支持按订单状态、创建时间、渠道来源等条件筛选,同时按创建时间倒序分页。最初查询慢得离谱,第100页之后的请求经常超过3秒。

看EXPLAIN结果:

EXPLAIN SELECT id, order_no, amount, status, create_time FROM order_record WHERE status = 2 ORDER BY create_time DESC LIMIT 100000, 20;

执行计划里type等于ALL,rows估算十几万,Extra字段显示Using filesort。这说明MySQL根本没有用到任何索引约束,而是全表扫描后做了文件排序,再把前100020条结果取出来丢掉前100000条。

这类问题的核心原因通常是索引缺失,或者索引顺序设计不合理。状态字段的区分度很低,即使建了(status)单列索引,MySQL优化器也可能因为估算扫描行数占比过高而放弃索引。真正需要的是一个复合索引:

ALTER TABLE order_record ADD INDEX idx_status_create_time (status, create_time);

这个索引能同时满足WHERE条件过滤和ORDER BY排序,MySQL可以直接按索引顺序读取数据,避免filesort。我加完索引后再看执行计划,type变成ref,rows从十几万降到几百,Extra里不再出现Using filesort。

注意:不是所有查询都适合建复合索引。区分度很低的字段放在最前面,可能导致大量行落入同一索引块,反而增加回表成本。实际设计时要结合业务筛选频率来决定字段顺序。

4.2 覆盖索引与延迟关联

索引优化后,前几十页的查询速度有了显著提升,但深度分页还是不够理想。原因在于,即便用上了索引,LIMIT 100000, 20仍然需要扫描前100000行索引数据,然后逐一回表获取完整行记录,最后丢弃99980行,这是巨大的无效开销。

第一个优化手段是覆盖索引,让查询列全部落在索引里,避免回表。比如上面的查询只查id、order_no、amount、status、create_time五个字段,如果索引是(status, create_time, amount, order_no),那么MySQL可以从索引直接返回所有列,不再访问聚簇索引。

但全字段覆盖在实际业务中很难做到,因为SELECT经常涉及十多个字段,全部塞进索引会让索引体积过大。更通用的方案是延迟关联。

延迟关联的思路是先通过索引快速定位到所需的主键ID集合,然后再用主键去关联原表获取完整数据:

SELECT o.id, o.order_no, o.amount, o.status, o.create_time FROM ( SELECT id FROM order_record WHERE status = 2 ORDER BY create_time DESC LIMIT 100000, 20 ) t INNER JOIN order_record o ON t.id = o.id ORDER BY o.create_time DESC;

子查询部分只返回主键id,索引扫描和排序都发生在聚簇索引之外,减少了回表带来的随机IO。外层再用主键INNER JOIN获取完整行,因为主键是聚簇索引,回表效率极高。

实测数据:LIMIT 200000, 20场景下,原始查询耗时2.1秒,延迟关联后耗时0.15秒,提升超过10倍。

4.3 深分页的终极形态:游标分页

延迟关联能解决一部分问题,但本质上LIMIT大偏移量的少量结果依然需要扫描大量索引数据。如果列表是滚动加载模式,比如App里的下拉加载,最优解是游标分页,也叫keyset分页。

游标分页的核心思路是App在请求时带上上一页最后一条记录的排序字段值,下一页查询直接基于这个游标定位:

SELECT id, order_no, amount, status, create_time FROM order_record WHERE status = 2 AND create_time < '2024-05-20 10:00:00' ORDER BY create_time DESC LIMIT 20;

因为这个游标条件天然符合索引有序性,MySQL可以直接定位到目标位置,前100000条数据完全不扫描。查询耗时稳定在10ms以内,和翻页深度无关。

Java代码侧需要做的改造是把页数参数替换为游标参数。前端不再传pageNum,而是传lastCreateTime和lastId,后端通过这两个字段拼条件。

public List<OrderRecord> queryNextPage(String status, Timestamp lastCreateTime, Long lastId, int pageSize) { if (lastCreateTime == null) { // 第一页 return mapper.selectFirstPage(status, pageSize); } return mapper.selectNextPage(status, lastCreateTime, lastId, pageSize); }

注意一点:如果create_time在业务上可能出现重复值,游标要采用复合条件,避免漏掉或者重复数据。常用写法是create_time < ? OR (create_time = ? AND id < ?),同时用id作为第二排序键兜底。

这个方案并不适合所有页面。比如跳页场景下,用户必须能直接跳到第100页,游标分页并不适用。对于那种情况,我建议仍然使用延迟关联,或者直接限制最大可查页数,业务侧剪裁需求。

4.4 select优化的最终结果

选取三个典型场景做验证,数据量是80万行订单记录:

查询场景优化前耗时优化后耗时核心手段
第10页,每页20条320ms25ms复合索引+避免filesort
第500页,每页20条1600ms180ms延迟关联,减少回表
滚动加载第100次1500ms8ms游标分页,消除深翻页

这四个数字背后其实是同一类问题的不同解法:让MySQL少干活,少扫描,少回表,少排序。

还有一点值得单独说。加索引不是越多越好,索引越多,写入时需要同步更新的索引结构越多,insert和update的开销也越大。这次优化过程中我清理了三个已经不用的单列索引,把写入性能又拉回了几个百分点。索引设计没有标准答案,但原则上能用复合索引覆盖多种查询场景,就不要堆一堆冗余单列索引。

5. 优化工具箱与排查技巧速查

5.1 常用性能分析工具组合

如果你也准备排查类似的Java数据库性能问题,我建议先准备好以下工具链,排查效率会高很多:

  • 数据库侧:开启慢查询日志,设置long_query_time为1秒,定期分析慢日志输出。
  • 执行计划分析:使用EXPLAIN查看SQL的扫描行数、索引使用情况、Extra字段内容。
  • 调用链追踪:通过arthas的trace命令定位方法热点,确认耗时是花在数据库调用还是业务逻辑。
  • 连接池监控:观察活跃连接数、等待获取连接数、连接创建销毁频率。连接池被打满往往意味着SQL执行时间过长。

这些工具不需要一次性全部上,但至少要有一个慢查询日志,否则排查无从下手。

工具选型方面,MySQL自带的mysqldumpslow可以快速聚合统计慢日志中Top N SQL,EXPLAIN加上 format=json 还能看到更详细的cost信息,都是免费且可靠的手段。

5.2 SQL执行计划阅读要点

很多同学看EXPLAIN只看有没有用到索引,这个习惯要改。我列了一下排查时的高频关注点:

关注点解析方法优化方向
type字段ALL表示全表扫描,ref表示普通索引匹配,eq_ref表示主键或唯一索引匹配目标是range以上,尽量避免ALL
rows字段预估扫描行数和实际耗时强相关,越小越好
Extra中的Using filesort表示排序无法利用索引建立能同时满足排序条件的索引
Extra中的Using temporary表示查询创建了临时表调整GROUP BY或DISTINCT相关字段
key_len字段索引使用长度对比说明哪些索引部分被用到

我一般判断SQL是否要优化的标准很简单:EXPLAIN里rows超过十万,并且type不是range级别,就直接列进优化清单。如果一条查询每秒钟执行很多次,这个阈值还要更严。

5.3 避坑清单与个人心得

这次优化踩过的坑值得集中梳理一下:

第一,批量操作并不是越大越好。我一开始把批大小调到5000,结果一个批次持锁时间太长,产生了大量锁等待,后来调回1000才稳定。批大小的选择要结合行平均大小和数据库max_allowed_packet来估算,实在不好估算就先用1000作为基线,压力测试之后再做调整。

第二,事务内不要穿插远程调用。优化过程中我顺手清理了一次循环里查缓存、调外部接口、再更新数据库的写法。远程调用把事务生命周期拉得非常长,行锁长时间无法释放。正确做法是先完成所有外部调用,再进入数据库写入链路。

第三,改完SQL一定要重新看执行计划。有一次给表加了索引,但SQL里的条件写法是WHERE status = '2',字段类型是INT,MySQL发生了隐式类型转换,索引完全没法用。这类坑非常隐蔽,排查半天最后发现只是引号和类型的问题。

第四,线上变更要先备份和执行计划对比。数据库索引变更或者大批量UPDATE,在非高峰期操作,先EXPLAIN再执行。如果UPDATE影响行数非常大,建议分批执行,每批1000到5000条,批次之间加一点间隔,避免和正常业务产生锁冲突。

第五,性能测试必须包含读和写两条链路。单纯的select优化看起来效果很好,但如果新增了复合索引,insert和update的写入性能可能下降5%到10%。这次在select优化后,我就顺手做了写入链路的回归测试,确认批量insert和update没有劣化才放行。

最后分享一个我个人的小习惯。每次优化完一类SQL,我会把修改前后的执行计划截图保存下来,连同慢SQL日志、压测数据一起记录到项目文档里。下次再做性能优化,或者有人问“这个接口怎么突然变快了”,直接翻记录就能说清楚来龙去脉。这个习惯救了我很多次,尤其是接手老项目时,历史优化的上下文就是最快的排查线索。

回到这次的经历本身,原地不动的话,数据库连接池迟早被打满;动手之后,同样的业务量下系统轻松了不少。技术上的收获其实不在于某个SQL写得有多花哨,而在于理解了数据库底层的执行方式和锁的行为,后续遇到类似问题,心里会更有底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询