PHP轻量脚本实现MySQL批量删表与DDL备份
简介:这是一款用PHP编写的MySQL数据库维护小工具,面向需要频繁清理开发和测试库的开发者与数据库管理员,核心解决按统一前缀批量删除数据表时逐表操作费时且易误删的问题。压缩包共3个文件,以php脚本为主,辅以htm说明文档和txt参考信息,整体仅3KB,轻量易部署,可在支持PHP的服务器环境中运行。现有202人学习关注,适合作为数据库清理与维护的入门参考。主脚本围绕数据库连接、按前缀检索表名并执行删除展开,说明文档则对参数配置、使用流程和安全注意事项做了交代,读者可借此理解前缀匹配批量删表的实现思路,并将其改造成符合自身项目规范的维护脚本;同时可参考其中的错误处理与备份提醒,降低误操作风险,提升测试环境重置和临时表清理的效率。
1. 一个没人敢点确认的删除按钮,被这份 PHP 源码拆成了四步
日志表按天建、导入任务按批次建,几百张tmp_开头的表堆在一个库里,DELETE 语句删不掉它们,只能一张张 DROP。用客户端右键删除,删到第四十张手就开始抖——前缀手滑多打一个字母,主表就跟着陪葬。这份 php 源码针对的就是这个具体场景:给它库名和一个前缀,它先扫描、再列清单、再要你确认、最后分批执行 DROP,中间顺手把SHOW CREATE TABLE的建表语句存成一份可回滚的 SQL。它既不是数据库中间件,也不是运维平台,就是一个入口文件加一个配置数组的轻量脚本,任何能跑 PHP 的环境都能塞进去。适合手上有几十上百张同前缀历史表、又不敢直接对information_schema动刀的人。
2. 技术选型与文件分工:五个 PHP 文件撑起一次可控的批量删表
这个项目的技术栈很简单:原生 PHP,不依赖任何框架和 Composer 包,数据库访问走 PDO,元数据查询走information_schema。看起来朴素,但三个选型都是有理由的。第一,用 PDO 而不是 mysqli,是因为 PDO 的异常模式能把「表不存在」「锁等待超时」「权限不足」统一成PDOException,删表这种高危操作,逐表捕获异常比中途崩掉强太多。第二,元数据不从SHOW TABLES来,而是查information_schema.TABLES,因为后者能一次带回表的行数、数据容量和创建时间,才能让操作者在确认前判断「这批表里有没有一张其实还在用的大表」。第三,不上框架,是因为这东西大概率要丢进一台已经跑着老业务的机器里临时执行,多一个依赖就多一次装不上。
2.1 目录结构:五个文件如何分工
一份按职责切开的写法大致是这样,源码包解压后基本就是这几层:
| 文件 | 职责 | 通常要改的地方 |
|---|---|---|
config.php | 连接参数、前缀白名单、批大小、备份目录 | 必改,数据库账号密码 |
index.php | Web 入口,渲染扫描结果和确认表单 | 一般不改进程逻辑 |
cli.php | 命令行入口,支持--dry-run与--confirm | 接 CI 时改参数 |
lib/Scanner.php | 按前缀查information_schema,返回待删清单 | 要加过滤条件时改这里 |
lib/Dropper.php | 分批执行 DROP、逐表异常隔离、写日志 | 要换成软删除时改这里 |
lib/Backup.php | 导出建表语句,生成回滚用的.sql | 要改成连数据一起备份时改这里 |
backup/ | 落地 DDL 备份和运行日志 | 注意别放在 Web 可访问目录 |
把入口和逻辑分开是这份源码最值得抄的一点。真正跑起来以后你会发现,出问题的从来不是「怎么执行 DROP」,而是「执行到一半发现前缀选错了,想回退」。入口薄、逻辑厚,改起来才不会牵连一片。config.php最好放在 Web 根目录之外,用require __DIR__ . '/../config.php'引进来,否则哪天配置里的账号密码被扫目录的爬虫捞走,麻烦比删错表还大。
2.2 连接层:PDO 里三个必须打开的属性
配置数组通常长这样,每一项都有明确用途,不是随手写的:
<?php // config.php —— 建议放在 Web 根目录之外 return [ 'host' => '127.0.0.1', 'port' => 3306, 'user' => 'db_cleaner', // 只给目标库的 DROP 权限,别用 root 'pass' => 'change-me', 'dbname' => 'app_log', 'charset' => 'utf8mb4', // 前缀白名单:即使前端传了别的值,也要在这里出现过才允许执行 'allow_prefix' => ['tmp_', 'log_bak_'], 'batch_size' => 50, // 每批 DROP 多少张表 'sleep_ms' => 200, // 每批之间的间隔,给主从复制留缓冲 'backup_dir' => __DIR__ . '/backup', ];连接函数的写法决定了后面的容错能力:
<?php function db(array $cfg): PDO { $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', $cfg['host'], $cfg['port'], $cfg['dbname'], $cfg['charset'] ); return new PDO($dsn, $cfg['user'], $cfg['pass'], [ // 1. 出错抛异常,而不是返回 false 让下面 continue 静默跳过 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 2. 关联数组取值,列名直接用 TABLE_NAME PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 3. 关掉模拟预处理,让占位符真正走到服务端 PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_TIMEOUT => 10, // 连接超时,避免页面一直转圈 ]); }三个属性的含义值得说清楚:ERRMODE_EXCEPTION是逐表try/catch的前提,没有它,DROP 失败只会返回false,你以为删了 50 张,实际只删了 12 张;FETCH_ASSOC决定后面拼清单时不用记数字下标;EMULATE_PREPARES => false让参数走真正的服务端预处理,顺便避免字符集不一致时参数被本地转义搞出乱码。ATTR_TIMEOUT容易被忽略,但删表脚本一旦卡在锁等待上,浏览器那边早就超时重发请求了,配上这个至少连接阶段不会无限挂着。
提示:脚本用的账号务必单独建,只授予目标库的
SELECT, DROP, SHOW VIEW,别图省事复用业务账号。后面第 4 章会讲为什么这个细节能救命。
2.3 前缀扫描为什么走 information_schema 而不是 SHOW TABLES LIKE
很多人第一反应是SHOW TABLES LIKE 'tmp_%',一行命令就出结果,为什么还要绕去information_schema?因为SHOW TABLES只给你表名,不给任何上下文。批量删表最需要的恰恰是上下文:这批表一共占多少空间、最近有没有新建的表混进来、哪几张表行数特别大需要单独处理。information_schema.TABLES一次就能带回来:
<?php // lib/Scanner.php function findTablesByPrefix(PDO $pdo, string $db, string $prefix): array { // 前缀里的 _ 和 % 是 LIKE 通配符,必须转义,否则 tmp_ 会匹配到 tmpX $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $prefix); $pattern = $escaped . '%'; $sql = 'SELECT TABLE_NAME, ENGINE, TABLE_ROWS, DATA_LENGTH, CREATE_TIME FROM information_schema.TABLES WHERE TABLE_SCHEMA = :db AND TABLE_TYPE = :type AND TABLE_NAME LIKE :p ORDER BY CREATE_TIME ASC'; $stmt = $pdo->prepare($sql); $stmt->execute([ ':db' => $db, ':type' => 'BASE TABLE', // 排除视图,视图要用 DROP VIEW ':p' => $pattern, ]); return $stmt->fetchAll(); }几个参数值得单独拎出来:TABLE_SCHEMA = :db必须绑死当前库,否则在同一个连接上换库执行时会扫到别的库;TABLE_TYPE = 'BASE TABLE'是把视图排除掉,DROP TABLE撞上视图会直接报错,混在批量任务里就是一个卡点;ORDER BY CREATE_TIME ASC让最老的表排前面,先删老的、后删新的,中途叫停时留下的都是较新的数据,损失最小。DATA_LENGTH拿来求和,页面上显示「本次将释放约 4.7 GB」,比单纯列 200 个表名更有说服力。
3. 核心链路拆解:从 information_schema 扫描到分批 DROP 的完整流程
整个流程被拆成四步:扫描、预览、备份、执行。这四步的顺序不能换,尤其备份必须夹在预览和执行之间——因为 MySQL 的 DROP TABLE 是 DDL,会隐式提交,事务回滚对它无效。这条特性决定了整个项目的设计:既然数据库层面没有后悔药,那就只能在应用层自己造一份。
3.1 扫描:前缀转义加白名单双保险
扫描函数本身不长,但入口处要先过一道白名单,这一步是防前端传参被人为篡改:
<?php // index.php 里接收参数的那几行 $cfg = require __DIR__ . '/../config.php'; $prefix = $_POST['prefix'] ?? ''; $db = $_POST['db'] ?? $cfg['dbname']; // 白名单校验:不是配置里登记过的前缀,一律拒绝 if (!in_array($prefix, $cfg['allow_prefix'], true)) { exit('前缀不在白名单内:' . htmlspecialchars($prefix, ENT_QUOTES)); } $pdo = db($cfg); $scanner = new Scanner($pdo); $tables = $scanner->findByPrefix($db, $prefix); if (count($tables) === 0) { exit('没有匹配到任何表,检查前缀是否写错'); }这里的in_array(..., true)第三个参数一定要开,严格比较。SQL 里不会出现类型混淆,但 PHP 里会——如果白名单里配了'0'之类的值,松散比较会把它和空字符串、false都判成相等。前缀白名单的意义在于,就算攻击者或者手滑的同事在表单里提交了空字符串,扫描函数也不会退化成「匹配全部表」。空前缀加上LIKE '%',那是能把整个库清空的操作,一定要在进入查询前就拦掉。
3.2 预览:dry-run 生成待删清单和容量报表
预览层做的事,是把数据库元数据翻译成人能看懂的一张表:
<?php // lib/Report.php function renderPreview(array $tables): void { $totalRows = 0; $totalBytes = 0; echo "<table border='1' cellpadding='6'>"; echo "<tr><th>#</th><th>表名</th><th>引擎</th><th>约行数</th><th>大小</th><th>创建时间</th></tr>"; foreach ($tables as $i => $t) { $totalRows += (int)$t['TABLE_ROWS']; $totalBytes += (int)$t['DATA_LENGTH']; printf( "<tr><td>%d</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>", $i + 1, htmlspecialchars($t['TABLE_NAME'], ENT_QUOTES), htmlspecialchars($t['ENGINE'] ?? '-', ENT_QUOTES), number_format((int)$t['TABLE_ROWS']), round($t['DATA_LENGTH'] / 1048576, 2) . ' MB', htmlspecialchars((string)$t['CREATE_TIME'], ENT_QUOTES) ); } printf( "<tr><td colspan='3'>合计 %d 张表</td><td>%s 行</td><td>%s MB</td><td></td></tr>", count($tables), number_format($totalRows), round($totalBytes / 1048576, 2) ); echo "</table>"; }TABLE_ROWS对 InnoDB 来说是个估算值,误差可能到 30% 以上,所以页面上写「约行数」而不是「行数」,别让人拿这个数去做业务对账。DATA_LENGTH只统计数据和索引占用的页空间,不含碎片,实际释放的磁盘可能小于这个数——不过它作为量级参考已经够用。全部输出都套了htmlspecialchars,因为表名由数据库管理员创建,但也不能保证里面不含尖括号。
预览页底下通常跟一个确认表单,把当前待删清单的哈希写进隐藏域,执行时再比对一次。这个细节是为了防止「预览看了 12 张,提交时前缀被改成了 200 张」这种前后不一致的情况,本质上是给自己加一层廉价的乐观锁。
3.3 执行:分批 DROP、单表失败隔离、幂等推进
执行层是整个项目最容易翻车的地方,因为它必须同时处理三种情况:单表失败要继续、大表删除要限速、脚本被中断要能接上。
<?php // lib/Dropper.php function dropInBatches(PDO $pdo, array $tables, array $cfg, string $logFile): array { $ok = $fail = 0; $chunks = array_chunk($tables, (int)$cfg['batch_size']); foreach ($chunks as $batchNo => $chunk) { foreach ($chunk as $t) { $name = $t['TABLE_NAME']; // 标识符不能用占位符,只能做反引号转义 + 前置白名单校验 $safe = '`' . str_replace('`', '``', $name) . '`'; try { $pdo->exec('DROP TABLE IF EXISTS ' . $safe); $ok++; logLine($logFile, "OK\t{$name}"); } catch (PDOException $e) { $fail++; // 单表失败不影响后续,把 SQLSTATE 和报错原样落盘 logLine($logFile, "FAIL\t{$name}\t" . $e->getCode() . "\t" . $e->getMessage()); } } $done = min(($batchNo + 1) * $cfg['batch_size'], count($tables)); file_put_contents($cfg['backup_dir'] . '/progress.json', json_encode([ 'done' => $done, 'ok' => $ok, 'fail' => $fail, 'ts' => time(), ])); usleep((int)$cfg['sleep_ms'] * 1000); } return ['ok' => $ok, 'fail' => $fail]; }代码里三个点值得展开。第一,DROP TABLE IF EXISTS避免了「表已被其他任务删掉」导致的报错,让脚本天然带点幂等性;第二,标识符无法用 PDO 占位符绑定,这是预处理机制的硬限制,所以表名的安全只能靠「先白名单、后反引号转义」两道关,千万别把用户输入直接拼进 SQL;第三,progress.json每批覆盖一次,脚本被 Ctrl+C 或者被超时打断后,重跑时读这个文件跳过已完成的部分,不用从头再来。sleep_ms那个 200 毫秒看着微不足道,但在上百张表的场景里,它能明显削平主从延迟的尖峰,血泪经验都是这么换来的。
3.4 备份:把 SHOW CREATE TABLE 落成一份能回滚的 SQL
DROP 不能回滚,唯一能做的就是提前把表结构存下来。生产环境里,删掉的表通常不需要恢复数据,但需要恢复一张「空架子」来接上后续的写入,所以只备份 DDL 就够用:
<?php // lib/Backup.php function dumpDdl(PDO $pdo, array $tables, string $dir): string { if (!is_dir($dir)) { mkdir($dir, 0700, true); } $file = $dir . '/ddl_' . date('Ymd_His') . '.sql'; $fh = fopen($file, 'wb'); // 恢复时先关外键检查,否则带外键的子表建不出来 fwrite($fh, "SET FOREIGN_KEY_CHECKS=0;\n"); fwrite($fh, "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';\n"); foreach ($tables as $t) { $name = $t['TABLE_NAME']; $safe = '`' . str_replace('`', '``', $name) . '`'; $row = $pdo->query('SHOW CREATE TABLE ' . $safe)->fetch(); if ($row === false || !isset($row['Create Table'])) { continue; // 视图或其他对象,跳过 } fwrite($fh, "DROP TABLE IF EXISTS {$safe};\n"); fwrite($fh, $row['Create Table'] . ";\n\n"); } fwrite($fh, "SET FOREIGN_KEY_CHECKS=1;\n"); fclose($fh); return $file; }SHOW CREATE TABLE返回的列名会随着对象类型变化,表是Create Table,视图是Create View,所以这里用isset判断而不是直接取。文件里在每条 CREATE 语句前加一句DROP TABLE IF EXISTS,是让这份备份同时具备「恢复」和「重放」两种能力:表还在的时候执行它,等于重建;表没了的时候执行它,等于还原。文件权限设成0700、目录设在 Web 根之外,因为建表语句里经常带着字段名和索引名,泄露出去对做数据的人不算小事。
4. 避坑与排查:五条删表翻车记录,每条都能耗尽一个下午的时间
批量删表的坑有个共同特征:出错那一刻不报错,等你发现时已经没有回退余地。下面五条按「现象 → 原因 → 解决」整理,都是这类脚本在真实环境里反复撞到的问题,看一遍能省掉很多返工。
4.1 现象:前缀tmp_把tmpX的表也删了
现象很直接:扫描结果里出现了tmpArchive、tmpX2023这种根本不该匹配的表,删之前没细看就执行了。原因是 SQL 的LIKE里_是单字符通配符,'tmp_%'的实际语义是「tmp 加任意一个字符再加任意后缀」,tmpX正好命中。解决办法是转义前缀里的元字符:str_replace(['\\','%','_'], ['\\\\','\%','\_'], $prefix),MySQL 默认用反斜杠做转义符,不用额外写ESCAPE子句。更稳妥的做法是在预览页把「匹配到的表名」全部列出来并要求勾选确认,人眼过一遍永远比相信一段正则便宜。
4.2 现象:同一个前缀跑了两遍,第二次报「表不存在」
现象是页面刷新或者点两次提交,第二次执行时满屏Table doesn't exist的报错,日志里 OK 和 FAIL 混在一起分不清。原因是浏览器表单重复提交加脚本自身没有状态记录,而 DDL 又无法回滚,两次请求就是两批真实操作。解决办法有两层:执行前用progress.json记录已处理清单,重跑时先读它做差集;SQL 上统一用DROP TABLE IF EXISTS,让重复执行变成无害操作。表单侧加一个一次性 token,同一 token 只能提交一次,挡住手快的人连点。
4.3 现象:删到一半主从延迟飙到几百秒,业务查询开始超时
现象是脚本跑到 60 多张表时,监控里从库延迟曲线直线上冲,读业务报超时。原因是一次性连发几十条 DROP,每条都要写 binlog 并在从库重放,从库单线程回放跟不上,越积越多。解决办法是把batch_size压到 20~50,每批之间usleep200~500 毫秒,让从库有时间追平;表特别大(数据容量超过 1 GB)时,先在从库上确认延迟,必要时临时把从库摘出读流量。事后一条简单规则:清理任务永远不要在业务高峰跑。
4.4 现象:用备份文件恢复时报外键约束错误
现象是把生成的.sql喂给mysql客户端,前面几张表建得好好的,突然来一句errno: 150 - Foreign key constraint is incorrectly formed。原因是备份里没关外键检查,子表先于父表被重建,引用找不到目标。解决办法是在备份文件开头写SET FOREIGN_KEY_CHECKS=0;、结尾写回 1,这在本项目里由dumpDdl负责。另外要确认恢复时连接的库跟原库同名,外键定义里带库名的话,跨库恢复还得改 SQL。
4.5 现象:扫描能出结果,执行全部Access denied
现象很迷惑:预览页正常列出 200 张表,一点执行全是权限拒绝。原因是脚本用的账号只有SELECT,information_schema的查询权限和表上的DROP权限是两回事。解决办法是给独立账号补权限:GRANT SELECT, DROP ON app_log.* TO 'db_cleaner'@'127.0.0.1';然后FLUSH PRIVILEGES。这里刻意不授全库权限,就是要让「删错库」这件事在权限层面直接不可能发生,这比任何代码校验都硬。
4.6 出问题时的三步定位顺序
真出事的时候容易慌,我一般按固定顺序查:先看backup/下最近那份ddl_*.sql,确认要恢复的表结构在不在里面;再翻同目录的运行日志,用grep FAIL找出具体失败的表和 SQLSTATE;最后进库跑一遍SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='app_log' AND TABLE_NAME LIKE 'tmp\_%',确认现在还剩什么。三步走完,损失范围就完全清楚了,再决定是恢复、重跑还是止损。日志里把 SQLSTATE 和原始错误信息一起记下来,这一步别嫌啰嗦,排查时它就是唯一的黑匣子。
5. 跑通与验证:本地造 100 张表,把 dry-run 和真删各走一遍
拿到源码的第一件事不是往生产库上连,而是本地造一批假表把全流程走通。这一章按「环境准备 → 配置 → 浏览器跑 → 命令行跑 → 造表验证」的顺序来,全部在测试库上做。
5.1 环境准备与配置项的含义
要求很基础:PHP 7.0 以上,打开pdo_mysql扩展,MySQL 5.6 以上即可,information_schema从 5.x 就有。检查扩展用php -m | grep pdo_mysql,没有输出就去php.ini里把extension=pdo_mysql前面的注释去掉。本地用集成环境或者自己起的 MySQL 都行,关键是别拿线上库练手。
配置上要动的只有config.php那几项,逐项说清楚:
| 配置项 | 含义 | 常见取值与注意点 |
|---|---|---|
host/port | 数据库地址与端口 | 本机127.0.0.1:3306,写 IP 别写localhost,避免走 socket |
user/pass | 专用清理账号 | 单独建号,只授目标库SELECT, DROP |
dbname | 目标库 | 改成你的库名,别留默认值 |
allow_prefix | 允许执行的前缀白名单 | 建议带下划线结尾,如tmp_,减少误匹配 |
batch_size | 每批删表数量 | 20~50,表大就往下压 |
sleep_ms | 批间隔毫秒 | 200 起步,从库延迟高就加到 500 |
backup_dir | DDL 备份与日志目录 | 放在 Web 根之外,权限 0700 |
5.2 浏览器端的三步操作
Web 入口的流程是刻意的三步,少一步都不行:
第一步,填库名和前缀,点「扫描」。这一步只查information_schema,不执行任何写操作,所以最坏的结果就是查了个空。
第二步,看预览表格。重点看三样:匹配到的表总数是不是符合预期、最老和最新的创建时间跨度、合计占用空间。如果总数比预期多出一大截,八成是前缀没加下划线或者写得太短。
第三步,勾选确认框、点「执行删除」。执行过程中页面会持续输出每张表的结果,别关浏览器标签,脚本虽然写了progress.json,但中途断开还是得手动重跑。
注意:
dry-run的意义在于让你在「零风险」状态下看一遍清单。养成习惯——不管多着急,先跑预览,再删。
5.3 命令行跑法:接 CI 做定时清理
Web 界面适合一次性操作,长期定时清理更适合命令行。入口一般这样写:
<?php // cli.php 用法:php cli.php --prefix=tmp_ --dry-run // php cli.php --prefix=tmp_ --confirm $opts = getopt('', ['prefix:', 'db::', 'dry-run', 'confirm']); $cfg = require __DIR__ . '/config.php'; $prefix = $opts['prefix'] ?? ''; $dryRun = isset($opts['dry-run']); $confirm = isset($opts['confirm']); if (!in_array($prefix, $cfg['allow_prefix'], true)) { fwrite(STDERR, "前缀不在白名单内\n"); exit(1); } if (!$dryRun && !$confirm) { fwrite(STDERR, "真删必须显式加 --confirm,先跑 --dry-run 看一眼\n"); exit(2); } $pdo = db($cfg); $tables = (new Scanner($pdo))->findByPrefix($opts['db'] ?? $cfg['dbname'], $prefix); if ($dryRun) { foreach ($tables as $t) { printf("%-40s %10d rows %8.2f MB\n", $t['TABLE_NAME'], $t['TABLE_ROWS'], $t['DATA_LENGTH'] / 1048576); } printf("共 %d 张表\n", count($tables)); exit(0); } $file = (new Backup($pdo))->dumpDdl($tables, $cfg['backup_dir']); fwrite(STDOUT, "DDL 已备份到 {$file}\n"); $r = dropInBatches($pdo, $tables, $cfg, $cfg['backup_dir'] . '/run.log'); fwrite(STDOUT, "完成:成功 {$r['ok']},失败 {$r['fail']}\n");getopt里prefix:后面那个冒号表示必须带值,db::两个冒号表示可选值。真删必须显式加--confirm这个设计很关键,CI 里写错一行参数的概率远比想象中高,多一道显式开关就少一次事故。备份在删之前同步执行,而不是异步排队,是因为一旦脚本在执行中途挂掉,异步备份可能还没落地,那批表就真的没了。
5.4 造 100 张表把流程跑通
验证用不着真数据,造一批空表就够:
# 造 100 张 tmp_ 前缀的测试表,外加 3 张不该被匹配的干扰表 for i in $(seq -w 1 100); do mysql -h127.0.0.1 -uroot -p"$MYSQL_PWD" app_log \ -e "CREATE TABLE tmp_import_${i} (id BIGINT PRIMARY KEY AUTO_INCREMENT, payload TEXT);" done for n in tmpArchive tmpX2023 keep_tmp_1; do mysql -h127.0.0.1 -uroot -p"$MYSQL_PWD" app_log \ -e "CREATE TABLE ${n} (id INT PRIMARY KEY);" done造完先跑php cli.php --prefix=tmp_ --dry-run,预期结果是 100 张,tmpArchive和tmpX2023不能被列进去——如果列进去了,说明前缀转义那一步没生效,回去检查 3.1 的str_replace。确认清单没问题,再跑php cli.php --prefix=tmp_ --confirm,然后ls backup/看 DDL 文件是不是生成了、里面是不是 100 条CREATE TABLE。最后把备份文件导回一个空库,验证恢复链路能走通,整个闭环才算跑完。
6. 二次开发:把「直接 DROP」改造成能后悔的两阶段删除
真正上线一段时间后,多数人都会想改一件事:不希望点下确认的那一刻表就消失了。这时最值得做的改造是两阶段删除——第一次只做重命名,把表挪进「回收站」,隔几天再统一清理。
改法很轻,把Dropper里那一句换成两条分支即可:
<?php // 阶段一:重命名到回收站前缀,随时可以改回来 $trash = 'zz_trash_' . date('Ymd') . '_' . $name; $pdo->exec(sprintf('RENAME TABLE `%s` TO `%s`', str_replace('`', '``', $name), str_replace('`', '``', $trash))); // 阶段二:N 天后由定时任务扫 zz_trash_ 前缀,真正 DROP // 只处理创建时间早于 now() - INTERVAL 7 DAY 的回收站表配套要加一张审计表,字段至少包含原表名、回收站表名、操作人、执行时间、DDL 备份文件路径。这张表本身就是二次开发的入口:想做审批流,加一个status字段,从pending走到approved才允许执行阶段二;想做白名单动态化,把allow_prefix从配置数组挪到这张表里按库区分。再进一步,把扫描函数里TABLE_NAME LIKE换成REGEXP,就能支持「删掉log_开头且创建时间早于 90 天」这类组合条件,适用面立刻宽一圈。
验证改造是否成功,别只看表有没有改名,要跑三个检查:RENAME TABLE之后原表名在information_schema里确实消失、zz_trash_前缀的表能正常查询、把回收站表改回原名后业务读写恢复。第三个检查最容易被跳过,但恰恰是它决定了这套机制在真出事的那天到底能不能救命。我自己的习惯是每次改完删表逻辑,都先在测试库造 200 张表跑一遍完整的两阶段流程,再对着审计表逐行核对数量,对不上就不许发。从那以后我每次动这类高危脚本,都强制走一遍「造表、dry-run、真删、恢复」四步,一次没落过。希望帮到你。
本文还有配套的精品资源,点击获取