记得第一次认认真真研究 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 循环 addBa
