1. 数据搬运这件事,为什么总是卡在"导入导出"这一步
做数据库的人,早晚都会遇到这一关:要把一百多万行数据从一套环境搬到另一套环境,或者把某张大表导出来给数据分析团队。我见过太多人第一反应是打开图形化客户端,选中表、右键、导出,然后看着进度条跑一个下午,导出完了又是一下午导入。等我把LOAD DATA INFILE和mysqldump的组合拳打完之后,整个迁移时间从四小时压缩到了十几分钟,这个反差是很大的。
先摆个结论:MySQL 里真正高效的数据导入导出,核心就三样——LOAD DATA INFILE、SELECT INTO OUTFILE,以及mysqldump。这三者各有边界,组合起来覆盖了绝大多数业务场景。这篇文章我打算把这三种方式背后的原理、适用场景、参数细节和踩坑经验一次性讲透,适合正在做数据库迁移、数据备份恢复、或者定期要给业务方提供数据文件的开发者参考。
先说一下三者的定位,方便你在脑子里建立坐标系:
| 工具 | 方向 | 产出物 | 典型场景 |
|---|---|---|---|
LOAD DATA INFILE | 文件进MySQL | 表数据 | 大批量导入,如日志入库、迁移恢复 |
SELECT INTO OUTFILE | MySQL出文件 | 文本文件(CSV/TSV等) | 导出给其他系统,配套导入前的导出 |
mysqldump | MySQL出文件 | SQL语句或分隔文本 | 逻辑备份、建库迁移、结构+数据导出 |
很多人以为mysqldump就能解决一切,实际远不是这样。mysqldump导出的 SQL 文件在回放时本质上是逐条执行INSERT,单行插入开销极大,到了千万级数据量就会让你等得怀疑人生。而LOAD DATA INFILE走的是 MySQL 内部的高速加载通道,速度能比逐条INSERT快数十倍。后面我会把这条"速度差异从哪来"的逻辑讲清楚。
2. LOAD DATA INFILE 的加速原理与参数拆解
2.1 为什么 LOAD DATA 比单条 INSERT 快那么多
一句话概括:INSERT是客户端把 SQL 文本发给服务器,服务器解析、优化、执行,每条语句都有固定的解析开销和网络往返;而LOAD DATA INFILE是文件直接交给 MySQL 服务端,由服务端直接读取文件内容,跳过了一大半 SQL 解析和网络传输环节。
打个比方。你去银行存一百张存单,一张一张递进窗口,柜员每张都要验、要录、要复核,这是INSERT。而你把一整捆存单直接交进去,银行走批量清分通道,整捆过机器,最后只登一笔总账,这是LOAD DATA INFILE。
两者的底层差异主要体现在四方面:
- 网络开销:
INSERT语句要从客户端传到服务器,网络延迟叠加每行都产生一次往返。LOAD DATA则一次读取整个文件,网络只在文件传输阶段产生一次开销(如果是LOCAL模式),或完全没有网络开销(服务端本地文件模式)。 - SQL解析开销:每一条
INSERT都要经过 SQL 词法、语法、语义解析,生成执行计划。LOAD DATA绕过了这些步骤,直接把文本行按字段规则切分进存储引擎。 - 日志和事务提交:逐条
INSERT如果不开事务,每条都触发一次提交(sync_binlog、innodb_flush_log_at_trx_commit参数决定刷盘频率),这些 fsync 操作非常费时。LOAD DATA在执行过程中记录 binlog 和 undo/redo 日志的方式是打包处理的,刷盘次数少得多。 - 索引更新:逐条插入时每行都要走索引查找和 B+ 树更新路径。
LOAD DATA会对批量加载做一些合并优化,尤其在导入前临时禁用或延迟唯一性检查的情况下,差距更加悬殊。
所以你会发现一个现象:用mytop或者SHOW PROCESSLIST观察执行中的LOAD DATA,状态显示通常是 "Reading from net" 或 "Bulk insert",server 端资源消耗处于高位,但整体耗时非常短。
2.2 核心语法结构与参数详解
LOAD DATA的完整语法很长,但日常 90% 的场景只需要这一套骨架:
LOAD DATA [LOCAL] INFILE '/path/to/file.csv' INTO TABLE `your_table` CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\' LINES TERMINATED BY '\n' IGNORE 1 LINES (`col1`, `col2`, `col3`, ...) SET `create_time` = NOW();逐个拆开说,每个参数都不是随便写的。
LOCAL关键字
LOCAL决定了文件在哪台机器上。
- 不带
LOCAL:文件必须在 MySQL 服务器本地,MySQL 服务进程直接读取。 - 带
LOCAL:文件在客户端机器上,客户端先把文件内容传送到服务器临时目录,再由服务器加载。
带LOCAL更灵活,比如你在自己电脑上用mysql命令行连接远程实例时,文件在本地,就必须加LOCAL。但它有个副作用:文件内容要从客户端经过网络传到服务端,大数据量(几个G)时会有网络传输耗时,同时受local_infile参数和客户端编译选项控制,某些发行版默认是关闭的。
FIELDS TERMINATED BY
字段分隔符。CSV 常用逗号,TSV 常用\t。有一点容易被忽略:如果某些字段值里本身包含逗号,单纯的TERMINATED BY ','会把一个字段截成两个,需要配合ENCLOSED BY来解决。
OPTIONALLY ENCLOSED BY
我这里的OPTIONALLY意思是"字段值可能被引号包裹,也可能没有,两种都能处理"。比如这一行:
101,"张三","北京市朝阳区",28如果只写FIELDS TERMINATED BY ',',"张三"会被当成含引号的字符串存进去,引号被原样保留,后面查询你还要自己REPLACE去引号,很麻烦。加上OPTIONALLY ENCLOSED BY '"'之后,MySQL 会自动剥掉字段两边的引号,存进入的是干净值。
LINES TERMINATED BY
行结束符。Linux 下文件通常是\n,Windows 下可能是\r\n,Mac 老式文件可能是\r。如果导入的行数总是比预期少一行或者最后一行内容异常,八成是行结束符没配对。从 Windows 机器导出的 CSV 在 Linux 服务器上导入时,一定要先检查文件里有没有\r。
IGNORE 1 LINES
跳过前 N 行。最常见的用途是跳过 CSV 文件的表头行。我见过有人不跳表头,把列名那一行当成数据导进去,然后表里多了一行id='id', name='name'的脏数据,排查半天才发现。
字段列表
(col1,col2, ...)指定文件中每一列对应表的哪些字段。文件列顺序和表字段顺序不一致时,这一步是必须的。没有列出的字段要么走默认值(DEFAULT、自增、NULL),要么在后面的SET里赋值。
SET子句
可以在导入过程中做表达式转换,比如把导入时间统一写成当前时间、把字符串类型做STR_TO_DATE转换,或者把源文件的某个状态码映射成业务状态。它的执行顺序是:先按字段映射填入数据,再执行SET里的表达式覆盖对应列。这比先导入再UPDATE性能好太多,因为省了一次全表更新。
2.3 实际速度对照:从"一小时"到"三分钟"
说点实测感受。我这边一套库,单表 800 万行,每行 20 个字段,含两个索引。用第三方客户端工具的"导入数据"功能,本质是批量拼接INSERT逐条执行,跑完花了将近两个小时。后来改用LOAD DATA INFILE,同样的数据,CSV 文件 3.2GB,导入耗时大约 3 分 40 秒。差距到了 30 倍以上。
这不是个例。官方文档曾经给过参考数据,几千到一万行的INSERT批量插入,和LOAD DATA相比差距在 20 到 50 倍之间,具体取决于表结构复杂度和磁盘性能。你可以在自己环境做一个快速验证:
-- 先创建一张空表 CREATE TABLE load_test ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50), score INT ); -- 用存储过程生成100万行测试数据 DELIMITER $$ CREATE PROCEDURE gen_data() BEGIN DECLARE i INT DEFAULT 1; START TRANSACTION; WHILE i <= 1000000 DO INSERT INTO load_test (name, score) VALUES (CONCAT('user_', i), FLOOR(RAND() * 100)); SET i = i + 1; END WHILE; COMMIT; END$$ DELIMITER ; CALL gen_data();这个跑完你应该会明显感知到:一百万条INSERT哪怕是包在一个事务里,也要几十秒到几分钟。然后你把它导出成 CSV,再LOAD DATA导入一张同构空表,时间通常只要十秒上下。这种直观对比比我在这说破嘴都管用。
不过要注意,LOAD DATA的速度受目标表索引数量影响很大。表上索引越多,导入越慢。一个不加压测的表和一个五个索引的表,导入时长差两三倍是家常便饭。
3. SELECT INTO OUTFILE:先把数据变成文件,才有资格谈导入
3.1 权限前提与默认目录限制
很多人忽略一个问题:SELECT INTO OUTFILE不是你在 SQL 客户端里随手敲一行就能跑的。它有两个硬性前提:
- 当前用户必须有
FILE权限。 - MySQL 服务端
secure_file_priv参数不能把它禁止掉。
secure_file_priv这个参数是个老坑。它的含义是"允许LOAD DATA INFILE和SELECT INTO OUTFILE读写文件的目录限定"。
- 如果值是
/var/lib/mysql-files/,那你只能往这个目录里写文件。 - 如果值是空字符串(在配置里写成
secure_file_priv=''),表示不限制目录。 - 如果值是
NULL,表示彻底禁用了文件导入导出功能。
我遇到过不止一次这样的情况:SQL 执行完提示ERROR 1290 (HY000): The MySQL server is running with the --secure-file-priv option so it cannot execute this statement。你去看一眼SHOW VARIABLES LIKE 'secure_file_priv';,多半是指向了一个固定目录或者返回NULL。解决办法是在my.cnf里配置:
[mysqld] secure_file_priv=/data/exchange配置完重启 MySQL,记得确认/data/exchange目录存在并且mysql用户有读写权限,否则下一轮报错就是权限问题了。
3.2 导出语法与字段格式对齐
SELECT INTO OUTFILE的基本语法如下:
SELECT id, name, score INTO OUTFILE '/data/exchange/users.csv' CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '\\' LINES TERMINATED BY '\n' FROM users;注意一个细节:INTO OUTFILE导出的文件,换行符在 Linux 默认是\n,但如果你在 Windows 上用记事本打开,可能看起来是乱码一样的一整行。这不影响数据本身,但如果你要把文件传给业务方,最好先统一约定行结束符为\r\n,或者用unix2dos转一下。
字段分隔符和封闭符的选择,需要和你后面导入端的解析规则完全对齐。这里有一个选型逻辑,不只是"看心情":
- 如果字段值里没有逗号、没有引号,用
FIELDS TERMINATED BY ','就够了,不要画蛇添足加ENCLOSED BY,否则文件体积会变大。 - 如果字段值是中文、包含特殊符号,强烈建议加
OPTIONALLY ENCLOSED BY '"',这样值里的逗号、换行符都被引号保护起来。 - 如果字段里可能存在换行(比如地址、备注类字段),
LINES TERMINATED BY就要小心。理想情况是业务上禁止这类字段含换行,否则导出文件和导入方的解析规则都会变得很脆弱。
我自己的习惯是:统一导出成 CSV 格式,FIELDS TERMINATED BY ',',OPTIONALLY ENCLOSED BY '"',LINES TERMINATED BY '\n'。这套组合在绝大多数下游系统(Python 的csv模块、Shell 的awk、Excel 的导入向导)都能正常解析。
3.3 和 mysqldump 的本质区别:导出的是"数据"还是"命令"
接着聊个关键区别。SELECT INTO OUTFILE导出的是"干净的纯数据",文件里没有任何 SQL 语句,只有分隔开的字段值。而mysqldump导出的是"SQL 语句集合",是CREATE TABLE和INSERT INTO这种回放命令。这两种产物的应用场景完全两码事:
| 比较项 | SELECT INTO OUTFILE | mysqldump |
|---|---|---|
| 产物内容 | 纯文本数据(CSV等) | SQL 语句脚本 |
| 是否含表结构 | 不含 | 默认包含,可只用--no-data导结构 |
| 回放方式 | LOAD DATA INFILE速度快 | mysql < dump.sql逐条执行,速度慢 |
| 适用场景 | 跨系统数据交换、大数据量迁移 | 结构备份、小数据量迁移、异地重建 |
| 依赖工具 | MySQL 客户端和导入机制 | mysqldump二进制和 mysql 客户端 |
一句话总结:要做数据交换,用SELECT INTO OUTFILE导纯文件,再LOAD DATA导入目标库,这是性能路径。要做结构和整库迁移,用mysqldump更省心,但数据量大时回放会慢。两者不是替代关系,而是互补关系,下一篇我展开讲怎么组合。
4. mysqldump 的正确打开方式:它没那么慢,是你用错了
4.1 常用参数的作用和取舍
mysqldump人人都会用,但不是每个人都明白每个参数的代价。我把日常最常用的一组列出来:
mysqldump -h 127.0.0.1 -P 3306 -u backup_user -p \ --single-transaction \ --quick \ --opt \ --default-character-set=utf8mb4 \ --hex-blob \ --set-gtid-purged=OFF \ mydb > mydb_dump.sql逐个说:
--single-transaction
这是 InnoDB 表导出一致性快照的关键。它通过开启一个可重复读事务拿到一致性快照,导出的过程中其他会话的 DML 不会污染导出结果,同时也不会阻塞其他会话的正常读写。这对在线备份是刚需。
但注意,它只对事务表(InnoDB)有效。如果库里混着 MyISAM 表,--single-transaction对它们不生效,会退化为LOCK TABLES,直接锁住 MyISAM 表直到导出结束。生产环境要提前排查哪些表是 MyISAM,别等到导出的时候才发现写不进去了。
--quick
逐行从服务器读取数据而不是一次性把所有数据加载到内存。不加这个参数时,对于大表,mysqldump可能先把整张表的数据拉到客户端内存,内存不够就直接 OOM。加上--quick后是流式输出,默认情况下mysqldump也会带这个行为,但显式写出来更稳妥。
--opt
这个参数是个组合开关,等价于启用了--quick、--add-drop-table、--add-locks、--extended-insert、--lock-tables等一堆优化选项。其中--extended-insert很关键:它会把多条INSERT合并成一条多值的INSERT INTO table VALUES (...),(...),(...)语句。回放时,这个比逐条插入快非常多,因为减少了客户端和服务器之间的交互次数。
但--opt里自带--lock-tables,导出时会锁所有表。如果你用--single-transaction,建议再显式加--skip-lock-tables,让锁表逻辑彻底不触发。
--set-gtid-purged=OFF
如果你在用 GTID 模式(MySQL 5.7 之后的常见配置),不带这个参数导出的文件里会包含SET @@GLOBAL.GTID_PURGED=...语句。导入到另一个实例时经常遇到 GTID 冲突或者空事务报错。在不需要同步 GTID 信息的数据迁移场景,直接加OFF干净利落。
--hex-blob
二进制字段(BLOB、BINARY、VARBINARY)如果不用 hex 编码导出,回放时可能因为字符集转换导致数据损坏。加了这个参数,二进制数据会以十六进制字符串写入 SQL 文件,语义无损。
4.2 只导结构、只导数据怎么拆
我实际迁移中经常遇到一种情况:目标库的表结构已经建好了,只需要搬数据。这时你用完整mysqldump导出再全部回放,等于重复建表,浪费时间还容易因为表已存在报错。正确姿势是拆分成两种模式:
# 只导表结构(不含数据) mysqldump --single-transaction --no-data mydb > mydb_schema.sql # 只导数据(不含建表语句) mysqldump --single-transaction --no-create-info --complete-insert mydb > mydb_data.sql--no-data好理解。--no-create-info表示不输出CREATE TABLE,但--complete-insert值得说道一下:它生成的INSERT语句会包含完整的列名列表,比如:
INSERT INTO `users` (`id`, `name`, `score`) VALUES (1, 'a', 90);而不是:
INSERT INTO `users` VALUES (1, 'a', 90);前者在目标表字段顺序不同、缺列或新增列的情况下依然能正确回放,后者全靠列顺序猜。做跨环境迁移时,源库和目标库结构大概率不是完全一致的,建议始终带上--complete-insert。
4.3 mysqldump 回放时的加速技巧
数据量一大,回放 SQL 文件也会很痛苦。mysql < mydb_data.sql这种简单粗暴的方式不是不行,但有几个加速手段可以叠加:
第一,加大max_allowed_packet。多值INSERT可能体积很大,默认的 4MB 或 16MB 不够用。客户端导入前先执行:
SET GLOBAL max_allowed_packet = 128 * 1024 * 1024; SET SESSION max_allowed_packet = 128 * 1024 * 1024;不然你会看到ERROR 1153 (08S01): Got a packet bigger than 'max_allowed_packet' bytes这类报错。
第二,导入前临时关闭外键检查和唯一性检查。在 SQL 文件开头加上:
SET FOREIGN_KEY_CHECKS = 0; SET UNIQUE_CHECKS = 0; SET SQL_LOG_BIN = 0;第三行SQL_LOG_BIN=0需要谨慎:在 binlog 开启的实例上,这会让导入操作不记录 binlog。如果这个导入本身不需要被从库复制,那它能省掉大量 binlog 写入的开销。但一旦这是主库,且从库需要同样数据,这个开关反而会导致数据不一致。所以挂从库的环境不要用,单机导入或者导入后马上重建从库的场景才适用。
第三,分割文件并行导入。一个 20GB 的 SQL 文件,串行导入可能得一小时。如果用split按行拆成多个小文件,每个文件一个连接并行导入,时间能成倍压缩。但主从环境要小心:并行导入的多个连接在从库回放时依然是串行的,所以对从库延迟没有改善作用。
第四,导入时把innodb_flush_log_at_trx_commit临时调到 2 甚至 0。它的默认值是 1,每次事务提交都要把 redo log 刷到磁盘;调到 0 后每秒才刷一次,对导入速度提升显著。但要注意,这是以掉电丢日志为代价的,只能用于导入这种一次性、可重来、丢数据无所谓的过程,导入完恢复成 1。
5. 大数据量迁移的完整链路:结构、数据、校验一个都不能少
5.1 组合拳设计思路
前面讲到三个工具各有分工,现在就来说说我实际迁移一套复杂业务库的标准流程。假设源库source_db要整体迁到目标库target_db,数据总量大约 300GB,表结构有几百张表,其中最大一张表 2 亿行。
这个数据量级,直接mysqldump整库导出再整库回放是很不理智的,耗时以天计。实操我通常拆成三步走:
第一步:结构迁移
mysqldump -h source-host --single-transaction --no-data \ --set-gtid-purged=OFF --default-character-set=utf8mb4 \ source_db > schema.sql mysql -h target-host target_db < schema.sql结构迁移很快,几百张表几分钟搞定。这里有个细节:schema.sql里可能包含CREATE DATABASE和USE语句,如果你只迁移部分表而不是整库,建议用sed过滤掉CREATE DATABASE和USE行,只在目标库内回放建表语句:
grep -v "CREATE DATABASE\|USE \`" schema.sql | mysql -h target-host target_db第二步:数据迁移
大表用SELECT INTO OUTFILE+LOAD DATA INFILE,小表直接mysqldump --no-create-info。判断"大表"的阈值我一般定在 500 万行,超过就走文件通道。
# 大表导出到文件 mysql -h source-host -u backup_user -p source_db <<'SQL' SELECT * FROM big_table INTO OUTFILE '/data/exchange/big_table.csv' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n'; SQL # 传输到目标服务器(假设你有权限操作目标服务器文件系统) scp /data/exchange/big_table.csv target-host:/data/exchange/ # 目标库导入 mysql -h target-host -u backup_user -p target_db <<'SQL' LOAD DATA INFILE '/data/exchange/big_table.csv' INTO TABLE big_table FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n'; SQL这个操作的前提是secure_file_priv允许你往/data/exchange写。如果不行,就改用SELECT ... INTO OUTFILE ...配合LOAD DATA LOCAL INFILE组合,文件留在本地客户端传输,但速度会打折。
第三步:数据校验
迁移完最怕的是"看起来迁移了,实际缺行"。我习惯做三件事交叉验证:
先看行数是否一致:
SELECT COUNT(*) FROM source_db.big_table; SELECT COUNT(*) FROM target_db.big_table;再抽关键业务字段的校验和:
SELECT SUM(CRC32(CONCAT_WS('|', id, name, score))) FROM source_db.big_table; SELECT SUM(CRC32(CONCAT_WS('|', id, name, score))) FROM target_db.big_table;两个值一致,基本可以确信数据没丢。第三步随机抽几条记录比对明细,特别是含时间类型、浮点数的字段,确认精度没有因字符集或格式转换受损。
5.2 迁移中自增ID、外键和字符集的坑
先说自增 ID。源库表AUTO_INCREMENT当前值可能是 100000,但用LOAD DATA导入纯数据时,并不会把当前自增计数器带过去。如果导入后你执行INSERT,可能从 1 开始,直接碰撞已有记录的主键。正确的修复姿势:
-- 导入后把自增计数器调整到当前最大值+1 ALTER TABLE big_table AUTO_INCREMENT = 1;如果你用的是mysqldump导 SQL 文件回放,且--opt默认带的--complete-insert保留了自增值的时间点逻辑,那表结构里的AUTO_INCREMENT=xxxxx会被一起带上,不会出现这个问题。但走LOAD DATA文件通道就必须手工处理。
再说外键。mysqldump导出的 SQL 文件默认会带SET FOREIGN_KEY_CHECKS=0;和恢复的语句,所以直接回放没问题。但LOAD DATA不会处理外键关系,如果你的表有外键依赖,导入时按依赖关系顺序处理,先导主表再导子表,或者导入期间临时关闭外键检查:
SET FOREIGN_KEY_CHECKS = 0; LOAD DATA INFILE '...' INTO TABLE child_table ...; LOAD DATA INFILE '...' INTO TABLE parent_table ...; SET FOREIGN_KEY_CHECKS = 1;字符集是最容易埋雷的点。源库表是latin1,导出文件里中文已经按latin1存成了字节,导入到utf8mb4表时,如果你在LOAD DATA里指定的CHARACTER SET不是对应的转换字符集,中文全部变成乱码。稳妥做法是导出时统一指定源库真实字符集,导入时指定目标库字符集,并在连接层也保持一致:
mysql --default-character-set=utf8mb4 -h target-host ...如果已经导入乱了,优先用CONVERT函数修正,而不是删了重导。但更靠谱的是在导入之前就在小数据集上试跑一遍,用SELECT HEX(content)对比源库和目标库的字节,确认无转换损失再全量导入。
5.3 binlog 和从库视角下的特殊处理
如果你迁移的目标库挂着从库,或者本身就是主从架构的一部分,要考虑的就不只是导入速度了,还有复制一致性。
主从环境下,LOAD DATA在 binlog 里的记录方式是LOAD DATA事件,其中包含文件内容本身。从库回放时会在本地重建临时文件再执行,耗时和主库差不多,不会因为主库的"高速"造成从库接收瞬时的巨大 I/O 风暴,但会出现从库延迟拉大。这也是为什么超大文件的导入,我建议拆成多个中等文件,分批导入,让从库有机会追平延迟。
mysqldump时,如果你的主库开启了 GTID,导出文件里可能包含GTID_PURGED设置,导入到另一个已有 GTID 执行历史的实例,会报ERROR 3546 (@@GLOBAL.GTID_PURGED cannot be changed). 解决方案是导出时加--set-gtid-purged=OFF,如前面所说。但要注意,关闭 GTID 信息后再导入,主库上这条导入操作也会以普通事务方式执行,不影响复制,但要求从库也具备同样的表结构和数据基础。
6. 实战中绕不开的坑:文件权限、编码、NULL值和性能毛刺
6.1 secure_file_priv 与导入文件所有权
你按官方文档写完LOAD DATA INFILE命令,报错ERROR 13 (HY000): Can't get stat of '/path/to/file.csv' (Errcode: 13),十有八九是这两类问题之一:
一是文件不在secure_file_priv限定的目录里。查看一下:
SHOW VARIABLES LIKE 'secure_file_priv';确认你读的文件在返回的目录前缀下。
二是 MySQL 服务进程的运行用户(通常是mysql)没有文件的读权限。把 CSV 文件放到/tmp下,权限是 777,虽然能用但不好,生产环境secure_file_priv指向专用目录,并把目录 owner 改成mysql:mysql,权限 750 就够。
LOAD DATA LOCAL INFILE走客户端本地路径时,服务端仅校验local_infile变量,不校验secure_file_priv,但客户端和服务端都需要把local_infile打开,否则报:
ERROR 1148 (42000): The used command is not allowed with this MySQL version这里特别提醒:线上生产环境默认把local_infile关闭是安全实践,不应当为了图省事全局开启。如果确实有需求,在会话级别临时开启再关闭即可。
6.2 空字符串和 NULL 的界定
导入数据时最常见的数据失真场景,就是源文件里""和NULL被混为一谈。CSV 文件里,一个字段写成什么都不写、写成""、写成NULL,在业务语义上是三种不同的东西:
- 什么都不写,
LOAD DATA默认置为该列的默认值(可能是NULL,可能是DEFAULT 0)。 - 写成
"",空字符串真的会被存成空串''。 - 写成字符串
NULL,如果不用SET处理,会被原样存成'NULL'这四个字符。
如果你希望文件里的空位和NULL文本都变成 SQL NULL,可以这样写:
LOAD DATA INFILE '/data/exchange/users.csv' INTO TABLE users FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' (id, @name, @score) SET name = NULLIF(@name, ''), score = NULLIF(@score, '');NULLIF(expr, '')的语义是:如果表达式结果等于空字符串,则返回NULL,否则返回原值。配合@变量接收原始字段值,等于在导入管道里做了一层"空值清洗"。
反过来,导出时如果表里某些字段是NULL,SELECT INTO OUTFILE默认输出的是\N两个字符(不是 NULL,也不是空)。导入方如果不认识\N,就会把 NULL 当成字符串\N存回去,这是另一种"看起来对、实则丢了语义"的坑。解决办法是导出时用IFNULL(col, '')把你业务里的 NULL 统一成空串:
SELECT id, IFNULL(name, ''), IFNULL(score, 0) INTO OUTFILE '...' FROM users;这样下游解析逻辑简单统一,不需要专门处理\N。
6.3 编码不一致的连锁反应
字符集问题不一定会报错,但报出来就很隐蔽。比如你LOAD DATA一个 UTF-8 的 CSV 到utf8mb4表,一切正常。但如果文件是 GBK 编码,你忘了指定CHARACTER SET gbk,MySQL 会把 GBK 字节流按 UTF-8 解析,轻则插入非法字符报错Incorrect string value,重则"顺利插入"但全是乱码——后者更麻烦,因为不报错意味着你已经污染了数据。
判断文件编码的快速方法是用file命令:
file -i users.csv # 输出可能类似: users.csv: text/plain; charset=utf-8然后对比源表本身的SHOW CREATE TABLE users\G里声明的 charset,不一致就转码:
iconv -f GBK -t UTF-8 users.csv > users_utf8.csv这里建议建立一条不成文的规矩:任何数据交换文件,宁可多此一举全部统一成 UTF-8,再在LOAD DATA里显式写CHARACTER SET utf8mb4,也不要指望 MySQL"自动识别"。它能猜,但猜错的成本太高了。
6.4 大文件导入时性能毛刺的来源
大文件导入时,你盯着服务器监控会看到几个奇怪的"毛刺":导入开始阶段 CPU 突然飙高,中间出现一段 IO 等待,快要结束的时候又有一波缓冲池刷盘。这些不是故障,是正常的资源节奏:
- 开始阶段:文件读取、解析、行切分,CPU 密集。
- 中间段:随着导入行数增加,脏页越来越多,触发了
innodb_buffer_pool的 LRU 淘汰和刷盘,IO 打到峰值。 - 结束阶段:事务提交或 binlog 落盘,可能有一次小规模的同步 IO。
如果毛刺过于严重,比如导入速度骤降十倍,多半是innodb_buffer_pool_size太小,或者innodb_io_capacity没调大。更好的做法是导入前把这些参数临时调大:
[mysqld] innodb_buffer_pool_size = 16G innodb_io_capacity = 2000 innodb_io_capacity_max = 4000导入完成后恢复原值。这些参数调整在某些版本里支持动态设置,生产环境可以先在会话或实例级别临时SET GLOBAL innodb_io_capacity = 2000;,不用改配置文件重启。
7. 数据量不同,打法完全不同:我的分档经验
聊到最后,我想把这些年做数据导入导出的一些经验按数据量档位做个归纳。技术方案永远不能脱离数据量谈,同样的操作在 10 万行和 10 亿行面前完全是两回事。
万级以内
这个量级没什么好纠结的,mysqldump导出再mysql < dump.sql回放,或者直接客户端工具导出成 SQL 导入,全程都在你的耐心范围之内。没必要为了省几秒钟引入LOAD DATA的权限配置复杂度。
百万级到千万级
这是LOAD DATA INFILE的主场。导出用SELECT INTO OUTFILE生成 CSV,导入用LOAD DATA,配合--single-transaction的mysqldump做结构备份,整体迁移时间在一小时内完成。这个量级优化收益最明显,投入产出比最高。
亿级以上
单靠LOAD DATA已经不够了。亿级表导入前必须做的几件事:
- 评估是否可以先删掉非必要索引,导入完再重建。索引重建的时间远小于带索引导入多出的时间。
- 拆分成多个千万级的 CSV 分片,多个
LOAD DATA并行导入(注意实例并发度,别把 IO 打满)。 - 导入完成后立即
ANALYZE TABLE更新统计信息,防止优化器用错误的统计值生成烂执行计划。 - 考虑用
pt-table-schema-change这类外部工具做在线结构变更,而非直接ALTER TABLE。
这里分享一个小技巧:并行导入的时候,每个LOAD DATA文件的行数要对齐到大致相等的量级,不要让某一个文件特别大,否则最后那个连接拖后腿,整体完成时间会无限接近最慢的那个文件。
我自己常用的并行方式是用xargs拉多进程:
ls /data/exchange/chunk_*.csv | xargs -P 4 -I {} \ mysql -h target-host -u load_user -p target_db \ -e "LOAD DATA INFILE '{}' INTO TABLE big_table FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' LINES TERMINATED BY '\n'"-P 4表示四个并发,数字根据目标机器的磁盘能力和 MySQL 并发写能力调整,压测后通常 2 到 6 是比较合理的区间。
最后说一个我个人的体会:数据导入导出看起来是一个"零门槛"的基础操作,但真正把速度拉开差距的,往往不是某一个单点技巧,而是对整个链路的理解——从字符集对齐、索引策略、事务刷盘、binlog 代价,到并发度的控制。每一条路径省出来的时间一点点叠加,最终才是那个让你"去喝杯茶回来就完事"的效果。
我在实际项目里见过最快的迁移,就是把mysqldump只用来导表结构,大表全部走SELECT INTO OUTFILE配合并行LOAD DATA INFILE,全程没有一条 SQL 是逐行插入的。这套打法迁移 100GB 数据,从开始到校验通过,四十分钟内完成。理解了上面这些原理,你自己也能按同样的思路搭出一套适合你业务的数据搬运流水线。