数据库版本号"幽灵递增":我的 `dbDelta` 升级脚本在 `wp_options` 里写了 5 次 `_version`,插件却以为从未更新过

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

上周给 Zsens Admin 做 3.2→3.3 的数据库迁移,踩了个让我怀疑人生的坑:dbDelta 执行成功、表结构正确、_version 选项值也刷新了,但下次加载时升级逻辑又跑了一遍——而且不是跑一遍,是每次页面请求都跑,直接把某张日志表插了 400 多万条重复数据。

根因特别蠢,但藏得极深。我当时的版本检测长这样:

// 灾难现场,不要复制
$installed_ver = get_option( 'zsens_admin_db_version' );
if ( $installed_ver !== ZSENS_ADMIN_DB_VERSION ) {
    // 执行 dbDelta...
    update_option( 'zsens_admin_db_version', ZSENS_ADMIN_DB_VERSION );
}

问题出在 ZSENS_ADMIN_DB_VERSION 的定义:

define( 'ZSENS_ADMIN_DB_VERSION', 3.3 );  // 浮点数!

get_option 从数据库读出来的是字符串 "3.3"3.3 !== "3.3",PHP 的严格比较直接判假,升级逻辑永动机启动。

更坑的是,我之前用 == 的时候明明好好的,某次代码审查被同事改成 === 就炸了。浮点数、字符串、严格比较,三杀。


现在我定了一套"迁移三件套"规矩

1. 版本号必须是字符串,且带日期/序号双保险

define( 'ZSENS_ADMIN_DB_VERSION', '20240831-003' );  // 永不歧义

拒绝纯数字、拒绝浮点。日期前缀还能一眼看出这版迁移是哪天的,排查时不用翻 git。

2. 升级函数必须幂等,dbDelta 只是起点

dbDelta 本身对字段变更有防护,但对数据迁移(比如拆表、改枚举值、重建索引)完全不设防。我的做法是升级函数内部再包一层版本分段:

function zsens_upgrade_routine() {
    $current = get_option( 'zsens_admin_db_version', '0' );
    $target  = ZSENS_ADMIN_DB_VERSION;
    
    if ( version_compare( $current, $target, '>=' ) ) {
        return;  // 真正的短路,不是伪短路
    }
    
    // 分段迁移,每段独立事务
    if ( version_compare( $current, '20240815-001', '<' ) ) {
        zsens_migrate_20240815_001();  // 加新表
    }
    if ( version_compare( $current, '20240831-002', '<' ) ) {
        zsens_migrate_20240831_002();  // 改字段
    }
    if ( version_compare( $current, '20240831-003', '<' ) ) {
        zsens_migrate_20240831_003();  // 数据清洗
    }
    
    update_option( 'zsens_admin_db_version', $target );
}

关键点:version_compare 专门吃字符串版本号,不会搞出浮点精度问题;每段迁移独立判断,即使某次中断,下次也能从断点续传。

3. 卸载时版本号不是删了就行,要考虑"重装复现"

用户卸载插件后重新安装,如果 uninstall.php 里把版本号清干净了,但某些表因为外键或权限没删掉,下次安装时 dbDelta 看到表存在、版本号为 0,会跳过升级直接走"全新安装"分支——然后新代码读老表结构,字段缺失报错。

我的卸载逻辑现在分两层:

// uninstall.php
function zsens_uninstall() {
    global $wpdb;
    
    // 先标记"脏卸载",不是直接删版本号
    update_option( 'zsens_admin_db_version', 'uninstalled-' . time() );
    
    // 再清数据
    $tables = [ 'zsens_logs', 'zsens_queue', 'zsens_meta' ];
    foreach ( $tables as $table ) {
        $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}{$table}" );
    }
    
    // 最后才删版本号,且只在全部表确认删除后
    $remaining = $wpdb->get_results( "SHOW TABLES LIKE '{$wpdb->prefix}zsens_%'" );
    if ( empty( $remaining ) ) {
        delete_option( 'zsens_admin_db_version' );
    }
    // 如果还有残留表,版本号留着当"犯罪现场标记"
}

这样重装时如果检测到 uninstalled- 前缀,就知道上次没卸干净,可以走修复流程而不是假装全新安装。


一个额外发现:dbDelta 的"沉默失败"

有次升级脚本里我把 KEY 写成了 INDEX(MySQL 支持,但 dbDelta 的解析器不认),dbDelta 返回空数组、没报错,表也建了,只是索引没加上。后续查询慢到超时,我以为是数据量问题,查了一下午执行计划才发现索引缺失。

现在我的升级函数必带 dbDelta 结果审计:

require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$result = dbDelta( $sql );
foreach ( $result as $table => $feedback ) {
    if ( stripos( $feedback, 'error' ) !== false ) {
        error_log( "zsens dbDelta fail: {$table} => {$feedback}" );
        // 抛异常或发告警,绝不能静默吞掉
    }
}

dbDelta 的返回值是个关联数组,Created table xxxxxx already exists 都算正常,但如果出现 error 字样或者某张表完全没出现在返回值里,那就是有问题。


你们有没有遇到过更阴间的版本号坑?比如多站点环境下 get_blog_optionget_site_option 混用导致子站点版本号"夺舍"主站点?我下一个要攻的就是这个。

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