1. 先看报错现场:Errcode: 13 到底是谁在拒绝你
我第一次被这个报错撞懵,是在调试一张订单表的导出任务。当时只是很平常的一条 SQL:
SELECT id, order_no, amount FROM order_info INTO OUTFILE '/tmp/order_export.csv' FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"';MySQL 扭头就甩回来这么一段:
ERROR 1 (HY000) at line 1: Can't create/write to file '/tmp/order_export.csv' (Errcode: 13 - Permission denied)先别急着去检查 MySQL 账号的权限,这个报错和GRANT一点关系都没有。Errcode: 13 对应的是操作系统层级的EACCES,意思是真正拒绝你的不是 MySQL 的权限表,而是 Linux 的文件系统访问控制、SELinux 或者 AppArmor 这类外部机制。很多人在这条报错上绕了一晚上,其实就是没搞清“MySQL 权限”和“文件系统权限”这两层逻辑。
注意:MySQL 客户端提示里的
Can't create/write to file是 MySQL 自己说的话,后面括号里的Errcode: 13 - Permission denied则是操作系统回传的错误码。13 就是 Linux errno 里的EACCES,在 Windows 上可能显示为 Permission denied。明白这一点,你才能分清排查方向。
这篇文章我会从一条报错出发,把根因、排查链路、修复手段一条条拆开,最后再给一份高频问题的速查表。适合刚被这个报错劝退的新手,也适合想彻底搞懂“mysqld 进程到底以谁的身份在写文件”的运维和打工人。
2. 为什么 MySQL 会写不进文件:三层权限交错
2.1 真正写文件的是 mysqld 进程,不是你的客户端
你打开一个 MySQL 命令行客户端,执行SELECT ... INTO OUTFILE ...,直觉上会觉得是“我”在写这个文件。其实不对。INTO OUTFILE是在 MySQL 服务端执行的,真正去打开文件、写入数据的是 mysqld 进程。客户端只负责发送这条 SQL,然后接收错误。
所以判断一切问题的出发点,是搞清楚 mysqld 进程以什么操作系统用户身份运行。常规二进制部署或发行版软件包安装,这个用户一般是mysql;源码编译如果没手动指定,也可能就是启动脚本里的用户。可以在服务器上确认:
ps aux | grep [m]ysqld # 或者只取用户列 ps -o user,pid,cmd -C mysqld看到类似mysql 12345 ... /usr/sbin/mysqld这样的输出,就说明当前 mysqld 以mysql用户身份运行。后面所有系统层的检查,都要站在这个用户的视角去看。
2.2 文件系统权限链条
Linux 下能不能在某个目录里创建文件,取决于三样东西:目录是否存在、目录的写权限是否给了目标用户、文件系统是否允许写入。
具体到 MySQL 导出的场景,要满足的条件是:
- 目标文件所在的目录要存在,并且 mysqld 进程的用户(比如
mysql)对该目录有w和x权限。只有w没有x也没用,因为进入目录需要x权限。 - 如果目标文件已存在,mysqld 用户还要对该文件有写权限,否则同样报错 13。
- 如果目录挂在只读文件系统、NFS 导出成只读、或者设置了 ACL 限制,即使
ls -ld看着是 777,也可能写不进去。
这里有个容易忽略的坑:/tmp目录在多数 Linux 发行版上是 1777 权限,所有用户都能创建文件,所以很多人觉得“写到 /tmp 肯定没问题”。但 MySQL 5.7 以后,secure_file_priv默认会把INTO OUTFILE限制在指定目录下。等你看到报错时,操作系统这一层还没走到,MySQL 自己先把你挡在门外了。
2.3 SELinux 和 AppArmor,第三层看不见的围栏
如果你的服务器开启了 SELinux(CentOS、RHEL、部分云主机默认开启)或 AppArmor(Ubuntu、Debian 常见),文件系统权限检查通过之后,还会过一遍安全模块的防线。SELinux 判断文件能不能被某个进程写入,不看属主和权限位,而是看“标签”。
比如 mysqld 进程有mysqld_t类型标签,它想写入的目标目录标签是default_t,即使目录是777,SELinux 一样会拦截,而且不给你任何明显的系统日志,只会静默拒绝。这就是为什么很多人chmod 777之后问题依旧。
AppArmor 则是按路径规则来限制,它给 mysqld 进程指定了一组可读可写的路径模式,路径不在允许列表内,写入也会被拒绝,错误同样表现为 Errcode: 13。
所以完整的判断链其实是:MySQL 自己的限制(secure_file_priv)→ 操作系统文件权限(属主、权限位、acl、挂载选项)→ 安全模块(SELinux / AppArmor)。任何一环卡住,你看到的都是同一个报错。
3. 一套完整的排查流程:从 MySQL 到操作系统
3.1 先查 secure_file_priv,别让 MySQL 自己把路堵死
第一步永远是进 MySQL 看看这个变量当前是什么:
SHOW VARIABLES LIKE 'secure_file_priv';结果有三种含义:
| 值 | 含义 |
|---|---|
| NULL | 未设置,等同于禁止INTO OUTFILE和LOAD DATA INFILE写文件 |
| 空字符串 | 不限制路径,任何位置都能写(高危,不建议生产环境这样用) |
/path/to/dir | 只允许在这个目录下读写 |
如果你写的路径不在这个限制目录里,MySQL 会在任何系统调用之前直接拒绝。即使你chmod 777、关了 SELinux 也没用,因为根本没走到那一步。
修改方式是编辑 MySQL 配置文件,比如/etc/mysql/mysql.conf.d/mysqld.cnf或/etc/my.cnf,在[mysqld]段写入:
[mysqld] secure_file_priv=/var/lib/mysql-files然后重启 MySQL 服务:
systemctl restart mysql # 或者你的发行版对应的 service mysql restart这里建议不要把secure_file_priv设为空字符串来图省事。等于把 MySQL 的文件读写限制全部打开,一旦数据库被注入或账号被拖库,攻击者可以直接把数据写进 web 目录,或者把系统文件读走。技术债可以欠,这种口子尽量别开。
3.2 第二步:以 mysql 用户做一次真实落盘测试
查询完变量,确认目标路径在允许范围内,接下来才轮到操作系统权限。这时候最好的办法,是直接模拟 mysqld 用户的身份去目标目录写一个文件:
sudo -u mysql touch /var/lib/mysql-files/test.txt && echo "ok"如果这条命令提示Permission denied,说明系统层就没给 mysql 用户写这个目录的权利。查看目录属主和权限:
ls -ld /var/lib/mysql-files理想状态应该是目录所有者为mysql:mysql,权限是750或700。如果是root:root且只有755,mysql 用户只有读和进入权限,没有写权限。
修正方法:
sudo mkdir -p /var/lib/mysql-files sudo chown mysql:mysql /var/lib/mysql-files sudo chmod 750 /var/lib/mysql-files注意,如果你改变了secure_file_priv指向的目录,还要重新确认目录的权限和 SELinux 标签,二者缺一不可。
3.3 第三步:检查 SELinux 和 AppArmor 是否拦路
先看 SELinux 状态:
getenforce如果是Enforcing或Permissive,就要进一步看拦截记录:
ausearch -m avc -ts recent | grep mysqld常见的结果是大量avc: denied { write } ... comm="mysqld"。如果确认是 SELinux 拦截,有两个方向:
临时验证:setenforce 0再执行导出一条,如果能成功,基本锁定了 SELinux。验证完记得setenforce 1开回来。
长期修复:给导出目录打一个正确的 SELinux 类型,例如:
sudo semanage fcontext -a -t mysqld_db_t "/var/lib/mysql-files(/.*)?" sudo restorecon -Rv /var/lib/mysql-files不同版本和发行版里 mysqld 允许写文件的目标类型不一样,有的用mysqld_db_t,有的用mysqld_etc_t,有的场景需要开布尔量。可以先试试:
sudo setsebool -P mysqld_export_all 1如果是 Ubuntu / Debian 系,优先怀疑 AppArmor:
sudo aa-status | grep mysqld dmesg | grep apparmor | tail -20一旦看到与 mysqld 相关的apparmor="DENIED",去编辑对应的配置文件,常见的是/etc/apparmor.d/local/usr.sbin.mysqld,加上你自定义的导出目录:
/var/lib/mysql-files/ rw, /var/lib/mysql-files/** rw,然后重载:
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.mysqld3.4 进阶:用 strace 一次性看清楚卡在哪一步
如果你遇到的场景比较诡异,比如已经确认路径在secure_file_priv之内、目录权限也给了、SELinux 也关了,但依旧报错,那就直接上strace抓系统调用:
# 先找到 mysqld 的 PID pgrep mysqld # 追踪 open / openat 相关调用 sudo strace -f -e trace=open,openat,newfstatat -p <mysqld_pid>然后在另一个终端执行那条导出 SQL,观察 strace 输出里EPERM、EACCES出现在哪个文件路径上。这段输出能直接告诉你,到底是路径不对、目录权限失败、还是文件已存在且不可写。strace 是最后手段,但也是最不容易误判的手段。
4. 修复方案怎么选:目录、配置还是换个姿势导出
4.1 常规推荐:专门建一个 MySQL 可写目录
在实际项目里,我最推荐的方式不是把/tmp改得乱七八糟,而是给 MySQL 建一个独立目录,既作为secure_file_priv的限制路径,又作为所有导出操作的统一出口。
比如在/var/lib/mysql-files下建目录,并把所有权交给 mysql 用户,再在配置里固定下来。这样你再执行:
SELECT * FROM order_info INTO OUTFILE '/var/lib/mysql-files/export/order_20240101.csv';基本不会再看到错误 13。后面要清理或备份这批导出文件,也只需要关注这一个目录。注意每次建子目录时,子目录的属主同样要保证是 mysql,否则导出到子目录照样被拒。
4.2 思路转换:用客户端导出,绕开服务端写文件
INTO OUTFILE是服务端写文件,所以才会牵扯目录权限。如果你的需求只是“把查询结果导出成文件”,其实完全可以用客户端工具来做:
mysql -h 127.0.0.1 -u某账号 -p 某database \ -e "SELECT id, order_no, amount FROM order_info" \ --batch --raw > /home/某用户/exports/order_export.csv这里--batch让输出按制表符分割,--raw不转义特殊字符,重定向到本地文件是 shell 用户的权限,跟 mysqld 进程一点关系没有。缺点是格式控制不如INTO OUTFILE灵活,比如要控制引号、换行符,得靠后续处理。
这种方式特别适合临时排查问题:不用改任何配置、不用重启数据库、不用担心 SELinux,对生产环境最友好。
4.3 临时货还是长期修复,心态上要分清楚
如果是生产环境,我不建议为了一个导出需求就去关 SELinux、改secure_file_priv为全局可写、或者把目录直接chmod 777。这种操作本质上是把整个数据库的安全边界放宽,一次看似“小改动”,后面可能成为数据泄露的入口。
优先级应该是:
- 优先修改目标路径,让导出落到 MySQL 已经允许的目录;
- 实在改不了路径,再调整
secure_file_priv或目录属主权限,改完记录变更,知会相关同学; - 只在临时排查时用
setenforce 0验证 SELinux 影响,用完马上恢复; - 能走客户端导出的场景,不碰服务端配置。
注意:如果 MySQL 所在的目录属于某种自动化部署工具或容器管理平台,每次重启可能把
/var/lib/mysql-files这类目录重建或重置权限。遇到“今天改好了,明天又报错”的情况,先去查部署流程里有没有把目录和配置重复覆盖。
5. 几个最容易混淆的同类场景
5.1 mysqldump 重定向到文件,报错的是 shell 用户
有同学会把这个错误和另一个现象搞混:我用mysqldump导出,明明权限也给了,还是提示无法写入。
区别在于mysqldump本身是客户端程序。你执行mysqldump ... > /backup/db.sql,写入/backup/db.sql的是你当前登录的 shell 用户,不是 mysql 用户。如果你用 root 执行,文件可能只属于 root,后续其他用户就没法覆盖。这类报错的排查对象是当前登录用户和目标目录,而不是 mysqld 进程。
5.2 开启通用日志或慢查询日志时的同类报错
在配置里开启general_log_file或者slow_query_log_file,如果指定的日志目录和文件无法写入,MySQL 服务甚至可能无法启动,或者在启动后日志里不断刷File ... (Errcode: 13)。
排查方式和普通文件写入一样:先看日志文件所在目录的属主是不是 mysql,再确认 SELinux 类型。不知道为什么有的同学喜欢把日志放在/home/某用户/logs下,而/home目录往往有非常严格的访问限制,mysql 用户根本进不去。
5.3 LOAD DATA INFILE 的读取权限也受同样限制
LOAD DATA INFILE是从服务端读文件,同样受secure_file_priv影响,而且文件必须对 mysql 用户可读。如果你要读取一个 root 创建的 CSV,却只给了644权限,mysql 用户能读;如果文件权限只有600,那就会遇到Can't open file ... (Errcode: 13)。
如果用的是LOAD DATA LOCAL INFILE,那是客户端读文件后传给服务端,限制逻辑完全不同。遇到读取类报错时,先确认你用的是LOCAL还是非LOCAL,别拿着查服务端权限的方式去查客户端问题。
6. 高频问题速查表与排障顺序
| 症状 | 最可能原因 | 检查命令 | 修复建议 |
|---|---|---|---|
| 导出自定义路径直接报 13 | secure_file_priv限制 | SHOW VARIABLES LIKE 'secure_file_priv' | 改用允许目录,或修改配置并重启 |
| 目标目录已建好、属主正确,但仍报错 | SELinux 标签不对 | getenforce/ausearch -m avc | 改标签(semanage fcontext)或调布尔量 |
| Ubuntu 上报错,目录权限没问题 | AppArmor 限制 | aa-status/ `dmesg | grep apparmor` |
| 昨天还好好的,今天突然报错 | 目录被部署流程重置 | 检查自动部署脚本和 chown 时机 | 把目录创建和授权固化到部署流程 |
写入/tmp都失败 | 挂载选项或目录权限异常 | `mount | grep /tmp/ls -ld /tmp` |
用 root 执行mysqldump重定向失败 | 当前 shell 用户无权写目标位置 | touch /目标路径/test | 以正确用户执行,或修正目录权限 |
我个人的排查顺序是固定的一套,已经形成肌肉记忆:
1. SHOW VARIABLES LIKE 'secure_file_priv' 2. 确认 mysqld 进程用户 3. sudo -u mysql touch 到目标目录 4. 看 SELinux/AppArmor 状态和日志 5. 还不行就 strace这套流程下来,绝大多数报错都能在十分钟内定位。真正耗时间的往往不是某一步卡住,而是跳着排查、反复试错。
7. 最后分享几个亲测有效的处理习惯
第一次踩这个坑之后,我在服务器上养成了几个小习惯,现在基本不会再被这个问题纠缠。
第一,凡是要给 MySQL 做导出或导入,先在规划阶段就确定目录归属。不要在 SQL 里随手写一个/home/某个用户或/root下的路径,然后回头去补权限。正确做法是先建目录、授权、配置,再写业务代码。目录、配置、账号三者的变更应该一起提交到变更流程里。
第二,遇到权限报错时,先ls -ld看属主和权限位,再sudo -u mysql touch一次,这一步能过滤掉八成问题。如果这两步都通过了但 MySQL 还是报错,立刻去查 SELinux/AppArmor,别盯着chmod 777纠结。
第三,临时验证和长期修复要分开。验证时可以用setenforce 0或者临时改secure_file_priv,但要把“我临时改过哪几项”记录下来,验证结束后逐项恢复。否则过几天系统重启,配置残留了一个宽松值,数据库安全等级莫名其妙就降了。
第四,不要小看 AppArmor。不少 MySQL 部署在 Ubuntu 上,很多人的做法是直接卸载 AppArmor 或者把配置文件删掉。如果没做好记录,后续审计时根本说不清这是有意为之还是误操作。我倾向保留配置,只在配置文件里加上精确目录,坏影响远小于直接关闭整个安全模块。
写文件的权限问题,看起来是个小错误,背后牵出的其实是 Linux 权限模型、数据库进程身份、安全模块拦截这一连串概念。把这条链路的思路理清,以后不管是 MySQL 还是其他服务,再遇到 Errcode 13 都能多一份笃定。