在 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,插入时却显式给 NULL,MySQL 并不会自动把默认值替上去,而是直接报错。只有省略该字段或写 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_mode:
SELECT @@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, 'tom@example.com');第二种写法更安全,表结构后续新增字段也不会立刻把这条 SQL 弄挂。
当需要插入多行时,可以在一条语句里带多个 VALUES,这也是应用层批量插入最常用的形式:
INSERT INTO t_user (name, age, email) VALUES ('Tom', 20, 'tom@example.com'), ('Jerry', 25, 'jerry@example.com'), ('Lucy', 22, 'lucy@example.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 INTO:
REPLACE 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, 'tom@example.com', NOW());如果字段声明了默认值,比如 status 默认 0,可以省略不写,也可以显式写 DEFAULT:
INSERT INTO t_user (name, age, email, status) VALUES ('Tom', 20, 'tom@example.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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';主键用 BIGINT UNSIGNED AUTO_INCREMENT,避免使用 INT 类型时自增耗尽。实际生产中 INT 自增到 21 亿多并不需要很长时间,尤其在做压测造数的时候,玩过头的代价就是AUTO_INCREMENT溢出,整张表写入失败。直接上 BIGINT 是一劳永逸的做法。
3.2 JDBC 插入用户数据:批次、连接参数与事务控制
后端同学最常见的插入场景就是 Java 程序里往 MySQL 写数据。很多项目最初的写法是循环单条 insert:
for (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" + "?useSSL=false" + "&rewriteBatchedStatements=true" + "&characterEncoding=utf8" + "&serverTimezone=Asia/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(); }这里最关键的是连接串里的rewriteBatchedStatements=true。没有这个参数时,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.csv:
name,age,email Tom,20,tom@example.com Jerry,25,jerry@example.com Lucy,22,lucy@example.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 连接里还要加
allowLoadLocalInfile=true。 - 导入前如果表上有外键,可以先
SET FOREIGN_KEY_CHECKS=0,导入完成后记得恢复为 1,并校验数据完整性。 - 目标表如果有大量二级索引,LOAD DATA 时每行数据仍然要维护索引。极端的做法是导入前先 DROP 掉非必要二级索引,导入完成后用
ALTER TABLE ... ALGORITHM=INPLACE, LOCK=NONE在线重建索引。这在大批量初始化数据时非常有效,但线上业务表不要随便删索引。
3.5 索引太多,插入速度必然变慢
很多业务发展到后期,表上会堆出十几个二级索引。索引对查询来说当然是加速,但对插入来说全是负担:每插一行,都要在这些索引的 B+ 树中写入相应的索引项。
所以如果你在做数据初始化、重构表结构、批量迁移数据这类场景,一个实用经验是:先建一个最简表结构,只保留主键,数据导完再一次性补二级索引。比如刚才的用户表,如果最终需要idx_age、idx_status这些索引,建表时可以暂时不建,LOAD DATA 完成后再执行:
ALTER TABLE t_user ADD INDEX idx_age (age), ADD INDEX idx_status (status), ALGORITHM=INPLACE, LOCK=NONE;MySQL 8.0 的在线 DDL 允许在不阻塞读写的情况下加索引,所以这种做法很适合大批量初始化场景。但在存量线上库上,即使 INPLACE + LOCK=NONE,加索引本身也有额外资源消耗,不要在高负载时段贸然执行。
4. 插入数据最常见的问题与排查实录
4.1 插入很慢?先查这几个地方
遇到“插入慢”时,我一般按下面的顺序排查:
第一步,看有没有锁等待。执行:
SHOW ENGINE INNODB STATUS\G重点看 TRANSACTIONS 段落,如果显示LOCK WAIT,说明当前有事务在等另一个事务释放锁。更直接的方式是查 performance_schema:
SELECT * 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_binlog=1和innodb_flush_log_at_trx_commit=1是数据安全性最高的配置,每次事务提交都要把 binlog 和 redo log 刷到磁盘,性能上确实会有损耗。对高并发低延迟写入业务,可以在安全允许范围内评估是否调整innodb_flush_log_at_trx_commit=2,但这是典型的“用安全性换性能”的取舍,生产上必须经过团队评审,绝不能为了压测好看就自己改掉。
最后,确认应用层是否真的在批量写。很多“插入慢”的真相就是代码里逐条 executeUpdate,而且没有关闭 autoCommit,等于每一条都独立做一次事务提交。把批量参数和提交频率调好,性能立刻不一样。
4.2 锁等待和死锁:批量插入也会卡住
InnoDB 在 REPEATABLE READ 隔离级别下,插入操作并不像想象中那样只加行锁。插入本身需要加插入意向锁,同时如果插入的位置落在一个被其他事务持有的间隙锁范围内,还要等待间隙锁释放。批量插入最怕的场景是多个事务往同一个区间插入记录,互相等待对方的间隙锁释放,最终超时报Lock wait timeout exceeded。
死锁则更加隐蔽。比如两个事务分别插入 id=5 和 id=7 的数据,接着又向对方占住的区间插数据,就可能在等待中形成循环。如果遇到死锁,错误信息会包含死锁检测结果,提示“Transaction deadlock detected”,然后其中一个事务被回滚,应用层只需要重新执行一次被回滚的语句即可。
定位死锁细节的方法是:
SHOW ENGINE INNODB STATUS\G输出里的LATEST DETECTED DEADLOCK段落会列出两个事务各自持有的锁和等待的锁。解决死锁的常用手段:
- 多个事务访问多张表时,统一按相同的顺序访问。
- 缩小事务范围,减少锁持有时间。
- 批量插入控制每批行数,避免一个事务锁住过多区间。
- 如果业务允许,适当提高
innodb_lock_wait_timeout只能缓解报错,不能解决死锁本身,真正的解法还是从应用层收敛写入路径。
4.3 字符集不一致导致插入乱码或报错
字符集问题在插入阶段最典型的表现有两类。
第一类是乱码。表是 utf8mb4,客户端连接却是 latin1,插入中文后就可能变成“??”或“杩滅▼”这样的内容。解决办法是保证应用连接字符集一致,比如 JDBC 连接串里设置characterEncoding=utf8,或者连接建立后执行SET NAMES utf8mb4。
第二类是报错。向 utf8mb3 的列插入 emoji 时,MySQL 会报:
Incorrect string value: '\xF0\x9F\x98\x80' for column 'name' at row 1原因就是 utf8mb3 无法表示四字节字符。老表中这类问题很常见,解决思路是把相关字段和表都转成 utf8mb4:
ALTER 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 null | NOT 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。测试环境最简单的解法是把连接串改为useSSL=false&requireSSL=false。生产环境如果开了 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 最简单的操作,但把它用好比“会写”这件事重要得多。