数据库版本号"跳变"引发的升级链断裂:我是如何用一张"影子版本表"治好 migration 的"失忆症"
上周帮同事救火,遇到一个挺典型的 migration 翻车现场。插件从 1.2.0 升到 1.5.0,中间跳过了 1.3.x 和 1.4.x,结果数据库结构卡在了半中间——新代码里用了新字段,但 upgrade 脚本因为版本号判断条件写得太"精确",直接没被触发。
先贴一下原来踩坑的代码,估计不少人写过类似的:
function my_plugin_upgrade() {
$current_ver = get_option('my_plugin_version', '1.0.0');
if ($current_ver === '1.2.0') {
// 1.2.0 -> 1.3.0 的改表逻辑
$this->add_column_votes();
update_option('my_plugin_version', '1.3.0');
}
if ($current_ver === '1.3.0') {
// 1.3.0 -> 1.4.0
$this->create_table_logs();
update_option('my_plugin_version', '1.4.0');
}
// ... 以此类推
}
问题很明显了:用户要是从 1.2.0 直接装 1.5.0,或者从 1.1.0 升级上来,这个链就断了。更隐蔽的是,如果某个中间版本的 upgrade 里做了数据迁移(比如拆表、改字段类型),跳过去等于直接丢了那步操作。
我后来换了个思路,不再用"当前版本号"做等值判断,而是改成"已执行的 migration 记录"模式。核心就两张表/选项:
// 记录每一条 migration 是否执行过,而不是只记一个版本号
private function run_migration($migration_id, $callback) {
$executed = get_option('my_plugin_migrations', []);
if (in_array($migration_id, $executed, true)) {
return; // 已经跑过,幂等跳过
}
try {
$callback();
$executed[] = $migration_id;
update_option('my_plugin_migrations', $executed);
} catch (Exception $e) {
// 这里千万别吞异常,但要保证不会重复执行
error_log("Migration {$migration_id} failed: " . $e->getMessage());
throw $e; // 抛出去让上层决定是回滚还是告警
}
}
调用的时候这样写,每条 migration 有个独立 ID,和版本号解耦:
public function do_upgrades() {
$this->run_migration('001_add_votes_column', [$this, 'add_column_votes']);
$this->run_migration('002_create_logs_table', [$this, 'create_table_logs']);
$this->run_migration('003_migrate_old_settings', [$this, 'migrate_settings_to_json']);
// 后续继续追加,老的不会重复跑
}
这个方案有个副作用要解决:uninstall 的时候得想清楚要不要清掉 migration 记录。我的做法是分两种情况——
测试环境/彻底重装:uninstall 钩子里全删,包括 migration 记录和版本号;
生产环境"软卸载":只删业务数据,保留 migration 记录,这样重新启用时不会把已经改过的表结构又跑一遍。
register_uninstall_hook(__FILE__, 'my_plugin_uninstall');
function my_plugin_uninstall() {
if (defined('MY_PLUGIN_PURGE_ALL') && MY_PLUGIN_PURGE_ALL) {
delete_option('my_plugin_migrations');
delete_option('my_plugin_version'); // 兼容旧版
// drop tables...
}
// 否则只清业务数据,结构保留
}
还有一个坑是 dbDelta 的"模糊匹配"。有时候你改了个字段长度,比如 VARCHAR(100) 改成 VARCHAR(255),dbDelta 可能觉得"差不多"就不动了。这种时候别指望它,得显式写 ALTER TABLE 包在 migration 里。
最后说个血泪教训:migration 里千万别用 WP_Query 或者业务层的 model 去批量改数据。我有一次在 migration 里调了个 save_post,结果那个钩子又触发了插件的其他逻辑,死循环把测试库打爆了。migration 里只写裸 SQL,最多用 $wpdb,保持最小依赖。
你们 migration 还踩过哪些"版本号玄学"的坑?比如 WordPress 自带的 wp_get_db_schema 和插件自己的 schema 冲突之类的,欢迎丢案例。

