只要跟数据库打交道,INSERT 就是你躲不开的第一个动作。哪怕你写了五年 SQL,天天用 INSERT,也照样能在插入数据时踩出各种意想不到的坑:莫名其妙的锁等待、主键冲突导致任务失败、大批量插入慢到怀疑服务器配置、MyBatis Plus 插入时一点报错都没有但数据就是没进去。这篇文章不聊那些玄学,我就把 MySQL 的 INSERT 从语法、执行细节、批量插入优化、主键冲突处理到故障排查,完整拆一遍,都是我在实际项目里验证过的东西。
标题里所谓“详解”不是把官方文档翻译一遍,而是把真正会在生产环境遇到的行为差异和坑讲清楚。文章适合刚学 MySQL 的初学者建立完整认知,也适合写了好几年增删改查但没深究过 INSERT 执行细节的开发者,帮你把这块知识补扎实。
1. INSERT 基础语法与执行逻辑
1.1 单条插入的完整语法
先看最基本的写法,这个大家都会:
sql复制INSERT INTO user (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com');
这行语句的含义是把一条新记录插入到 user 表中。括号里指定了三列,VALUES 里对应三个值,顺序跟列清单一一对应。这里有一个看起来很小、但影响很深远的点:列清单的顺序可以不等于表结构里的顺序。比如表定义是 id, name, email, age,你上面这样写完全没问题,因为插入是以你给出的列清单为准,不存在的列走默认值。
如果把列清单整个省略,直接写:
sql复制INSERT INTO user VALUES (1, '张三', 'zhangsan@example.com', 25);
这种写法要求 VALUES 里的值必须严格等于表结构所有列的顺序和数量。我不建议在正式项目里这么写,尤其表结构经常变动的场景下,前面加一列后面整个 INSERT 就全废了,报错还不好排查。省那几个字符,后期换来的是维护成本和线上故障风险,不划算。
还有一个高频需求:插入时指定默认值。你可以在 VALUES 里写 DEFAULT 关键字,也可以用 DEFAULT(col) 函数取某一列的默认值:
sql复制INSERT INTO user (name, age, status) VALUES ('李四', 30, DEFAULT);
注意 DEFAULT 和 DEFAULT(col) 的行为差异:前者直接取该列定义时的默认值,后者适用于类似 DEFAULT(created_at) 这种需要取指定列默认值的场景。实际开发中,我更习惯让数据库自动填充默认值,干脆不写这一列,效果一样但 SQL 更简洁。
1.2 INSERT 与事务、自动提交的底层逻辑
很多人把 INSERT 理解成“写一行数据”,但从 InnoDB 的角度看,一次 INSERT 涉及的动作远比表面复杂:检查约束、分配自增 ID、插入聚簇索引、维护二级索引、写 undo log、写 redo log、可能还要写 binlog。这一串操作必须保证原子性,所以 INSERT 本质上是在一个事务里执行的。
MySQL 默认的 autocommit 是开启的,意味着你执行一条 INSERT,如果没有显式包在事务里,它会自动提交,不可回滚。这是一个平时无感、出问题时才意识到重要的机制。
实际项目里,多行插入或者插入后还要更新关联表,就要手动控制事务:
sql复制START TRANSACTION;
INSERT INTO orders (order_no, user_id, amount) VALUES ('ORD20240001', 100, 99.90);
INSERT INTO order_item (order_id, product_id, num) VALUES (LAST_INSERT_ID(), 501, 2);
COMMIT;
这里有一个非常实用的点:LAST_INSERT_ID() 拿到的是当前会话最后一次 INSERT 生成的自增 ID,不是全局的、也不是其他会话的。多行插入时拿到的是第一个自增 ID(后面细说),这些细节在高并发环境下很容易写错,导致关联数据错乱。
为什么要开事务?核心是保证数据一致性。比如主表插入成功、子表插入失败,如果不开事务,主表那条数据就留在库里了,后续对账时发现对不上,非常麻烦。开了事务后任一步出错都能 ROLLBACK 回到起点。
还有一个点,很多人不知道:大事务里执行很多条 INSERT,在 COMMIT 时会把所有变更一次性刷盘,相比每一条自动提交,能显著减少磁盘 I/O 次数。你批量导数据时,如果一条 INSERT 一个事务,跑几万条数据会慢得让你怀疑人生。把几万条包在一个事务里提交,速度能快一个量级,但是要注意事务别开太大,后面第 2 节会讲怎么权衡。
1.3 为什么强烈建议“写列清单”
这条经验是我很早以前在一次线上事故里学到的。当时有个表加了一个新字段,不是可空也没有默认值,结果老代码里所有省略列清单的 INSERT 全部报错,因为新列没有默认值且不能为 NULL,一条数据都插不进去。如果当时写 SQL 都带列清单,这次变更根本不会影响线上。
带列清单还有一个好处:让 INSERT 的意图变得明确,后续维护的人能一眼看出这条插入到底填了哪些字段、哪些字段在走默认值。别人接手你的代码时,这种“显式优于隐式”的习惯非常加分。
sql复制-- 推荐:明确列出所有插入字段
INSERT INTO user (name, age, email) VALUES ('王五', 28, 'wangwu@example.com');
-- 不推荐:完全依赖表结构顺序
INSERT INTO user VALUES (5, '王五', 'wangwu@example.com', 28);
如果你处理的是 ETL 任务或者数据回刷脚本,建议在 INSERT 前先用 DESC 或者 SHOW CREATE TABLE 确认表结构,不要靠记忆写列清单。表结构有时候跟你想的不一样,比如字段顺序调整过、自增列不在第一位等,这些都会影响查询结果。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 批量插入与数据迁移的实操写法
2.1 多值批量写入
一次 INSERT 插入多行,是最常用也最有效的批量写入方式:
sql复制INSERT INTO user (name, age) VALUES
('用户1', 20),
('用户2', 21),
('用户3', 22);
相比循环单条 INSERT,这种方式把多条插入合并成一次客户端与 MySQL 的交互,减少了网络往返和 SQL 解析开销。在 InnoDB 下,多值 INSERT 还会走批量插入的优化路径,整体性能提升非常明显。
我实际压过数据:往一个 10 万行级别的表里插 1 万条数据,单条循环 INSERT 大概耗时 30 到 40 秒,改成每批 500 条的多值 INSERT,总耗时降到 3 秒以内,差距接近 10 倍。
不过多值 INSERT 有几个限制需要提前规避:
- 单条 SQL 不要无限长。受
max_allowed_packet参数限制,默认值通常是 64MB,听起来很大,但如果有大的 TEXT 字段,几千条就可能超限。超了之后 MySQL 会直接报Packet too large,整个 SQL 被拒。 - 批量长度建议控制在 500 到 1000 行一组。太长了虽然也能跑,但事务持续时间变长,锁占用时间变长,并发一高容易引发锁等待和主从延迟。
- 如果插入的数据里某些行违反唯一约束,会导致整批全部失败。很多人踩过这个坑:1000 条里有一条重复,结果 999 条也没插进去。解决思路是用
INSERT IGNORE或者ON DUPLICATE KEY UPDATE,第 3 节详聊。
2.2 INSERT INTO SELECT 复制表数据
日常开发和数据迁移中,INSERT INTO SELECT 出镜率极高:
sql复制INSERT INTO user_backup (id, name, age, email)
SELECT id, name, age, email FROM user WHERE created_at < '2024-01-01';
这会把 user 表中符合条件的数据全部复制到 user_backup。执行时 MySQL 会先执行 SELECT 部分,再把结果集插入目标表。这段逻辑本身并不复杂,但有几个坑必须注意。
第一个坑:SELECT 出来的列必须与 INSERT 的列一一对应,数据类型要能隐式转换。如果源表是 VARCHAR,目标表是 INT,MySQL 会做隐式转换,但遇到非数字字符串时会转成 0(非严格模式)或者直接报错(严格模式)。建议先用 CAST 做显式转换,别让 MySQL 替你猜。
第二个坑:自增主键。如果目标表已经有数据,直接复制包含 id 的列,有可能踩中主键冲突。要么 SELECT 时排除自增列,要么在 INSERT 语句里明确指定新主键。全表迁移场景中,我通常选择保留原 id(用于关联关系不打断),这时候需要先确认目标表 id 范围不冲突。
第三个坑:INSERT INTO SELECT 执行期间,源表的行会加共享锁。如果数据量很大、执行时间长,源表上的写入会被阻塞,影响线上业务。所以大表做数据归档时,不要一次性全量 SELECT,按 id 范围分批,每批几万行,加个 LIMIT,循环跑,这样可以显著降低锁影响。
2.3 大批量入库:拆批与事务提交节奏
当你要插入几十万甚至上百万条数据时,无脑一条 SQL 插入是不可行的。MySQL 本身有 SQL 语句大小的限制,而且单条超大事务也会带来 InnoDB 层面的性能问题。我的经验值是:每批 500 到 1000 条左右,事务提交一次,整体节奏比较稳。
伪代码思路是这样的:
text复制每批收集 500 条记录
组装成多值 INSERT 语句
执行并 COMMIT
循环直到全部插完
为什么每批 500 条而不是一次性 5 万条?因为 InnoDB 在事务提交时要把该事务修改的页刷到 redo log,并且记录 undo log。事务越大,提交时要做的工作越重,而且长时间持有行锁,其他会话更新同一批数据只能干等。一旦主从复制开启,超大事务还会导致从库延迟飙升,主库上一条 100 万行的 INSERT,从库可能要执行好几分钟,这个延迟高峰期会直接影响读业务的 SLA。
如果你追求极致的写入速度,可以考虑 LOAD DATA LOCAL INFILE。这是 MySQL 原生支持的高效导入方式,比逐条 INSERT 快很多,原理是绕过了部分 SQL 层解析开销,直接把文本数据导入表中。不过它要求文件格式规范,缺列多列都会报错,适合一次性初始化数据,不适合业务代码里高频调用。
还有一个值得提的细节:大批量插入时先删掉非必要的二级索引,插入完成后再重建索引,速度会更快。因为每插入一行,InnoDB 都要维护所有二级索引,索引越多写入越慢。这个技巧在做数据初始化、表重建时非常实用,但在线业务表不要随便删索引,要考虑查询那边的影响。
2.4 存储过程里模拟批量插入
有些场景下不方便在应用层写循环,可以直接在 MySQL 里用存储过程生成测试数据。这里有一个很经典的写法:
sql复制DROP PROCEDURE IF EXISTS batch_insert;
DELIMITER $$
CREATE PROCEDURE batch_insert()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 10000 DO
INSERT INTO user (name, age) VALUES (CONCAT('用户', i), i % 80 + 18);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL batch_insert();
注意 DELIMITER $$ 的作用:MySQL 默认用分号作为语句分隔符,但存储过程内部本身就有分号,不修改分隔符的话,客户端会在第一个分号处就认为语句结束,导致语法错乱。这个细节面试也常考。不过存储过程逐条 INSERT 10 万条是很慢的,如果只是造测试数据,我会在存储过程里拼多值 INSERT,每 1000 条提交一次,性能好得多:
sql复制DELIMITER $$
CREATE PROCEDURE batch_insert_fast()
BEGIN
DECLARE i INT DEFAULT 1;
START TRANSACTION;
WHILE i <= 10000 DO
INSERT INTO user (name, age) VALUES (CONCAT('用户', i), i % 80 + 18);
SET i = i + 1;
IF i % 1000 = 0 THEN
COMMIT;
START TRANSACTION;
END IF;
END WHILE;
COMMIT;
END$$
DELIMITER ;
存储过程最大的价值不是性能,而是固定执行逻辑,适合报表初始化、数据订正这种低频但逻辑稳定的操作。如果业务代码是高频写入,建议还是把 SQL 放在应用层,便于代码评审、灰度、监控,别全塞进数据库里。
3. 主键冲突与重复数据处理的三套方案
3.1 INSERT IGNORE:忽略重复,静默跳过
业务中经常遇到导入一批数据,里面可能包含已存在的记录。如果直接 INSERT,遇到主键冲突或唯一键冲突,整条语句会报错,若在多值 INSERT 里则整批回滚。这时候 INSERT IGNORE 就派上用场了:
sql复制INSERT IGNORE INTO user (id, name, age) VALUES (1, '张三', 25);
执行后如果 id=1 已经存在,MySQL 不会报错,而是忽略这条插入,继续执行后续语句。它的本质是把冲突的“报错”降级为“警告”,你执行后可以用 SHOW WARNINGS 查看具体被忽略的行。
但这个方案有一个很容易被忽略的点:INSERT IGNORE 不只是忽略主键冲突,它还会忽略其他类型的错误,比如非空约束违反、CHECK 约束失败、类型转换错误。换句话说,有些数据质量问题会被静默吞掉,你根本不知道哪些行插失败了。所以我不建议在数据质量要求高的场景下用它,宁可先查询判重、或者用下面提到的 UPDATE 方案,让异常暴露出来。
3.2 ON DUPLICATE KEY UPDATE:存在就更新
如果业务需求是“有则更新、无则插入”,比如订单幂等写入、用户最后登录时间更新,那么 ON DUPLICATE KEY UPDATE 是首选:
sql复制INSERT INTO user (id, name, age) VALUES (1, '张三', 26)
ON DUPLICATE KEY UPDATE age = VALUES(age);
语句的含义是:如果插入导致唯一索引或主键冲突,则执行 UPDATE 操作。这里的 VALUES(age) 是指引用 INSERT 部分提供的 age 值,等价于把插入的值赋给已有记录。
这个语法有一个很典型的坑:返回的影响行数语义跟直觉不一样。插入成功返回 1;发生冲突执行 UPDATE 返回 2;冲突了但 UPDATE 后数据没变化返回 0。很多应用用受影响行数判断“是否插入成功”,这里就会踩雷,比如把 0 或 2 当成失败去重试,结果造成无效更新或者重复操作。
从 MySQL 8.0.20 开始,官方不建议继续使用 VALUES() 这种写法,推荐使用别名方式:
sql复制INSERT INTO user (id, name, age) VALUES (1, '张三', 26) AS new
ON DUPLICATE KEY UPDATE age = new.age;
这个写法更直观,也避免了一些边界问题。如果是 5.7 及以下版本,还是只能继续用 VALUES()。
实际使用中我觉得这个语法特别适合做“配置表”和“汇总表”。比如统计每日活跃用户,当天第一次访问插入一条,后续访问直接累加计数,一条 SQL 就能搞定,不用先查再判断再更新,性能好且代码简洁。
3.3 REPLACE INTO:能不用就别用
REPLACE INTO 看起来跟 ON DUPLICATE KEY UPDATE 很像,但它俩的执行逻辑完全不一样。REPLACE INTO 遇到主键或唯一键冲突时,会先 DELETE 掉旧记录,再 INSERT 新记录:
sql复制REPLACE INTO user (id, name, age) VALUES (1, '赵六', 30);
如果 id=1 存在,这条语句会先把 id=1 的行删除,再插入一行新的。这种“先删后插”有几个副作用:
- 如果表有外键约束,DELETE 旧记录可能触发外键限制,导致 REPLACE 失败。
- 如果表有触发器,DELETE 的触发器会执行,INSERT 的触发器也会执行,重复数据可能在日志表里出现两次。
- 自增 ID 会变化。因为旧记录被删了,新记录会占用新的自增 ID,哪怕业务上感觉只是更新了同一条数据。
- 删掉旧记录后,如果有其他表通过非外键方式引用这条数据,会产生悬空虚引用。
所以我的建议是:除非你明确知道要“删掉重建”且能接受上述副作用,否则一律用 ON DUPLICATE KEY UPDATE。这个我也踩过坑,曾经为了省事在用户扩展表上用 REPLACE,结果用户 ID 没变但自增主键不断增长,关联日志全乱了,排查了很久。
3.4 先查后插为什么在并发下会失效
新手常见写法是先 SELECT 判断存不存在,不存在再 INSERT:
sql复制SELECT COUNT(*) FROM user WHERE email = 'test@example.com';
-- 如果返回 0
INSERT INTO user (email, name) VALUES ('test@example.com', '测试');
这种写法在单线程下没问题,但并发场景下会出大问题。两个请求同时 SELECT,都发现不存在,然后同时 INSERT,后插入的那条就会主键冲突或唯一键冲突。中间那几毫秒的间隙,就是并发写入的安全隐患。
正确做法是依靠数据库的唯一约束兜底,再配合 ON DUPLICATE KEY UPDATE 或者捕获插入异常后走更新逻辑。记住一点:应用层的判断永远不能替代数据库层的约束,唯一索引才是防重复的最后防线。
4. 进阶场景:触发器、存储过程与类型转换细节
4.1 触发器里写 INSERT 的注意点
触发器是很多业务系统里做数据同步、审计日志的常见手段。比如在 user 表上建一个 AFTER INSERT 触发器,每次插入用户后自动往日志表写一条记录:
sql复制DELIMITER $$
CREATE TRIGGER trg_user_after_insert
AFTER INSERT ON user
FOR EACH ROW
BEGIN
INSERT INTO user_log (user_id, action, create_time)
VALUES (NEW.id, 'INSERT', NOW());
END$$
DELIMITER ;
这里有两个必须注意的点。第一是 NEW.列名 和 OLD.列名 的区别:INSERT 触发器里只有 NEW,表示即将插入的新行;UPDATE 触发器里两者都有;DELETE 触发器里只有 OLD。用错的话会直接报错。
第二是分隔符问题。在命令行或 Navicat 里创建触发器,如果不用 DELIMITER 修改结束符,MySQL 会把触发器内部的第一个分号当作整个 CREATE TRIGGER 语句的结束,导致语法错误。这是初学者最容易卡住的地方。
还有一类问题是触发器内部出错对主表的影响。默认情况下,触发器执行失败会导致触发它的 INSERT 语句整体失败,主表的数据也插不进去。如果日志表出现结构变更或磁盘满,你的主业务写入会直接瘫痪,这在生产环境是很恐怖的事。所以我个人对触发器的态度是:能用应用代码解决的,尽量不用触发器;确实要用,必须对触发器内部的表做充分监控,并且保持触发器逻辑足够简单。
4.2 存储过程中的动态 INSERT 与错误处理
存储过程内写 INSERT 时,除了拼接 SQL,还有一个常见需求是捕获错误。下面是一个典型的插入用户并做异常处理的写法:
sql复制DELIMITER $$
CREATE PROCEDURE sp_insert_user(
IN p_name VARCHAR(50),
IN p_age INT
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SELECT 'insert failed' AS msg;
END;
START TRANSACTION;
INSERT INTO user (name, age) VALUES (p_name, p_age);
COMMIT;
SELECT 'insert success' AS msg;
END$$
DELIMITER ;
DECLARE EXIT HANDLER FOR SQLEXCEPTION 的作用是:只要存储过程内发生任何 SQL 异常,就直接回滚事务并退出。这在批量数据处理里非常实用,可以把“多条插入必须同时成功”的逻辑封装在数据库端,减少应用层的协调成本。
不过,存储过程里的动态 SQL 也要小心 SQL 注入。比如用 CONCAT 拼接用户传入的表名或字段名,如果参数被恶意构造,可能执行非预期语句。存储过程适合固定逻辑,不适合把外部输入直接拼进 SQL 中。
4.3 字段类型与隐式转换:int + 5 会怎样
热搜词里的 “mysql中int+5” 其实就是字段类型隐式转换的问题。比如表中有一列 score VARCHAR(10),里面存的是数字字符串,你执行:
sql复制SELECT score + 5 FROM exam;
MySQL 会尝试把 score 的字符串隐式转换成数值类型,再做加法。大部分情况下结果符合预期,但如果某个字符串不是合法数字,比如 'abc',转换结果变成 0,最后输出 5。这种问题在数据质量参差不齐的表中很容易被忽略。
更麻烦的是隐式转换导致索引失效。比如 id 是 VARCHAR 类型,但查询时传入的是数字:
sql复制SELECT * FROM user WHERE id = 1001;
MySQL 会把 VARCHAR 的 id 转成数字再比较,导致这一列上的索引失效,查询变成全表扫描。如果反过来,id 是 INT 类型,查询传字符串 '1001',MySQL 会尝试把字符串转成数字,索引还能用。所以经验法则是:字段是什么类型,应用层就传什么类型,别依赖 MySQL 的隐式转换。
插入时也会遇到类型问题。比如表里字段是 DECIMAL(10,2),你插入一个浮点数值,MySQL 会做四舍五入,可能跟你预期的精度有差异。建议在应用层把金额用整数“分”存储,避免浮点精度问题,或者插入前用 ROUND 显式处理。
4.4 插入 JSON 类型数据时的转义细节
MySQL 从 5.7 开始支持 JSON 类型,插入 JSON 数据时最常见的坑是字符串转义。JSON 本身用双引号,如果你的值里有双引号,在 SQL 里又要用单引号包字符串,很容易漏转义:
sql复制-- 错误写法,JSON 里的双引号和 SQL 字符串引号混在一起
INSERT INTO t_json (data) VALUES ('{"name": "张三", "remark": "他说"你好""}');
-- 正确写法,内层双引号转义
INSERT INTO t_json (data) VALUES ('{"name": "张三", "remark": "他说\"你好\""}');
还有一种更推荐的做法是直接用 JSON_OBJECT 函数构造 JSON 值,不用手写字符串:
sql复制INSERT INTO t_json (data) VALUES (JSON_OBJECT('name', '张三', 'remark', '他说"你好"'));
这样既不用关心转义,MySQL 还会自动校验 JSON 格式合法性。如果 JSON 格式错误,插入时会直接报 Invalid JSON text。这个函数用法可以帮你避免一大批转义错误。
5. 写入异常排查:从“没报错但没写进去”说起
5.1 MyBatis Plus 插入不报错但数据没写的排查清单
热搜词里有 “mybatis plus insert 数据没有写成功,但是也没有报错”,这是我在社区见到的高频问题,也是实际开发中很隐蔽的坑。先说结论:MyBatis Plus 插入没报错但数据没进去,绝大多数不是 SQL 的问题,而是 ORM 框架层面的“假成功”。
我曾经排查过一个案例,现象是接口返回成功,数据库里什么都没多。顺着代码看下去,发现 Service 方法上加了 @Transactional,调用链里另一个方法抛了异常被上层 try-catch 吞掉了,事务被标记为 rollback-only,最后整个事务静默回滚,接口却依然返回成功。这种“异常被吞+事务注解”的组合,是最典型的假成功。排查时首先要看 service 方法有没有事务注解,有没有 try-catch 吞异常。
第二个常见原因是实体类和表结构的映射不一致。MyBatis Plus 默认按驼峰转下划线映射字段,比如 userName 映射到 user_name。如果表字段是 username,而实体里是 userName,插入 SQL 里就会多出一个不存在的列或者字段匹配不上。更隐蔽的是表里字段有默认值,但实体里对应字段是 null,MyBatis Plus 默认的插入策略会把 null 字段排除掉,结果插入后数据库里该字段走了默认值。看起来像是“数据没写全”,但其实 SQL 生成的列清单里根本没有这一列。
第三个原因是逻辑删除字段。MyBatis Plus 的逻辑删除会在插入时正常写入,但查询时自动加 deleted=0 条件。如果你插入的数据逻辑删除标记写成了 1,那插入是成功的,只是查不到,看起来像没写入。这种问题需要检查 deleted 字段的赋值逻辑。
第四个原因是主键生成策略。MyBatis Plus 默认使用雪花算法生成 ID,如果你的主键字段类型是 Long,但数据库表主键是 INT,插入的 ID 超出 INT 范围,MySQL 严格模式下会报错,非严格模式下可能写入失败或被截断。解决方式是确认主键类型一致,或者把 ID 策略改成 AUTO。
排查这类问题,最直接的手段是开启 MyBatis SQL 日志,看实际执行的 INSERT 语句是什么。SQL 打出来,问题基本就露馅了。可以配置:
yaml复制logging:
level:
com.example.mapper: debug
然后对照日志里的 SQL 去数据库执行,看有没有异常。这比盯着代码猜高效太多。
5.2 锁等待、行锁阻塞与死锁
并发写入时,INSERT 也会遇到锁问题。最常见的现象是:一条 INSERT 卡住不动,过一会儿报 Lock wait timeout exceeded。这通常是另一个事务持有同一行的锁,一直没提交。
排查锁等待要先看当前有哪些事务在跑:
sql复制SELECT * FROM information_schema.innodb_trx\G;
再看哪些事务阻塞了别人:
sql复制SELECT * FROM sys.innodb_lock_waits\G;
innodb_trx 里的 trx_state 是 RUNNING 但长时间不提交的事务,通常就是问题源头。生产环境出现过一种典型场景:开发在测试环境执行了一条 UPDATE 忘记提交,事务一直挂着,导致后续所有针对同一行的 INSERT 全部阻塞超时。
还有一个参数要关注:innodb_lock_wait_timeout,默认 50 秒。如果经常出现锁等待超时,不要急着调大这个值,先解决长事务。调大只是把问题延后,并不能消除锁竞争。
死锁和锁等待是两回事。死锁是多个事务互相持有对方需要的锁,MySQL 会自动检测并回滚其中一个事务。死锁报错信息通常是 Deadlock found when trying to get lock。出现死锁后,业务代码一定要做重试机制,捕获死锁异常后重新执行一次插入。无论你把 SQL 写得再好,并发场景下死锁无法完全避免,应用层重试是最后的兜底。
多值 INSERT 在并发插入时还有一个特性:如果数据落在相邻主键范围,InnoDB 会使用插入意向锁,多个事务在插入互不冲突的间隙时可以并行。但如果两个事务都往同一个范围插入且中间没有间隙,就可能相互阻塞。所以高并发写入场景下,尽量让主键有序生成,避免大量随机主键插入导致页分裂和锁冲突。
5.3 自增主键不连续与跳号问题
自增主键跳号是一个让很多新人困惑的现象:明明只插了几条数据,表的 AUTO_INCREMENT 值已经跳到了几十甚至上百。原因主要有三个。
第一个原因是事务回滚。InnoDB 在分配自增 ID 时会预分配,并不会因为事务回滚而回收。比如一个事务里插入了 5 条数据,但第 5 条违反约束导致整体回滚,前 4 条插入也回滚了,但这 5 个 ID 已经被消耗掉,后续插入从第 6 个开始。
第二个原因是 INSERT IGNORE 和 ON DUPLICATE KEY UPDATE。插入冲突时,MySQL 已经为这次插入分配了自增 ID,即使最终没有插入成功,ID 也不会复用。批量插入 1000 条,如果 200 条重复被忽略,自增 ID 也会跳到 1000 这个位置,而不是只增长 800。
第三个原因是 MySQL 8.0 对 AUTO_INCREMENT 的持久化机制。从 8.0 开始,自增计数器会持久化到 redo log,重启后不会回退,这是为了杜绝 5.7 及以前版本中重启后自增值回退导致的主键重复问题。代价就是跳号更“坚决”。
只要主键是 BIGINT,跳号完全不影响业务,不要去追求自增主键的连续性。如果有业务要求 ID 必须连续,说明设计思路有问题,应该考虑用独立的发号器或者业务编号,而不是依赖数据库自增。
5.4 字符集与中文乱码的根因
插入中文乱码,绝大多数不是 MySQL 的问题,而是“客户端连接字符集、SQL 语句字符集、表字符集”三者不一致导致的。排查时先确认表的字符集:
sql复制SHOW CREATE TABLE user;
确认连接字符集:
sql复制SHOW VARIABLES LIKE 'character_set_connection';
如果表是 utf8mb4,连接却是 latin1,你插入的中文会被 MySQL 当成 latin1 字符处理,自然乱码。注意,MySQL 的 utf8 跟标准 UTF-8 并不完全一样,utf8 在 MySQL 里最多支持 3 字节,存储 emoji 这类 4 字节字符会报错,所以建表建议直接用 utf8mb4。
JDBC 连接串也要显式指定字符集:
text复制jdbc:mysql://localhost:3306/app_db?useUnicode=true&characterEncoding=utf8mb4
没有 characterEncoding 参数时,驱动会用 MySQL 服务端默认字符集,两边不一致就乱码。这个属于经典老坑,排查时别只盯着业务代码。
6. 面试与工程实践中被追问的 INSERT 细节
6.1 “INSERT ON UPDATE” 和 “INSERT INTO SELECT” 别搞混
热搜词里出现了两个容易混淆的写法:insert on update 和 insert into select。前者其实是 ON DUPLICATE KEY UPDATE 的口语化表达,指的是“插入时遇到冲突就更新”,后者是从查询结果集批量插入。面试时经常有人把这两个混为一谈,答非所问。如果你在简历里写了熟悉 MySQL,这两个概念最好分开讲清楚,并且能各自给出一个应用场景。
我面试候选人时,如果对方提到 INSERT ON DUPLICATE KEY UPDATE,我会追问它和 REPLACE INTO 的区别。能准确说出“REPLACE 是先删后插,会触发 DELETE 触发器、改变自增 ID”的人,基本可以确定是写过真实项目的。讲不出这一层的,多半是背了面试题。
6.2 INSERT 与隔离级别、binlog 格式的关系
INSERT 不只是写入数据,它对主从复制的影响也很大。在 binlog 为 STATEMENT 格式时,主库执行 INSERT 语句直接记录 SQL 文本,从库重新执行一遍。如果用了 NOW()、UUID() 这种非确定性函数,主从执行结果可能不一致。所以线上建议使用 ROW 格式的 binlog,记录的是实际变更的行数据,更安全。
事务隔离级别也会影响 INSERT。默认的 REPEATABLE READ 下,如果两个事务同时插入相同唯一键,一个先提交,另一个在提交时会报死锁或唯一键冲突。理解这一点对设计高并发写入逻辑有帮助:不要以为 INSERT 不需要加锁,它同样要参与事务的锁协调。
6.3 大表插入与索引维护代价
向一张大表插入数据,最大的开销往往不是插入聚簇索引本身,而是维护二级索引。每插入一行,所有二级索引都要插入对应的索引条目。如果有 5 个二级索引,一次 INSERT 相当于要做 6 次索引写入。
所以在做大量数据初始化时,一个常用优化是先 DROP 掉非必要索引,插完再重建。这个操作在建表迁移、数据仓库初始化场景中效果显著,速度能提升两到三倍。但生产中正在提供查询服务的表不能这么干,删索引期间查询性能会严重退化,需要做充分的窗口评估。
每次插入导致的页分裂也是一个隐性成本。随机主键会让聚簇索引频繁分裂页面,增加碎片和 I/O。用自增主键或者有序 ID 作为主键,就是为了让插入顺序贴合索引顺序,从源头减少页分裂。这一点在 TPS 比较高的写入场景中体会非常明显。
6.4 写 INSERT 代码时的工程习惯
最后分享几个我在代码评审里经常提到的 INSERT 工程习惯,都是小细节但很影响线上稳定性:
- 所有 INSERT 语句必须显式列出列清单,禁止
INSERT INTO table VALUES (...)。 - 批量插入的每条 SQL 大小控制在 1MB 以内,行数控制在 1000 行以内,避免
max_allowed_packet超限。 - 业务代码里,INSERT 的异常处理要区分“唯一键冲突”和其他数据库异常,唯一键冲突是业务预期,不需要打印错误堆栈,但要打一条 warn 日志方便排查。
- 涉及金额、数量的插入,先确认 DECIMAL 精度和类型,避免隐式转换损失精度。
- 高并发写入接口,务必配置数据库连接池的超时时间和重试机制,防止连接池被打满。
这些习惯单看都不起眼,但叠加在一起,能让你的写入链路稳定很多。我自己在做过一次全链路压测后,把团队所有 INSERT 代码按这个标准过了一遍,线上写入超时和主键冲突报警立刻少了很多。INSERT 确实是入门第一天就学的东西,但要做到生产级可靠,里面的细节远比想象中多。
