数据库迁移"静默失败":我的建表脚本在本地跑通、线上却漏了半张表,原来是 `dbDelta` 对换行符的洁癖在作怪
上周发版一个新插件,本地 Docker 环境 `register_activation_hook` 里跑 `dbDelta` 建三张表,一气呵成。推到预发环境,用户反馈功能异常,一查数据库——只建了两张,第三张凭空消失,没有任何报错,error log 干净得像没执行过。
问题出在第三张表的 SQL 字符串里我手贱用了 `\r\n` 换行。`dbDelta` 这个老古董对换行符有近乎偏执的要求:必须是 `\n`,多一个 `\r` 它就直接跳过整段 CREATE 语句,而且不抛异常、不返回错误,只在你传进去的 `$queries` 数组里少一个元素,连 `wpdb::last_error` 都是空的。
贴个我当时用的"裸奔版"和修复后的对比:
// 翻车版本:从 Windows 编辑器复制过来的 SQL,带了 \r\n
$sql = "CREATE TABLE {$wpdb->prefix}my_plugin_logs (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
action varchar(50) NOT NULL,
created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
PRIMARY KEY (id)
) {$charset_collate};";
// dbDelta 直接吞掉,仿佛这行代码不存在
dbDelta($sql);
修复就一行,但找这行花了两小时:
// 建表前统一消毒换行符
$sql = str_replace("\r\n", "\n", $sql);
dbDelta($sql);
更隐蔽的是升级场景。插件从 1.0 升到 1.1,加了个 `status` 字段,我复用了同样的 SQL 拼装逻辑,结果线上 ALTER 语句也被静默跳过。用户数据表结构新旧混杂,查询时 `status` 列不存在,直接 500。
现在我给迁移相关代码套了三层保险:
第一层:SQL 出厂前强制换行标准化
private function sanitize_sql(string $sql): string {
// dbDelta 只认 \n,且每行必须以字段定义或关键字开头,前面不能有空格
$sql = str_replace(["\r\n", "\r"], "\n", $sql);
$lines = explode("\n", $sql);
$lines = array_map('trim', $lines);
return implode("\n", $lines);
}
第二层:执行后校验表结构,不轻信 dbDelta 返回值
public function verify_table(string $table_name, array $expected_columns): bool {
$actual = $this->wpdb->get_col("DESCRIBE {$table_name}");
$missing = array_diff($expected_columns, $actual);
if (!empty($missing)) {
// 写进插件自己的迁移日志表,别依赖 PHP error log
$this->log_migration_error("Missing columns: " . implode(', ', $missing));
return false;
}
return true;
}
第三层:卸载时反向清理,但留个"后悔药"开关
之前卸载直接 `DROP TABLE`,有用户误删后数据全没。现在改成:
register_uninstall_hook(__FILE__, 'my_plugin_uninstall');
function my_plugin_uninstall() {
// 默认只标记删除,真实 DROP 需要用户在常量里显式开启
if (defined('MY_PLUGIN_HARD_UNINSTALL') && MY_PLUGIN_HARD_UNINSTALL) {
// 先改名备份,30 天后 cron 清理
$backup_name = $wpdb->prefix . 'my_plugin_backup_' . time();
$wpdb->query("RENAME TABLE {$wpdb->prefix}my_plugin_data TO {$backup_name}");
}
// 常规卸载只删选项,表留着
delete_option('my_plugin_version');
delete_option('my_plugin_settings');
}
还有个坑是 `dbDelta` 对索引的处理。如果你第一次建表没写索引,升级时补 `KEY`,`dbDelta` 能识别并添加;但如果你第一次写了 `UNIQUE KEY`,升级时想改成普通 `KEY`,它直接无视——不会删旧索引,也不会报错。这时候只能手写 `DROP INDEX` 再重建,别指望它全包。
最后建议:把迁移脚本拆成 `install()`、`upgrade($from_version)`、`uninstall()` 三个独立类,别全塞在入口文件里。版本号用 `option` 存,但比较时用 `version_compare()`,别自己写字符串判断,"1.10" 和 "1.2" 的大小关系很容易反直觉。
你们迁移时还踩过 `dbDelta` 什么暗坑?我目前集卡集了换行符、索引修改、多字节字符集声明位置三张,欢迎补充。

