perf(2meet-data-optimizer): route status and date ordering to entity tables

- TMDO_Post_Query_Router: when a group's post_status / post_date copies
  are ready and none is NULL (one index probe per group per request), a
  single-post-type search with explicit statuses and date ordering INNER
  JOINs the flat table, filters the copied status, orders by the copied
  post_date and skips GROUP BY. Otherwise the query keeps its old shape.
- New posts_orderby filter; bench gains an `entity+copy` variant.
- Bench (1M, with SQL_CALC_FOUND_ROWS): scenario 1 p95 3,739 -> 773 ms
  (p50 670 -> 770); scenario 2 p50 356 -> 448 ms. Without found-rows the
  same shape runs in 8 ms (PR-030).
- Verify: core unit 659 / integration 471 green, phpcs clean
- PR: PR-029
This commit is contained in:
2026-09-26 01:41:07 +08:00
parent aeca12b41d
commit 754f904e3b
4 changed files with 259 additions and 2 deletions
+1
View File
@@ -55,6 +55,7 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個
2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。
3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
- **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。
- **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。
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)。
- **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`。
+118 -2
View File
@@ -20,6 +20,13 @@
* 3. posts_where — append WHERE conditions targeting flat columns
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication
*
* Status / date on the flat table (O-1): when the group's post_status /
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies, orders by the copied
* post_date and skips GROUP BY (one row per post), so MySQL can walk
* `idx_post_status_date` and stop at the LIMIT.
*
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
* only entity_type='post'.
*
@@ -43,6 +50,16 @@ class TMDO_Post_Query_Router {
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
private const QUERY_VAR = 'wpdo_post_clauses';
/** Query var holding the status / date copy route, when one applies. */
private const COPY_VAR = 'wpdo_post_copy_route';
/**
* Per-request answer to "are this group's copies usable?".
*
* @var array<string, bool>
*/
private static array $copies_usable = array();
/**
* Register all WP_Query hooks.
*
@@ -53,6 +70,7 @@ class TMDO_Post_Query_Router {
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
}
/**
@@ -134,6 +152,11 @@ class TMDO_Post_Query_Router {
$query->set( 'meta_query', $remaining );
$query->set( self::QUERY_VAR, $routed );
$copy = self::copy_route( $query, $routed );
if ( $copy ) {
$query->set( self::COPY_VAR, $copy );
}
}
/**
@@ -167,7 +190,8 @@ class TMDO_Post_Query_Router {
continue;
}
$join .= " LEFT JOIN `{$table}` AS `{$alias}` "
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
@@ -201,9 +225,31 @@ class TMDO_Post_Query_Router {
}
}
$copy = $query->get( self::COPY_VAR );
if ( ! empty( $copy ) ) {
$alias = self::table_alias( $copy['post_type'], $copy['group'] );
$where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] );
}
return $where;
}
/**
* Order by the flat table's post_date copy when the copy route applies.
*
* @param string|null $orderby Current ORDER BY SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified ORDER BY SQL.
*/
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
$orderby = (string) ( $orderby ?? '' );
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $orderby;
}
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
}
/**
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
*
@@ -214,7 +260,7 @@ class TMDO_Post_Query_Router {
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $groupby;
}
@@ -242,6 +288,76 @@ class TMDO_Post_Query_Router {
return TMDO_Mode_Manager::reads_from_flat( 'post' );
}
/**
* Forget per-request copy checks (tests, or after `post-status-sync`).
*
* @return void
*/
public static function flush_copy_cache(): void {
self::$copies_usable = array();
}
/**
* The status / date copy route for a routed query, or [] when it does not apply.
*
* @param \WP_Query $query Current WP_Query.
* @param array<string, array<int, array>> $routed Routed clauses keyed by post type.
* @return array{post_type:string, group:string, statuses:string[], order:string}|array{}
*/
private static function copy_route( \WP_Query $query, array $routed ): array {
if ( 1 !== count( $routed ) ) {
return array();
}
$post_type = (string) array_key_first( $routed );
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) {
return array();
}
$statuses = $query->get( 'post_status' );
$statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) );
if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) {
return array();
}
return array(
'post_type' => $post_type,
'group' => $groups[0],
'statuses' => $statuses,
'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC',
);
}
/**
* Whether the copy route applies to this post type's join.
*
* @param \WP_Query $query Current WP_Query.
* @param string $post_type Post type slug.
* @return bool
*/
private static function copy_route_for( \WP_Query $query, string $post_type ): bool {
$copy = $query->get( self::COPY_VAR );
return ! empty( $copy ) && $copy['post_type'] === $post_type;
}
/**
* Copies are marked ready and no row lacks them (bulk writes that bypass
* the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again).
* One index probe per group per request.
*
* @param string $group Entity group name.
* @return bool
*/
private static function copies_usable( string $group ): bool {
if ( ! isset( self::$copies_usable[ $group ] ) ) {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' )
&& 'ready' === TMDO_Post_Status_Sync::state( $group )
&& TMDO_Schema_Manager::table_exists( $table )
&& null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" );
}
return self::$copies_usable[ $group ];
}
/**
* Generate a deterministic JOIN alias for a (post_type, group) pair.
*
+14
View File
@@ -76,6 +76,12 @@ function flat_search( string $table, string $alias, string $conditions ): string
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
}
/** TMDO router shape with post_status / post_date copies (PR-029): INNER JOIN, no GROUP BY. */
function flat_copy_search( string $table, string $alias, string $conditions ): string {
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p INNER JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)"
. " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} AND `{$alias}`.`post_status` IN ('publish') ORDER BY `{$alias}`.`post_date` DESC LIMIT 0, 20";
}
function random_ids( int $count, int $offset, int $max ): string {
$ids = array();
for ( $i = 0; $i < $count; $i++ ) {
@@ -105,6 +111,10 @@ $scenarios = array(
[ $lo, $hi ] = $price();
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
},
'entity+copy' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' );
},
),
'2. 價格區間 + verified=1' => array(
'native' => function () use ( $price ) {
@@ -127,6 +137,10 @@ $scenarios = array(
[ $lo, $hi ] = $price();
return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
},
'entity+copy' => function () use ( $price ) {
[ $lo, $hi ] = $price();
return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' );
},
),
'3. 某 vendor 名下 listing' => array(
'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ),
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Routed searches filter status and sort by date on the flat table's
* post_status / post_date copies (O-1), when those copies are trustworthy.
*/
class PostQueryRouterCopyTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_post_rc_core';
private const ALIAS = '`wpdoflat_rc_item_rc_core`';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/adapters/class-tmdo-adapter-post.php',
'includes/integrations/class-tmdo-post-fields.php',
'includes/engine/class-tmdo-post-status-sync.php',
'includes/query/class-tmdo-post-query-router.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, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" );
$wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" );
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) );
$GLOBALS['_wp_is_admin'] = false;
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
TMDO_Post_Query_Router::flush_copy_cache();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group( 'post', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init();
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, null );
}
/** @return array{join:string, where:string, orderby:string, groupby:string} */
private function sql( array $vars ): array {
$query = new WP_Query();
foreach ( array_merge(
array(
'post_type' => 'rc_item',
'post_status' => 'publish',
'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ),
),
$vars
) as $k => $v ) {
$query->set( $k, $v );
}
$router = new TMDO_Post_Query_Router();
$router->pre_get_posts( $query );
return array(
'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
'groupby' => $router->posts_groupby( '', $query ),
);
}
private function assert_legacy_shape( array $sql ): void {
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
$this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] );
}
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
$sql = $this->sql( array( 'order' => 'ASC' ) );
$this->assertStringContainsString( 'INNER JOIN', $sql['join'] );
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
$this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' );
}
public function test_several_explicit_statuses_are_kept(): void {
$sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) );
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] );
$this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] );
}
public function test_copies_not_marked_ready_keep_the_legacy_shape(): void {
$GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) );
$this->assert_legacy_shape( $this->sql( array() ) );
}
public function test_rows_with_missing_copies_keep_the_legacy_shape(): void {
global $wpdb;
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' );
$this->assert_legacy_shape( $this->sql( array() ) );
}
public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void {
$this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
}
}