尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP 8.3 数据库备份失败怎么排查

PHP 8.3 数据库备份失败怎么排查 前言用 PHP 定时脚本调mysqldump做数据库备份出问题时最迷惑的地方是它不报错。日志里没有异常脚本也「跑完了」直到某天真的要恢复数据才发现备份文件是 0 字节或者从三个月前就再没更新过。这类静默失败通常来自三个层面。调用层面exec()的返回值被丢掉、disable_functions把函数禁掉、FPM 进程的PATH里根本没有mysqldump。执行层面管道把mysqldump的退出码吞掉了、PHP 内存被 SQL 文本撑爆、磁盘写满。权限层面备份账号缺少必要的权限或者大表上的锁把备份拖到超时。本文按「先拿证据 → 查调用 → 查权限 → 查环境 → 验证产物」的顺序走一遍完整排查流程最后给出一份本身就会自检、失败会报警的备份脚本。示例环境为PHP 8.3的 CLI 模式mysqldump与MySQL 8.0的命令行客户端。一、第一件事把三样证据收集齐排查任何「静默失败」先固定收集这三样东西缺一样都会走弯路。1. 退出码。命令行的退出码是有统一约定的退出码含义常见原因0成功——1一般错误参数非法、连接被断开2用法/语义错误库名或表名不存在126命令不可执行目标文件没有执行权限127命令不存在PATH里找不到mysqldump128 N被信号 N 杀死1371289通常是被 OOM 杀掉2. 标准错误。mysqldump的报错信息全部写在 stderr绝不会出现在 stdout 里。只捕获 stdout 的人会看到一个「成功」的空文件。3. 产物的物理属性。文件是否存在、大小是多少、开头几行是不是可读的 SQL、修改时间是什么时候。这四个数字能立刻区分「脚本没跑」和「跑了但没写出东西」。# 手工复现一次不带任何 PHP 干扰先确认命令本身是好的 /usr/bin/mysqldump --version /usr/bin/mysqldump --defaults-extra-file/etc/my-backup.cnf \ --single-transaction --no-tablespaces --set-gtid-purgedOFF \ shop /tmp/probe.sql echo 退出码$? ls -l /tmp/probe.sql head -c 200 /tmp/probe.sql命令行能成功而 PHP 里失败问题一定在调用层命令行也失败问题在权限或数据库侧。二、调用层exec 的四个陷阱?php declare(strict_types1); // 反例集合不要照抄 $out shell_exec(mysqldump shop /tmp/a.sql); // 1. 只拿到 stdoutstderr 丢了 exec(mysqldump shop /tmp/a.sql); // 2. 退出码被丢弃 system(mysqldump shop /tmp/a.sql); // 3. 输出直接打到响应体 $r exec(mysqldump shop /tmp/a.sql 21; echo $?); // 4. 危险写法见下四条对应四个真实问题shell_exec只返回 stdout 字符串而且是把全部输出读进内存的。备份文件一大PHP 进程先被自己的内存限制干掉。同时 stderr 完全丢失你永远看不到「Access denied」。exec不传第二个参数就没有输出不传第三个参数就没有退出码。不检查退出码的备份脚本等于没有备份。system会把输出直接写到当前输出流。在 Web 请求里这意味着整个 SQL 文件流进了 HTTP 响应体——既泄漏数据又可能把响应头撑爆。备份必须走 CLI。21; echo $?这种拼接会让返回值变成「最后一行」一旦 SQL 输出混进来解析就错乱了。推荐的调用方式是proc_open它把 stdout、stderr、退出码三者分开不经过 shell 解析也不占用 PHP 内存。?php declare(strict_types1); /** * 执行外部命令返回「退出码 stderr stdout」。 * * return array{code: int, stderr: string, stdout: string} */ function runCommand(array $argv, ?string $stdoutFile null): array { $descriptors [ 1 $stdoutFile null ? [pipe, w] : [file, $stdoutFile, wb], // 直接落盘不占 PHP 内存 2 [pipe, w], ]; // 用数组形式传参完全绕开 shell不会被文件名里的空格和引号坑到 $proc proc_open($argv, $descriptors, $pipes); if (!is_resource($proc)) { return [code -1, stderr proc_open 调用失败, stdout ]; } $stdout ; if ($stdoutFile null isset($pipes[1])) { $stdout (string) stream_get_contents($pipes[1]); fclose($pipes[1]); } $stderr ; if (isset($pipes[2])) { $stderr (string) stream_get_contents($pipes[2]); fclose($pipes[2]); } $code proc_close($proc); return [code $code, stderr trim($stderr), stdout $stdout]; }顺带确认一下exec类函数有没有被禁用——共享主机与部分容器镜像里这是默认配置报错信息是Call to undefined function exec()看着像扩展没装其实是被disable_functions屏蔽php -r var_dump(function_exists(proc_open), ini_get(disable_functions));三、权限备份账号需要什么很多团队图省事直接用应用账号做备份或者反过来给备份账号开了ALL PRIVILEGES。两者都不合适。备份账号需要的权限取决于参数参数作用相关权限/注意点--single-transaction用一致性快照读不锁表只对 InnoDB 有效--no-tablespaces跳过查询表空间信息不加它时MySQL 5.7.31/8.0 需要PROCESS权限--routines --triggers --events连存储过程、触发器、事件一起导出需要相应的读取权限--set-gtid-purgedOFF不写入 GTID 信息目标实例未启用 GTID 时必须加否则恢复报错只给这些就够GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT, PROCESS ON shop.* TO backup127.0.0.1; FLUSH PRIVILEGES;不要把密码写在命令行里。一方面它会出现在ps输出里同机器上的任何用户都能看到另一方面 MySQL 客户端本身会往 stderr 打一句Using a password on the command line interface can be insecure警告。正确做法是写一个只含[client]段的配置文件权限设成600再用--defaults-extra-file引用; /etc/my-backup.cnf 权限必须是 0600属主是运行备份的用户 [client] user backup password 你的密码 host 127.0.0.1 default-character-set utf8mb4mysqldump --defaults-extra-file/etc/my-backup.cnf shop文件权限不对时mysqldump会直接报Could not open required defaults file这本身就是一条「排查到一半发现是权限问题」的常见弯路。四、环境内存、超时、磁盘、管道内存。用--result-file让mysqldump自己写文件SQL 文本完全不经过 PHP 进程这是最省心的一招。如果用 shell 重定向则由 shell 负责写入同样不进 PHP 内存——真正会出事的是shell_exec那种把输出捕获回 PHP 的写法。超时。CLI 下max_execution_time默认是 0不限制但如果你把脚本挂在 Web 请求或队列任务里跑就会撞上max_execution_time、FPM 的request_terminate_timeout、以及 Nginx 的fastcgi_read_timeout。备份必须在 CLI 或独立任务进程里执行。磁盘。备份写到一半磁盘满mysqldump会报Got error 28 when writing to file退出码非 0但已经有一部分数据落了盘——一个「看起来有内容、实际不完整」的文件是最危险的产物。备份前后各取一次磁盘剩余空间并记录是成本极低的保险。管道。这一条是静默失败的经典来源# ❌ 退出码是 gzip 的mysqldump 失败了你也看不出来 mysqldump --defaults-extra-file/etc/my-backup.cnf shop | gzip /backup/shop.sql.gz echo 退出码$? # 恒为 gzip 的状态 # ✅ 方案一bash 打开 pipefail任一环节失败整条管道就失败 set -o pipefail mysqldump --defaults-extra-file/etc/my-backup.cnf shop | gzip /backup/shop.sql.gz # ✅ 方案二不用管道先落盘再单独压缩每一步都能单独检查 mysqldump --defaults-extra-file/etc/my-backup.cnf --result-file/backup/shop.sql shop gzip -f /backup/shop.sql方案二虽然多占一次磁盘 IO但每一步的退出码都是自己的排查成本最低。备份这种「平时没人看、出事才用」的东西可观测性比省一点 IO 重要得多。五、完整可运行的备份脚本这份脚本把「执行、校验、报警」串在一起任何一个环节不通过都不会留下一个看似正常的假备份。?php declare(strict_types1); // 最低 PHP 8.0建议 CLI 下运行php backup.php // 需要在 PHP 中启用 proc_openCLI 一般不限制 const BACKUP_DIR /backup/mysql; const DEFAULTS_CFG /etc/my-backup.cnf; const DATABASE shop; const MYSQLDUMP /usr/bin/mysqldump; // 用 absolute 路径不依赖 PATH const MIN_SIZE 1024; // 小于 1KB 视为无效备份 function logLine(string $msg): void { fwrite(STDERR, date(Y-m-d H:i:s) . . $msg . PHP_EOL); } $stamp date(Ymd_His); $file BACKUP_DIR . / . DATABASE . _ . $stamp . .sql; if (!is_dir(BACKUP_DIR) || !is_writable(BACKUP_DIR)) { logLine(备份目录不存在或不可写 . BACKUP_DIR); exit(1); } $free disk_free_space(BACKUP_DIR); if ($free false) { logLine(无法读取磁盘剩余空间); exit(1); } logLine(sprintf(备份前剩余空间%.2f GB, $free / 1024 / 1024 / 1024)); $argvList [ MYSQLDUMP, --defaults-extra-file . DEFAULTS_CFG, --single-transaction, --quick, --routines, --triggers, --events, --no-tablespaces, --set-gtid-purgedOFF, --default-character-setutf8mb4, --result-file . $file, DATABASE, ]; $result runCommand($argvList); // runCommand 见上文第二节 if ($result[code] ! 0) { logLine(sprintf(备份失败退出码 %dstderr%s, $result[code], $result[stderr])); unlink($file); // 不完整的产物必须删掉别留下假备份 exit(1); } // ---- 产物校验这是最容易被跳过、也最值钱的一步 ---- clearstatcache(true, $file); $size is_file($file) ? filesize($file) : 0; if ($size MIN_SIZE) { logLine(sprintf(备份文件过小%d 字节判定为无效已删除, $size)); unlink($file); exit(1); } $head (string) file_get_contents($file, false, null, 0, 256); if (stripos($head, MySQL dump) false) { logLine(备份文件头部不是预期的 dump 头可能被截断); unlink($file); exit(1); } logLine(sprintf(备份成功%s%d 字节, $file, $size)); exit(0); function runCommand(array $argv): array { $proc proc_open($argv, [1 [pipe, w], 2 [pipe, w]], $pipes); if (!is_resource($proc)) { return [code -1, stderr proc_open 失败, stdout ]; } $stdout (string) stream_get_contents($pipes[1]); fclose($pipes[1]); $stderr (string) stream_get_contents($pipes[2]); fclose($pipes[2]); return [code proc_close($proc), stderr trim($stderr), stdout $stdout]; }再补一条同样重要的事定期做恢复演练。备份文件能不能用唯一有说服力的证据是把它恢复到一台新实例上并跑通业务查询。没有演练过的备份只能算「一份体积不断增长的文件」。常见坑点1. 只看「脚本没抛异常」就认为备份成功❌exec($cmd);后面什么都不判断 —— 命令不存在、权限不足、磁盘满统统静默。 ✅ 检查退出码必须是 0 校验产物文件大小与头部。2. FPM 进程的 PATH 里没有 mysqldump❌ 登录 shell 里which mysqldump能找到就跑在 Web 请求里直接调mysqldump结果退出码 127。 ✅ 用绝对路径或者把 mysqldump 的路径写进配置项同时确认PATH环境变量的实际取值。3. 管道吞掉退出码❌mysqldump ... | gzip x.gz之后echo $?—— 拿到的是gzip的退出码恒为 0。 ✅ 用set -o pipefail或者分两步落盘再压缩。4. 用system()在 Web 请求里执行备份❌ SQL 内容直接进了 HTTP 响应体既泄漏整库数据又可能把 PHP 内存打满。 ✅ 备份只在 CLI 或独立任务进程执行Web 侧只负责投递任务。5. 用应用账号做备份导致导出结果缺对象❌ 只导出了表数据视图、存储过程、触发器全丢 —— 因为账号没有SHOW VIEW、TRIGGER权限而mysqldump对这些对象失败时的提示容易被忽略。 ✅ 单独建备份账号按第三节的清单授权并且显式加上--routines --triggers --events。6. 把密码写在命令行参数里❌mysqldump -u root -pSecret ...——ps一查就能看到明文密码。 ✅ 用--defaults-extra-file引用权限为 0600 的配置文件顺带也消除了客户端的明文密码警告。7. 恢复时才发现的 GTID 与 DEFINER 问题❌ 把启用 GTID 的实例导出恢复到未启用 GTID 的实例报错与 GTID 相关或者导出文件里带DEFINERolduser%恢复到没有该用户的实例时报 1449 错误。 ✅ 导出时加--set-gtid-purgedOFF恢复前先确认DEFINER对应的账号存在或改用忽略 DEFINER 的方式导入。8.--single-transaction用在 MyISAM 表上❌ 以为加了--single-transaction就一定有全局一致性快照 —— 它只对 InnoDB 生效混有 MyISAM 表时仍然会读到不一致的状态。 ✅ 确认目标库的表引擎有 MyISAM 表时要么接受锁表要么先把表迁移到 InnoDB。总结排查层看什么典型现象证据退出码 stderr 产物属性文件 0 字节但脚本「跑完了」调用proc_open分离三路输出exec丢退出码、system污染响应权限备份账号的最小授权视图/触发器丢失、需PROCESS权限环境内存、超时、磁盘、管道error 28、退出码被 gzip 顶替产物大小与头部校验半截文件看起来「有内容」兜底定期恢复演练真出事时才发现备份不可用数据库备份失败之所以难查是因为它的默认形态是「静默成功」。把退出码、stderr、产物校验这三道关卡补上再加上一次真实的恢复演练这个环节才算真正闭环。
返回列表