
在 MySQL 的日常操作里插入数据大概是 SQL 语句中长得最朴实的一个命令。但真正做过一段时间研发和运维之后你就会发现线上很多突如其来的故障根子其实全在这条普普通通的 INSERT 上比如某张表突然写不动了某次批量导入把数据库卡死或者半夜告警报锁等待超时排查到最后基本都是插入姿势不对。这篇文章我不打算从“什么是增删改查”这种入门阶段讲起而是按我自己在真实项目里的实操顺序把 MySQL 插入数据这件事从头到尾捋一遍从单行、多行、INSERT...SELECT、唯一键冲突处理这些常规写法到 JDBC 批量写入、存储过程造测试数据、大数据量导入再到插入慢、锁等待、字符集、SSL 连接这些高频坑。无论你刚接触 MySQL还是已经在项目里写过不少 INSERT 但被各种报错折磨过这篇文章应该都能给你一份可落地的参考。1. 插入数据前先想清楚这几件事1.1 插入不只是写一行执行链路的隐藏成本很多同学对 INSERT 的理解停留在“给表里加一行数据”但在 InnoDB 引擎下一条看似简单的插入语句要经过的环节远比想象中多。一条 INSERT 到达 MySQL 后首先经过 Server 层的词法语法解析和权限校验然后交给存储引擎执行。到了 InnoDB 这里事情才开始变得复杂要检查唯一键冲突、外键约束、CHECK 约束要为自增主键分配 ID要写 undo log 以便事务回滚要在聚簇索引上插入新记录还要维护所有二级索引的 B 树变更。最后进入提交阶段时还需要完成 redo log 和 binlog 的两阶段写入。所以插入性能并不是“写一行数据”这么轻描淡写它涉及磁盘 IO、索引维护、锁竞争、事务日志持久化等多重因素。这也是为什么同一个 INSERT 语句放在一张只有主键的空表和放在一张有十几个二级索引的大表上耗时可能相差一个数量级以上。理解了这条链路后面再看“怎么插入更快”“为什么插入会慢”思路就会清晰很多。1.2 表结构层面的坑主键、字符集、默认值建表时看起来不起眼的决定会在插入数据时集中体现出来。首先是主键。我强烈建议业务表使用自增的 BIGINT 作为主键而不是 UUID 或其他随机字符串。InnoDB 的聚簇索引按照主键顺序组织数据自增主键的插入总是追加到页末尾写入量小页分裂少。如果用随机字符串做主键插入会导致大量随机页写入和页分裂IO 开销直线上升。其次是字符集。新项目统一用 utf8mb4不要再用 utf8因为 MySQL 里的 utf8 实际上是 utf8mb3无法存储 emoji 这类四字节字符。如果建表时用了 utf8一旦业务要插入含表情符号的数据就会遇到Incorrect string value: \xF0\x9F\x98\x80这类报错到时候再改字符集全表迁移成本远大于一开始就选对。第三个是默认值。比如业务想要“新增用户状态默认 0”正确写法是status TINYINT NOT NULL DEFAULT 0这本身没问题但注意一个容易混淆的点如果字段声明了 NOT NULL插入时却显式给 NULLMySQL 并不会自动把默认值替上去而是直接报错。只有省略该字段或写 DEFAULT 关键字时默认值才会生效。最后提一句 CHECK 约束。MySQL 8.0.16 开始才对 CHECK 约束真正生效建表时声明的 CHECK 会在插入时校验违反会整行拒绝。早年间的 5.7 或更老版本里写了 CHECK 也只是“装饰”8.0 之后这个习惯要改过来。1.3 SQL 模式和严格模式为什么看起来正常的插入会报错很多插入报错并不是数据有问题而是 sql_mode 和预期不一致。MySQL 的 sql_mode 决定了服务端接受数据时的宽松程度。比如经典组合里包含STRICT_TRANS_TABLES这就是严格模式的核心插入超长字符串时会直接报Data too long for column ...而不是像老版本那样截断后只给一个 warning。同样的往 INT 列插入abc这类非数字内容严格模式下会报Incorrect integer value非严格模式下可能只警告然后插入 0。这就是为什么你在本地跑得好好的 SQL部署到一台配置了标准 sql_mode 的服务器上就纷纷报错。我一般建议新项目保持严格模式宁可让应用层提前拦截非法数据也不要让数据库悄悄“矫正”数据否则脏数据会一路沉淀到统计报表和下游系统里到时候找问题更难。查看当前 sql_modeSELECT sql_mode;修改会话级 sql_mode 可以这样SET SESSION sql_mode STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION;但要记住这只能作为临时排查手段生产环境应该保证所有环境 sql_mode 一致。2. INSERT 语法家族不同场景该用哪一种2.1 单行与多行插入以及一次插多少行最合适最基础的插入写法有两种-- 不指定列必须提供全部字段值不推荐 INSERT INTO t_user VALUES (1, Tom, 20); -- 明确指定列推荐 INSERT INTO t_user (name, age, email) VALUES (Tom, 20, tomexample.com);第二种写法更安全表结构后续新增字段也不会立刻把这条 SQL 弄挂。当需要插入多行时可以在一条语句里带多个 VALUES这也是应用层批量插入最常用的形式INSERT INTO t_user (name, age, email) VALUES (Tom, 20, tomexample.com), (Jerry, 25, jerryexample.com), (Lucy, 22, lucyexample.com);一次插入多少行合适我个人的实践是控制在 500 到 1000 行之间。一方面减少网络往返和 SQL 解析开销另一方面避免单条 SQL 过大撑爆内存与网络缓冲区。一次插几万行不是不行但会产生超大的 undo log、更长的锁持有时间一旦出错回滚代价也高。批量操作时宁可拆成多批每批一个事务。还有一种 MySQL 8.0.19 之后新增的 VALUES 语句写法可以把行值表用在 SELECT 里例如SELECT * FROM (VALUES ROW(1, a), ROW(2, b)) AS t(id, name)。这个写法在不需要建临时表时还挺好用但日常插入数据用不用它影响不大知道有这回事即可。2.2 INSERT ... SELECT从别的表搬数据把一个查询结果直接插入目标表是初始化数据表和做归档时的常用手段INSERT INTO t_user_backup (id, name, age, email, created_at) SELECT id, name, age, email, created_at FROM t_user WHERE created_at 2024-01-01;这种写法把查询和插入合为一条语句不需要在应用层来回搬运数据。但要注意锁问题在默认的 REPEATABLE READ 隔离级别下INSERT...SELECT 读取源表时可能加上类似 next-key lock 的记录锁和间隙锁导致源表在语句执行期间无法正常写入。大表迁移时尽量选择业务低峰期或者先用 SELECT INTO OUTFILE/导出文件再导入的离线方案。另一个相关操作是CREATE TABLE ... AS SELECT缩写 CTAS。它建表并导入数据但不会自动复制源表的索引、外键、默认值等属性生产上要慎用通常还需要在建表后再补一次 ALTER TABLE 加索引。2.3 唯一键冲突时的正确姿势ON DUPLICATE KEY UPDATE业务里最常见的场景是“有就更新没有就插入”对应 MySQL 的 upsert 语法INSERT INTO t_user (id, name, age) VALUES (1, Tom, 21) AS new ON DUPLICATE KEY UPDATE age new.age;这里要特别提醒MySQL 8.0.20 开始老写法ON DUPLICATE KEY UPDATE age VALUES(age)里的 VALUES() 函数已经被废弃未来会被移除。推荐使用上面的别名语法AS new 定义插入行的别名然后在新语句里引用 new.age。还有一个兄弟语法 REPLACE INTOREPLACE INTO t_user (id, name, age) VALUES (1, Tom, 22);它的语义是如果主键或唯一键冲突先删除旧记录再插入新记录。看起来功能更强但代价也更大删除会触发额外的 undo 和索引维护自增主键的分配也会产生新的值外键依赖也可能受影响。所以能不用 REPLACE 就不用绝大多数场景 ON DUPLICATE KEY UPDATE 是更稳妥的选择。还有个容易被忽略的细节ON DUPLICATE KEY UPDATE 影响行数的含义。插入成功返回 1更新成功返回 2更新后数值没变化返回 0。应用层判断“到底插没插进去”时别只看影响行数是否为 0逻辑上要仔细设计。2.4 默认值、函数与特殊取值插入时可以直接依赖字段默认值也可以使用表达式和函数INSERT INTO t_user (name, age, email, created_at) VALUES (Tom, 20, tomexample.com, NOW());如果字段声明了默认值比如 status 默认 0可以省略不写也可以显式写 DEFAULTINSERT INTO t_user (name, age, email, status) VALUES (Tom, 20, tomexample.com, DEFAULT);开发中常见的一个困惑是代码里传了 NULL结果字段没有存默认值 0而是直接报“Column xxx cannot be null”。原因前面已经提过NULL 是一个具体的值它不等于“没有提供”。想用默认值就必须不含这个字段或者显式写 DEFAULT。这个区别在使用 ORM 框架时尤其容易踩坑因为 ORM 经常会把 Java 里的 null 原样拼进 SQL。TIMESTAMP 类型的字段如果声明了DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP插入时可以完全省略MySQL 自动写入当前时间更新时也会自动刷新。这比在应用层维护时间字段省心得多。3. 实操从简单插入到高效插入3.1 准备一张基础业务表为了后面示例可以直接跑先建一张用户表。以下是我常用的一种比较稳的表结构CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 用户ID, name VARCHAR(64) NOT NULL COMMENT 昵称, age TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 年龄, email VARCHAR(128) DEFAULT NULL COMMENT 邮箱, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1有效 0禁用, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci COMMENT用户表;主键用 BIGINT UNSIGNED AUTO_INCREMENT避免使用 INT 类型时自增耗尽。实际生产中 INT 自增到 21 亿多并不需要很长时间尤其在做压测造数的时候玩过头的代价就是AUTO_INCREMENT溢出整张表写入失败。直接上 BIGINT 是一劳永逸的做法。3.2 JDBC 插入用户数据批次、连接参数与事务控制后端同学最常见的插入场景就是 Java 程序里往 MySQL 写数据。很多项目最初的写法是循环单条 insertfor (User u : users) { String sql INSERT INTO t_user(name, age, email) VALUES( u.getName() , ...); stmt.executeUpdate(sql); }这种写法毛病很多SQL 拼接容易注入每次 insert 都是一次完整的网络往返每次自动提交都要刷盘。数据量一上去性能立刻拉垮。正确的做法是用 PreparedStatement 的批量功能同时打开 JDBC 驱动的批量重写参数。String url jdbc:mysql://localhost:3306/test ?useSSLfalse rewriteBatchedStatementstrue characterEncodingutf8 serverTimezoneAsia/Shanghai; String sql INSERT INTO t_user(name, age, email) VALUES(?, ?, ?); try (Connection conn DriverManager.getConnection(url, root, password); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); int batchSize 1000; for (int i 0; i users.size(); i) { User u users.get(i); ps.setString(1, u.getName()); ps.setInt(2, u.getAge()); ps.setString(3, u.getEmail()); ps.addBatch(); if ((i 1) % batchSize 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); }这里最关键的是连接串里的rewriteBatchedStatementstrue。没有这个参数时executeBatch 只是在客户端循环发送单条语句性能提升非常有限。加上之后驱动会把批量语句重写成一条多行 VALUES 的 INSERT性能有量级差别。我实测过 1 万条用户数据循环单条插入可能要十几秒到几十秒开批量后基本在几百毫秒到一秒左右。另外几个值得注意的点事务不要包太大每 1000 条左右提交一次避免 undo log 膨胀和锁时间过长。如果表上有唯一键冲突批量插入遇到其中一条冲突会比较麻烦需要配合 ON DUPLICATE KEY UPDATE 让整个批次继续但这样吞吐会下降需要结合业务取舍。MySQL 8.0 默认认证插件是 caching_sha2_password如果你的驱动版本太老连接时会报The server requested authentication method unknown to the client。解决方法是升级 JDBC 驱动到 8.0.x而不是在服务器端把认证插件退化回去。3.3 存储过程批量生成测试数据循环、事务与错误处理没有现成测试数据时存储过程是造数利器。尤其是你要模拟几十万上百万行数据来验证索引、压测接口时一个存储过程往往比写脚本连数据库再逐条插入更顺手。下面是一个生成 10 万条用户数据的存储过程示例DELIMITER $$ DROP PROCEDURE IF EXISTS generate_users$$ CREATE PROCEDURE generate_users(IN total INT) BEGIN DECLARE i INT DEFAULT 1; DECLARE err_code INT DEFAULT 0; DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET err_code 1; START TRANSACTION; WHILE i total DO INSERT INTO t_user (name, age, email, status) VALUES ( CONCAT(player_, i), FLOOR(18 RAND() * 43), CONCAT(u, i, example.com), 1 ); SET i i 1; -- 每5000行提交一次避免单事务过大 IF MOD(i, 5000) 0 THEN COMMIT; START TRANSACTION; END IF; END WHILE; COMMIT; IF err_code 1 THEN ROLLBACK; END IF; END$$ DELIMITER ; CALL generate_users(100000);这个版本我故意分成每 5000 行提交一次因为如果只用一个事务跑 10 万行插入一旦中间某条数据触发主键或唯一键冲突整个事务回滚成本很高。分段提交之后至少已经成功的那部分数据是保住的。不过要特别小心存储过程里的DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET err_code 1只是一个最简单的错误捕获方式它不会跳过出错的那条 INSERT只是记录标志并把控制流拉回主循环。真实业务里如果严格要求“要么全部成功要么全部不成功”就不能分段提交必须一个事务跑完同时需要更精细的异常处理判断怎么回滚。另外RAND() 生成的数据是可重复性较差的随机值。如果你希望压测数据可复现建议把随机函数换成基于 i 的确定性表达式比如18 MOD(i, 40)这样每次执行生成的数据完全一致排查问题时不会“重跑就变样”。3.4 大数据量导入LOAD DATA LOCAL INFILE 与批量事务当数据量上到几十万、上百万行时用 INSERT 语句一条条跑已经不太现实了即使分批批量插入也慢。最快的方式通常是 LOAD DATA。比如你有一个 CSV 文件/data/users.csvname,age,email Tom,20,tomexample.com Jerry,25,jerryexample.com Lucy,22,lucyexample.com导入命令SET GLOBAL local_infile 1; LOAD DATA LOCAL INFILE /data/users.csv INTO TABLE t_user CHARACTER SET utf8mb4 FIELDS TERMINATED BY , OPTIONALLY ENCLOSED BY LINES TERMINATED BY \n IGNORE 1 LINES (name, age, email);LOAD DATA 之所以快是因为它绕开了一部分常规 INSERT 的逐条SQL解析开销数据直接在存储引擎层批量装载官方文档的表述是比逐条 INSERT 快很多倍实际测试通常也有一到两个数量级的差距。使用时有几个容易踩的坑需要客户端和服务端都允许 local_infile。JDBC 连接里还要加allowLoadLocalInfiletrue。导入前如果表上有外键可以先SET FOREIGN_KEY_CHECKS0导入完成后记得恢复为 1并校验数据完整性。目标表如果有大量二级索引LOAD DATA 时每行数据仍然要维护索引。极端的做法是导入前先 DROP 掉非必要二级索引导入完成后用ALTER TABLE ... ALGORITHMINPLACE, LOCKNONE在线重建索引。这在大批量初始化数据时非常有效但线上业务表不要随便删索引。3.5 索引太多插入速度必然变慢很多业务发展到后期表上会堆出十几个二级索引。索引对查询来说当然是加速但对插入来说全是负担每插一行都要在这些索引的 B 树中写入相应的索引项。所以如果你在做数据初始化、重构表结构、批量迁移数据这类场景一个实用经验是先建一个最简表结构只保留主键数据导完再一次性补二级索引。比如刚才的用户表如果最终需要idx_age、idx_status这些索引建表时可以暂时不建LOAD DATA 完成后再执行ALTER TABLE t_user ADD INDEX idx_age (age), ADD INDEX idx_status (status), ALGORITHMINPLACE, LOCKNONE;MySQL 8.0 的在线 DDL 允许在不阻塞读写的情况下加索引所以这种做法很适合大批量初始化场景。但在存量线上库上即使 INPLACE LOCKNONE加索引本身也有额外资源消耗不要在高负载时段贸然执行。4. 插入数据最常见的问题与排查实录4.1 插入很慢先查这几个地方遇到“插入慢”时我一般按下面的顺序排查第一步看有没有锁等待。执行SHOW ENGINE INNODB STATUS\G重点看 TRANSACTIONS 段落如果显示LOCK WAIT说明当前有事务在等另一个事务释放锁。更直接的方式是查 performance_schemaSELECT * FROM performance_schema.data_lock_waits\G如果锁等待时间非常长基本就是某些事务长时间未提交把行锁或间隙锁一直攥在手里。这会阻塞同一条记录或邻近区间的插入操作。第二步看有没有长时间运行的事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx\G如果发现某个事务启动时间很久远大概率是连接池里某个连接没提交或者没回滚。找到后可以通过KILL thread_id杀掉对应连接。第三步看磁盘 IO 和刷盘配置。sync_binlog1和innodb_flush_log_at_trx_commit1是数据安全性最高的配置每次事务提交都要把 binlog 和 redo log 刷到磁盘性能上确实会有损耗。对高并发低延迟写入业务可以在安全允许范围内评估是否调整innodb_flush_log_at_trx_commit2但这是典型的“用安全性换性能”的取舍生产上必须经过团队评审绝不能为了压测好看就自己改掉。最后确认应用层是否真的在批量写。很多“插入慢”的真相就是代码里逐条 executeUpdate而且没有关闭 autoCommit等于每一条都独立做一次事务提交。把批量参数和提交频率调好性能立刻不一样。4.2 锁等待和死锁批量插入也会卡住InnoDB 在 REPEATABLE READ 隔离级别下插入操作并不像想象中那样只加行锁。插入本身需要加插入意向锁同时如果插入的位置落在一个被其他事务持有的间隙锁范围内还要等待间隙锁释放。批量插入最怕的场景是多个事务往同一个区间插入记录互相等待对方的间隙锁释放最终超时报Lock wait timeout exceeded。死锁则更加隐蔽。比如两个事务分别插入 id5 和 id7 的数据接着又向对方占住的区间插数据就可能在等待中形成循环。如果遇到死锁错误信息会包含死锁检测结果提示“Transaction deadlock detected”然后其中一个事务被回滚应用层只需要重新执行一次被回滚的语句即可。定位死锁细节的方法是SHOW ENGINE INNODB STATUS\G输出里的LATEST DETECTED DEADLOCK段落会列出两个事务各自持有的锁和等待的锁。解决死锁的常用手段多个事务访问多张表时统一按相同的顺序访问。缩小事务范围减少锁持有时间。批量插入控制每批行数避免一个事务锁住过多区间。如果业务允许适当提高innodb_lock_wait_timeout只能缓解报错不能解决死锁本身真正的解法还是从应用层收敛写入路径。4.3 字符集不一致导致插入乱码或报错字符集问题在插入阶段最典型的表现有两类。第一类是乱码。表是 utf8mb4客户端连接却是 latin1插入中文后就可能变成“??”或“杩滅▼”这样的内容。解决办法是保证应用连接字符集一致比如 JDBC 连接串里设置characterEncodingutf8或者连接建立后执行SET NAMES utf8mb4。第二类是报错。向 utf8mb3 的列插入 emoji 时MySQL 会报Incorrect string value: \xF0\x9F\x98\x80 for column name at row 1原因就是 utf8mb3 无法表示四字节字符。老表中这类问题很常见解决思路是把相关字段和表都转成 utf8mb4ALTER TABLE t_user MODIFY name VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;8.0 里默认排序规则是 utf8mb4_0900_ai_ci如果是 5.7 迁移上来的库常见的是 utf8mb4_general_ci。排序规则不同会影响字符串比较和唯一键判重迁移后要仔细检查索引和表定义。4.4 严格模式下典型的插入报错速查我把日常最容易遇到的几类插入报错整理成了一张速查表遇到直接对着看就好。报错信息原因处理思路Data too long for column xxx字符串超长严格模式拒绝截断扩容字段长度或应用层先做长度校验Incorrect integer value: abc for column age非数字字符串写入数值列应用层参数校验不要依赖数据库兜底Column xxx cannot be nullNOT NULL 字段被显式插入 NULL调整插入值或给字段设默认值后省略该列Duplicate entry xxx for key yyy主键或唯一键冲突结合业务用 ON DUPLICATE KEY UPDATE 处理Cannot add or update a child row: a foreign key constraint fails外键依赖的父记录不存在先确认父表数据或检查外键约束是否必要AUTO_INCREMENT value overflow自增主键达到类型上限主键类型改为 BIGINT避免后续再犯额外提醒一下隐式类型转换的坑。某些情况下 MySQL 会强行把字符串转成数字来比较或存储比如往 INT 列插入26abc在非严格模式下可能只告警然后插入 26。这种“被数据库静默矫正”的数据往往会成为业务逻辑里最诡异的那一类 bug所以生产环境务必开启严格模式。4.5 事务没提交引发的一系列问题这是我在现网见过最多、也最隐蔽的问题。应用代码里开启了事务执行了 INSERT但因为异常分支没处理或者漏写 commit事务一直挂在连接上。表现是什么呢其他会话查询这个表会看不到新数据这在 REPEATABLE READ 隔离级别下还能理解更严重的是连接一直持有行锁和间隙锁其它会话对这个表的插入直接被卡住直到innodb_lock_wait_timeout超时退出。你以为是 MySQL 出问题了看一眼SHOW PROCESSLIST却发现罪魁祸首是一个 Sleep 状态的空闲连接。排查命令SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;看到事务状态为 RUNNING 且启动时间很久就可以去查这个连接对应的线程确认是不是应用连接池里的“僵尸连接”。解决问题的手段是 KILL 线程但根因必须回到代码里事务要么在 finally 里统一提交/回滚要么交给 Spring 这类框架的声明式事务并设置合理的超时和回滚规则。4.6 连接层面SSL 连接错误、认证插件不兼容插入数据的报错不一定来自 SQL 本身连接阶段就可能挂掉。MySQL 8.0 默认启用 SSL但又不会强制要求客户端使用。老的 JDBC 驱动在连接时如果主动尝试建立 SSL却因为证书不对可能报SSL connection error或Communications link failure。测试环境最简单的解法是把连接串改为useSSLfalserequireSSLfalse。生产环境如果开了 SSL 强制要求就需要在连接串里配置正确的 trustStore 或 rather 服务器颁发证书这属于安全和运维同事配合处理的范围。另一个有代表性的报错是The server requested authentication method unknown to the client [caching_sha2_password]这是因为 MySQL 8.0 默认认证插件从 mysql_native_password 变成了 caching_sha2_password而低版本的驱动不支持。解决办法是升级驱动到 8.0 对应的版本驱动本身是向后兼容的能连 5.7也能连 8.0。另外连接池中的空闲连接超过 wait_timeout 会被服务端断开代码里如果拿着旧连接去 insert会报连接失效。不要天真地依赖连接池的 autoReconnect 参数最稳的办法是在每次申请连接后、执行关键操作前做一次轻量校验比如connection.isValid(2)或者从连接池正确获取新连接。5. 写给自己和后来者的一些建议最后说点实际体会。我处理过的“插入慢”“插入卡死”“插入丢数据”这类问题数量不少真正需要去调 MySQL 内核参数的情况反而是少数。绝大多数问题都出在三个地方应用层循环单条执行 INSERT、事务范围没控制好、表上堆了太多无关索引。只要先把这三件基本功做对插入性能通常都不会差到哪里去。如果非要给一条优先级我会建议先优化写入方式再精简事务后维护索引最后才考虑刷盘参数和硬件升级。顺序反过来的话你很可能为了掩盖一个循环插入的问题去调innodb_buffer_pool_size结果钱花了、效果却非常有限。这篇文章里的 SQL 我基本都按 MySQL 8.0 的语法写的建表、存储过程、LOAD DATA你可以拿一个小环境完整跑一遍遇到报错时再回头看对应的小节应该就行。插入数据看起来是 MySQL 最简单的操作但把它用好比“会写”这件事重要得多。