// +---------------------------------------------------------------------- declare (strict_types = 1); namespace ywxapp\library; use think\facade\Db; /** * 数据库备份 / 还原(纯 PHP 实现) * * 运行环境禁用了 exec / shell_exec,无法调用 mysqldump、mysql 命令行, * 因此这里全部走 PDO: * 备份:SHOW CREATE TABLE 导出结构 + 分块 SELECT 生成 INSERT,边生成边写文件(支持分卷) * 还原:流式读取 .sql,按分号切句逐条执行 * 优化:OPTIMIZE TABLE * * 备份落地结构:runtime/backup/<备份名>/<备份名>-1.sql、<备份名>-2.sql … * * @author ywxapp */ class DbBackup { /** * 备份根目录 */ protected string $dir; /** * 单卷最大字节数 */ protected int $partSize; /** * 每次读取的数据行数 */ protected int $chunk; /** * @param string|null $dir 备份根目录,默认 runtime/backup * @param int $partSize 单卷字节数,默认 2MB * @param int $chunk 每次取数行数 */ public function __construct(?string $dir = null, int $partSize = 2097152, int $chunk = 500) { $this->dir = $dir ?: runtime_path() . 'backup'; $this->partSize = max(65536, $partSize); $this->chunk = max(50, $chunk); } /** * 备份根目录 */ public function dir(): string { return $this->dir; } /** * 数据表清单(不含视图) * * @return array{name:string,rows:int,size:int,comment:string,engine:string}[] */ public function tables(): array { $prefix = $this->prefix(); try { $rows = Db::query('SHOW TABLE STATUS'); } catch (\Throwable $e) { return []; } $list = []; foreach ($rows as $row) { $row = array_change_key_case($row, CASE_LOWER); $list[] = [ 'name' => $row['name'] ?? '', 'rows' => (int) ($row['rows'] ?? 0), 'size' => (int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0)), 'comment' => (string) ($row['comment'] ?? ''), 'engine' => (string) ($row['engine'] ?? ''), 'prefix' => $prefix, ]; } return $list; } /** * 执行备份 * * @param array $tables 表名(不带前缀也可);空数组=全部表 * @param string|null $name 备份名,默认按时间生成 * @return array{name:string,files:array,size:int,tables:int,rows:int} */ public function backup(array $tables = [], ?string $name = null): array { $all = $this->tables(); $map = []; foreach ($all as $t) { $map[$t['name']] = $t; } // 允许用户传不带前缀的表名 $chosen = []; foreach ($tables as $t) { $t = (string) $t; if (isset($map[$t])) { $chosen[] = $t; } else { $full = $this->prefix() . $t; if (isset($map[$full])) { $chosen[] = $full; } } } if ($chosen === []) { $chosen = array_column($all, 'name'); } $name = $name ?: date('Ymd_His') . '_' . substr(md5(uniqid('', true)), 0, 6); $name = preg_replace('/[^a-zA-Z0-9_\-]/', '', $name) ?: date('Ymd_His'); $dir = $this->dir . DIRECTORY_SEPARATOR . $name; if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { throw new \RuntimeException('备份目录不可写:' . $dir); } $files = []; $part = 1; $size = 0; $rows = 0; $fh = null; $cur = ''; $open = function () use (&$fh, &$part, &$cur, &$files, $dir, $name): void { $cur = $dir . DIRECTORY_SEPARATOR . $name . '-' . $part . '.sql'; $fh = fopen($cur, 'wb'); if ($fh === false) { throw new \RuntimeException('无法写入备份文件:' . $cur); } fwrite($fh, $this->header($part)); $files[] = basename($cur); }; // 写入并保证分卷:超出 partSize 就换卷 $write = function (string $sql) use (&$fh, &$part, &$size, $open): void { if ($fh === null) { $open(); } $len = strlen($sql); if ($this->currentSize($fh) > 0 && $this->currentSize($fh) + $len > $this->partSize) { fclose($fh); $part++; $open(); } fwrite($fh, $sql); }; try { foreach ($chosen as $table) { // 1) 表结构 $create = Db::query('SHOW CREATE TABLE `' . $table . '`'); $create = array_change_key_case($create[0] ?? [], CASE_LOWER); if (empty($create['create table'])) { continue; } $write("-- -----------------------------\n"); $write("-- 表结构:`{$table}`\n"); $write("-- -----------------------------\n"); $write("DROP TABLE IF EXISTS `{$table}`;\n"); $write($create['create table'] . ";\n\n"); // 2) 数据:分块读取,避免大表撑爆内存 $offset = 0; $hasField = false; while (true) { $data = Db::query('SELECT * FROM `' . $table . '` LIMIT ' . $offset . ',' . $this->chunk); if (empty($data)) { break; } if (!$hasField) { $write("-- -----------------------------\n"); $write("-- 数据:`{$table}`\n"); $write("-- -----------------------------\n"); $hasField = true; } $values = []; foreach ($data as $row) { $vals = []; foreach ($row as $v) { $vals[] = $this->quote($v); } $values[] = '(' . implode(', ', $vals) . ')'; } $write("INSERT INTO `{$table}` VALUES\n" . implode(",\n", $values) . ";\n"); $rows += count($data); $offset += $this->chunk; if (count($data) < $this->chunk) { break; } } $write("\n"); } $write("SET FOREIGN_KEY_CHECKS=1;\n"); $write("-- 备份结束 " . date('Y-m-d H:i:s') . "\n"); } finally { if (is_resource($fh)) { fclose($fh); } } foreach ($files as $f) { $size += (int) filesize($dir . DIRECTORY_SEPARATOR . $f); } return [ 'name' => $name, 'files' => $files, 'size' => $size, 'tables' => count($chosen), 'rows' => $rows, ]; } /** * 备份历史(按备份名聚合,多分卷合并为一条) * * @return array{name:string,files:array,size:int,time:int,parts:int}[] */ public function backups(): array { if (!is_dir($this->dir)) { return []; } $list = []; foreach (glob($this->dir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) { $name = basename($dir); $files = glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: []; if (!$files) { continue; } sort($files); $size = 0; $time = 0; $names = []; foreach ($files as $f) { $size += (int) filesize($f); $time = max($time, (int) filemtime($f)); $names[] = basename($f); } $list[] = [ 'name' => $name, 'files' => $names, 'parts' => count($names), 'size' => $size, 'time' => $time, ]; } usort($list, fn($a, $b) => $b['time'] <=> $a['time']); return $list; } /** * 还原备份 * * @return array{files:int,sql:int} */ public function restore(string $name): array { $dir = $this->safePath($name); if ($dir === null || !is_dir($dir)) { throw new \RuntimeException('备份不存在:' . $name); } $files = glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: []; sort($files); $filesCount = 0; $sqlCount = 0; foreach ($files as $file) { $filesCount++; $sqlCount += $this->executeFile($file); } return ['files' => $filesCount, 'sql' => $sqlCount]; } /** * 删除备份 */ public function delete(string $name): bool { $dir = $this->safePath($name); if ($dir === null || !is_dir($dir)) { return false; } foreach (glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: [] as $f) { @unlink($f); } return @rmdir($dir); } /** * 备份文件绝对路径(下载用),非法名返回 null */ public function fileOf(string $name, string $file = ''): ?string { $dir = $this->safePath($name); if ($dir === null || !is_dir($dir)) { return null; } $files = glob($dir . DIRECTORY_SEPARATOR . '*.sql') ?: []; sort($files); if ($file !== '') { $target = $dir . DIRECTORY_SEPARATOR . basename($file); return is_file($target) ? $target : null; } return $files[0] ?? null; } /** * 优化表 * * @return array{table:string,result:string}[] */ public function optimize(array $tables): array { $out = []; foreach ($tables as $table) { $table = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $table) ?: ''; if ($table === '') { continue; } try { Db::execute('OPTIMIZE TABLE `' . $table . '`'); $out[] = ['table' => $table, 'result' => 'ok']; } catch (\Throwable $e) { $out[] = ['table' => $table, 'result' => $e->getMessage()]; } } return $out; } /* ============================ 内部方法 ============================ */ /** * 备份目录(防路径穿越:只允许 [a-zA-Z0-9_-]) */ protected function safePath(string $name): ?string { $name = basename($name); if ($name === '' || preg_match('/[^a-zA-Z0-9_\-]/', $name)) { return null; } return $this->dir . DIRECTORY_SEPARATOR . $name; } /** * 分卷文件头 */ protected function header(int $part): string { return "-- YwxApp 数据库备份\n" . "-- 生成时间:" . date('Y-m-d H:i:s') . "\n" . "-- 分卷:第 {$part} 卷\n" . "SET NAMES utf8mb4;\n" . "SET FOREIGN_KEY_CHECKS=0;\n" . "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n\n"; } /** * 当前分卷已写字节数 * * @param resource $fh */ protected function currentSize($fh): int { return is_resource($fh) ? (int) ftell($fh) : 0; } /** * 值转义:NULL / 数字 / 二进制 / 字符串 */ protected function quote(mixed $value): string { if ($value === null) { return 'NULL'; } if (is_bool($value)) { return $value ? '1' : '0'; } if (is_int($value) || is_float($value)) { return (string) $value; } $value = (string) $value; // 含控制字符的按二进制处理,避免写入时截断 if ($value !== '' && preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value)) { return '0x' . bin2hex($value); } return $this->pdoQuote($value); } protected function pdoQuote(string $value): string { try { $pdo = Db::connect()->getPdo(); $q = $pdo->quote($value); return $q === false ? "'" . addslashes($value) . "'" : $q; } catch (\Throwable $e) { return "'" . addslashes($value) . "'"; } } /** * 流式执行一个 SQL 文件 */ protected function executeFile(string $file): int { $fh = fopen($file, 'rb'); if ($fh === false) { throw new \RuntimeException('无法读取:' . $file); } $count = 0; $buffer = ''; while (($line = fgets($fh)) !== false) { $line = rtrim($line, "\r\n"); $trim = ltrim($line); if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#') || str_starts_with($trim, '/*')) { continue; } // 存储过程/触发器依赖 DELIMITER,本实现不支持,直接跳过声明行 if (str_starts_with(strtoupper($trim), 'DELIMITER')) { continue; } $buffer .= $line . "\n"; if (substr($trim, -1) === ';' && !str_ends_with($trim, '\\;')) { $sql = rtrim(rtrim($buffer), ';'); $buffer = ''; if ($sql === '') { continue; } Db::execute($sql); $count++; } } fclose($fh); return $count; } protected function prefix(): string { $prefix = (string) config('database.connections.mysql.prefix', ''); return $prefix; } }