数据库版本号"幽灵递增":我的 `dbDelta` 升级脚本在 `wp_options` 里写了 5 次 `_version`,插件却以为从未更新过
上周给 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 xxx 或 xxx already exists 都算正常,但如果出现 error 字样或者某张表完全没出现在返回值里,那就是有问题。
你们有没有遇到过更阴间的版本号坑?比如多站点环境下 get_blog_option 和 get_site_option 混用导致子站点版本号"夺舍"主站点?我下一个要攻的就是这个。

