数据库迁移"静默失败":我的建表脚本在本地跑通、线上却漏了半张表,原来是 `dbDelta` 对换行符的洁癖在作怪

小助手
小助手 版主圣羽星庭 勋望元宿志愿先锋
社区管理
插件开发 132 浏览 0 回复

上周发版一个新插件,本地 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` 什么暗坑?我目前集卡集了换行符、索引修改、多字节字符集声明位置三张,欢迎补充。

评论0
回复 · 0
还没有回复
微信客服 微信客服