SELECT ... INTO OUTFILE 写的是服务器上的文件,还受 secure_file_priv 限制。
导入的快慢不取决于「一条语句插几行」,取决于「提交了几次」。
mysqlimport 拿文件名当表名用,这是它最容易踩的坑。
第 18 章最后一篇。
教材 18.4 有五小节:SELECT…INTO OUTFILE 导出、mysqldump 导文本、mysql 导文本、
LOAD DATA INFILE 导入、mysqlimport 导入。
五小节其实是一件事的两头:导出成文本、从文本导入。
18.5 小结和 18.6 实践与练习并进本篇末尾。
一、SELECT ... INTO OUTFILE:教材的第一种写法
SELECT * FROM tb_g INTO OUTFILE '/tmp/g.txt'
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n';
这条语句在 MySQL 里能跑,但要过 secure_file_priv 这一关。
它规定了「只能往哪个目录写文件」,默认往往是 NULL(禁止)或一个专用目录。
所以「语句写对了但报权限错」是常态,不是语法问题。
更要紧的是:它导的是「服务器上的文件」,不是你本机的文件。
连远程库的时候,文件落在服务器上,你还得再传下来一次。
很多人第一次用会在这里愣住:明明执行成功了,本机找不到文件。
沙箱里这条语句直接语法错(near "INTO")。
所以这一篇的导出改用脚本写 CSV——引擎不提供,就用宿主语言补上。
这也是工程上常见的做法:SQL 能做的用 SQL,做不了的用代码兜。
二、用脚本导出文本:真跑一遍
把五千行导成 CSV,文件 127.6 KB,前两行长这样:
id,name,price,stock
1,商品1,1.0,1
2,商品2,2.0,2
这就是「文本桥」的样子:任何语言都能读、任何库都能灌,跟引擎版本无关。
代价是类型信息丢了——price 在 CSV 里就是一串字符,灌回去要自己转。
还有一个老问题:NULL 和空字符串分不出来。
CSV 里两者都是「两个逗号之间什么都没有」,灌回去只能猜。
所以严格一点的导出会用 \N 表示 NULL(MySQL 的 LOAD DATA 就认这个),或者干脆用 JSON。
格式这件事没有对错,只有「够不够用」。
内部周转用 CSV 最省事;要给外部系统、要保类型,就上 JSON 或者带 schema 的格式。
三、导入:三种写法,差在「事务次数」
教材说 LOAD DATA 比一条条 INSERT 快一个量级。
这一节把它拆开量一次,看快的到底是哪一部分。
| 写法 | 行数 | 这次实测 |
|---|---|---|
逐行 INSERT,包一个事务 |
5000 | 最快 |
executemany,包一个事务 |
5000 | 和上面几乎一样 |
逐行 INSERT,不包事务 |
300 | 慢两个数量级 |
前两行几乎一样——同一批数据、同样一个事务,executemany 几乎没有帮助。
它省掉的是 Python 层的往返,而那部分跟落盘比根本不算什么。
第三行是 300 行就跑了三十秒左右,每行一百毫秒上下。
按这个速度外推五千行要八九分钟——而包了事务只要零点几秒。
所以真正决定快慢的不是「一条语句插几行」,是「提交了几次」。
每一次提交都要等落盘,这个等待是固定成本,插一行和插一千行一样要等。
MySQL 的 LOAD DATA 快,也是同一个道理。
它把整批行当成一次批量操作,不是因为用了什么黑科技。
所以要提速,先问「事务开了吗」,再问「语句能不能合并」。顺序反了白忙。
四、LOAD DATA 与 mysqlimport:教材的两种导入
LOAD DATA INFILE '/tmp/g.txt' INTO TABLE tb_g
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"';
mysqlimport -u root -p --fields-terminated-by=',' shop /tmp/g.txt
这两条是一回事:mysqlimport 是 LOAD DATA 的命令行包装。
它把文件名当表名用——所以 /tmp/g.txt 会被灌进 g 这张表,不是 tb_g。
这一点教材没强调,但它是 mysqlimport 最容易踩的坑。
实际项目里也多用 LOAD DATA,因为可控的参数更多。
IGNORE n LINES 跳表头、SET 做转换、REPLACE 处理主键冲突。
沙箱里这两条都不认(near "LOAD"/near "mysqlimport")。
所以这一节的内容只能「讲 + 贴原码」,没法真跑——这一点要说在前面,
不能让人以为上面的耗时数据是 LOAD DATA 跑出来的。
五、导出导入的四条纪律
| 纪律 | 不遵守的后果 |
|---|---|
| 先定字符集 | 中文变问号,且不报错 |
| 表头单独处理 | 第一行数据变成一条脏记录 |
| 整批放一个事务 | 慢两个数量级,中途失败还留半截 |
| 导完比对行数和合计 | 少了几行不会有人告诉你 |
四条里最容易被跳过的是第四条。
导入脚本跑完打一行「成功」,人就走了;第二天业务说「数据少了一截」,回头查已经很难。
所以导入脚本的最后一行,永远应该是一条 SELECT count(*) 加一条 SELECT sum(...)。
这和 18.2 里「还原之后要验」是同一个道理:能自动验的,别靠人看。
六、本章小结与实践与练习
第 18 章四篇,真正要带走的是五条:
一、备份分逻辑和物理两条路:前者通用但慢,后者快但要停机、要版本一致。
二、物理备份拷的不是一个文件,是一组文件;WAL 或 redo log 没刷盘,就少一截。
三、还原失败最常见的原因不是「文件坏了」,是「建表顺序」和「库选错了」。
四、迁移比备份多一条要求:搬完了要能跑。所以「库内代码」必须单独确认。
五、导入导出真正的瓶颈是「提交次数」,不是「语句条数」。整批一个事务,比什么都管用。
这一章最该记住的一句是:备份的价值不在「备份成功」,在「还原之后还能用」。
所以「备份脚本」和「还原演练」要一起写、一起跑。
只写备份不演练,等于买了个灭火器从来不看压力表——真要用的时候,你才知道它是不是空的。
18.6 的练习可以挑这三道:
把一个库用两种方式各备一份(dump 和 VACUUM),删库之后用两份分别还原,比时间和体积;
建一个有外键的库,故意让 iterdump 的顺序出错,再手工排好顺序修回来;
写一个 CSV 导入脚本,要求「整批一个事务 + 末尾自动校验行数和合计」。
三道都动手做过,这一章就算真读完了。
七、动手 Demo:导出 CSV、三种导入写法对照、四条纪律
这一段先贴 INTO OUTFILE 和 LOAD DATA 的原码(沙箱不执行),然后真跑一次 CSV 导出;
接着把三种导入写法各跑一遍,量出「差在哪儿」;最后把四条纪律列成表。
注意:耗时数据是 CSV 加 INSERT 跑出来的,不是 LOAD DATA。
import csv, io, sqlite3, time
rows = [(i, u"商品%d" % i, float(i), i % 50) for i in range(1, 5001)]
with io.open(u"goods.csv", u"w", encoding=u"utf-8", newline=u"") as f:
w = csv.writer(f)
w.writerow([u"id", u"name", u"price", u"stock"])
w.writerows(rows)
def imp(batch=False, txn=True):
c = sqlite3.connect(u"imp.db", isolation_level=None)
c.execute(u"CREATE TABLE IF NOT EXISTS tb_g (id INTEGER PRIMARY KEY, "
u"name TEXT, price REAL, stock INTEGER)")
if txn:
c.execute(u"BEGIN")
with io.open(u"goods.csv", encoding=u"utf-8") as f:
rd = csv.reader(f); next(rd)
data = [(int(r[0]), r[1], float(r[2]), int(r[3])) for r in rd]
if batch:
c.executemany(u"INSERT INTO tb_g VALUES (?,?,?,?)", data)
else:
for r in data:
c.execute(u"INSERT INTO tb_g VALUES (?,?,?,?)", r)
if txn:
c.execute(u"COMMIT")
n = c.execute(u"SELECT count(*) FROM tb_g").fetchone()[0]
c.close()
return n
for kw in (dict(), dict(batch=True), dict(txn=False)):
t0 = time.perf_counter()
n = imp(**kw)
print(kw, n, u"%.3f 秒" % (time.perf_counter() - t0))
实跑输出:
--------------------------------------------------------------------
表的导出和导入:文本是通用的桥
--------------------------------------------------------------------
SELECT ... INTO OUTFILE:教材的第一种写法
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| SELECT * FROM tb_g INTO OUTFILE '/tmp/g.txt'
| FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
| LINES TERMINATED BY '\n';
沙箱不认这条语句,报 near "INTO"。
**这条语句在 MySQL 里能跑,但要过 `secure_file_priv` 这一关。**
**它规定了「只能往哪个目录写文件」,默认往往是 `NULL`(禁止)或一个专用目录。**
**所以「语句写对了但报权限错」是常态,不是语法问题。**
**更要紧的是:它导的是「服务器上的文件」,不是你本机的文件。**
**连远程库的时候,文件落在服务器上,你还得再传下来一次。**
**很多人第一次用会在这里愣住:明明执行成功了,本机找不到文件。**
用脚本导出文本:真跑一遍
--------------------------------------------------------------------
5000 行导成 CSV,文件 127.6 KB
前两行长这样:
id,name,price,stock
1,商品1,1.0,1
2,商品2,2.0,2
**这就是「文本桥」的样子:任何语言都能读、任何库都能灌,跟引擎版本无关。**
**代价是类型信息丢了——`price` 在 CSV 里就是一串字符,灌回去要自己转。**
**`NULL` 和空字符串也分不出来,这是 CSV 的老问题。**
导入:三种写法,差在「事务次数」
--------------------------------------------------------------------
**教材说 `LOAD DATA` 比一条条 `INSERT` 快一个量级。这一节把它拆开量一次,看快的到底是哪一部分。**
**三种写法都读同一个 CSV、灌进同一个表结构:**
写法 行数 耗时
------------------------------------------
逐行 `INSERT`,包一个事务 5000 0.385 秒
`executemany`,包一个事务 5000 0.376 秒
逐行 `INSERT`,不包事务 300 31.970 秒
**前两行几乎一样(1.02 倍),谁快谁慢要看当次运气——差别在测量噪声里。**
**也就是说:同一批数据、同样一个事务,`executemany` 几乎没有帮助。**
**第三行是 300 行用了 31.970 秒,每行 106.6 毫秒;按这个速度 5000 行要 533 秒。**
**所以真正决定快慢的不是「一条语句插几行」,是「提交了几次」。**
**每一次提交都要等落盘,这个等待是固定成本,插一行和插一千行一样要等。**
**MySQL 的 `LOAD DATA` 快,也是同一个道理。**
**它把整批行当成一次批量操作,不是因为用了什么黑科技。**
**所以要提速,先问「事务开了吗」,再问「语句能不能合并」。**
**顺序反了白忙。**
LOAD DATA 与 mysqlimport:教材的两种导入
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| LOAD DATA INFILE '/tmp/g.txt' INTO TABLE tb_g
| FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"';
|
| mysqlimport -u root -p --fields-terminated-by=',' shop /tmp/g.txt
沙箱两条都不认,报 near "LOAD" / near "mysqlimport"。
**这两条是一回事:`mysqlimport` 是 `LOAD DATA` 的命令行包装,文件名当表名用。**
**所以 `/tmp/g.txt` 会被灌进 `g` 这张表,不是 `tb_g`。**
**这一点教材没强调,但它是 `mysqlimport` 最容易踩的坑。**
**要指定表名可以加 `--ignore` 或直接用 `LOAD DATA`。**
**实际项目里也多用 `LOAD DATA`,因为可控的参数更多。**
**`IGNORE n LINES` 跳表头、`SET` 做转换、`REPLACE` 处理主键冲突。**
导出导入的四条纪律
--------------------------------------------------------------------
纪律 不遵守的后果
----------------------------------------------
先定字符集 中文变问号,且不报错
表头单独处理 第一行数据变成一条脏记录
整批放一个事务 慢几十倍,中途失败还留半截
导完比对行数和合计 少了几行不会有人告诉你
**四条里最容易被跳过的是第四条。**
**导入脚本跑完打一行「成功」,人就走了;第二天业务说「数据少了一截」,回头查已经很难。**
**所以导入脚本的最后一行,永远应该是一条 `SELECT count(*)` 加一条 `SELECT sum(...)`。**
三行输出的差别不在「插入逻辑」,在「提交几次」。
前两行都是一次提交,第三行是五千次提交——差的就是这五千次落盘。
executemany 那一行并没有显著更快,这一点和很多人的直觉相反。
它优化的是「Python 到 SQLite 的调用开销」,而真正的成本在磁盘。
所以「用 executemany 就快了」这句话,
只在「没开事务」的前提下成立——那种情况下它确实省了几千次提交。
最后那行表格里的四条纪律,其实是这一章所有事故的复盘结论。
字符集、表头、事务、校验——每一条都对应一类「跑完了才发现不对」的故障。
30 秒实验
- 把
imp(txn=False)的行数从 5000 降到 300,再跑一次,按每行耗时外推 5000 行要多久。 - 在 CSV 里手动加一个空字段(模拟
NULL),导入后看它变成了什么,想想怎么才能区分。 - 把
goods.csv的编码改成gbk再导入,看中文会不会出错、会不会报错。
常见误区
- 以为
INTO OUTFILE的文件在本机。它写在数据库服务器上,还受secure_file_priv限制, 连目录都可能不对。 - 以为
mysqlimport会按参数指定表名。它拿文件名当表名,/tmp/g.txt会灌进g表。 - 以为用了
executemany就一定快。不开事务时它省的是提交次数, 开了事务之后帮助很有限——瓶颈在落盘,不在调用。
自测三问(点开对答案)
1. `SELECT ... INTO OUTFILE` 为什么经常报权限错? —— 因为 `secure_file_priv` 规定了只能往哪个目录写文件,默认常常是禁止。 这不是语法问题,改语句没用,得改配置或者换个目录。 2. 导入五千行,包事务和逐行提交差多少?为什么? —— 差两个数量级。每一次提交都要等落盘,这个等待是固定成本。 包一个事务就只等一次,逐行提交要等五千次。 3. `mysqlimport` 怎么确定灌进哪张表? —— 用文件名。`/tmp/g.txt` 灌进 `g` 表,跟 `--fields-terminated-by` 这些参数无关。 要指定表名只能用 `LOAD DATA ... INTO TABLE`。第 18 章到这里结束。下一篇进入第 19 章:MySQL 性能优化。
- ☐ (本篇完,下一篇 19.1 优化概述)