建表时 `dbDelta` 的"空格洁癖"让我丢了三个索引:从 `KEY` 语法到字段排序的隐性规则

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

上周帮同事排查一个诡异问题:插件激活后表是建出来了,但三个索引全没生效。`EXPLAIN` 一看全是 `ALL` 全表扫描,后台查询直接爆炸。最后发现是 `dbDelta` 对空格的敏感度远超预期——不是"有没有空格",而是"空格出现在哪"。

先贴个最小复现。下面这段 SQL 在 phpMyAdmin 里跑毫无问题,`dbDelta` 却静默跳过索引创建:

CREATE TABLE `{$wpdb->prefix}my_logs` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `action` varchar(50) NOT NULL DEFAULT '',
  `created_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
  PRIMARY KEY (`id`),
  KEY `user_action` (`user_id`,`action`),
  KEY `created_lookup` (`created_at`)
) ENGINE=InnoDB {$wpdb->get_charset_collate()};

问题藏在 `KEY` 声明的格式里。dbDelta 的正则匹配要求 KEY 后面必须紧跟恰好一个空格,然后是索引名。如果你写成 KEY user_action(两个空格)或者 KEY `user_action`(带反引号),它直接认不出来,不会报错,只是默默不建索引。

更坑的是字段排序。我习惯把 PRIMARY KEY 放最后,但 dbDelta 的对比逻辑是按"期望 SQL"的顺序来校验现有表结构的。如果你的升级 SQL 里字段顺序和当前表不一致,它会认为需要重建——而重建过程中如果新索引语法有问题,老索引先被删、新索引建失败,结果就是索引丢失

现在我的建表 SQL 必须过三道检查:

1. 空格标准化

写了个小函数预处理,把多个空格压成一个,去掉多余的反引号包裹(索引名不需要):

function normalize_dbdelta_sql( $sql ) {
    // KEY 后面必须单空格,索引名不加反引号
    $sql = preg_replace( '/KEY\s+`?(\w+)`?\s*\(/', 'KEY $1 (', $sql );
    // 多个空格合并
    $sql = preg_replace( '/\s+/', ' ', $sql );
    return trim( $sql );
}

2. 升级时的"索引保险"

别光依赖 dbDelta 的返回值。我在升级钩子末尾手动校验关键索引是否存在,没有就补建:

function ensure_indexes_exist() {
    global $wpdb;
    $table = $wpdb->prefix . 'my_logs';
    $expected = [ 'user_action', 'created_lookup' ];
    
    $existing = $wpdb->get_results( "SHOW INDEX FROM {$table}", ARRAY_A );
    $existing_names = wp_list_pluck( $existing, 'Key_name' );
    
    foreach ( $expected as $index ) {
        if ( ! in_array( $index, $existing_names, true ) ) {
            // 补建,同时写错误日志
            $wpdb->query( "ALTER TABLE {$table} ADD INDEX {$index} (...)" );
            error_log( "Index {$index} was missing and recreated" );
        }
    }
}

3. 卸载时的"索引依赖"陷阱

这个反直觉:如果你插件 A 建了表,插件 B 通过 ALTER TABLE 加了索引,你 A 的卸载逻辑里直接 DROP TABLE IF EXISTS,B 的索引跟着陪葬,B 下次查询直接炸。

我的做法是卸载前检查表是否"干净"——只有本插件创建的索引才允许连带删除:

// uninstall.php
$prefix = 'myplugin_'; // 本插件索引命名前缀
$indexes = $wpdb->get_results( "SHOW INDEX FROM {$table}" );

foreach ( $indexes as $index ) {
    if ( 0 === strpos( $index->Key_name, $prefix ) ) {
        // 安全的,可以 DROP TABLE
    } else {
        // 存在外部索引,改走清理数据而非删表
        $safe_to_drop = false;
        break;
    }
}

最后说个血泪教训:某次升级我把 datetime 改成了 timestampdbDelta 没检测到变化(它不认这种"同义替换"),结果新老数据混存,时区处理全乱。现在凡是涉及字段类型变更,我直接走 ALTER TABLE 显式迁移,不再赌 dbDelta 的"智能"。

你们有没有被 dbDelta 的"沉默"坑过?我好奇它到底还有多少隐性规则没写在文档里。

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