同样是插 10 万行,最快的写法比最慢的快 103 倍
写法 耗时 吞吐 (行/s) 相对最慢
-----------------------------------------------------------------
逐条 INSERT, 每条一个事务 52.54 s 1,903 1.0x
逐条 INSERT, 全部一个事务 16.06 s 6,228 3.3x
批量 INSERT, 每批 100 行 1.49 s 66,951 35.2x
批量 INSERT, 每批 1000 行 1.42 s 70,179 36.9x
LOAD DATA LOCAL INFILE 0.51 s 197,028 103.5x
52 秒 vs 0.51 秒。 数据完全一样,表结构完全一样,只是写法不同。
(这还是走 Unix socket 的本地连接。加上生产环境的网络往返,第一行会更惨——参考之前那篇 N+1 的实测:本地 17 倍的差距,在 1ms RTT 下变成 170 倍。)
第一层:事务
每条一个事务 1,903 行/s
一个大事务 6,228 行/s 3.3x
默认 autocommit=1 时,每一条 INSERT 都是一个完整的事务:开启、写 undo log、写 redo log、提交、fsync。
那个 fsync 是大头。实测把 innodb_flush_log_at_trx_commit 调松:
flush=1 2,380 行/s 每次提交都 fsync (最安全, 默认)
flush=2 4,124 行/s 写 OS 缓存, 每秒 fsync 1.7x
flush=0 6,005 行/s 每秒写 + 每秒 fsync 2.5x
fsync 占了逐条提交开销的 2.5 倍。
但注意:把 10 万条放进一个事务只拿到 6,228 行/s,而批量 INSERT 有 70,000 行/s。也就是说,消掉 fsync 之后还有 11 倍的差距没解释。
第二层:每条 SQL 的固定开销
剩下那 11 倍,来自每一条 SQL 语句本身的成本:
- 一次客户端 → 服务端的往返(即使是 Unix socket 也有系统调用)
- SQL 文本的词法分析、语法分析
- 查询优化器走一遍(哪怕是最简单的 INSERT)
- 权限检查、表元数据查找
- 一次 InnoDB 的行插入 + 索引维护
前面那一长串,和「插入一行」这件事本身没什么关系,但每条都要付一次。
批量 INSERT 把 1000 行合并成一条 SQL:
sql
INSERT INTO t(uid, city, amount) VALUES
(1,'北京',100),
(2,'上海',200),
... 再写 998 行
解析一次、优化一次、往返一次,然后连续插 1000 行。固定开销被摊薄了 1000 倍。
批大小:收益在 100 就饱和了
每批行数 耗时 吞吐 (行/s) 相对逐条 单条 SQL 长度
-------------------------------------------------------------
1 15.65 s 6,389 1.0x —
10 2.78 s 35,970 5.6x 1 KB
50 1.76 s 56,755 8.9x 5 KB
100 1.53 s 65,270 10.2x 9 KB
500 1.40 s 71,591 11.2x 46 KB
1000 1.43 s 69,952 10.9x 93 KB
5000 1.52 s 65,971 10.3x 464 KB
20000 1.41 s 70,692 11.1x 1855 KB
最大的一跳是从 1 到 10——5.6 倍。 之后迅速收敛,到 100 就拿到了 10.2 倍,500 之后完全是平台期。
所以实践上的建议很简单:批大小 100 到 1000 之间随便选,不用纠结。
追求更大的批次反而有风险:
- 撞上
max_allowed_packet(MariaDB 默认 16 MB,MySQL 8 默认 64 MB),报错Packet too large - 单个事务太大,undo log 膨胀、锁持有时间长、主从复制延迟
- 失败时整批回滚,重试成本高
第三层:LOAD DATA 的 197,028 行/s
LOAD DATA LOCAL INFILE 0.51 s 197,028 行/s 103.5x
比批量 INSERT 还快 2.8 倍。
因为它根本不走 SQL 那条路:
- 没有 SQL 解析——数据是按分隔符切的,不是按语法树
- 流式读取——不需要把整批数据构造成一个巨大的字符串塞进内存
- 批量构建索引——可以更高效地维护 B+ 树
- 更少的内部锁获取
代价是它很「粗暴」:
- 文件格式要和表结构严格对齐
- 错误处理弱(一行格式错了,要么整批失败,要么静默变成默认值)
LOCAL关键字涉及客户端读文件,有安全考量(MySQL 8 默认关闭local_infile,要显式打开)
所以它的定位是数据导入、离线迁移、ETL 落库,不是在线业务写入。
一张选型表
| 场景 | 用什么 | 预期吞吐 |
|---|---|---|
| 在线单条写入(下单、注册) | 单条 INSERT | 不用优化,它本来就是单条 |
| 批处理任务、消息消费 | 批量 INSERT,每批 100~1000 | 几万行/秒 |
| 数据导入、迁移、回填 | LOAD DATA | 十几万行/秒 |
| 主键已知的 upsert | INSERT ... ON DUPLICATE KEY UPDATE 批量 | 接近批量 INSERT |
另外几个实用点:
1. 先删索引,灌完再建。 大批量导入时,逐行维护 5 个二级索引是很大的开销(之前那篇索引实测里,4 个二级索引让写入吞吐掉了 33%)。先 DROP INDEX,灌完 ADD INDEX 一次性构建,通常更快。
2. 按主键顺序插入。 之前那篇 UUID 主键的实测里,随机主键让插入慢 11.8 倍。批量导入前先按主键排序,收益很大。
3. ORM 的「批量」不一定是批量。 很多 ORM 的 bulk_create / saveAll 只是在一个事务里循环单条 INSERT——那只能拿到 3.3 倍,不是 36 倍。看日志确认它真的发出了多值 INSERT。
4. rewriteBatchedStatements=true。 JDBC 的 MySQL 驱动默认不会把 addBatch 合并成多值 INSERT,必须显式开这个连接参数。这是 Java 生态里最常见的一个「以为自己在批量」的坑。
最后
52 秒和 0.51 秒的差距,不在数据库,也不在磁盘。
在于你跟数据库说了 10 万次话,还是说了 1 次。