1. 从一条最简单的 INSERT 说起
MySQL 的 INSERT 语句大概是每个后端开发每天写的最多的 SQL 了。但就是这么一条基础语句,藏着的细节远比想象中多:隐式默认值、自增锁、唯一键冲突、批量插入的参数上限、主从环境下的写入差异、甚至 ORM 层不报错但数据写不进去的诡异问题。这篇文章把我这些年实际踩过的坑、排查过的案例一起整理出来,尽量把 INSERT 从语法到底层细节都讲透。
这篇内容适合谁看?刚学 MySQL 的同学可以完整过一遍语法和参数;写了好几年业务代码但没深究过数据库行为的老开发,可以直接跳到第 4 章和第 5 章,那些问题你可能正在踩。我会在关键位置给出可以直接复制的 SQL 示例和参数配置,也会解释每一个操作背后真正的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. INSERT 基础语法拆解:你真的会写单行插入吗
2.1 基础语法与两种主流写法
INSERT 最简单的形式就是往表里插一行数据。标准写法是:
sql复制INSERT INTO user (id, name, age, create_time)
VALUES (1, '张三', 25, NOW());
另一种写法省略列名:
sql复制INSERT INTO user
VALUES (1, '张三', 25, NOW());
这两种写法我强烈建议只用第一种,理由很实在:只要表结构变动,第二种写法就会当场崩掉。比如你给表加了一个 remark 字段,省略列名的 INSERT 语句如果没有同步修改 VALUES 里的参数个数,MySQL 会直接报 Column count doesn't match value count。而写上列名之后,即使表结构变了,只要新字段有默认值或允许 NULL,这条 SQL 依然能跑通。
还有一个容易忽略的细节:列名的顺序不需要和表结构保持一致。比如表结构是 id, name, age,你完全可以直接 INSERT INTO user (age, name, id) VALUES (25, '张三', 1),写入的结果是一样的。唯一的要求是列名和值一一对应。
2.2 默认值、NULL 与隐式行为
INSERT 时不给某个列赋值,MySQL 会使用列的默认值。但这里有三个容易踩的坑。
第一个坑是 NOT NULL 列没有默认值。如果某列是 NOT NULL 且没有 DEFAULT,INSERT 时也不给值,在 MySQL 5.7 及以下版本里会直接报错;但如果你开启了 sql_mode 中的宽松模式(不包含 STRICT_TRANS_TABLES),MySQL 会插入一个隐式的默认值:数字类型插 0,字符串类型插空字符串,时间类型插当前时间。这种“隐式默认行为”会导致一个问题:数据进来了,但和你期望的不一样。比如你往一个 age INT NOT NULL 的表里插入一条不包含 age 的记录,在宽松模式下得到的是 age = 0,而不是报错。很多线上数据异常就是这么来的。
我接手过一个项目,业务表里的 status 字段在代码里是 1 表示启用、0 表示禁用,结果查询分析时发现大量 status = 0 的数据,排查到最后是 INSERT 语句压根没给 status 赋值,而建表语句里 status 默认值为 0,代码里读取的却是 null,于是一堆数据被误判成了禁用。
第二个坑是关于 NULL 和默认值的关系。显式地写 INSERT INTO user (name, remark) VALUES ('张三', NULL),如果 remark 列允许 NULL,那么写入的就是 NULL,而不是默认值。你写不写这个 NULL 是有本质区别的:不写这个列,MySQL 用默认值;写了 NULL,MySQL 写入的就是 NULL。在很多业务里,默认值和 NULL 代表的含义完全不同。
第三个坑是 timestamp 字段的自动赋值。如果列定义为 create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,那不赋值时自动取当前时间;但如果你显式插入 '0000-00-00 00:00:00',在严格模式下会直接报错。如果真的要插入一个“无效时间”,需要先把列改成允许 NULL,或者用 NULL 表示“未知时间”,不要硬塞零值日期。
3. 批量插入:从一条一条到一次万条
3.1 批量 INSERT 的核心优势
批量插入是性能优化里性价比最高的一项改动。同样插入 1 万条数据,一条一条循环插入可能要 10 秒以上,改成一条 INSERT 多条 VALUES 通常 1 秒内能完成。这个差距来自两个层面。
第一层是 减少网络往返和 SQL 解析。每执行一条 SQL,客户端和服务端都要经历一次网络传输、SQL 解析、权限检查、执行计划生成的过程。即使使用了预处理语句,减少了重复解析的开销,网络往返依然免不了。批量插入把 1 万次请求变成了 1 次,开销直接就抹平了。
第二层是 减少事务提交开销。如果是循环单条插入又不显式开事务,每次 INSERT 就是一次隐式事务提交,要经历一次 fsync 刷盘;批量插一条 SQL 只提交一次事务,日志刷盘次数少了好几个数量级。
最基础的批量写法长这样:
sql复制INSERT INTO user (name, age, create_time)
VALUES
('张三', 25, NOW()),
('李四', 30, NOW()),
('王五', 28, NOW());
注意 VALUES 后面的每条记录用逗号分隔,最后一条记录末尾不用逗号。这里的每条记录都要保证列数和列顺序一致,否则直接报错。
3.2 参数限制:max_allowed_packet 才是真正的天花板
批量插入不是你想塞多少条就能塞多少条的。真正卡你的是 max_allowed_packet 参数,它限制了单次发送给 MySQL 服务端的包大小。在 MySQL 8.0 里,这个参数默认值是 64MB,看起来很大,但如果你一次性执行一条包含几十万条记录的 INSERT,很容易就超出限制,报出 Packet too large 错误。
可以通过下面这条 SQL 查看当前值:
sql复制SHOW VARIABLES LIKE 'max_allowed_packet';
调整方式有两种。临时生效可以执行:
sql复制SET GLOBAL max_allowed_packet = 134217728;
永久生效需要改配置文件 my.cnf,在 [mysqld] 段下加一行:
ini复制max_allowed_packet = 128M
改完之后要重启 MySQL 服务。注意 SET GLOBAL 只对新连接生效,当前连接里执行 SET SESSION max_allowed_packet = 134217728 才能让自己立刻生效。
怎么估算单次批量插入的大小? 以一条 INSERT 记录平均 200 字节计算,128MB 大概能容纳 60 万条记录。但实际不要顶着上限去操作,因为 MySQL 还要为这条 SQL 分配内存做解析和优化,我个人的经验是单次批量控制在 1000 到 5000 条之间,既不会触发包大小问题,也不至于因为单条 SQL 太大导致锁持有时间过长,影响其它写入。
3.3 JDBC 批量插入参数:rewriteBatchedStatements
很多用 Java 的同学会写这样的代码:
java复制Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
PreparedStatement ps = conn.prepareStatement("INSERT INTO user (name, age) VALUES (?, ?)");
for (User user : userList) {
ps.setString(1, user.getName());
ps.setInt(2, user.getAge());
ps.addBatch();
}
ps.executeBatch();
conn.commit();
这段代码的问题在于:如果你没有在 JDBC 连接串上加 rewriteBatchedStatements=true,MySQL 驱动会老老实实把每一条 VALUES 当成一条独立的 SQL 发给服务端。也就是说,你以为的批量插入实际上还是单条插入,唯一的优化仅仅是减少了部分网络开销。真正的批量合并需要驱动把多条 VALUES 拼接进同一条 INSERT 语句。这个开关就是 rewriteBatchedStatements。
MySQL JDBC 驱动 5.1.13 及以上版本开始支持该参数,实测效率差异非常明显。我在压测里对比过:插入 10 万条数据,不开这个参数耗时约 8 秒,开启后降到 1 秒以内。连接串写法:
text复制jdbc:mysql://localhost:3306/test?rewriteBatchedStatements=true&useServerPrepStmts=false
注意一个细节:如果连接串同时配置了 useServerPrepStmts=true,某些驱动版本下 rewriteBatchedStatements 可能不生效。稳妥的做法是保持 useServerPrepStmts=false。
4. INSERT INTO ... SELECT:跨表复制的高效与风险
4.1 基本用法与典型场景
INSERT INTO ... SELECT 可以从一张表查出数据直接插入另一张表,避免先查出来再到应用层转发一遍的繁琐流程,也不需要在应用层写循环。典型场景包括:订单表按月分表时把上个月数据归档到历史表、创建临时备份表、在测试环境造数等。
语法如下:
sql复制INSERT INTO user_bak (id, name, age, create_time)
SELECT id, name, age, create_time
FROM user
WHERE create_time < '2024-01-01';
组合套路非常灵活。你可以 SELECT 时用聚合函数、JOIN 多张表、甚至 UNION 合并多个结果集。一个常用场景是把统计结果直接落成报表:
sql复制INSERT INTO user_city_stats (city, user_count, create_date)
SELECT city, COUNT(*), CURDATE()
FROM user
GROUP BY city;
这样一条 SQL 就把统计结果写入了报表表,应用层完全不用参与,也不存在数据在传输过程中的格式转换问题。
4.2 这个操作可能在主从环境里炸掉
如果你在线上主从环境执行 INSERT INTO ... SELECT 且目标表没有索引或数据量特别大,一定要先意识到:这是一条会持有行锁和间隙锁的语句,在查询源表数据时可能锁住源表的相关记录。如果源表的数据量达到百万级,这条 SQL 跑上几十秒甚至几分钟,会阻塞其它对这个表的写入请求。更严重的场景是:从库的并行复制线程执行这条语句时,如果里面有大量的锁等待,可能导致从库延迟暴涨,甚至整个从库卡住。
解决思路有几种:
- 加上明确的 WHERE 条件,控制每次搬运的数据量,比如每次只处理 1 万条,用循环执行。
- 如果源表和目标表是同一张表(即表内数据迁移),考虑使用
CREATE TABLE ... AS SELECT加临时表的方式,减少锁冲突。 - 在低峰期执行这种操作,并且先用
EXPLAIN确认 SELECT 走了索引。
4.3 INSERT INTO ... SELECT 的经典去重技巧
基于 INSERT INTO ... SELECT,有一个经典的去重写法。假设表里有一批数据存在重复的 user_id,要把去重后的数据插入新表,可以用:
sql复制INSERT INTO user_distinct (id, user_id, name)
SELECT MAX(id), user_id, MAX(name)
FROM user
GROUP BY user_id;
通过 GROUP BY user_id 配合 MAX(id) 取得每组里最大的一条 id 作为保留记录。如果你希望保留最小 id,把 MAX 换成 MIN 即可。这里的逻辑是:先按业务唯一键分组,再决定保留哪条物理记录。
这种写法比“导出数据到 Excel 去重再导回来”之类的方式靠谱得多,所有操作都在数据库内部完成,不需要挪动数据。
5. 唯一键冲突处理:ON DUPLICATE KEY UPDATE 的实战选择
5.1 两种写法的适用场景
业务中经常遇到一种需求:数据存在就更新,不存在就插入。两种常见写法要能分清楚。
第一种是 INSERT IGNORE:
sql复制INSERT IGNORE INTO user (id, name, age)
VALUES (1, '张三', 25);
如果主键或唯一键冲突,这条记录会被丢弃,不报错,也不更新已有记录。适合用来“只保留首次数据,后续到达的直接忽略”这种场景,比如埋点日志对同一事件的重复上报。
第二种是 INSERT ... ON DUPLICATE KEY UPDATE:
sql复制INSERT INTO user (id, name, age)
VALUES (1, '张三', 25)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
age = VALUES(age);
冲突时执行更新,把 name 和 age 覆盖成新值。适合“幂等写入”场景:同一业务单号重复提交时,后到的数据覆盖前面的。
注意 MySQL 8.0.20 之后,VALUES() 函数在 ON DUPLICATE KEY UPDATE 里被标记为废弃,官方推荐的写法是使用别名:
sql复制INSERT INTO user (id, name, age)
VALUES (1, '张三', 25) AS new
ON DUPLICATE KEY UPDATE
name = new.name,
age = new.age;
实测两种写法在 MySQL 8.0 里都还能用,但推荐新项目直接使用别名写法,避免未来版本升级后报错。
5.2 ON DUPLICATE KEY UPDATE 的本质与性能
这个语法的执行逻辑可以理解为:先尝试插入,如果插入时触发了主键或唯一键冲突,改成执行 UPDATE 分支。实现原理是 MySQL 在插入时检测到键冲突,根据冲突的索引找到已有记录,再执行更新操作。
几个容易踩的坑需要单独说明。
第一个坑:多个唯一键时容易误更新。如果表里有多个唯一键,只要其中一个冲突,就会触发更新。假设一个 user 表同时有主键 id 和唯一键 phone,执行:
sql复制INSERT INTO user (id, phone, name)
VALUES (100, '13800001111', '张三')
ON DUPLICATE KEY UPDATE name = '张三';
如果 id 没有冲突但 phone 与已有记录冲突,MySQL 也会触发更新,且会更新那张 phone 冲突的记录。它不会告诉你到底是哪个键冲突了。这个行为在业务上可能与预期不符,尤其当你想“按主键更新”时,数据可能被 phone 冲突带偏。
第二个坑:自增主键不连续。即使触发了 UPDATE,MySQL 仍然会消耗一个自增 id。执行这条语句后,AUTO_INCREMENT 的值可能会跳跃。如果业务依赖自增 id 的连续性来估算数据规模,这个行为会干扰你的统计。解决方式是换用 UPDATE 前置判断逻辑,或者忽略自增跳跃,把它当成正常现象。
第三个坑:更新子句中的表达式。你可以在 UPDATE 分支里做计算:
sql复制INSERT INTO user_count (id, cnt)
VALUES (1, 1)
ON DUPLICATE KEY UPDATE cnt = cnt + 1;
这种写法可以实现并发安全的计数器。相比“先 SELECT 再 UPDATE”的方式,在并发下更可靠,因为 SELECT 和 UPDATE 之间容易被其它事务插入间隙,导致逻辑覆盖丢失。
5.3 REPLACE INTO 和 ON DUPLICATE KEY UPDATE 的取舍
还有一个容易混淆的写法:REPLACE INTO。
sql复制REPLACE INTO user (id, name, age)
VALUES (1, '张三', 25);
核心区别在于:遇到冲突时,REPLACE INTO 会先删除原有记录,再插入一条新记录。这意味着:
- 原有记录被删掉,新记录的 id 可能与原记录一致,但其它未指定列的默认值会被重置,比如 create_time 如果默认是当前时间,会变成新时间,不再保留原始写入时间。
- 如果表上有外键约束,删除操作可能引发级联删除,把关联表的数据一起干掉。
- 会额外产生一次 DELETE 加 INSERT 的写放大,性能比 ON DUPLICATE KEY UPDATE 差。
所以我的建议是:绝大多数场景都用 INSERT ... ON DUPLICATE KEY UPDATE,只有当你确定要“完全替换整行”且不关心历史列值时才用 REPLACE INTO。存量数据需要保留审计时间戳的业务表,千万不要用 REPLACE INTO,这是很多人踩过的坑。
6. INSERT 高频问题与排查方法实录
这一节把平时群友问得最多、以及我自己实际遇到过的几个 INSERT 相关问题集中整理一下,直接给结论和排查步骤。
6.1 MyBatis Plus 插入数据没报错,但库里查不到
这是非常经典的诡异问题。代码里调用了 save() 或 insert(),方法正常返回,没有抛异常,但数据库里就是查不到这条记录。我从实际案例里总结出最常踩的几个原因。
原因一:事务没有提交。Service 方法上标了 @Transactional,但方法内部抛出了异常后被吞掉,或者事务内其它操作失败导致整体回滚,但异常被 try-catch 吃掉,你的代码看到的只是“正常执行完”。排查方式很简单:在 save() 之后手动执行 int result = userMapper.insert(user); 然后打印 result。如果 result = 1,但库里没有,多半是事务回滚了。开启日志查看实际 SQL 执行记录:
yaml复制logging:
level:
com.example.mapper: debug
原因二:MyBatis 的 useGeneratedKeys 配置问题。如果主键是数据库自增,且你没有配置 useGeneratedKeys="true" 和 keyProperty="id",MyBatis 也能正常插入,但返回的实体对象里的 id 会是 null。你在后续逻辑里用了这个 null 的 id 去做关联操作,看起来像是插入失败。本质是数据已经写进去了,只是你没拿到自增主键。这种情况不算插入失败,但很容易让人误判。
java复制@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(User user);
原因三:连的库不是你以为的库。多数据源环境下,事务管理器绑定的数据源和 MyBatis 使用的数据源不一致,导致插入写到了 A 库,你查的是 B 库。检查 @DS 注解或数据源配置,确认插入操作实际路由到了哪个库。
6.2 中文乱码:能插进去但读不出来
INSERT 的中文变成问号,问题基本都出在字符集。先确认三处一致:数据库字符集、连接字符集、表字符集。
sql复制SHOW VARIABLES LIKE 'character_set%';
SHOW CREATE TABLE user;
如果表是 utf8,而连接是 latin1,中文写入后就会变成乱码。MySQL 8.0 默认字符集已经是 utf8mb4,但很多老项目还是 utf8。强烈建议把所有库表统一成 utf8mb4,utf8 在 MySQL 里实际是 utf8mb3,不支持下 emoji,且部分生僻字会存不进去。
如果确认表结构正确,还要检查 JDBC 连接串:
text复制jdbc:mysql://localhost:3306/test?characterEncoding=utf8
characterEncoding 参数必须显式指定,不要依赖驱动默认值。
6.3 插入慢、频繁锁等待
INSERT 本身是最快的写入操作,如果慢,一般先查几个方向:一是是否有大量二级索引,每插入一行数据,所有二级索引都要同步更新,索引越多写入越慢;二是是否触发了外键约束检查;三是是否有其他事务持有了目标表的锁,导致 INSERT 在等待。
排查锁等待用这条语句:
sql复制SELECT * FROM performance_schema.data_lock_waits\G
或者看正在运行的线程:
sql复制SHOW PROCESSLIST;
关注 State 列,如果是 Waiting for table metadata lock,说明有其它会话持有表的元数据锁,常见的来源是未提交的 DDL 或长事务。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 插入报错 Column count doesn't match | INSERT 的列数和 VALUES 数不一致 | 检查列名和值是否一一对应 |
| 插入报错 Data too long for column | 字符串超出列定义长度 | 检查字符集和字段长度定义 |
| 插入报错 Duplicate entry | 主键或唯一键冲突 | 确认业务上是否需要更新,改用 ON DUPLICATE KEY UPDATE |
| 插入成功但中文是问号 | 字符集不一致 | 检查表、连接、JDBC URL 三处字符集 |
| 插入成功但查不到 | 事务回滚或数据源路由错误 | 开启事务日志、检查 @Transactional 行为 |
| 批量插入超时 | SQL 过大或锁等待 | 减小批大小,检查 max_allowed_packet |
| INSERT 后 id 为 null | useGeneratedKeys 未配置 | 在 @Options 中配置 keyProperty |
| INSERT 阻塞 | 行锁冲突或元数据锁 | SHOW PROCESSLIST 结合实际锁监控分析 |
7. 关于 INSERT 的几条个人建议
写 INSERT 这件事,看着简单,真正做好有几个原则我一直在坚持:能批量就不单条,能指定列就不省略列,能幂等就不要裸 INSERT。批量是为了性能,指定列是为了结构变更时不被波及,幂等是为了业务重试时数据不出乱子。
第二点,线上 DML 操作一定要先预估行数。不管你用的是 INSERT SELECT 还是大批量 VALUES,先 SELECT COUNT 看一眼影响范围,心里有数再执行,这不是谨慎过度,而是长期操作数据库的基本素养。
第三点,INSERT 语句一定要在测试环境先验证 EXPLAIN。如果 INSERT 后面带 SELECT,EXPLAIN 能告诉你查询是否走索引,会不会把整张表扫一遍。我见过太多线上事故,就是一条带着全表扫描的 INSERT SELECT 把数据库 IO 直接打满,最后只能 kill 进程。花十秒钟验证一下,能省下半夜爬起来救火的精力。
