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:
@@ -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`。
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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' ) ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user