1. 批量导入大CSV这件事,坑比想象中多
手里攒了七八个CSV文件,每个少则几百兆,多则好几个G,要一股脑塞进MySQL里做分析或者做数据迁移。这事听起来简单,真动手就知道没那么轻松。我第一次接这种活的时候,想当然地用客户端工具一个个点导入,结果第一个文件跑到一半就超时断开,第二个文件直接卡死界面,第三个文件因为编码问题中文全变问号。折腾了一下午,一个文件都没完整进去。
后来换了思路,用LOAD DATA INFILE配合批量脚本,才把这件事做顺。这篇文章就把我踩过的坑和总结出来的方案完整讲一遍,包括为什么客户端工具不适合干这个活、LOAD DATA INFILE到底怎么配置才能跑满速度、多个文件怎么批量处理、遇到脏数据怎么兜底。不管你是刚接触MySQL导入的新手,还是已经用过LOAD DATA但总觉得速度不够快的老手,应该都能从里面找到能直接用的东西。
核心关键词就一个:MySQL快速导入多个大CSV文件。围绕这个目标,我会把方案选型、参数调优、批量脚本、异常处理这几个环节拆开讲,每个环节都给出具体的操作和背后的逻辑。
2. 方案选型:为什么客户端工具不适合干这个活
2.1 几种常见导入方式的真实对比
很多人第一反应是用Navicat、DBeaver这类图形化工具,或者MySQL Workbench自带的导入向导。这些工具不是不能用,而是有明确的适用边界。我把常见的几种方式拉出来对比一下,你就知道该在什么场景下选什么。
| 导入方式 | 适合的数据量 | 速度 | 多文件支持 | 主要问题 |
|---|---|---|---|---|
| 图形化工具导入向导 | 几万行以内 | 慢 | 差 | 超时断开、内存占用高、编码处理弱 |
INSERT语句批量拼 | 十万行以内 | 中等 | 需自己写脚本 | SQL包体过大、解析开销高 |
LOAD DATA INFILE | 千万行级别 | 快 | 需自己写脚本 | 需要服务端配置配合 |
mysqlimport命令行 | 千万行级别 | 快 | 原生支持多文件 | 参数灵活性略低 |
| 编程语言驱动批量插入 | 百万行以内 | 中等偏慢 | 灵活 | 网络往返开销大 |
结论很清楚:数据量上了百万行、文件上了几百兆,就该用LOAD DATA INFILE或者mysqlimport。这两个本质上是同一套底层机制,mysqlimport是LOAD DATA INFILE的命令行封装。图形化工具在数据量小的时候图个方便没问题,但面对多个大文件,它那个逐行解析再拼SQL的方式,性能瓶颈非常明显。
2.2 LOAD DATA INFILE为什么快
要理解它为什么快,得先知道普通INSERT慢在哪。你写一条INSERT INTO t VALUES (...),MySQL要做的事情包括:解析SQL语句、检查权限、打开表、逐行写入、更新索引、写binlog、提交事务。如果是一万条INSERT,这一整套流程要走一万遍。就算你用批量INSERT拼成一条插一千行,SQL解析和网络传输的开销还是省不掉。
LOAD DATA INFILE走的是完全不同的路径。它绕过了SQL解析层,直接由存储引擎按格式读取文件内容写入表。官方文档里说它比INSERT快20倍,这个数字在实测中基本靠谱,数据量越大差距越明显。它快的原因有几个:
- 绕过SQL解析:不需要把每一行拼成SQL语句再解析,直接按分隔符切分字段。
- 批量提交:可以一次性提交大量行,减少事务开销。
- 顺序写入:如果表是空表且没有二级索引,写入基本是顺序追加,磁盘IO效率高。
- 可控的约束检查:可以通过参数关闭部分检查,进一步提速。
注意:
LOAD DATA INFILE默认要求文件在MySQL服务端所在机器上,用的是LOAD DATA INFILE。如果文件在客户端机器上,要用LOAD DATA LOCAL INFILE,但后者需要服务端和客户端都开启local_infile,而且速度会受网络影响。生产环境里,通常是把文件先传到数据库服务器本地,再用LOAD DATA INFILE。
2.3 多文件处理的两种思路
多个CSV文件,处理思路无非两种:串行和并行。
串行就是写个循环,一个文件一个文件地导。优点是简单、可控、对数据库压力小。缺点是总耗时是各个文件耗时之和,如果每个文件都要跑十分钟,八个文件就是一个多小时。
并行就是同时开多个导入任务。优点是总耗时取决于最慢的那个文件。缺点是会争抢数据库的IO和CPU资源,如果表上有索引或者触发器,并发写入还可能造成锁竞争。
我的建议是:先串行跑通,确认单个文件的导入逻辑没问题,再根据服务器配置决定要不要并行。如果服务器IO够强(比如SSD阵列),并行度开到2到4是比较稳妥的。再高的话,收益递减不说,还容易把数据库拖垮,影响其他业务。
3. 核心细节:LOAD DATA INFILE的参数怎么配
3.1 文件格式与字段分隔的对应关系
CSV文件看着简单,实际上格式差异很大。有的用逗号分隔,有的用制表符;有的字段带引号,有的不带;有的换行是\n,有的是\r\n。这些细节不搞清楚,导入进去的数据就是一团乱。
先看一个标准的LOAD DATA INFILE语句长什么样:
LOAD DATA INFILE '/var/lib/mysql-files/orders_202401.csv' INTO TABLE orders FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (order_id, user_id, amount, created_at);逐段解释一下:
FIELDS TERMINATED BY ',':字段之间用逗号分隔。如果是制表符,写成'\t'。OPTIONALLY ENCLOSED BY '"':字段可能被双引号包起来。OPTIONALLY的意思是可有可无,有些字段带引号有些没带也能处理。LINES TERMINATED BY '\n':行结束符。Windows导出的CSV通常是\r\n,如果这里写错,导入后每行末尾会多一个\r,导致最后一个字段值不对。IGNORE 1 LINES:跳过表头行。如果CSV没有表头,这行去掉。- 最后的字段列表:指定CSV各列对应表中的哪些字段。顺序要和CSV里的列顺序一致。
实操心得:判断CSV的行结束符,可以用
file命令看,或者用head -1 file.csv | xxd | tail -2看末尾字节。0d 0a就是\r\n,0a就是\n。这个细节不注意,导入后最后一个字段会莫名其妙多出乱码。
3.2 编码问题:中文乱码的根源
CSV文件的编码和数据库的字符集不一致,是中文乱码的头号原因。常见的情况是CSV是GBK编码,数据库是utf8mb4,直接导入中文全变问号。
解决办法有两个:
方案一:导入前转码。用iconv把文件转成utf8:
iconv -f GBK -t UTF-8 source.csv > source_utf8.csv这个方案的好处是文件层面就统一了,后续导入不用操心编码。坏处是多一步转换,大文件转换也要花时间。
方案二:在LOAD DATA时指定字符集:
LOAD DATA INFILE '/path/to/file.csv' INTO TABLE orders CHARACTER SET gbk FIELDS TERMINATED BY ',' ...这个方案不用改文件,但要求你准确知道源文件的编码。如果搞错了,乱码更严重。
注意:
CHARACTER SET指定的是源文件的字符集,不是目标表的字符集。MySQL会自动把它转成表的字符集。这个参数很容易理解反。
3.3 关键性能参数:让导入跑满速度
默认参数下,LOAD DATA INFILE已经比INSERT快很多了,但还没到极限。有几个参数调一下,速度还能再上一个台阶。
innodb_flush_log_at_trx_commit
这个参数控制事务日志的刷盘策略。默认值是1,意思是每次事务提交都要把日志刷到磁盘,保证不丢数据。导入大量数据时,这个刷盘动作会成为瓶颈。临时改成2或者0,可以大幅提升写入速度。
SET GLOBAL innodb_flush_log_at_trx_commit = 2;2:每次提交写日志到操作系统缓存,每秒刷一次盘。性能和数据安全折中。0:每秒写日志并刷盘,提交时不触发。性能最好,但崩溃可能丢一秒数据。
导入完成后记得改回1。
unique_checks和foreign_key_checks
如果表上有唯一索引和外键约束,导入时每插一行都要检查,开销不小。导入前临时关闭:
SET unique_checks = 0; SET foreign_key_checks = 0; -- 执行导入 SET unique_checks = 1; SET foreign_key_checks = 1;注意:关闭唯一性检查的前提是你确信导入的数据本身没有重复。如果数据有重复,关闭检查会导致重复数据进去,后续再想清理就麻烦了。这个操作适合数据来源可靠、已经清洗过的场景。
批量提交大小
LOAD DATA INFILE本身没有直接的批量提交参数,但可以通过SET autocommit和手动事务来控制。不过实测下来,LOAD DATA内部已经做了优化,手动包事务的收益不明显,反而可能因为事务过大导致回滚段膨胀。一般不建议额外包事务。
3.4 索引和约束的处理策略
这是影响导入速度最大的因素之一,也是最容易被忽略的。
如果目标表在导入前是空的,先建表不建索引,导入完再建索引,比带着索引导入快得多。原因很简单:带着索引导入,每插一行都要更新B+树,随机IO多;导入完再建索引,可以走排序后批量构建,顺序IO为主。
具体操作:
-- 1. 建表时只建主键,不建二级索引 CREATE TABLE orders_staging ( order_id BIGINT PRIMARY KEY, user_id BIGINT, amount DECIMAL(10,2), created_at DATETIME ); -- 2. 导入数据 LOAD DATA INFILE '...' INTO TABLE orders_staging ...; -- 3. 导入完成后建索引 ALTER TABLE orders_staging ADD INDEX idx_user (user_id); ALTER TABLE orders_staging ADD INDEX idx_created (created_at);如果目标表已经有数据,不能删索引,那就只能接受索引维护的开销。这种情况下,可以考虑先导入到临时表,再用INSERT INTO ... SELECT的方式合并,但合并本身也有开销,要权衡。
4. 批量导入的完整实操流程
4.1 环境准备与文件归置
第一步是把文件放到MySQL服务端能访问的位置。LOAD DATA INFILE默认只能读secure_file_priv指定的目录。先查一下这个目录在哪:
SHOW VARIABLES LIKE 'secure_file_priv';假设返回的是/var/lib/mysql-files/,那就把所有CSV文件复制到这个目录下。如果返回为空,说明没有限制,任意路径都能读。如果返回NULL,说明禁止了文件导入,需要在配置文件里设置。
# 查看文件列表和大小 ls -lh /var/lib/mysql-files/*.csv # 确认文件编码 file -i /var/lib/mysql-files/*.csv把文件归置好之后,先拿一个文件做小批量测试,确认字段对应、编码、行结束符都没问题,再批量跑。
4.2 单文件导入的验证流程
不要一上来就导全量。先取前1000行做个测试:
head -1000 orders_202401.csv > test_sample.csv然后用LOAD DATA导入这个样本,检查几件事:
- 行数对不对:
SELECT COUNT(*) FROM orders_staging;应该是1000(如果跳过了表头就是999)。 - 中文字段有没有乱码:
SELECT * FROM orders_staging LIMIT 10;肉眼看一下。 - 数值字段有没有精度丢失:特别是金额、时间字段。
- 最后一个字段有没有多余字符:这是行结束符配错的典型症状。
样本验证通过后,再跑全量。
4.3 多文件批量导入脚本
单个文件跑通后,写个脚本批量处理。用bash最简单直接:
#!/bin/bash DB_NAME="your_database" TABLE_NAME="orders_staging" FILE_DIR="/var/lib/mysql-files" LOG_FILE="/var/log/import_batch.log" # 导入前优化参数 mysql -e "SET GLOBAL innodb_flush_log_at_trx_commit = 2;" for csv_file in ${FILE_DIR}/*.csv; do filename=$(basename "$csv_file") echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始导入: $filename" >> "$LOG_FILE" start_time=$(date +%s) mysql "$DB_NAME" -e " LOAD DATA INFILE '${csv_file}' INTO TABLE ${TABLE_NAME} CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' LINES TERMINATED BY '\n' IGNORE 1 LINES (order_id, user_id, amount, created_at); " 2>> "$LOG_FILE" if [ $? -eq 0 ]; then end_time=$(date +%s) duration=$((end_time - start_time)) echo "[$(date '+%Y-%m-%d %H:%M:%S')] 完成: $filename, 耗时 ${duration}秒" >> "$LOG_FILE" else echo "[$(date '+%Y-%m-%d %H:%M:%S')] 失败: $filename" >> "$LOG_FILE" fi done # 恢复参数 mysql -e "SET GLOBAL innodb_flush_log_at_trx_commit = 1;" echo "全部导入完成" >> "$LOG_FILE"这个脚本做了几件事:循环遍历目录下所有CSV、记录每个文件的开始和结束时间、失败时记录到日志但不中断后续文件、导入前后自动调整性能参数。
实操心得:脚本里用
2>> "$LOG_FILE"把错误输出也重定向到日志,这样如果某个文件导入失败,错误信息会留在日志里,方便排查。不要图省事把错误输出丢掉,出了问题连原因都找不到。
4.4 并行导入的取舍与实现
如果串行跑下来总时间太长,可以考虑并行。但并行不是简单地把循环改成后台执行,要考虑几个问题:
- 目标表是否相同:如果多个文件导入同一张表,并行写入会有锁竞争,InnoDB的行锁在批量插入时可能升级,反而更慢。这种情况下,可以每个文件导入一张独立的临时表,最后再合并。
- 服务器IO能力:机械硬盘并行意义不大,SSD可以适当并行。
- 并行度控制:一般2到4比较合适,再多收益递减。
一个简单的并行实现:
#!/bin/bash MAX_PARALLEL=3 count=0 for csv_file in /var/lib/mysql-files/*.csv; do filename=$(basename "$csv_file") table_name="orders_${filename%.csv}" mysql your_database -e " CREATE TABLE IF NOT EXISTS ${table_name} LIKE orders_staging; LOAD DATA INFILE '${csv_file}' INTO TABLE ${table_name} FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"' LINES TERMINATED BY '\n' IGNORE 1 LINES; " & count=$((count + 1)) if [ $((count % MAX_PARALLEL)) -eq 0 ]; then wait fi done wait这个脚本每个文件导入一张独立表,避免锁竞争,每3个文件等一次。最后再用INSERT INTO ... SELECT把各表数据合并到主表。
5. 常见问题与排查技巧实录
5.1 导入报错速查表
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
The MySQL server is running with the --secure-file-priv option | 文件不在允许目录 | 把文件移到secure_file_priv目录,或修改配置 |
Can't get stat of file | 文件路径错误或权限不足 | 检查路径,确保mysql用户有读权限 |
Incorrect integer value | 字段类型不匹配或空值 | 检查CSV对应列,用SET子句处理空值 |
Data too long for column | 字段长度不够 | 扩大列长度或截断数据 |
Duplicate entry for key | 主键或唯一键冲突 | 导入前清空表,或用IGNORE关键字跳过重复 |
Row X doesn't contain data for all columns | 列数不匹配 | 检查字段列表和CSV列数是否一致 |
| 中文乱码 | 编码不一致 | 指定CHARACTER SET或提前转码 |
5.2 导入速度慢的排查思路
如果导入速度远低于预期,按这个顺序排查:
第一,看是不是带着索引导入的。用SHOW INDEX FROM table_name;确认。如果有多个二级索引,考虑先删索引再导入。
第二,看innodb_flush_log_at_trx_commit是不是1。默认值1在导入场景下是性能杀手,临时改成2。
第三,看磁盘IO是不是瓶颈。用iostat -x 1观察,如果%util接近100%,说明磁盘跑满了,这时候再怎么调参数也没用,得从硬件或者并行策略上想办法。
第四,看是不是在跑LOAD DATA LOCAL INFILE。本地文件导入走网络传输,速度受网络带宽限制。把文件传到服务端本地再导。
第五,看表上有没有触发器。触发器会在每行插入时执行,开销巨大。导入前禁用触发器。
5.3 数据质量兜底:脏数据怎么处理
CSV文件来自不同系统,脏数据几乎不可避免。常见的脏数据包括:字段缺失、类型不对、超长、特殊字符。
处理脏数据有两个思路:
思路一:用SET子句做转换。LOAD DATA支持在字段列表里用变量和表达式:
LOAD DATA INFILE '...' INTO TABLE orders_staging FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n' IGNORE 1 LINES (order_id, @user_id, @amount, created_at) SET user_id = IF(@user_id = '', NULL, @user_id), amount = IF(@amount = '', 0, @amount);这里用@user_id和@amount接收原始值,然后在SET里做判断和转换。空字符串转成NULL或0,避免导入报错。
思路二:先导入全字符串的临时表。建一张所有列都是VARCHAR的临时表,先把数据原样导进去,再用SQL做清洗和转换,最后插入正式表。这个方案适合脏数据多、清洗逻辑复杂的场景。
-- 临时表全用VARCHAR CREATE TABLE orders_raw ( order_id VARCHAR(50), user_id VARCHAR(50), amount VARCHAR(50), created_at VARCHAR(50) ); -- 原样导入 LOAD DATA INFILE '...' INTO TABLE orders_raw ...; -- 清洗后插入正式表 INSERT INTO orders_staging (order_id, user_id, amount, created_at) SELECT CAST(order_id AS UNSIGNED), CAST(NULLIF(user_id, '') AS UNSIGNED), CAST(NULLIF(amount, '') AS DECIMAL(10,2)), STR_TO_DATE(NULLIF(created_at, ''), '%Y-%m-%d %H:%i:%s') FROM orders_raw WHERE order_id REGEXP '^[0-9]+$';实操心得:临时表方案虽然多了一步,但排查问题方便得多。数据有问题时,原始值还在临时表里,可以反复清洗重试,不用重新导入文件。对于来源不可靠的CSV,我一般都用这个方案。
5.4 导入后的数据校验
导入完成不代表万事大吉,必须做校验。校验分三个层次:
行数校验:CSV文件的行数(减去表头)应该等于导入后的表行数。
# 统计CSV行数 wc -l orders_202401.csv-- 统计表行数 SELECT COUNT(*) FROM orders_staging;抽样校验:随机取几行,和CSV里的原始数据对比。
SELECT * FROM orders_staging ORDER BY RAND() LIMIT 5;聚合校验:对金额、数量这类数值字段做SUM或AVG,和CSV里用命令行算出来的结果对比。
# CSV里金额列求和(假设是第3列) awk -F',' 'NR>1 {sum+=$3} END {print sum}' orders_202401.csvSELECT SUM(amount) FROM orders_staging;两个结果一致,说明数据完整性和准确性都没问题。
6. 几个容易被忽略的细节
6.1 文件权限与目录归属
LOAD DATA INFILE是以MySQL服务进程的用户身份读文件的。如果文件是root创建的,权限是600,mysql用户读不了,就会报Can't get stat of file。解决办法是给文件加上读权限,或者把文件属主改成mysql。
chmod 644 /var/lib/mysql-files/*.csv # 或者 chown mysql:mysql /var/lib/mysql-files/*.csv6.2 大文件的内存占用
LOAD DATA INFILE本身是流式读取的,不会把整个文件加载到内存。但如果表上有大的TEXT或BLOB字段,或者用了复杂的SET表达式,内存占用会上升。导入期间用top观察mysqld进程的内存,如果持续增长不释放,可能是innodb_buffer_pool_size设置过大或者有内存泄漏,需要关注。
6.3 binlog的影响
如果数据库开了binlog,LOAD DATA的每一行都会写binlog,这会显著增加IO开销。如果导入的数据不需要主从同步,可以临时关闭binlog:
SET sql_log_bin = 0; -- 执行导入 SET sql_log_bin = 1;注意:关闭binlog只对当前会话有效,且需要
SUPER权限。生产环境操作前要确认不会影响主从复制。
6.4 导入中断后的续传
如果导入到一半中断了,不要直接重跑整个文件,会重复导入。正确的做法是:
- 先确认已经导入了多少行:
SELECT COUNT(*) FROM orders_staging; - 用
tail -n +N跳过已导入的行,生成剩余部分的文件。 - 导入剩余部分。
# 假设已导入50000行,加上表头,从50002行开始 tail -n +50002 orders_202401.csv > orders_202401_remain.csv这个操作要求CSV的行顺序和导入顺序一致,且没有并行导入。如果用了并行或者顺序不确定,就只能清空表重来。
7. 我的实操体会
这套方案我在几个不同的项目里用过,导入过累计几十G的CSV数据。最大的体会是:导入速度的瓶颈往往不在LOAD DATA本身,而在前期的准备和参数配置。把索引处理、日志刷盘、编码这几个点搞定,速度能差出好几倍。
另一个体会是,不要迷信并行。我试过把并行度开到8,结果数据库IO跑满,其他查询全部超时,导入本身也没快多少。后来降到3,整体反而更稳。并行度这个东西,得根据服务器实际配置来调,没有万能值。
最后,日志和校验不能省。批量导入最怕的就是某个文件悄悄失败了,你没发现,后面分析数据时才发现少了一块。脚本里记录日志、导入后做行数和聚合校验,这两个习惯帮我省了很多事后排查的时间。