☰
saveOrUpdateBatch太慢?用一条SQL实现MySQL upsert
2026/10/14 4:35:41 网站建设 项目流程

上个月在某个数据汇总模块做性能排查,接口总耗时 4 秒多,点开链路一看,几乎全花在写库上。业务端用的是 MyBatis-Plus 自带的saveOrUpdateBatch,一次性塞进三四千条日结记录,看起来很省心,但实际耗时完全压不住。后来我把它底层拆开看了一遍,又把同样数据量改成 MySQL 的一条 SQL 批量 upsert,耗时直接掉了近一个数量级。

这篇文章就来聊聊我为什么不在线上业务里首选 MP 的saveOrUpdateBatch,以及“存在即更新、不存在即插入”这类批量增改,怎么用一条 SQL 真正落到 MySQL 上。内容偏实战,适合正在用 MyBatis-Plus 做中后台、报表、库存、积分等高频写库场景的同学参考。

1. 先把 saveOrUpdateBatch 的“保存”二字拆开看

1.1 MP 内部对“或”的处理方式

saveOrUpdateBatch从命名上给人的感觉是:批量保存,MySQL 要插入就插入,要更新就更新。但它不是一条 SQL 做完的,MyBatis-Plus 在拿到一个 entity 集合后,本质上还是在 Java 层一个个判断。

常见的 3.x 版本实现逻辑大致是:

  • 取每个实体主键值;
  • 主键为空,直接走 insert;
  • 主键非空,先根据主键查一次库里是否存在;
  • 不存在走 insert,存在走 updateById。

也就是说,saveOrUpdateBatch这个“或”的语义是在应用层拆出来的。这里有两个很隐性的代价:

第一,如果主键都给出来了,它很可能要先发一批 select 到数据库确认记录在不在,然后再发 insert 或 update。N 条数据,至少是 N 次主键查询加 N 次写操作。第二,所有判断都是一个实体一个实体循环完成的,并没有把相同结构的 SQL 合并成一条多值INSERT ... ON DUPLICATE KEY UPDATE。MP 的 ExecutorType.BATCH 只是在 MyBatis 层面帮你把多个相同 SQL 攒成一个 JDBC batch,不能改变“逐条操作”这个事实。

注意:不同版本的 MP 实现细节会有差异,部分版本会通过 UPDATE 影响的行数来判断,但整体上没有跳出“逐条处理”的框。

1.2 它和 JDBC batch 的区别要分清

有人可能会说:MP 不是已经开了ExecutorType.BATCH吗?这还不能算批量吗?

严格说,MyBatis 的 batch executor 只是把相同 SQL 按批发送到驱动,真正有没有在一次网络往返里把所有 SQL 发出去,取决于 JDBC 驱动是否支持 rewrite。MySQL 的 JDBC 驱动有一个rewriteBatchedStatements参数,设为 true 时,普通的批量 insert 会被改写成多值INSERT INTO ... VALUES (...),(...)再发送,效果非常好。

但问题在于:saveOrUpdateBatch的业务路径里既有 select,又有 insert,又有 update,还有可能混着不同表的操作。驱动并不是什么 SQL 都能 rewrite,尤其带ON DUPLICATE KEY UPDATE的 insert 基本不参与改写。结果是:你以为用了 batch,实际落到 MySQL 上的小 SQL 数量没有任何本质变化。

在“纯新增”场景,MP 的saveBatch配上rewriteBatchedStatements=true是可以达到比较理想的速度的;但一旦需求变成“存在则更新、不存在则新增”,saveOrUpdateBatch就尴尬了,因为它并不是为数据库原生 upsert 设计的。

1.3 什么时候它“够用”

我也不是全盘否定saveOrUpdateBatch。单次写入行数比较少,比如不超过一两百行,表列也不多,硬件条件又好的情况下,它确实够用,而且 API 简洁、实体状态处理方便,审计字段、乐观锁都能自动填充,这是它的优势。

但如果你的接口一次要写几千行,或者调用方是一个定时任务,每天凌晨把几百个维度几千条指标全量推进来,那这个方法的代价就非常明显了。我碰到的真实场景就是后者,单次接口耗时 4 秒多,压测一上来直接拖垮连接池。

2. 逐条小 SQL 为什么比一条大 SQL 慢一个量级

2.1 MySQL 每一条语句都要重新解析

一条 SQL 从网络到达 MySQL 后,要经过语法解析、权限校验、执行计划优化,最后走到存储引擎。批量执行一千条小 SQL,这些步骤就要做一千遍。MySQL 虽然有 prepare 语句缓存,但很多操作仍然绕不开完整执行流程。

而一条多行 SQL 把这些记录一次性带进来,整个过程只需要一次解析、一次权限校验、一次执行计划生成。虽然单个 SQL 变长,解析成本会有所增加,但这种增加是近似线性的,绝不等同于“一千倍解析”。

可以把它类比成快递:你寄 1000 个包裹,可以分 1000 次去快递点,也可以打一个清单让快递员一次拉走。寄件点处理单个包裹的开支固定存在,分开走并不会更省。

2.2 网络和连接池成本才是大头

单条小 SQL 即使什么都不做,一次网络往返时间也固定在那。本机调试可能只要 0.05ms,但线上业务服务器和数据库往往不在同一台机器,跨交换机走内网普遍有 0.3ms 到 1ms 的延迟。

假设一条往返 1ms,5000 条数据只算一次写操作就是 5 秒的纯网络等待。如果saveOrUpdateBatch还要先查后写,那就是一万次往返,10 秒秒级起步。这还没算连接池分配连接、线程切换、GC 等应用层开销。

一条 SQL 批量 upsert 的情况下,网络往返基本只有一次。数据量再大,也可以分批到每条 2000 行左右,几十次往返就能完成,量级完全不一样。

2.3 时序日志和同步刷盘的双重放大

这部分很多人会忽略。InnoDB 要保证持久性,需要记录 redo log;如果开了 binlog,并且sync_binlog和innodb_flush_log_at_trx_commit都设置为 1,那每次事务提交都要做磁盘刷盘。

saveOrUpdateBatch如果不包在外部事务里,默认就是逐条自动提交,每提交一次就可能触发一次 binlog 刷盘。机械盘或普通 SSD 的 fsync 性能其实没有大家想的那么高,一秒能扛几百次到几千次就不错了。5000 条数据就有 5000 次提交,光是刷盘都能吃掉大量时间。

很多人说“数据库批量插入慢,换 SSD 就好了”,其实不一定。SSD 降低了单次刷盘耗时,但没有降低刷盘次数。把 5000 次提交合成一次提交,收益远比换硬件明显。真的一条 SQL 完成 upsert,那 5000 行数据一次解析、一次锁获取、一次提交,逻辑上才是匹配的。

2.4 锁竞争和死锁概率也会被放大

每个写操作都会在 InnoDB 上申请行锁、间隙锁,提交时释放。逐条执行时,锁的申请和释放反复进行,如果多条记录之间还有唯一键交集,多个线程并发跑同一批操作,锁等待的概率直线上升。

批量 upsert 也不是没有锁,但整体在一个边界清晰的事务里,锁的范围更可控。更关键的是,一条 SQL 内的行按固定顺序访问,避免逐条循环时不同批次访问顺序不一致带来的不必要死锁。

3. 落到 MySQL:批量增改的两种正确打开方式

3.1 先有一张能承载 upsert 的表结构

以我那个日结报表表为例,核心表结构是联合主键唯一标识一条记录:

CREATE TABLE biz_daily ( stock_id BIGINT NOT NULL COMMENT '股票/标的ID', biz_date DATE NOT NULL COMMENT '业务日期', cnt INT NOT NULL DEFAULT 0 COMMENT '业务数量', amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '业务金额', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (stock_id, biz_date) ) ENGINE = InnoDB;

这里的唯一性由(stock_id, biz_date)保证。业务方的诉求非常明确:如果当天这批数据已经写过,就更新;没写过,就插入。这个语义和 MySQL 的INSERT ... ON DUPLICATE KEY UPDATE天然匹配。

3.2 upsert:一条多值 INSERT 直接完成增改

Mapper 里的写法很直白,用 foreach 拼接 VALUES 列表:

<insert id="batchUpsert"> INSERT INTO biz_daily (stock_id, biz_date, cnt, amount, updated_at) VALUES <foreach collection="list" item="item" separator=","> (#{item.stockId}, #{item.bizDate}, #{item.cnt}, #{item.amount}, NOW()) </foreach> ON DUPLICATE KEY UPDATE cnt = VALUES(cnt), amount = VALUES(amount), updated_at = NOW() </insert>

这条 SQL 干了三件事:

  • 尝试把整批记录插入;
  • 遇到主键或唯一键冲突时,更新对应字段;
  • 每行都刷新更新时间。

Java 侧就是普通调用,不再需要 MP 的包装:

int affected = bizDailyMapper.batchUpsert(entityList);

有几处值得注意的细节:

第一,业务字段如果是主键之外单独的唯一索引,也一样会触发ON DUPLICATE KEY UPDATE。这条特性并不只针对主键。

第二,MySQL 8.0.20 以后,VALUES(col)这种读取插入值的方式被标记为废弃,官方推荐写成行别名形式:

INSERT INTO biz_daily (...) VALUES (...),(...) AS new ON DUPLICATE KEY UPDATE cnt = new.cnt, amount = new.amount;

第三,不要忘记updated_at这类字段。MP 有自动填充还能省事,手写 SQL 必须显式写好。很多坑不是 SQL 写错,而是审计字段漏更新。

3.3 纯更新场景:CASE WHEN 合并成一条 UPDATE

如果你的业务不是“插入或更新”,而是已经存在的记录一次性修正状态,最典型的就是按主键把每个字段改成不同值。多值 INSERT 那套不适用,更适合用 CASE WHEN 把多个更新合并进一条语句:

<update id="batchUpdateByIds"> UPDATE biz_daily SET cnt = CASE id <foreach collection="list" item="item"> WHEN #{item.id} THEN #{item.cnt} </foreach> END, amount = CASE id <foreach collection="list" item="item"> WHEN #{item.id} THEN #{item.amount} </foreach> END WHERE id IN <foreach collection="list" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>

注意一个容易踩的细节:如果 WHERE 里有某个主键不存在,CASE 匹配不到分支,字段会被更新成 NULL。业务字段如果是非空就会报错,就算允许为空也会把数据冲坏。

所以我通常会在每个 CASE 后加一层保护,把缺失分支的字段原样保留:

cnt = COALESCE( CASE id WHEN 100 THEN 30 WHEN 101 THEN 40 END, cnt )

或者更直白一点,WHERE 之前先把这批主键查一遍,确认都存在再更新。这样 SQL 更干净,不容易出隐藏风险。

3.4 更新列数太多时的另一条路:临时表 JOIN

如果一张表要同时更新十几个字段,CASE WHEN 拼出来的 SQL 会很长,每列都要来一遍 foreach,看起来可以,但维护起来很头疼。另一种常见做法是先在 MySQL 里建一张临时表,把新值插进去,然后用 JOIN 完成更新:

CREATE TEMPORARY TABLE tmp_biz_daily ( id BIGINT PRIMARY KEY, cnt INT, amount DECIMAL(18,2) ); INSERT INTO tmp_biz_daily (id, cnt, amount) VALUES (1, 100, 123.45), (2, 200, 234.56); UPDATE biz_daily t JOIN tmp_biz_daily tmp ON t.id = tmp.id SET t.cnt = tmp.cnt, t.amount = tmp.amount;

从执行效率上,JOIN 更新也是全表或索引扫描一次,做一次关联更新,不会比 CASE WHEN 差太多。它的优点是 SQL 结构清晰,加字段只在临时表插入和 SET 里各加一行即可;缺点是需要在数据库账号上有创建临时表的权限,而且连接池场景下临时表只在当前连接生命周期内有效。

如果你是在项目里自己封装工具,临时表方案会更适合多字段、高维护频率的表;如果只是某个 Mapper 里一次性任务,CASE WHEN 就够用,不额外引入权限和跨连接问题。

4. 同样 5000 行,实测数据说明问题不只在“省时间”

4.1 我的测试环境与口径

为了避免纸上谈兵,我在本地环境做了个简单对比测试。环境是一台 4 核 8G 的虚拟机,MySQL 8.0,数据表结构就是上文那张biz_daily,预先塞了 50 万行基础数据。

对比两组:

  • 第一组:实体列表传入saveOrUpdateBatch(entityList, 5000),不包外部事务,完全默认配置;
  • 第二组:同一个实体列表,通过batchUpsert一条 SQL 完成 upsert。

为了公平,两组都没有额外开事务,数据量从 500 行递增到 1 万行,每组跑三次取最优值。这里不是严谨压测,主要是看数量级差异。

4.2 实测数据对比

数据量saveOrUpdateBatch一条 SQL upsert大致倍率
500 行1.4s0.2s7x
1000 行3.1s0.4s8x
5000 行14.8s1.6s9x
10000 行33.4s3.1s10.7x

这里有一个非常直观的斜率差异:MP 方案的时间基本随行数线性放大,而且斜率很陡;一条 SQL 的方案时间也有增长,但平缓得多。

有人可能会说本机测试网络延迟几乎为 0,差异为什么还这么大。这恰恰说明:除了网络往返,MySQL 每一句小 SQL 的解析、锁申请、事务提交和 binlog 刷盘开销同样可观。真实线上环境如果网络有 0.5ms 以上的延迟,倍率只会更夸张。

4.3 数据背后另一层成本:连接池和线程占用

接口慢,不只是调用方等多几秒的问题。后端线程池线程在等待写库时是阻塞的,Tomcat 默认线程池有限,一旦慢 SQL 多,线程全被卡在数据库等待上,其他请求的响应时间也跟着一起崩。

如果我把 5000 行数据用一条 SQL 入库,单次写库操作从秒级压缩到百毫秒级,接口的 TPS 自然就上来了。同样的压测 QPS,MP 方案在 200 QPS 时就开始大量超时,单条 SQL 方案到 600 QPS 还很稳,这个差异本质上是线程释放快慢的差距。

5. 一条 SQL 批量增改的边界条件与避坑清单

5.1 SQL 体积和 max_allowed_packet

一条 SQL 也不是越大越好。MySQL 有max_allowed_packet参数控制单次网络包大小,超过就直接报PacketTooBigException。云数据库默认值常见 4M 到 64M,如果你一次性拼 5 万行,哪怕每行数据 50 个字节,SQL 也轻松超过 4M。

我的习惯是一次控制在 1000 到 2000 行左右,既不会打爆 SQL 大小限制,也不会让单条 SQL 执行时间过长。数据总行数很大时,应用层循环分批传入 Mapper。分批的另外一个好处是:即使最后一批失败,已提交的前面几批还在库里,排查范围更小。

5.2 自增主键模式下的 ID 跳跃

如果表的主键是自增 ID,使用INSERT ... ON DUPLICATE KEY UPDATE时,就算命中的都是更新路径,InnoDB 也会预分配自增 ID,导致主键空洞。日志表不依赖主键连续性就没问题,但如果你有“主键必须连续”的洁癖,或者另一个表用这个主键做外键且默认值处理不当,就会踩坑。

批量 upsert 还有一个不能忽视的隐患:并发写同一条唯一键时,deadlock 并不罕见。MySQL 在冲突检测时加锁的时机不同,高并发下容易出现两个事务互相等锁。我的对策是:

  • 能限定批次就限定批次,不要一万行一起塞;
  • 应用层对同一业务键做本地队列串行化;
  • 捕获死锁异常后做一次简单的重试。

5.3 NULL 值、默认值和乐观锁字段

手写 SQL 不会帮你做 MP 那套空值策略。MP 默认插入时忽略 null 字段,但一条原生多值 INSERT 里的 null 真的会被当成 null 写入。

在ON DUPLICATE KEY UPDATE里尤其要小心:如果业务某次更新只带来了三个非空字段,而其他字段不希望被覆盖,那 UPDATE 部分就只写这三列,千万别写全字段。否则别的线程刚改完的数据会被这次操作覆盖掉。

如果表里有 MyBatis-Plus 逻辑删除字段,还需要再想一层:逻辑删除后的记录,和下次 upsert 的同一条唯一键是否会冲突。MP 的saveOrUpdateBatch会自动把逻辑删除条件带上,手写 SQL 就全靠自觉了。

乐观锁版本字段也是同样道理。手写一条批量 SQL 时,每个实体行的版本号可能不同,SQL 很难优雅表达“version = version + 1 WHERE version = 旧值”这种逐行动态条件。如果你的业务强依赖乐观锁,批量写这块就要么退回到 MP,要么从需求层面改一把锁。

5.4 哪些业务场景我仍会保留 saveOrUpdateBatch

不是所有场景都适合手写一条 SQL,以下情况我依然会用 MP:

  • 单次写入不超过几十行,性能无所谓,但代码要写得快;
  • 实体字段非常多且 null 语义复杂,MP 的填充策略能省事;
  • 业务需要跨多种数据库兼容,不能绑定 MySQL 专有语法;
  • 团队里负责维护的同事对原生 SQL 不熟,saveOrUpdateBatch的语义更直白。

如果你不追求极致的写库性能,几十行的数据量下 MP 完全可接受。这个方案真正的病根在于“默认看起来很百搭”,于是被带到了大数据量场景,才暴露出问题。

6. 我在实际项目里的取舍习惯

这几次优化之后,我给自己总结了一套很简单的选择标准:写库量日均超过几千行的接口,默认不走 MP 的 saveOrUpdateBatch;业务语义适合 upsert 的,优先用多值 INSERT + ON DUPLICATE KEY UPDATE;纯更新类大任务,优先用 CASE WHEN 合并 SQL,字段太多时用临时表 JOIN。

还有一个不算技术的习惯:凡是手写批量 SQL,我都会在 Mapper XML 里把批量上限注释清楚,写清楚“本方法建议单批不超过 2000 行,超过请调用分片方法”。代码 Review 的时候往往揪不出来的性能问题,靠注释和约定反而能拦住一半。

最后分享一个小技巧:如果犹豫某批操作该不该合并成一条 SQL,可以先在环境上打印 MySQL 的last_query_cost,对比逐条和合并后的优化器代价。代价数字会非常直观地告诉你,MySQL 为这次批量操作做了多少索引扫描和行估算,然后你就知道该怎么改了。

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

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

立即咨询