feat(2meet-data-optimizer): support composite indexes on entity groups

- register_group() accepts $args['indexes'] (name => ordered keys); indexes on
  unknown keys are dropped. Kept outside the field definition, so schema
  hashes are unchanged.
- Schema_Manager::ensure_group_indexes() adds missing cidx_* indexes to the
  existing flat table in place; the applied definition is remembered in
  tmdo_entity_indexes so later requests skip SHOW INDEX.
- Bench (1M listings, scenario 2): Entity p50 337 ms -> 278 ms with
  (hp_verified, hp_price), on par with Hot (269 ms).
- Verify: core unit 656 / integration 459 green, phpcs clean
- PR: PR-027
This commit is contained in:
2026-09-26 01:05:23 +08:00
parent d6b2e417f4
commit 65c75dfc8d
5 changed files with 224 additions and 0 deletions
+1
View File
@@ -56,6 +56,7 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。 3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。 4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。
5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。 5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。
- **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。 6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。 7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
@@ -40,6 +40,9 @@ final class TMDO_Entity_Registry {
/** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */ /** @var array<string, array<string, string[]>> 實體類型 → 群組名 → 限定的 post_type */
private static array $scopes = array(); private static array $scopes = array();
/** @var array<string, array<string, array<string, string[]>>> 實體類型 → 群組名 → 索引名 → 欄位 key */
private static array $indexes = array();
/** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ /** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array(); private static array $collisions = array();
@@ -74,6 +77,7 @@ final class TMDO_Entity_Registry {
self::$unscoped_index = array(); self::$unscoped_index = array();
self::$scoped_index = array(); self::$scoped_index = array();
self::$scopes = array(); self::$scopes = array();
self::$indexes = array();
self::$pending_schemas = array(); self::$pending_schemas = array();
self::$collisions = array(); self::$collisions = array();
self::$restored = array(); self::$restored = array();
@@ -119,6 +123,9 @@ final class TMDO_Entity_Registry {
* 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬; * 多個群組共用 meta_key 時,依 post 本身的類型決定歸屬;
* 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。 * 不屬於任何宣告 post_type 的 post 不受管理(走原生 meta)。
* 不列入欄位定義,所以不影響 schema hash 與衝突比對。 * 不列入欄位定義,所以不影響 schema hash 與衝突比對。
* indexes (array<string, string[]>) — 複合索引:名稱 → 欄位 key(依序);
* 含未知欄位的索引會被略過。同樣不影響 schema hash,
* 由 TMDO_Schema_Manager::ensure_group_indexes() 補建到既有表。
* *
* @return bool * @return bool
*/ */
@@ -151,6 +158,13 @@ final class TMDO_Entity_Registry {
if ( $post_types ) { if ( $post_types ) {
self::$scopes[ $entity_type ][ $group_name ] = $post_types; self::$scopes[ $entity_type ][ $group_name ] = $post_types;
} }
$keys = array_column( $normalized, 'key' );
foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) {
$columns = array_values( array_map( 'strval', (array) $columns ) );
if ( $columns && ! array_diff( $columns, $keys ) ) {
self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns;
}
}
foreach ( $normalized as $normalized_field ) { foreach ( $normalized as $normalized_field ) {
self::index_field( $entity_type, $normalized_field, $post_types ); self::index_field( $entity_type, $normalized_field, $post_types );
} }
@@ -372,6 +386,15 @@ final class TMDO_Entity_Registry {
return array_keys( self::$groups[ $entity_type ] ?? array() ); return array_keys( self::$groups[ $entity_type ] ?? array() );
} }
/**
* 群組宣告的複合索引。
*
* @return array<string, string[]> 索引名 → 欄位 key
*/
public static function get_group_indexes( string $entity_type, string $group_name ): array {
return self::$indexes[ $entity_type ][ $group_name ] ?? array();
}
/** /**
* 群組限定的 post_type(未限定時為空陣列)。 * 群組限定的 post_type(未限定時為空陣列)。
* *
@@ -20,6 +20,9 @@ defined( 'ABSPATH' ) || exit;
final class TMDO_Schema_Manager { final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */ /** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array(); private static array $table_exists_cache = array();
@@ -60,6 +63,7 @@ final class TMDO_Schema_Manager {
$schema['group'], $schema['group'],
$schema['fields'] $schema['fields']
); );
self::ensure_group_indexes( $schema['type'], $schema['group'] );
} }
TMDO_Entity_Registry::clear_pending_schemas(); TMDO_Entity_Registry::clear_pending_schemas();
@@ -164,6 +168,50 @@ final class TMDO_Schema_Manager {
return true; return true;
} }
/**
* Add the group's declared composite indexes that its table lacks.
*
* Indexes are not part of the schema hash, so an existing table (which
* may already hold aeav_only data) gets them in place — no rebuild. The
* applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
public static function ensure_group_indexes( string $entity_type, string $group_name ): void {
global $wpdb;
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( empty( $declared ) ) {
return;
}
$id = "{$entity_type}/{$group_name}";
$signature = md5( (string) wp_json_encode( $declared ) );
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return;
}
$table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) {
return;
}
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) {
continue;
}
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" );
}
$applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true );
}
/** /**
* 計算 Schema Hash(用於偵測欄位變動) * 計算 Schema Hash(用於偵測欄位變動)
*/ */
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Composite indexes declared on a group reach its existing flat table.
*
* The table already exists with data (its schema hash is unchanged), so the
* index is added in place rather than through a table rebuild.
*/
class GroupCompositeIndexTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_post_ci_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/adapters/class-tmdo-adapter-post.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
$GLOBALS['_wp_options'] = array();
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group(
'post',
'ci_core',
array(
array( 'key' => 'ci_flag', 'type' => 'integer' ),
array( 'key' => 'ci_price', 'type' => 'decimal' ),
),
array( 'indexes' => array( 'flag_price' => array( 'ci_flag', 'ci_price' ) ) )
);
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
}
/** @return array<string, string[]> index name → ordered columns */
private function indexes(): array {
global $wpdb;
$out = array();
foreach ( (array) $wpdb->get_results( 'SHOW INDEX FROM `' . self::TABLE . '`', ARRAY_A ) as $row ) {
$out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name'];
}
return $out;
}
public function test_missing_composite_index_is_added_to_the_existing_table(): void {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertSame( array( 'ci_flag', 'ci_price' ), $this->indexes()['cidx_flag_price'] ?? null );
global $wpdb;
$this->assertSame( '2', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ), 'data untouched' );
}
public function test_applied_definition_is_remembered_so_later_requests_skip_the_check(): void {
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` DROP INDEX `cidx_flag_price`' );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'same definition → no SHOW INDEX / ALTER' );
$this->assertArrayHasKey( 'post/ci_core', get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
}
public function test_group_without_indexes_is_left_alone(): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'ci_core', array( array( 'key' => 'ci_flag', 'type' => 'integer' ) ) );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertSame( array( 'PRIMARY', 'uk_entity' ), array_keys( $this->indexes() ) );
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ) );
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Composite indexes declared on entity groups.
*
* Declared through register_group()'s $args like post_types, so a group's
* field definition — and its persisted schema hash — never changes when an
* index is added.
*/
class EntityRegistryIndexesTest extends TestCase {
protected function setUp(): void {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
}
private function fields(): array {
return array(
array( 'key' => 'x_flag', 'type' => 'integer' ),
array( 'key' => 'x_price', 'type' => 'decimal' ),
);
}
public function test_declared_indexes_are_kept_per_group(): void {
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
$this->assertSame( array( 'flag_price' => array( 'x_flag', 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
$this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'other' ) );
}
public function test_indexes_on_unknown_columns_are_dropped(): void {
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'bad' => array( 'x_flag', 'nope' ), 'ok' => array( 'x_price' ) ) ) );
$this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
}
public function test_indexes_leave_the_field_definition_and_hash_alone(): void {
TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() );
TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );
$plain = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'plain' ) );
$indexed = TMDO_Schema_Manager::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( 'post', 'indexed' ) );
$this->assertSame( $plain, $indexed );
}
public function test_init_clears_indexes(): void {
TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'ok' => array( 'x_price' ) ) ) );
TMDO_Entity_Registry::init();
$this->assertSame( array(), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) );
}
}