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 里找不到 mysqldump |

|---------|----------|-----------------------|
| 128 + N | 被信号 N 杀死 | 137(128+9)通常是被 OOM 杀掉 |

2. 标准错误。 mysqldump 的报错信息全部写在 stderr,绝不会出现在 stdout 里。只捕获 stdout 的人会看到一个「成功」的空文件。

3. 产物的物理属性。 文件是否存在、大小是多少、开头几行是不是可读的 SQL、修改时间是什么时候。这四个数字能立刻区分「脚本没跑」和「跑了但没写出东西」。

bash 复制代码
# 手工复现一次,不带任何 PHP 干扰,先确认命令本身是好的

/usr/bin/mysqldump --version

/usr/bin/mysqldump --defaults-extra-file=/etc/my-backup.cnf \

  --single-transaction --no-tablespaces --set-gtid-purged=OFF \

  shop > /tmp/probe.sql

echo "退出码=$?"

ls -l /tmp/probe.sql

head -c 200 /tmp/probe.sql

命令行能成功而 PHP 里失败,问题一定在调用层;命令行也失败,问题在权限或数据库侧。

二、调用层:exec 的四个陷阱

php 复制代码
<?php

declare(strict_types=1);



// 反例集合,不要照抄

$out = shell_exec('mysqldump shop > /tmp/a.sql');        // 1. 只拿到 stdout,stderr 丢了

exec('mysqldump shop > /tmp/a.sql');                     // 2. 退出码被丢弃

system('mysqldump shop > /tmp/a.sql');                   // 3. 输出直接打到响应体

$r = exec("mysqldump shop > /tmp/a.sql 2>&1; echo $?");  // 4. 危险写法,见下

四条对应四个真实问题:

  1. shell_exec 只返回 stdout 字符串 ,而且是把全部输出读进内存的。备份文件一大,PHP 进程先被自己的内存限制干掉。同时 stderr 完全丢失,你永远看不到「Access denied」。
  2. exec 不传第二个参数就没有输出,不传第三个参数就没有退出码。不检查退出码的备份脚本,等于没有备份。
  3. system 会把输出直接写到当前输出流。在 Web 请求里,这意味着整个 SQL 文件流进了 HTTP 响应体------既泄漏数据,又可能把响应头撑爆。备份必须走 CLI。
  4. 2>&1; echo $? 这种拼接会让返回值变成「最后一行」,一旦 SQL 输出混进来,解析就错乱了。

推荐的调用方式是 proc_open:它把 stdout、stderr、退出码三者分开,不经过 shell 解析,也不占用 PHP 内存。

php 复制代码
<?php

declare(strict_types=1);



/**

 * 执行外部命令,返回「退出码 + 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 屏蔽:

bash 复制代码
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-purged=OFF | 不写入 GTID 信息 | 目标实例未启用 GTID 时必须加,否则恢复报错 |

只给这些就够:

sql 复制代码
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT, PROCESS

  ON shop.* TO 'backup'@'127.0.0.1';

FLUSH PRIVILEGES;

不要把密码写在命令行里。 一方面它会出现在 ps 输出里,同机器上的任何用户都能看到;另一方面 MySQL 客户端本身会往 stderr 打一句 Using a password on the command line interface can be insecure 警告。正确做法是写一个只含 [client] 段的配置文件,权限设成 600,再用 --defaults-extra-file= 引用:

ini 复制代码
; /etc/my-backup.cnf  权限必须是 0600,属主是运行备份的用户

[client]

user = backup

password = 你的密码

host = 127.0.0.1

default-character-set = utf8mb4
bash 复制代码
mysqldump --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,但已经有一部分数据落了盘------一个「看起来有内容、实际不完整」的文件是最危险的产物。备份前后各取一次磁盘剩余空间并记录,是成本极低的保险。

管道。 这一条是静默失败的经典来源:

bash 复制代码
# ❌ 退出码是 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 复制代码
<?php

declare(strict_types=1);



// 最低 PHP 8.0,建议 CLI 下运行:php backup.php

// 需要在 PHP 中启用 proc_open(CLI 一般不限制)



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-purged=OFF',

    '--default-character-set=utf8mb4',

    '--result-file=' . $file,

    DATABASE,

];



$result = runCommand($argvList);   // runCommand 见上文第二节



if ($result['code'] !== 0) {

    logLine(sprintf('备份失败,退出码 %d,stderr:%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 相关;或者导出文件里带 DEFINER='olduser'@'%',恢复到没有该用户的实例时报 1449 错误。 ✅ 导出时加 --set-gtid-purged=OFF;恢复前先确认 DEFINER 对应的账号存在,或改用忽略 DEFINER 的方式导入。

8. --single-transaction 用在 MyISAM 表上

❌ 以为加了 --single-transaction 就一定有全局一致性快照 ------ 它只对 InnoDB 生效,混有 MyISAM 表时仍然会读到不一致的状态。 ✅ 确认目标库的表引擎;有 MyISAM 表时要么接受锁表,要么先把表迁移到 InnoDB。

总结

排查层 看什么 典型现象

|----|---------------------|-----------------|
| 证据 | 退出码 + stderr + 产物属性 | 文件 0 字节但脚本「跑完了」 |

|----|--------------------|---------------------------|
| 调用 | proc_open 分离三路输出 | exec 丢退出码、system 污染响应 |

|----|-----------|-------------------------|
| 权限 | 备份账号的最小授权 | 视图/触发器丢失、需 PROCESS 权限 |

|----|-------------|-------------------------|
| 环境 | 内存、超时、磁盘、管道 | error 28、退出码被 gzip 顶替 |

|----|---------|--------------|
| 产物 | 大小与头部校验 | 半截文件看起来「有内容」 |

|----|--------|--------------|
| 兜底 | 定期恢复演练 | 真出事时才发现备份不可用 |

数据库备份失败之所以难查,是因为它的默认形态是「静默成功」。把退出码、stderr、产物校验这三道关卡补上,再加上一次真实的恢复演练,这个环节才算真正闭环。

相关推荐
bnmoel1 小时前
C++ 基础入门篇(六)类和对象-第三篇:运算符重载
开发语言·c++·算法·语法·运算符重载
CRMEB系统商城1 小时前
CRMEB开源商城系统(PHP)v8.0,打造全新AI二开商城底座!
开发语言·人工智能·小程序·开源·php
宇卿.1 小时前
C#字段、属性、索引器、常量
开发语言·计算机视觉·c#
菜鸟~noob2331 小时前
【电子战】第31篇:3D AOA 定位——方位+俯仰联合
开发语言·matlab·3d
小七在进步1 小时前
初识C++中的模板
java·开发语言·c++
程序员Linc1 小时前
ONNXOCRSharp 开源手记 02|v0.4.0 PDF识别
开发语言·pdf·c#·ocr·onnxocrsharp
回眸&啤酒鸭2 小时前
【回眸】HireKey 智能招聘全流程应用场景
开发语言·人工智能·python
云川之下2 小时前
【存储】搭建nfs 存储示例
开发语言·php
天空属于哈夫克32 小时前
企业微信消息回调处理:如何避免慢查询拖垮整个服务?
数据库·php·企业微信