前阵子有朋友问我:“MySQL 的 INSERT 不就是一行 SQL 吗?有什么好研究的?”我当时没直接回答,反问了他一句:“如果你把一条 INSERT 写进一个高并发订单系统里,服务端突然报死锁,你知道要看哪个参数吗?如果你 insert 一条数据,程序没报错,但表里就是没有,你能在五分钟内定位吗?”他沉默了。
其实很多看似基础的知识点,恰恰是线上故障的第一来源。MySQL 的 INSERT(插入数据) 语法看起来简单,但往下挖,里面有默认值规则、自增机制、锁竞争、事务隔离、批量性能、变体语法(INSERT IGNORE、ON DUPLICATE KEY UPDATE、REPLACE INTO)等一堆门道。这篇内容我就从实际使用的角度,把 INSERT 相关的知识完整过一遍,从语法细节到性能优化,再到真实排错案例,最后聊聊面试里那些容易被问倒的点。无论你是刚接触 MySQL 的初学者,还是写了几年业务代码但没系统梳理过数据库细节的开发,这篇都值得花十几分钟看完。
1. 从一行 INSERT 说起:那些你以为理所当然的默认行为
1.1 完整的 INSERT 语法形态
先看最标准的形态:
sql复制INSERT INTO t_user (id, name, age) VALUES (1, '张三', 20);
这个写法大家都会,但我想强调几个平时容易忽视的细节。
第一,字段列表可以省略,但省略后必须按表结构里所有字段的顺序给值,少一个都不行:
sql复制INSERT INTO t_user VALUES (1, '张三', 20);
这种写法在实际项目里我非常不建议用,原因是表结构一旦调整(新增字段、调整顺序),这条 SQL 直接报错或错位插入,排查起来很痛苦。而且代码评审的时候,别人根本看不出你插的 1、张三、20 分别对应哪个字段。显式列出字段名,维护成本会低得多。
第二,MySQL 还有一个非标准但很实用的 INSERT ... SET 语法:
sql复制INSERT INTO t_user SET name = '李四', age = 25;
这种写法在某些 ORM 框架的底层会用到,适合插入的字段比较少、可读性要求高的场景。它的可执行效率和 INSERT ... VALUES 基本一致,没有性能差别。
第三,多值插入(也叫扩展插入)也是标准语法的一部分:
sql复制INSERT INTO t_user (name, age) VALUES
('王五', 22),
('赵六', 23),
('孙七', 24);
一条语句插入多行,是日常批量写入最常用的方式,后文我会专门讲它的性能优势。
1.2 没写进去的列到底填了什么
这是新手最容易误解的地方。执行 INSERT 时,如果某个字段既没出现在字段列表里,也没在 SET 里赋过值,MySQL 会按这条规则处理:
- 如果字段定义了
DEFAULT值,就填入默认值; - 如果字段没定义默认值,但允许
NULL,就填入NULL; - 如果字段既没默认值,又设置了
NOT NULL,在不报错的情况下,MySQL 会根据数据类型选择一个隐式默认值(比如整数填 0、字符串填空串、时间戳填当前时间)。
重点说一下第三种。这个“隐式默认值”行为受 sql_mode 控制。如果 sql_mode 里包含 STRICT_TRANS_TABLES(默认通常是有的),插入时报错:
sql复制ERROR 1364 (HY000): Field 'age' doesn't have a default value
若在非严格模式下,MySQL 会悄悄填一个隐式默认值进去。这种“悄悄填”非常危险,你本来以为 age 必须有值,结果插进去全是 0 或 NULL,后续统计全部失真。所以生产环境开启严格模式,是非常必要的基线配置。
1.3 sql_mode:宽松模式才是最隐蔽的坑
聊到严格模式,就展开多说几句 sql_mode,因为它直接影响 INSERT 的成败。
我见过一个真实案例:某个系统从测试环境迁移到生产环境,测试环境用的 MySQL 5.7 默认 sql_mode,生产环境被人手动改成了空。结果线上往一个 DECIMAL(10,2) 字段插 1000.126,严格模式会四舍五入成 1000.13 并给 warning,非严格模式直接插成功,但小数部分被截断,账面金额对不上,财务查了好几天才发现是数据库配置的问题。
检查当前 sql_mode 用这条:
sql复制SELECT @@sql_mode;
推荐在配置文件里显式设置,不要依赖默认值:
ini复制[mysqld]
sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
这套设置能保证:插入非法日期、除零、字段超长等情况都会报错而不是静默处理。日常开发中,让数据库把问题暴露在当下,远比让它把错误藏进数据里要好。
1.4 自增列的手动插入会把序列顶到哪里去
还有一个容易被忽略的点:向 AUTO_INCREMENT 列手动指定值。
sql复制INSERT INTO t_user (id, name) VALUES (100, '测试');
如果你手动插入了 id=100,那 MySQL 的自增计数器会直接跳到 101。后续再插入不指定 id 的数据时,会从 101 开始,而不是 1。
这个行为本身是设计如此,但它的副作用是:当你把一张表的数据导入导出、清洗数据后,再插入新数据时,id 可能一下子跳到一个很大的数字。 有些人误以为这是“ID 泄露”或“数据错乱”,其实不是。如果确实需要重置自增计数,可以用:
sql复制ALTER TABLE t_user AUTO_INCREMENT = 1;
但注意,这个语句只会把计数调整为“大于当前表内最大 id”的值,如果表里已经有 id=100 的数据,你设成 1 也不会生效。另外在高并发插入场景下,自增锁的分配策略会影响跳号情况,这在后面第 4 章会详细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不只是 INSERT:几个变体语法的真实应用场景
2.1 INSERT IGNORE:要的是“没冲突就插,冲突就跳过”
有些业务场景里,我们希望“某些重复数据不要报错,直接忽略”。比如初始化一批用户标签,标签名设置了唯一索引,已经存在就跳过,不打断批量导入流程。
sql复制INSERT IGNORE INTO t_tag (tag_name) VALUES ('热门'), ('推荐'), ('热门');
如果 tag_name 上有唯一索引,第二条“热门”会被忽略,影响行数为 0,SQL 不会报错。
这个语法在数据导入、初始化字典表、幂等写入场景下非常实用。但要注意两个点:
INSERT IGNORE不只忽略唯一键冲突,还会忽略其他类型的错误(比如数据超长、类型转换失败等),这会导致脏数据悄悄进入表。如果业务对数据质量要求高,别滥用。- 从 MySQL 8.0 开始,
INSERT IGNORE对不可见索引、CHECK 约束冲突、外键约束错误的处理也有变化,行为可能和 5.7 不一致,升级版本后要回归测试。
2.2 ON DUPLICATE KEY UPDATE:实现幂等写入的常用手段
这个语法可能是业务开发里用得最多的一个,标准称呼是 “upsert”(存在则更新,不存在则插入)。
sql复制INSERT INTO t_counter (biz_key, cnt) VALUES ('order_20240601', 1)
ON DUPLICATE KEY UPDATE cnt = cnt + 1;
如果 biz_key 上有唯一索引,且已经存在 order_20240601 的记录,则执行更新,cnt 加 1;如果不存在,则插入新记录,cnt 初始化为 1。这个写法非常适合计数器、每日汇总、状态上报之类的场景。
用的时候有两点提醒。
第一,判断是否命中的“重复”依据是唯一索引或主键,而不是任意字段。如果表里没有唯一约束,这个语法等于纯插入,没有任何 upsert 效果。
第二,影响行数要理解对。如果执行结果是插入了一行,返回影响行数为 1;如果命中了唯一键并执行了更新,返回影响行数是 2(这是 MySQL 把“尝试插入”和“执行更新”都算进去了)。如果你的代码里用影响行数判断“是否插入成功”,这里很容易踩坑。
从 MySQL 8.0.20 开始,官方已经在 ON DUPLICATE KEY UPDATE 子句中废弃了 VALUES() 函数,推荐用别名方式:
sql复制INSERT INTO t_counter (biz_key, cnt) VALUES ('order_20240601', 1) AS new
ON DUPLICATE KEY UPDATE cnt = cnt + new.cnt;
虽然 VALUES() 目前还能用(只是 warning),但新代码建议直接采用新写法,避免以后升级踩雷。
2.3 REPLACE INTO 的代价:为什么我劝你别乱用
REPLACE INTO 的语义是:如果唯一键或主键冲突,先把旧记录删除,再插入新记录。
sql复制REPLACE INTO t_user (id, name, age) VALUES (1, '张三', 30);
看起来和 ON DUPLICATE KEY UPDATE 效果类似,但本质差别巨大:
ON DUPLICATE KEY UPDATE执行的是更新操作,不删除原记录;REPLACE INTO执行的是“先 DELETE 再 INSERT”。
所以 REPLACE INTO 的代价明显更高:
- 删除旧记录时,如果有外键引用,会报错或触发级联删除;
- 自增 id 可能变化(删除重建,如果主键不是业务主键,id 会变);
- 旧记录上的“附属数据”可能顺带丢,比如使用 MyISAM 引擎时的全文索引、某些触发器;
- 高并发下,删除+插入比更新更容易产生锁竞争。
我的建议是:除非你有明确理由必须“整行替换”,否则一律用 ON DUPLICATE KEY UPDATE。 特别是涉及资金、订单这类核心数据,REPLACE INTO 的删除动作风险太大。
2.4 INSERT INTO SELECT:表间复制时容易被忽略的锁
把一个表的数据插入另一个表,最高效的写法之一:
sql复制INSERT INTO t_user_copy (id, name, age)
SELECT id, name, age FROM t_user WHERE create_time < '2024-01-01';
如果 t_user 表数据量很大,这个操作不会像你想的那么“默默无闻”,它有几个特点:
- 目标表会被写入,源表会被读取。在默认隔离级别(REPEATABLE READ)下,如果源表数据在 SELECT 阶段被其他事务修改,可能造成复制数据不一致。
- 从 MySQL 5.7.6 开始,
INSERT INTO SELECT在 binlog 里默认使用 row 格式时,会自动给源表加共享锁(LOCK IN SHARE MODE 的语义),防止数据不一致。这意味着源表在复制期间,其他事务的 UPDATE / DELETE 会被阻塞,影响线上业务。 - 如果只是把一张大表“抄”到另一张表做归档,建议分批执行,或者用
SELECT ... FOR UPDATE SKIP LOCKED(MySQL 8.0 支持)控制读取范围,避免一次锁太久。
另外,这个语句在两个表结构兼容性上如果出现问题,报错信息可能不太直观。比如字符集不一致导致中文乱码、字段类型不一致导致隐式转换,插入的数据可能不是你想要的。复制前最好先确认两边表结构、字符集、排序规则一致。
3. 批量插入的性能账:同样一万行,为什么差几十倍
3.1 一条 SQL 插多行 vs. 循环单行插入
很多人刚接触数据库时,写业务代码会用循环一条条 INSERT,比如 Java 里的 for 循环 + PreparedStatement。这种做法在数据量小的时候没什么感觉,但到了几千、几万条就明显变慢。
原因在于:每一条 INSERT 都是一次独立的 SQL 解析、权限检查、事务操作、日志写入(binlog / redo log)、索引更新。 网络往返次数、SQL 解析开销、磁盘同步次数都随行数线性增长。
换成多值插入:
sql复制INSERT INTO t_user (name, age) VALUES
('a', 1), ('b', 2), ('c', 3), ...;
一条 SQL 能插几百行(在 max_allowed_packet 允许范围内),解析开销只发生一次,binlog 写入和 redo log 的刷盘次数也大幅减少,性能差距通常是数量级的。
我做过一个很直观的测试:往本地 MySQL 8.0 的 InnoDB 表里插入 10 万条数据,每条结构包含 6 个字段。
| 方式 | 耗时(约) | 说明 |
|---|---|---|
| 单条循环 INSERT,每次 autocommit | 130 秒以上 | 每条都 fsync,慢在磁盘刷盘 |
| 单条 INSERT,手动事务每 1000 条提交一次 | 8 秒左右 | 刷盘次数大幅减少 |
| 多值 INSERT,每批 1000 行,手动事务 | 3 秒左右 | SQL 解析和网络开销进一步降低 |
| 多值 INSERT + 关闭唯一性检查(临时) | 2 秒左右 | 仅适合一次性导入,不建议生产使用 |
数据量越大,差距越明显。所以项目里做批量写入,首选多值 INSERT,再配合事务分批提交,这是性价比最高的组合。
3.2 事务与 autocommit 的影响
MySQL 默认开启 autocommit,也就是每条 SQL 自动提交。只要涉及数据变更,InnoDB 都要在提交时把 redo log 刷到磁盘(受 innodb_flush_log_at_trx_commit 参数影响)。如果每插入一行就提交一次,磁盘 fsync 次数 = 插入次数,自然慢。
把多行插入放在同一个事务里,提交次数降到几次,性能提升立竿见影。
sql复制START TRANSACTION;
INSERT INTO t_user (name, age) VALUES ('a', 1), ('b', 2), ...; -- 第一批
INSERT INTO t_user (name, age) VALUES ('c', 3), ('d', 4), ...; -- 第二批
COMMIT;
事务也不是越大越好。超大事务会持有大量行锁、占用 undo log、导致 binlog 文件暴涨,还会延迟 purge 线程的清理工作,甚至拖垮从库的同步延迟。 所以实际中,建议每批 500~2000 行提交一次,具体数值根据业务数据大小和网络情况调整。
我个人的习惯是:单条 INSERT(多值形式)控制在 1000 行以内,然后提交。10 万条数据分 100 批,每批 1000 条,在普通服务器上也就是几秒的事。
3.3 批量插入的合理尺寸与相关参数
三个参数和批量插入直接相关:
max_allowed_packet:单条 SQL 最大允许大小,默认一般是 64MB。如果你的多值 INSERT 拼接得太大,会报Packet too large。建议 128MB 以内。innodb_buffer_pool_size:InnoDB 缓存池。批量插入时,数据页和索引页都要在 buffer pool 里处理,如果缓冲池太小,会导致频繁的 LRU 淘汰和磁盘读写。生产环境通常建议设为物理内存的 60%~70%。innodb_flush_log_at_trx_commit:这个参数非常关键。默认值 1(每次提交刷盘,最安全);设为 2,每次提交只写 OS 缓存,每秒刷一次盘,性能更好,但数据库进程崩溃时可能丢最近 1 秒的事务;设为 0,由系统决定刷盘时机,性能最好但最不安全。
生产环境数据安全性优先,不建议把 innodb_flush_log_at_trx_commit 从 1 改成 0。如果一定要追求性能,最多折中到 2,并配合 UPS 电源保障硬件层面。
3.4 超大数据量时的替代方案:LOAD DATA
如果数据量到了百万、千万级别,多值 INSERT 也显得吃力。这时候可以用 MySQL 自带的 LOAD DATA 工具:
sql复制LOAD DATA LOCAL INFILE '/tmp/user_data.csv'
INTO TABLE t_user
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
(id, name, age);
LOAD DATA 比逐条 INSERT 快得多,它的原理是直接把文件内容解析后批量写入存储引擎,绕开 SQL 层的大部分开销。配合 FIELDS TERMINATED BY 等参数可以灵活处理 CSV、TSV 格式。
不过 LOAD DATA 有几个坑:
LOCAL关键字表示文件从客户端读取,需要客户端和服务端都开权限,否则会报错;- 如果文件中某行数据有问题,默认行为是跳过还是终止,由
IGNORE或REPLACE控制; - 大批量导入时,建议先
ALTER TABLE ... DISABLE KEYS关闭非唯一索引维护,导入完成后再ENABLE KEYS。注意,唯一索引不能用这个命令关闭,它只对普通二级索引有效。
4. 锁与并发:INSERT 在真实业务里踩过的坑
4.1 自增锁与 innodb_autoinc_lock_mode
只要用了 AUTO_INCREMENT 自增列,INSERT 就和锁脱不开关系。InnoDB 里有一个专门的自增锁机制,参数 innodb_autoinc_lock_mode 控制它的行为:
| 模式 | 含义 | 影响 |
|---|---|---|
| 0 | 传统模式,所有 INSERT 都加表级自增锁 | 并发插入性能差 |
| 1 | 连续模式(默认,MySQL 5.7/8.0),简单插入(预先知道插入行数)不加锁,用互斥量;批量插入仍加表级锁 | 大多数场景性能 OK |
| 2 | 交错模式,所有 INSERT 都不加表级自增锁 | 并发高,但自增 ID 可能不连续,主从复制在 binlog 格式为 statement 时可能数据不一致 |
所以默认的 mode=1 是最平衡的选择。如果你用 INSERT ... SELECT 这种批量插入,它无法预知插入行数,还是会加自增锁,执行期间其他 INSERT 要排队等自增 id。大表之间复制数据时,这可能成为并发写入的瓶颈。
4.2 间隙锁、插入意向锁与死锁实例
InnoDB 默认隔离级别是 REPEATABLE READ,走唯一索引或主键索引执行 INSERT 时,如果目标位置“前后”没有对应记录,InnoDB 会在索引间隙上加 间隙锁(Gap Lock) 或 Next-Key Lock(记录锁+间隙锁),防止其他事务在这个间隙插入数据。
这就引出了一个经典死锁场景:
sql复制-- 事务 A
INSERT INTO t_user (id, name) VALUES (10, 'A');
-- 事务 B
INSERT INTO t_user (id, name) VALUES (20, 'B');
-- 事务 A 此时执行:
INSERT INTO t_user (id, name) VALUES (15, 'A_2');
-- 事务 B 此时执行:
INSERT INTO t_user (id, name) VALUES (18, 'B_2');
如果两条 insert 的 id 落在同一个间隙区域,双方都在等待对方释放间隙锁,就可能死锁。最常见的死锁报错是:
sql复制ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
遇到这种情况,先别慌,死锁是 InnoDB 的正常机制,它会自动回滚其中一个事务,另一个继续执行。关键是业务代码要增加重试逻辑(捕获死锁异常后尝试重新执行事务),而不是直接报错给用户。
同时,减少死锁的手段包括:批量插入按固定顺序排好、减少事务内无关操作、尽量走唯一索引而不是普通索引、保持事务短小。插入数据前,先想好锁的获取顺序,是最有效的预防方式。
4.3 长事务拖垮 INSERT 的连锁反应
这个坑特别隐蔽。一个事务开启后长时间不提交,它会一直持有已经获取的行锁,后续针对这些行的 INSERT / UPDATE 都会被阻塞,越积越多,最终从“慢查询”变成“连接池打满”。
有个经典场景:业务代码里有一个定时任务,每 5 分钟跑一次,把一批旧数据从 A 表搬到 B 表。某天 A 表数据量暴增,搬数据的事务执行时间超过 30 分钟。结果是,所有对 A 表的 INSERT 都卡住,前端大量超时,最后 DBA 不得不手动 kill 掉那个长事务才恢复。
遇到这类问题,要检查以下几个信息:
sql复制-- 查看当前正在执行的事务
SELECT * FROM information_schema.INNODB_TRX\G
-- 查看哪些事务阻塞了其他事务
SELECT * FROM sys.innodb_lock_waits\G
定位到长事务之后,评估是否可以 kill:
sql复制KILL <trx_mysql_thread_id>;
别把“事务没提交”不当回事,它带来的锁阻塞可能比慢查询更致命。
4.4 线上一次死锁的完整复盘
我印象里有一次线上死锁问题,场景是用户下单接口同时写订单表和库存表。代码大致是这样:
事务内:先 INSERT 订单表,再 UPDATE 库存表。库存表扣减库存用:
sql复制UPDATE t_stock SET stock = stock - 1 WHERE sku_id = ?;
两个订单同时过来,如果 sku_id 相同,A 事务先持有了某一行库存的锁,B 事务也在等;A 事务继续插入订单表,恰好订单表也有一个唯一索引,B 事务之前已经插入过相同订单号,于是 A 等待 B 的订单表唯一键释放,B 等待 A 的库存行锁释放——死锁形成。
排查时我用 SHOW ENGINE INNODB STATUS 查看 LATEST DETECTED DEADLOCK,里面能很清楚地看到两个事务持有哪些锁、等待哪些锁。修复手段是:把订单表唯一键冲突提前做一次 SELECT 判断(或者捕获 DuplicateKey 异常后直接返回),不让死锁在事务内部形成。
这类经验其实就一句话:写业务代码时,把“锁顺序”当成接口设计的一部分,而不是只关注 SQL 对不对。
5. 数据没写进去但也没报错?完整排查链路
5.1 先确认事务到底提交没有
这是最常见的“没报错但没数据”原因。很多框架默认开启了事务,如果代码里在 INSERT 之后没有显式 commit(),事务一直未提交,其他会话当然看不到数据。
我见过一个真实案例:一个同事用 MyBatis-Plus 的 save() 方法插入数据,方法执行完没抛异常,但查库就是没有。后来发现他把整个方法标了 @Transactional,异常被外层 catch 吞掉了,事务回滚了,他还在疑惑“为什么没报错”。日志文件里其实有回滚记录,只是没人看。
所以排查第一步永远是:确认事务边界,确认方法是否被 @Transactional 包裹,确认异常是否被吞。 如果用了 Spring 事务,可以在日志里开启 debug 级别看 TransactionalInterceptor 的提交/回滚日志。
5.2 连接层的问题:只读连接、超时与连接池
第二步看连接。
数据库连接有几种“看似正常实则异常”的状态:
- 连接被设置为只读(
set session transaction read only),INSERT 会被拒绝或静默失败(取决于驱动版本和 sql_mode); - 数据库连接池里的连接因为网络原因早已断开,但连接池没有及时剔除,拿到这个“死连接”去执行 INSERT,可能会报连接异常,也可能因超时设置不当被吞掉;
- 连接字符集与表字符集不一致,插入的中文乱码,看起来“像没插进去”。
排查方式:在代码里打印当前连接的 isValid、autoCommit、isReadOnly 状态。很多问题一眼就能看出来。
5.3 字段层面的隐形陷阱:类型转换、字符集与默认值
第三步,排查表结构和数据本身。
比如表里有个 INT 字段,你插入的字符串是 "abc",在非严格模式下 MySQL 会把字符串转成 0 并插入,看起来没报错,但数据不符合预期;再比如 VARCHAR(10) 字段插入 11 个字符,严格模式报错,非严格模式直接截断。
还有一个更容易被忽视的:触发器里抛异常。如果表上有 BEFORE INSERT 触发器,而触发器内部操作有误(比如除零、违反约束),INSERT 会失败。但某些客户端驱动只报了“影响行数 0”,并没有抛出异常,业务代码就误以为成功了。
此时可以执行:
sql复制SHOW TRIGGERS LIKE 't_user';
检查表上是否有触发器,并逐条排查触发器逻辑。
5.4 用 binlog 和慢日志确认 INSERT 最终去向
如果上面三步都查不出问题,可以借助 MySQL 日志来还原真相。
确认是否开启了 binlog:
sql复制SELECT @@log_bin;
如果开着,可以查看 binlog 里到底有没有这条 INSERT 记录:
bash复制mysqlbinlog --base64-output=DECODE-ROWS -v /var/lib/mysql/binlog.000001 | grep "INSERT INTO t_user"
binlog 记录了所有已提交的写入操作。如果 binlog 里没有,说明这条 INSERT 要么没提交成功,要么被回滚了——这能帮你直接锁定问题发生在“提交之前”。
同时看 MySQL 的错误日志,别放过任何 warning 级别的信息。比如有些 INSERT 因为隐式类型转换触发了 warning:
sql复制SHOW WARNINGS;
这条命令在 SQL 执行后立刻执行,能列出 warning 明细,很多“没报错但数据不对”的真相都藏在这里。
6. 面试和日常最容易问到的 INSERT 考点
6.1 怎么拿到刚插入的自增 ID
不同语言驱动有不同做法,但核心都是 MySQL 的 LAST_INSERT_ID() 函数:
sql复制INSERT INTO t_user (name, age) VALUES ('测试', 20);
SELECT LAST_INSERT_ID();
注意 LAST_INSERT_ID() 与 SELECT MAX(id) 有本质区别:
MAX(id)取的是表里当前最大值,并发插入时会拿到别人的 id,完全不可靠;LAST_INSERT_ID()是当前会话级别跟踪的上一次自增值,不受其他会话影响,更准确。
如果是批量插入多行,LAST_INSERT_ID() 返回的是这批插入的第一行的自增 id(不是最后一行的),这在某些 ORM 的批量插入处理里需要注意。
6.2 自增 ID 为什么会跳号
这是面试高频题。几个常见原因:
- 插入事务回滚,自增 id 不会回退。InnoDB 为了保证并发性能,预先分配了自增值,事务回滚后这个值就“浪费”了;
INSERT IGNORE和ON DUPLICATE KEY UPDATE在冲突时,自增 id 也可能被消耗;- 批量插入时,如果使用 mode=2(交错模式),自增 id 可能不连续;
- 手动删除最大 id 的数据后,自增计数器不会自动回退。
所以面试时如果说“自增 id 不连续是因为删除过数据”,只答对了一部分,别把回滚和批量插入机制漏掉。
6.3 影响行数在 INSERT 相关语句里的“话外音”
面试官喜欢问“INSERT ... ON DUPLICATE KEY UPDATE 影响行数返回 2 是什么意思”。答案我在前面提过:插入新行返回 1,更新已有行返回 2。
还有一个和 INSERT IGNORE 相关的考点:如果插入的 10 行里,有 3 行因为唯一键冲突被忽略,返回的影响行数是 7,而不是 0。这点对于判断批量导入是否完全成功非常关键。如果代码里用影响行数来判断“是否全部插入”,遇到 INSERT IGNORE 就容易产生误判。
6.4 一张表快速造几万条测试数据的写法
日常开发和面试中,经常需要快速造数据。用 INSERT 配合递归 CTE(MySQL 8.0):
sql复制INSERT INTO t_user (name, age)
WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10000
)
SELECT CONCAT('user_', n), n % 80 FROM seq;
这条 SQL 能直接插入 1 万条测试数据,不需要写脚本循环,也不依赖存储过程,执行速度很快。注意递归 CTE 默认递归上限是 1000,需要先设置:
sql复制SET cte_max_recursion_depth = 10000;
如果你用的是 MySQL 5.7,没有递归 CTE,就用存储过程:
sql复制DELIMITER $$
CREATE PROCEDURE insert_test_data()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 10000 DO
INSERT INTO t_user (name, age) VALUES (CONCAT('user_', i), i % 80);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL insert_test_data();
但说实话,存储过程循环单条插入效率很低,1 万条可能要跑几秒甚至十几秒。能用递归 CTE 就用递归 CTE,能一条 SQL 解决就不要循环。
最后再分享一个我自己的习惯:每次写完 INSERT 相关代码,我都会在测试环境开 SHOW WARNINGS 和 SHOW ENGINE INNODB STATUS 看一眼,确认没有隐式转换、没有锁等待,再上生产。这个东西看起来基础,真正踩过坑的人才知道,INSERT 这条 SQL 背后的坑,比表面上能看到的要多得多。
