LOAD DATA LOCAL 导入教程:客户端文件、暂存校验与整批回滚
导入报“找不到文件”,有人把文件复制到数据库服务器;另一位同事只加了 LOCAL,同一条语句就跑通了。两次读取的其实不是同一个文件系统。更容易漏掉的是,LOCAL 还改变了部分错误处理:没有 REPLACE 时,一些本应让导入失败的数据解释错误会变成警告。
如果把外部文件直接灌入金额、数量等强类型业务列,“语句执行成功”并不是一个可靠的验收条件。下面在 MySQL 8.4 上设计一条更容易检查的路线:客户端上传文件,先落文本暂存表,验证每条原始输入,再一次性写入目标表。实验同时覆盖重复编号、非法金额和重跑行为,不依赖关闭约束。
哪个进程打开文件,决定了从哪里开始排查
| 语句形式 | 打开原文件的一端 | 路径相对位置 | 额外能力 |
|---|---|---|---|
| LOAD DATA INFILE | mysqld 服务器 | 服务器文件系统 | FILE 权限及服务器目录限制 |
| LOAD DATA LOCAL INFILE | mysql 客户端 | 客户端文件系统 | 服务端与客户端都允许 LOCAL |
在容器中运行 mysql 命令时,客户端文件系统就是那个容器;在本机 GUI 工具执行时,是否支持文件传输又取决于工具实现。一个路径写成 /tmp/input.tsv,并不能说明它在数据库主机上。
LOCAL 不要求 FILE 权限,但客户端需要有读取原文件的操作系统权限,服务器也需要临时空间接收传输。它不是绕过所有文件限制的开关。这里只向受信任的实验服务器开放客户端文件传输;连接到不可信服务器时,这项能力会增加客户端文件暴露的风险。
在客户端准备两份确定的数据
要求 Python 3 和 MySQL 8.4 的 mysql 命令行客户端。数据库使用单独的实验实例或专用实验库,服务端 local_infile 已由管理者允许。先在客户端机器运行脚本,它会创建四行错误样本和三行修正样本:
from pathlib import Path
root = Path('/tmp/local35')
root.mkdir(mode=0o700, parents=True, exist_ok=True)
files = {
'bad.tsv': [
('1', '12.50', '鼠标'),
('2', '19.00', '键盘'),
('2', '20.00', '重复编号'),
('4', 'xyz', '非法金额'),
],
'good.tsv': [
('1', '12.50', '鼠标'),
('2', '19.00', '键盘'),
('3', '8.00', '连接线'),
],
}
for name, rows in files.items():
for row in rows:
assert len(row) == 3
assert all('\t' not in value and '\n' not in value and '\r' not in value
for value in row)
content = 'id\tamount\tlabel\n' + ''.join('\t'.join(row) + '\n' for row in rows)
target = root / name
target.write_bytes(content.encode('utf-8'))
target.chmod(0o600)
print(name, 'records=', len(rows), 'bytes=', target.stat().st_size)
文件采用 UTF-8、制表符分隔和 LF 换行;字段不允许含制表符、换行,也不使用反斜线转义。这个小协议足以支撑本次样本。要导入带多行描述的真实文件,应换成明确支持引用规则的格式,不能继续把一行当一条记录。
连接时仅对这次客户端运行开启 LOCAL:
mysql --host=127.0.0.1 --port=3306 --user=lab_user --password \
--local-infile=1 --default-character-set=utf8mb4
口令由客户端提示输入。--local-infile=1 只处理客户端这一端,服务端仍可以拒绝。连接后检查:
SELECT VERSION(), @@GLOBAL.local_infile, @@SESSION.sql_mode;
本例 SQL 用反斜线解释 \t、\n;当前会话若启用了 NO_BACKSLASH_ESCAPES,先停下并在独立实验连接中确认转义模式,再执行后面的文件语句,不能让同一段文本变成另一份字段协议。
如果服务端为 OFF,先停在这里,请实验实例管理者调整;反复更换文件路径解决不了协议能力未开放的问题。远程连接还应按实际证书配置校验服务器身份,不能因为要传文件就关闭连接身份验证。
暂存表故意不替你解释金额
目标表需要正确的数值类型;暂存表则保留输入文本,使 xyz 不会在导入阶段悄悄变成零。两张表都使用 InnoDB,后面的提交和回滚才有相应事务语义。
CREATE DATABASE IF NOT EXISTS local35_lab
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE local35_lab;
CREATE TABLE import_stage (
row_no BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
raw_id VARCHAR(32) NOT NULL,
raw_amount VARCHAR(32) NOT NULL,
raw_label VARCHAR(80) NOT NULL,
PRIMARY KEY (row_no)
) ENGINE=InnoDB;
CREATE TABLE product_price (
id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(10,2) NOT NULL,
label VARCHAR(40) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_price_nonnegative CHECK (amount >= 0)
) ENGINE=InnoDB;
暂存字段仍有长度限制。超长或列数不正确的文件可能产生警告,所以“先存文本”不能替代诊断检查。它只是把本次演练关心的数字解释推迟到可以审查的阶段。
开始一个事务,加载错误样本。路径属于刚才运行 Python 的客户端:
START TRANSACTION;
LOAD DATA LOCAL INFILE '/tmp/local35/bad.tsv'
INTO TABLE import_stage
CHARACTER SET utf8mb4
FIELDS TERMINATED BY '\t' ESCAPED BY ''
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(raw_id, raw_amount, raw_label);
SHOW COUNT(*) WARNINGS;
SHOW WARNINGS;
SELECT * FROM import_stage ORDER BY row_no;
这里的 IGNORE 1 LINES 只表示跳过表头,与处理重复键的 IGNORE 修饰符不是同一个位置。暂存表没有给 raw_id 建唯一索引,两个编号 2 都应留下来,便于明确报告重复,而不是在入口直接丢掉其中一条。
这份样本字段格式完整,数据又进入文本列,非法金额本身不应触发类型转换警告。若此时已有警告、记录数不是四条,先回滚并检查文件协议,不要继续执行后面的类型转换。
用可以解释的规则拒绝坏批次
本次输入编号只允许 1 到 9 位、不带前导零的正整数。金额只接受非负整数部分加两位小数,整数部分最多六位,不接受科学计数法或分组分隔符。规则比目标 DECIMAL 的存储范围更窄,这是导入协议的选择。
SELECT row_no, raw_id, raw_amount, raw_label
FROM import_stage
WHERE NOT REGEXP_LIKE(raw_id, '^[1-9][0-9]{0,8}$', 'c')
OR NOT REGEXP_LIKE(raw_amount, '^(0|[1-9][0-9]{0,5})[.][0-9]{2}$', 'c')
OR CHAR_LENGTH(raw_label) NOT BETWEEN 1 AND 40;
SELECT raw_id, COUNT(*) AS occurrences
FROM import_stage
GROUP BY raw_id
HAVING COUNT(*) > 1;
SELECT s.row_no, s.raw_id
FROM import_stage AS s
JOIN product_price AS p ON p.id = s.raw_id;
第一条应找出 xyz;第二条应找出重复编号 2;目标表此时为空,第三条应无结果。第三条是在筛查已存在的业务编号,只有前两项已确认合法,才适合继续做数值目标对照。真实程序应按这个顺序判断,不要把所有复杂校验拼成一个 SQL,再假定优化器一定按书写顺序执行。
这里的失败策略是拒绝整批,不进行“能导多少算多少”。在当前连接执行:
ROLLBACK;
SELECT COUNT(*) AS target_rows FROM product_price;
SELECT COUNT(*) AS staged_rows FROM import_stage;
两个计数都应为零。暂存表的自增计数不保证回到原值,下一次 row_no 可能有缺口;它只负责定位当前导入记录,不承担业务编号或连续序号的职责。
正确样本经过相同检查,再写目标表
重新开始事务,导入 good.tsv,并立即查看警告:
START TRANSACTION;
LOAD DATA LOCAL INFILE '/tmp/local35/good.tsv'
INTO TABLE import_stage
CHARACTER SET utf8mb4
FIELDS TERMINATED BY '\t' ESCAPED BY ''
LINES TERMINATED BY '\n'
IGNORE 1 LINES
(raw_id, raw_amount, raw_label);
SHOW COUNT(*) WARNINGS;
SHOW WARNINGS;
SELECT COUNT(*) AS staged_rows FROM import_stage;
确认三条记录、零警告,再执行上一节三条检查,必须全部为空集。它们不会替操作者自动阻断提交;在自动化导入工具中,任何检查有结果都应走回滚分支,不能继续沿用下面的成功分支。
INSERT INTO product_price (id, amount, label)
SELECT CAST(raw_id AS UNSIGNED), CAST(raw_amount AS DECIMAL(10,2)), raw_label
FROM import_stage;
SELECT id, amount, label FROM product_price ORDER BY id;
COMMIT;
应得到编号 1、2、3,金额分别为 12.50、19.00、8.00。金额转换只在文本语法、位数和重复条件全部通过后发生。数据库主键仍然保留:校验后到写入前若有别的事务抢先插入相同编号,INSERT 会失败,此时回滚整个事务,不应为了继续导入追加 IGNORE。
暂存内容与业务写入一起提交,便于这次实验检查。实际多批次系统需要 batch_id、批次状态与清理策略;不能让几名操作者共用同一张无批次区分的暂存表。示例采用单连接独占实验表,没有宣称解决并发批次管理。
完成后把 good.tsv 再导入一次,现有目标编号检查就会报告冲突。导入重跑究竟应拒绝、覆盖还是作为同批次重复返回成功,需要显式定义。REPLACE 也不是简单的“开更新”:它具有替换行的语义,会牵涉其他列、约束和触发器行为,不应拿来掩盖重复执行。
还有两个容易误读的计数。暂存表的 row_no 是入表顺序标识,不是文件的物理行号;样本跳过了表头,事务回滚又可能消耗自增值,不能直接用 row_no 加一就给用户定位原文件。需要精确报告文件位置时,应在应用解析阶段保存逻辑记录号,再把它作为独立字段传入。
警告展示也有容量边界。服务器保存的详细诊断数量受 max_error_count 影响,不能根据 SHOW WARNINGS 恰好列出十条,就断言只有十条输入有问题。这里把任何导入警告都视为拒绝信号,因此无需从被截断的诊断列表推导成功率。业务若允许部分接收,应另外维护逐行接收与拒绝记录,解释每一条输入的去向。
若传输中断、目标盘或服务器接收文件的临时空间不足,应把本次批次判为未验收;连接仍可用时显式 ROLLBACK。连接已经丢失时,重新确认服务器端会话和事务状态,按批次记录核对是否提交,不在另一条连接里把 COMMIT 当作补救。暂存表和目标表都建好以后才开启事务,期间不要穿插会隐式提交的建表或 TRUNCATE 操作。
文件本身应在一次导入期间保持不变。刚算完摘要就被别的进程覆盖,后面的加载可能读到另一份内容;专用只读批次目录、受控交付和文件所有权比仅仅记住路径更有帮助。原始文件包含业务资料时,失败排查也不要把整个文件贴进日志,记录批次标识、摘要及必要的错误字段即可。
这次演练最该保留的结果是两组可复核记录:坏批次的具体行和拒绝原因,以及成功批次的原始文件摘要、记录数和提交结果。传输能力、文件解析、业务合法性、事务提交各有自己的证据,不能压缩成客户端最后显示的一句 OK。