记得第一次认认真真研究 MySQL 的 INSERT,还是帮朋友排查一个线上数据重复的问题。他那边凌晨跑批量任务,总是报主键冲突,代码看了半天没毛病,最后才发现是 INSERT 语句在并发场景下把唯一键检查给绕过去了。从那之后我就意识到,插入数据这种看起来最基础的操作,真要往深了挖,水一点不比慢查询优化浅。
这篇就把 INSERT 从语法到底层存储、从单条插入到批量优化、从普通写入到异构数据搬运,完整拆一遍。包含我实际压测过的数据、踩过的坑,以及针对“插入重复数据到底该用哪种方式处理”这类高频问题给出的选型结论。
1. 一条最简单的 INSERT 背后,MySQL 到底做了什么
很多人写了几年的 INSERT INTO table VALUES (...),却不太清楚这一条语句在服务端究竟经历了什么。理解这部分,对后边排查性能问题、死锁问题、主键冲突问题都有帮助。
1.1 INSERT 的执行链路:解析、权限、存储引擎
当客户端把一条 INSERT 语句发给 MySQL 后,服务端先做语法解析和权限校验,这一步会确认你是否有这张表的 INSERT 权限,以及字段是否存在、类型是否匹配。语法没问题之后,优化器会生成执行计划。有人可能觉得插入操作没什么好优化的,其实不是,INSERT 的执行计划里包含了“插入哪张表、走哪个索引、是否涉及生成列、是否有 BEFORE INSERT 触发器”等关键信息。
接下来进入存储引擎层。以最常见的 InnoDB 为例,执行插入时并不是直接把数据丢进 .ibd 文件就完事,而是先做一个关键动作:把记录写入当前事务的 undo log,用来支持回滚和 MVCC 多版本控制。然后再写入 redo log buffer,最终由后台线程把 redo 刷到磁盘。这就是为什么 InnoDB 插入速度快的原因之一:它不是每插一条就 fsync 一次磁盘,而是攒一批再刷。这个设计叫 group commit,也是高并发写入场景下 InnoDB 能扛住的核心机制。
从 InnoDB 的行结构来看,每一行数据除了你定义的字段外,还有隐藏的 DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR 等系统字段。它们分别负责行唯一标识、最后一次修改该行的事务 ID、以及指向 undo log 中旧版本的指针。这意味着即使你只插入一个 INT 字段,实际写入磁盘的数据量也比表面大不少。
1.2 为什么说要尽量显式指定字段列表
INSERT INTO user (name, age) VALUES ('张三', 25) 和 INSERT INTO user VALUES ('张三', 25) 这两种写法,在效果上大多数时候是一样的,但推荐前者。原因很实际:如果哪天有人给表加了字段,或者调整了字段顺序,不带字段列表的 INSERT 会直接报 Column count doesn't match value count,或者更糟——数据错位插进了错误的列。
我遇到过一起事故:运营表加了一个 sort 字段,放在表结构中间,而老代码里有条 INSERT INTO table VALUES (...),正好少传了一个值,结果把本该写入 price 的值插到了 sort 列里,页面排序全乱。排查了大半天。所以我的建议是,生产环境的 INSERT 一律写字段列表,哪怕麻烦一点,这个习惯能挡掉很多低级故障。
1.3 返回值的含义与影响行数的判断
用 JDBC 或 Python 连接 MySQL 执行 INSERT 之后,客户端通常会返回 affected rows,这个数字代表成功插入的行数。但有一个特殊情况需要注意:如果在 INSERT 语句中显式给定了和当前值相同的值,并且开启了 CLIENT_FOUND_ROWS 标志,影响行数可能返回的是“找到的行数”而不是“修改的行数”,这会影响一些 ORM 对操作结果的判断。
更常见的还是默认行为:插入一行就返回 1,插入失败抛异常返回 0。对于 INSERT ... ON DUPLICATE KEY UPDATE 这种语句,影响行数的语义比较特殊:如果是新插入一条,返回 1;如果发生了冲突并执行了更新,返回 2;如果冲突了但更新的值和原值一样,返回 0。很多人在用框架判断“是否插入成功”时踩过这个坑,后续会细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 插入性能的实测对比:单条、批量拼接、rewriteBatchedStatements
插入性能是所有 MySQL 开发人员迟早要面对的问题。我自己做压测时发现,不同插入方式的性能差距可以达到几十倍,选错方案在高并发场景下就是事故。
2.1 逐条 INSERT 为什么慢
最直观的写法是循环执行 INSERT INTO table VALUES (...),一次插一条。这种方式在数据量小的时候体验不明显,但一旦上千上万条,性能就开始崩。拿我自己压测的数据来说,向一张 10 个字段的 InnoDB 表插入 1 万条记录,逐条插入耗时大约在 3 到 5 秒之间,取决于磁盘类型和网络延迟。如果再叠加 autocommit=1,也就是每条语句都自动提交事务,那每一次 INSERT 都包含一次事务提交、一次 redo flush 的等待,这个开销比执行 INSERT 本身还大。
逐条慢的核心原因有三个:一是每条语句都要单独做 SQL 解析、权限检查和执行计划生成;二是每条语句都触发一次事务提交与 redo 落盘,随机 IO 次数被放大;三是客户端与 MySQL 之间的网络往返次数太多,每次都有 RTT 延迟。所以即便 MySQL 服务端再快,这种模式的上限也高不了。
2.2 多值批量 INSERT 的表现
改进方向很直接:把多条记录拼进一条 SQL。也就是 INSERT INTO table (col1, col2) VALUES (1, 'a'), (2, 'b'), (3, 'c')。同样 1 万条数据,实测耗时从逐条的 3 到 5 秒降到了 200 毫秒左右,提升了 20 倍都不止。原因就在于:一条 SQL 只做一次解析,只在一个事务里执行,网络往返从 1 万次降到了 1 次。
多值批量 INSERT 也不是越大越好。MySQL 对单条 SQL 的大小有限制,受 max_allowed_packet 参数控制,默认通常是 64MB,但实际建议单条批量 SQL 控制在 1 到 5MB 之间,行数控制在几百到一两千行。为什么?因为太大的 SQL 在传输、解析、binlog 复制时都会产生额外的内存和延迟开销,一旦中途失败,回滚代价也更大。
2.3 JDBC 批量插入的正确打开方式
Java 项目里最常见的错误是:用 PreparedStatement 循环 addBatch(),然后统一 executeBatch(),以为这样就是批量插入了。但实际上,MySQL JDBC 驱动默认并不会把多条 insert 合并成一条多值语句发送,它仍然是一条一条发给服务端,性能提升非常有限。想真正生效,必须显式加上 rewriteBatchedStatements=true 这个连接参数。
我实测过一组数据:向 MySQL 8.0 插入 10 万条记录,使用 addBatch 但没开 rewrite 参数,耗时约 8 秒;加上 rewriteBatchedStatements=true 后,耗时直接降到 1.2 秒左右。差距在日常开发中感知不强,但在数据迁移、批量初始化、定时任务灌数这些场景下就是天壤之别。
注意这个参数只对 INSERT 语句有效,对 UPDATE、DELETE 的 batch 没有合并效果。配置方式是在 JDBC URL 上追加:
java复制jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true&useServerPrepStmts=true
这里 useServerPrepStmts=true 是为了让服务端预编译真正生效,配合 rewrite 参数效果更好。
2.4 Python 与 Go 等其他语言下的批量插入姿势
如果是 Python,推荐使用 executemany 配合 pymysql 或 mysqlclient。需要说明的是,executemany 底层同样是拼接多值语句,所以同样受 max_allowed_packet 限制。实际使用中可以把数据切成小块,每一块几百条,循环执行 executemany,这样不会撑爆 SQL 上限,也便于定位出错的数据批次。
Go 语言里,常用的 database/sql 并不自动拼接多值语句,需要自己构造占位符:
go复制// 构造 (?, ?), (?, ?), (?, ?) 形式的占位符
valueStrings := make([]string, 0, len(users))
valueArgs := make([]interface{}, 0, len(users)*2)
for _, u := range users {
valueStrings = append(valueStrings, "(?, ?)")
valueArgs = append(valueArgs, u.Name, u.Age)
}
query := fmt.Sprintf("INSERT INTO user (name, age) VALUES %s", strings.Join(valueStrings, ","))
db.Exec(query, valueArgs...)
这个方案实测性能与直接写多值 SQL 一致,因为本质上就是拼了一条多值语句。需要注意的是,如果列表长度为 0,不要执行这条 SQL,否则会生成一条 INSERT INTO table VALUES 的非法语句。每次批量的大小同样建议控制在 1000 行以内,兼顾性能与出错后的可排查性。
3. INSERT 的进阶用法:SELECT 子句、冲突处理与数据搬运
单纯插入常量值的场景只占一部分,实际开发里更常见的是把一张表的数据加工后插入另一张表、导入外部数据、或者处理“有则更新、无则插入”的逻辑。这一节把几种高频进阶用法梳理清楚。
3.1 INSERT INTO ... SELECT:比逐条读再插快得多
需要把 A 表的数据同步到 B 表,或者按条件汇总后写入结果表时,很多人会写一段程序:先 SELECT 出 A 表数据,在内存里处理,再一条条 INSERT 到 B 表。这个做法不是不行,只是在数据量大时效率太低。
SQL 层面可以直接用 INSERT INTO ... SELECT 一把梭:
sql复制INSERT INTO user_daily_stats (user_id, stats_date, order_count)
SELECT user_id, CURDATE(), COUNT(*)
FROM orders
WHERE create_time >= CURDATE() AND create_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
GROUP BY user_id;
这种写法的优势是全程在 MySQL 服务端完成,不经过网络传输,也没有应用层循环的开销。需要注意几个点:
- 目标表和源表的字段类型要兼容,长度不一致可能导致数据截断或报错。
- 如果目标表已有部分数据,要提前确认唯一键或主键的设计是否允许重复。
- 大表之间做 INSERT INTO ... SELECT 时,会持有源表的行锁或间隙锁,可能导致线上读写阻塞,建议在低峰期执行,或者分批 limit 处理。
3.2 INSERT IGNORE:忽略重复,不报错
业务里经常要做“只插入不存在的记录”这种幂等操作。比如初始化用户默认配置,用户已经有过配置了就直接跳过。传统做法是先 SELECT 判断存在性,再决定是否 INSERT,但这两步之间存在时间窗口,并发下依然可能插入重复数据。
INSERT IGNORE 就是为此设计的:插入时如果遇到主键冲突或唯一键冲突,MySQL 直接丢弃这条记录,不报错。来看一个典型场景:
sql复制INSERT IGNORE INTO user_config (user_id, config_key, config_value)
VALUES (1001, 'theme', 'dark');
如果 user_id 和 config_key 的组合唯一键已经存在,这条语句影响行数为 0,不会抛异常。相比先查再插,它既省掉了多次网络往返,也彻底消除了并发下的竞态问题。
不过要留个心眼:INSERT IGNORE 不只是忽略主键冲突。如果插入的数据里某个非空字段没有默认值,或者某个字段类型转换失败,INSERT IGNORE 也可能降级为警告,而不是报错。这会导致“数据没插进去但程序不知道”的问题。所以生产环境用 INSERT IGNORE 时,建议在测试环境先验证好字段约束,避免静默吞掉本应暴露的错误。
3.3 ON DUPLICATE KEY UPDATE:有则更新,无则插入
这个语法是处理“记录存在就更新,不存在就插入”最直接的工具,类似于其他数据库里的 UPSERT。它和 INSERT IGNORE 的区别在于:IGNORE 是冲突了就放弃,DUPLICATE KEY UPDATE 是冲突了就按你指定的规则更新。
最常见的用法是原子计数器:
sql复制INSERT INTO user_login_count (user_id, login_count)
VALUES (1001, 1)
ON DUPLICATE KEY UPDATE login_count = login_count + 1;
首次执行插入一条 login_count=1,之后每次执行,login_count 都自增 1。相比先 SELECT 再 UPDATE,它把两步压缩成一步,并且天然避免了并发覆盖。
从实际排查经验来看,这里最容易踩的坑是影响行数语义和锁范围。前面提过:新插入返回 1,更新返回 2,更新但值没变化返回 0。如果你用 affected rows 来判断操作类型,要记得这个规则。还有一点,ON DUPLICATE KEY UPDATE 在冲突时要执行更新,InnoDB 会先对冲突的索引记录加锁,再尝试更新。高并发同时插入相同唯一键时,可能出现锁等待甚至死锁,对热点行做 UPSERT 时要控制并发度。
3.4 REPLACE INTO 的隐藏风险:先删后插
REPLACE INTO 看起来和 ON DUPLICATE KEY UPDATE 类似,但底层处理逻辑差异很大。REPLACE INTO 遇到主键或唯一键冲突时,会先删除原有的行,再插入新行。
这个“先删后插”带来两个直接后果:
- 如果表上有自增主键,REPLACE 之后自增 ID 会变化,导致引用该行 ID 的外部数据错乱。
- 如果表上有外键或级联删除,REPLACE 会触发级联删除,可能把关联表的数据一起删掉。
曾经有同事用 REPLACE INTO 更新用户资料,因为用户 ID 没变,一直没发现问题。后来加了子表,子表通过用户 ID 关联,更新用户资料时子表记录被级联清空,数据直接丢了。所以我的结论是:常规业务里优先用 ON DUPLICATE KEY UPDATE,REPLACE INTO 只适合那些确实需要“删除重来”的场景。
3.5 从文件导入数据:LOAD DATA 与 mysqlimport
如果需要批量导入的数据在 CSV 等文本文件里,逐条 INSERT 是效率最低的方式。MySQL 提供了 LOAD DATA INFILE 命令,专门用于文本文件的高速导入。
以一个 10 万行的 CSV 为例,用 Python 逐条 INSERT 可能需要 30 秒以上,用 LOAD DATA 通常 1 秒内完成,差异非常大。基本用法:
sql复制LOAD DATA INFILE '/tmp/users.csv'
INTO TABLE user
FIELDS TERMINATED BY ','
OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(name, age, email);
几个实用参数:
- IGNORE 1 LINES 用于跳过 CSV 表头。
- FIELDS TERMINATED BY 指定列分隔符,默认是制表符。
- SET 子句可以在导入时做简单的数据转换:LOAD DATA INFILE ... SET created_at = NOW()。
- 如果文件在客户端机器上,需要加 LOCAL 关键字,写成 LOAD DATA LOCAL INFILE。
LOAD DATA 默认不经过应用层,所以很难在导入过程中对每一行做精细化校验。如果文件里有格式异常的行,可能导致整个导入失败或部分行被跳过。稳妥的做法是先导入一张临时表,在临时表里做数据质量检查,确认无误后再 INSERT INTO ... SELECT 到正式表。
4. 高频报错排查实录:从异常信息反推根因
INSERT 的报错信息通常简洁到让人摸不着头脑。这里把我排查频率最高的几类异常完整复盘一遍,每一类都给出从报错到根因的完整链路。
4.1 (5025, 'insert has filtered data in strict mode') 类问题
这类报错常见于使用 MyBatis 或 JDBC 批量插入时,出现一个比较隐晦的提示:insert has filtered data in strict mode。看到 “filtered data” 很容易懵,实际上它指的是:在严格模式下,MySQL 因为某个字段的值不符合约束,直接将整条数据过滤掉了。最常见的原因包括:
- 字符串超出字段定义长度。
- 数值类型的值超出取值范围。
- 日期格式不合法,比如 2023-13-45。
- 非空字段传入了 NULL。
我当时排查的一个实际案例,是批量插入 5000 条日志,其中有几条的 message 字段超过了 TEXT 类型可存储的最大长度。非严格模式下 MySQL 会截断并告警,但在严格模式下直接拒绝整批写入。由于批量插入是一条多值 SQL,任何一行有问题,整批都会失败。解决办法有几种:
- 在应用层对字段做长度校验和截断。
- 将 sql_mode 从严格模式切到非严格(不推荐,会让脏数据悄悄入库)。
- 把大批量拆成小批量,每批 100 到 200 条,这样失败时更容易定位是哪几条数据出了问题。
4.2 主键冲突:Duplicate entry 'xx' for key 'yy'
这个报错是 INSERT 系列里出现频率最高的,几乎每个开发都遇到过。它代表你要插入的数据在某个唯一索引或主键上已经存在了。
日志里给出的 key 名称不一定一眼就能对上字段,比如显示 PRIMARY 说明是主键冲突,显示 uk_user_name 则是名为 uk_user_name 的唯一索引冲突。正常排查步骤是:
sql复制SHOW INDEX FROM user;
查看表上的所有索引,找到对应 key name,确定冲突字段。如果是业务上允许重复的数据,检查是否建错了索引;如果是业务上不允许重复的数据,要结合并发场景判断是否需要在应用层做前置校验。
在并发插入相同唯一键的情况下,即使你在应用层先查再说,也可能因为两个请求同时查到“不存在”然后同时插入,导致其中一个失败。解决思路是用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE 替代先查再插。
4.3 字段长度不够:Data too long for column
这个报错在严格模式下很直白:某个字段的长度不够存储你给的值。最常见的场景是 VARCHAR(10) 却传入了 15 个字符的字符串。另一个隐蔽场景是字符集问题,utf8mb4 下一个汉字占 3 到 4 个字节,VARCHAR(10) 表示的是 10 个字符而不是 10 个字节,但如果是 VARBINARY 或 BLOB,长度单位则是字节,容易错估。
排查时用下面这条 SQL 查看字段定义:
sql复制SHOW FULL COLUMNS FROM user;
CharacterSet 和 Collation 列能帮你判断字段的字符集。如果数据本身超过业务允许的长度,应该从入口校验截断;如果确实是字段长度设计不合理,可以用 ALTER TABLE 扩容。注意,在数据量大的表上执行 ALTER TABLE 修改 VARCHAR 长度,如果新长度小于 256 字节,属于原地修改,速度较快;如果跨过了 256 字节的阈值,可能需要重建表,生产环境要提前评估。
4.4 非空字段没有默认值:Field 'xxx' doesn't have a default value
这是新手容易遇到的报错。执行 INSERT 时给某个 NOT NULL 且没有默认值的字段漏传了值,MySQL 在严格模式下会直接报错。在非严格模式下,MySQL 会根据字段类型自动填一个隐式默认值,比如数值类型填 0、字符串填空串,但这往往掩盖了代码缺陷。
排查思路很清晰:先确认插入语句是否遗漏字段,再确认表结构里是否应该给该字段设置默认值。比如说创建时间字段,如果应用层经常忘记传值,不如直接在 DDL 里设置 DEFAULT CURRENT_TIMESTAMP,这样代码更健壮。
4.5 唯一键冲突与死锁并存的疑难杂症
比普通冲突更麻烦的是,在某些场景下,并发 INSERT 相同唯一键会导致死锁。InnoDB 在插入时会对唯一索引执行一次“插入意图”检查,当两个事务同时插入相同的唯一键时,一个事务持有锁,另一个事务等待,如果加锁顺序不一致,就可能互相等待形成死锁。
死锁信息通常长这样:
text复制Deadlock found when trying to get lock; try restarting transaction
排查核心手段是执行 SHOW ENGINE INNODB STATUS,在 LATEST DETECTED DEADLOCK 部分查看两个事务分别持有什么锁、等待什么锁。最常见的死锁场景就是两条 INSERT ... ON DUPLICATE KEY UPDATE 语句以不同顺序更新多行。解决思路是让并发事务按固定顺序处理记录,或者把大事务拆小,降低锁持有的时间。
5. 事务、自增主键与 binlog:INSERT 不应忽略的三个幕后环节
前边讨论的更多是语句层面的细节,这块则是 INSERT 与 MySQL 整体机制之间的交互逻辑。如果不理解这三件事,遇到数据不一致、ID 断层、主从复制延迟时会一头雾水。
5.1 事务和 autocommit 对插入行为的影响
MySQL 默认开启事务自动提交,也就是每条 INSERT 都独立成一个事务,执行成功就立即提交。这个模式适合单条写入的场景,但在批量插入场景下非常浪费。因为每一次提交都涉及 redo log 刷盘、binlog 同步等操作。
更好的做法是显式地开启事务,比如用 BEGIN 或 START TRANSACTION,然后执行多条或批量 INSERT,最后统一 COMMIT。在 JDBC 代码里可以把自动提交设为 false,效果一样。
一个需要特别注意的点:如果事务长时间不提交,会对 InnoDB 的 purge 线程产生压力,导致 undo log 膨胀。很多人在批量导入时开启一个大事务,插了几百万行不提交,最后不仅导入慢,还会让数据库的磁盘占用和内存占用骤增。实务上建议控制单个事务的行数,比如每 1 万行提交一次,兼顾速度和资源占用。
5.2 自增主键为什么会出现空洞
插入数据后,有同学发现自增主键的值不是连续的,中间缺了很多号,然后怀疑是数据被删了。其实自增 ID 空洞的原因远比“删数据”多:
- 事务回滚后,已经申请的自增 ID 不会回收。
- 插入冲突时,自增 ID 已经分配,但插入失败,这个 ID 就浪费了。
- 批量插入时,MySQL 会按批量大小一次性申请一段自增 ID,比如一次插入 10 条,可能一次性申请 11 个 ID,多余的丢弃。
- 使用 REPLACE INTO 或 ON DUPLICATE KEY UPDATE 发生更新时,也会消费自增 ID。
这些空洞都不需要处理。自增主键的唯一要求是唯一且递增,并不保证连续。不要为了追求连续而手动重置 AUTO_INCREMENT,那可能引发主键冲突和复制错乱。
5.3 binlog 中的 INSERT 长什么样
如果开启了 binlog,INSERT 语句会被记录到 binlog 中,用于主从复制和时间点恢复。这里牵扯到一个性能问题:如果是多值 INSERT,binlog 里也会以多值语句的形式记录,从库重放速度也快;如果是一条条插入,binlog 就会产生海量的小事务,从库重放压力会很大。
另一个值得留意的配置是 binlog_format。在 MySQL 8.0 默认使用 ROW 格式,binlog 里记录的不是 SQL,而是变更前后的行镜像。这种格式对 UPDATE、DELETE 更安全,但会导致 binlog 体积明显增大。对于 INSERT 来说,ROW 格式下每条记录会完整记录所有字段值,如果有一张表几十个字段,批量插入生成 binlog 的体积会相当可观。在做大数据量导入时,要提前评估磁盘空间。
6. INSERT 语句与线上事故:三个真实案例复盘
讲了这么多理论,最后分享我亲历或深度参与排查的三个线上事故。每一个的根因都不复杂,但都足够隐蔽,值得引以为戒。
6.1 索引选择性不高导致的批量插入慢
某个定时任务每天凌晨从接口拉取全量商品数据,先 DELETE 清空中间表,再循环 INSERT 写入,单次任务大约 3 万条。上线初期一切正常,运行一个月后,任务执行时间从 3 分钟涨到了 20 分钟。
排查后发现,中间表有一个 VARCHAR(128) 的 URL 字段建立了普通索引,但 URL 长度大、区分度低,导致索引页占用空间巨大,每插入一条记录都要同步维护这个笨重的索引,插入性能被拖垮。而且因为任务先 DELETE 后 INSERT,DELETE 产生的 binlog 也是按行记录的,进一步拖慢了整体运维节奏。
解决方案是把该字段的索引改为前缀索引,只对 URL 前 50 个字符建索引,插入耗时从 20 分钟降回 4 分钟。这个案例提醒我:INSERT 慢不一定死在插数据本身,更常见的是死在索引维护上。索引不是越多越好,写多读少的表尤其要精简索引。
6.2 唯一键重复但未加幂等导致的线上脏数据
一个用户签到功能,表结构上对 user_id 和 sign_date 建了唯一索引。正常情况下,用户一天只能签到一次,第二次签到应该报错,然后前端提示“今日已签到”。结果某次版本上线后,陆续有用户反馈一天签到了多次,积分翻倍。
查代码发现,某次重构时有人把签到逻辑改成了先 SELECT 判断是否已签到,不存在才 INSERT。按说判断逻辑没问题,但他忽略了这是两个独立的 SQL 操作,并发窗口期两个请求同时通过 SELECT 判断、同时执行 INSERT,唯一索引确实拦截了后插入的那一条,但由于框架把后者产生的 Duplicate entry 异常吞掉了,接口直接返回成功,于是客户端拿到的是“假成功”,积分链路却已经走完。
这个问题的解法其实很简单:直接去掉 SELECT 判断,改成 INSERT ... ON DUPLICATE KEY UPDATE 或 INSERT IGNORE,用数据库的唯一索引做最终的幂等保障。我把这个案例写在这里,是希望读到这篇的人能少走一次弯路:业务幂等不能只靠应用层的先查后写,永远要让数据库的唯一约束兜底。
6.3 大批量 INSERT INTO ... SELECT 引发的锁等待风暴
某个备份任务每天凌晨把订单表近 7 天的数据复制到历史表,写的是 INSERT INTO order_history SELECT * FROM orders WHERE create_time > ...。在订单量小的时候没出过问题,直到某天大促后订单量翻了 10 倍,任务一跑,线上订单写入直接卡死,大量 INSERT 超时。
根因是 INSERT INTO ... SELECT 在默认隔离级别 REPEATABLE READ 下,会对 SELECT 源表上扫描过的区间加共享间隙锁,防止幻读。这个锁会阻塞其他事务对相同区间的 INSERT,导致线上写入全部排队。该任务执行了十几分钟,线上订单就卡了十几分钟。
修复方案是把大查询拆成多个小批次,每次只复制一小段主键范围的数据,减少锁的持有时间。同时把任务放到业务低峰期执行。另外一个可选思路是,在从库上先做 SELECT 导出,再用 LOAD DATA 导入主库,但这需要架构上支持读写分离,不是所有团队都能轻易做到。
7. 插入数据后,如何确认结果真的符合预期
写 INSERT 不是执行完就结束的,确认数据真的按预期落库也是重要一环。这里分享几个实用的验证手段和习惯。
7.1 影响行数与自增 ID 的获取
执行完 INSERT 后,很多框架会自动返回自增主键 ID。比如在 MyBatis 里可以用 useGeneratedKeys="true" 和 keyProperty="id",让插入后的主键值回填到实体对象上。为什么这个能力重要?因为很多时候插入只是第一步,后续还要拿主键去关联子表、写缓存、记录日志。
底层原理是 JDBC 的 getGeneratedKeys 方法。需要注意,这个值只有在数据库真正生成自增值时才有,如果插入语句显式指定了主键,或者目标是 MyISAM 表(基本已经没人用了),行为会稍有不同。在批量插入时,MySQL 8.0 的 JDBC 驱动可能只返回第一条记录的自增 ID,如果业务依赖每条记录的自增 ID,建议谨慎处理。
7.2 如何验证大批量插入没有丢数据
批量插入完成后,不要只看“没有异常”就万事大吉。推荐的验证方式是:插入前对源数据做一次总量统计,插入后对目标表做一次对比查询。
比如从文件导入 10 万条记录,可以执行:
sql复制SELECT COUNT(*) FROM user;
再对比文件总行数。更严格的校验,可以对关键字段做 SUM 或 MD5 对比。对于 INSERT INTO ... SELECT 这种方式,可以在插入前记录源表的 COUNT 和关键列 SUM,插入后在同一事务里对目标表做同样的统计,两个值一致即说明数据完整搬运。
7.3 慢日志与性能观测
如果发现 INSERT 变慢了,除了看表结构和索引,还应该看看慢查询日志。MySQL 的 long_query_time 默认是 10 秒,如果业务上有大批量插入,建议把阈值调低一些,比如 1 秒,方便及时暴露异常。
sql复制SET GLOBAL long_query_time = 1;
另外,观察 InnoDB 的状态也是排查插入性能的常用手段:
sql复制SHOW ENGINE INNODB STATUS;
重点看 History list length、Log sequence number 等指标。前者代表未清理的 undo 记录数量,持续偏高说明有大事务或长事务存在;后者如果增长过快,说明写入量很大,要留意磁盘 IO 吞吐和 binlog 落盘情况。
8. 针对不同场景的 INSERT 选型建议
做技术方案时常常要回答“这个场景到底用哪种插入方式最合适”。我按照平时的项目经验,整理了一套简单的选型逻辑。
| 场景 | 推荐方案 | 核心理由 |
|---|---|---|
| 新增一条业务数据 | INSERT 单条 | 简单直观,配合事务使用 |
| 初始化数据、数据迁移 | 多值批量 INSERT | 性能高,易控制事务粒度 |
| Java 项目批量插入 | JDBC 开启 rewriteBatchedStatements | 减少网络往返,性能提升明显 |
| 幂等写入,已存在则跳过 | INSERT IGNORE | 直接依赖唯一索引兜底 |
| 幂等写入,已存在则更新 | ON DUPLICATE KEY UPDATE | 原子 UPSERT,避免并发覆盖 |
| 需要删除旧行重新插入 | REPLACE INTO | 明确理解先删后插的副作用再用 |
| 从 CSV/文本文件导入 | LOAD DATA INFILE | 大数据量下性能最佳 |
| 表间数据复制 | INSERT INTO ... SELECT | 服务端完成,减少网络传输 |
选型的核心原则很简单:写入频率低的场景优先保证代码清晰,能表达业务意图;写入频率高的大数据量场景,优先考虑减少交互次数、控制锁粒度、便于定位问题。没有一套方案能覆盖所有场景,结合业务实际做压测,才是正路。
回看这些 INSERT 相关的经验和教训,哪怕是写了很多年 SQL 的人,也可能在批量插入、并发控制或事务边界上栽跟头。建议你手头维护的项目里,如果还没对写入链路做过一次系统梳理,可以按文中的几个角度过一遍:插入方式是否最优、幂等是否靠索引兜底、批量大小是否合理、事务边界是否清晰。这几项确认没问题,插入相关的坑基本就避开了大半。
