🛢️ 第18章 备份与恢复
📄 一句话概括

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 秒实验

  1. 把 imp(txn=False) 的行数从 5000 降到 300,再跑一次,按每行耗时外推 5000 行要多久。
  2. 在 CSV 里手动加一个空字段(模拟 NULL),导入后看它变成了什么,想想怎么才能区分。
  3. 把 goods.csv 的编码改成 gbk 再导入,看中文会不会出错、会不会报错。

常见误区

  1. 以为 INTO OUTFILE 的文件在本机。它写在数据库服务器上,还受 secure_file_priv 限制, 连目录都可能不对。
  2. 以为 mysqlimport 会按参数指定表名。它拿文件名当表名,/tmp/g.txt 会灌进 g 表。
  3. 以为用了 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 性能优化。

相关笔记