MyBatis批量插入INSERT_IGNORE踩坑记录
约 1854 字大约 6 分钟
2026-07-17
记一次 MyBatis 批量插入的"幽灵数据"事故
事情是这样的
最近在做批量银行验卡功能,逻辑不复杂:从代付订单表里捞一批数据,构造成验卡记录,批量插入数据库,然后丢到线程池里逐笔调第三方验卡接口。
听起来很常规对吧?问题就出在"批量插入"这一步。
我们的验卡记录表有一个唯一索引(基于代付订单ID + 收款账号),用来防止重复验卡。插入的时候用的是 INSERT IGNORE——遇到重复的直接跳过,不报错。
一开始,插入后我不需要关心返回的 ID,因为后续异步处理直接拿着内存里的 record 对象就开干了。直到有一天发现异步线程里拿到的数据"不对劲"——明明构造的时候 customerName 是 A,到了异步线程里变成了 B。
当时人就懵了。
第一反应:是不是异步线程的问题?
看到数据在异步线程里"变了",第一反应肯定是:是不是多线程把对象改了?
毕竟 ThreadPoolExecutor 里跑着呢,万一哪个线程把 record 对象的字段改了怎么办?
排查了一圈,代码里没有任何地方在提交异步任务之后还去修改 record 的字段。lambda 捕获的也是每个循环迭代里各自的 record 引用,不存在"串对象"的问题。
那就奇怪了。数据在提交前是对的,到了异步线程里就变了,中间到底发生了什么?
顺藤摸瓜:锁定批量插入
后来加了日志,在构造完 record 之后、批量插入之前、提交异步任务之前,分别打印了每条 record 的关键字段。
这一打印不要紧——批量插入之后,record 对象的 id 字段就"乱了"。
有的 record 拿到了不属于自己的 id,有的 record 明明被唯一索引跳过了,id 却非空。
问题锁定在 MyBatis 的批量插入语句上。
深入 useGeneratedKeys 机制
先说说 useGeneratedKeys="true" keyProperty="id" 是干嘛的。
简单讲,就是你 INSERT 完之后,MyBatis 会调用 JDBC 的 getGeneratedKeys() 拿到数据库自动生成的主键,然后按顺序塞回到你传入的对象列表里。
单条插入的时候,这个机制非常好使:
<insert id="insertRecord" useGeneratedKeys="true" keyProperty="id">
INSERT INTO xxx (...)
</insert>插入后 record.getId() 就有值了,不用手动查。
批量插入的时候,配合 foreach,理论上也应该好使:
<insert id="batchInsert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO xxx (...) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.a}, #{item.b}, ...)
</foreach>
</insert>MyBatis 拿到一组 generated keys,按索引位置依次赋给 list 里的每个对象。list[0] 拿第一个 key,list[1] 拿第二个 key,以此类推。
但是,这里有个巨大的坑:INSERT IGNORE。
INSERT IGNORE 的"自增消耗"陷阱
INSERT IGNORE 的行为是:遇到唯一键冲突就跳过,不报错。
但很多人不知道的是——MySQL 在遇到 IGNORE 跳过的行时,仍然会消耗自增值。
举个例子,假设当前自值是 100,我要批量插入 3 条记录:
| 列表位置 | 记录 | 实际结果 | MySQL 生成的 key |
|---|---|---|---|
| 0 | recordA | 插入成功 | 100 |
| 1 | recordB | 唯一键冲突,跳过 | 101(消耗了但不写入) |
| 2 | recordC | 插入成功 | 102 |
注意看,实际插入了 2 条,MySQL 返回的 generated keys 只有 2 个:[100, 102]。
但 MyBatis 是怎么处理的呢?它拿到这 2 个 key,按顺序赋给 list 里的对象:
records[0].id = 100✓ 没问题records[1].id = 102✗ 这条被跳过了,却拿到了 recordC 的 ID!records[2]→ 没有对应的 key 了,id 仍然是 null
看到问题了吗?
recordB 明明没有插入成功,但它拿到了 recordC 的 id。 后续代码判断 record.getId() != null,认为它插入成功了,拿去跑异步任务。结果拿着 recordB 的数据,去更新了 id=102(也就是 recordC)的那行数据。
数据就这么"乱了"。
排查思路复盘
回过头来看这个问题的排查过程:
第一步:定位数据变化的时间点。 在批量插入前后分别打日志,对比 record 对象的字段值,发现 id 在插入后发生了变化。
第二步:理解 useGeneratedKeys 的批量行为。 翻了 MyBatis 源码,发现它是按索引位置把 generated keys 赋给对象列表的。正常情况下没问题,但 INSERT IGNORE 会导致 generated keys 的数量和顺序跟原始列表对不上。
第三步:验证 INSERT IGNORE 的自增行为。 查了 MySQL 文档,确认 INSERT IGNORE 跳过的行确实会消耗自增值,导致 generated keys 不连续。
第四步:确认影响范围。 被跳过的记录拿到了其他记录的 id,后续异步处理会更新到错误的行。
怎么改的
最开始的思路是:插入后过滤掉 id 为 null 的记录,只把有 id 的提交给异步线程。
records = records.stream()
.filter(record -> record.getId() != null)
.collect(Collectors.toList());
submitAsyncVerifyTasks(records);但这个方案有个隐患:被跳过的记录可能拿到了错误的 id(不是 null,而是别人的 id),过滤 id != null 并不能把这些"幽灵 id"筛出去。
最终采用的方案是:不依赖内存里的 record 对象,改为从数据库重新查询。
private void submitAsyncVerifyTasks() {
// ...
// 直接从数据库查询 INIT 状态的记录
PayoutBankVerifyRecord params = new PayoutBankVerifyRecord();
params.setOrderStatus(BankVerifyOrderStatusEnum.INIT.getCode());
List<PayoutBankVerifyRecord> records =
payoutBankVerifyRecordMapper.selectPendingPayoutBankVerifyRecordList(params);
for (PayoutBankVerifyRecord record : records) {
// 从数据库查出来的数据,id 和字段都是准确的
executor.execute(...);
}
}这样做的好处是:
- 数据库里有什么就是什么,不存在 id 错位的问题
- 只有真正插入成功的记录才会被查到
- 每条记录的 id 都是数据库返回的真实值
总结几个教训
1. INSERT IGNORE + useGeneratedKeys 是个坑
这俩搭配在一起,generated keys 的数量和顺序是不可信的。如果你需要精确知道哪些记录插入成功、拿到了什么 id,不要用这个组合。
替代方案:
- 逐条插入,每条单独拿 generated key(性能差但准确)
- 插入后从数据库重新查询(我们的最终方案)
- 用
LAST_INSERT_ID()等 MySQL 特性手动处理
2. MySQL 的 INSERT IGNORE 会消耗自增值
这是个容易忽略的点。被跳过的行不会"回收"自增值,导致 generated keys 不连续。
3. 异步任务不要依赖共享的可变对象
把内存里的 record 对象直接传给异步线程,本身就有风险。万一主线程在异步执行前修改了对象,或者对象的 id 被错误地赋值,异步线程拿到的就是脏数据。更稳妥的做法是传递不可变的参数(id、业务单号等),或者在异步线程里重新从数据库查。
4. 加日志是排查并发问题的利器
这种问题靠看代码很难发现,因为代码逻辑本身"看起来没问题"。只有在关键节点打日志,对比数据在不同时间点的值,才能定位到数据到底在哪一步被改了。
说到底,这种 bug 就是那种"代码review看不出来,测试环境跑不出,一到生产就炸"的类型。共勉。