数据库版本号"跳变"引发的升级链断裂:我是如何用一张"影子版本表"治好 migration 的"失忆症"
上周帮同事救火,遇到一个挺典型的 migration 翻车现场。插件从 1.2.0 升到 1.5.0,中间跳过了 1.3.x 和 1.4.x,结果数据库结构卡在了半中间——新代码里用了新字段,但 upgrade 脚本因为版本号判断条件写得太"精确",直接没被触发。
先贴一下原来踩坑的代码,估计不少人写过类似的:
```php 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 记录"模式。核心就两张表/选项:
```php // 记录每一条 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,和版本号解耦:
```php 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 记录,这样重新启用时不会把已经改过的表结构又跑一遍。
```php 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 冲突之类的,欢迎丢案例。