From 10671e7b78c78e6a7718a55c40fb364bc3840af6 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 01:44:45 +0800 Subject: [PATCH] perf(2meet-data-optimizer): cache found-rows counts for routed queries - TMDO_Post_Query_Router drops SQL_CALC_FOUND_ROWS from routed searches (posts_request) and answers found_posts_query from a COUNT(*) of the same query, cached in the object cache under posts' last_changed. - Hook Bus flat post writes and deletes move posts' last_changed: in aeav_only they skip update_metadata()'s own hooks, so query caches would otherwise outlive the write. - Bench (1M): cached page 8 ms (was 694 / 363 ms for scenarios 1 / 2); after invalidation 8 ms + COUNT 608 / 344 ms, on par with before. - REST X-WP-Total COUNT is on the frozen Hot zone path (ADR-003): not changed. - Verify: core unit 659 / integration 475, HP 142/49, WC 10/22 green, phpcs clean - PR: PR-030 --- docs/BENCHMARK-SCALE.md | 1 + includes/engine/class-tmdo-hook-bus.php | 17 +++ .../query/class-tmdo-post-query-router.php | 53 +++++++ tests/bootstrap.php | 2 + .../PostQueryRouterFoundRowsTest.php | 136 ++++++++++++++++++ tests/integration/bootstrap.php | 2 + 6 files changed, 211 insertions(+) create mode 100644 tests/integration/PostQueryRouterFoundRowsTest.php diff --git a/docs/BENCHMARK-SCALE.md b/docs/BENCHMARK-SCALE.md index ef27d17..3eb6066 100644 --- a/docs/BENCHMARK-SCALE.md +++ b/docs/BENCHMARK-SCALE.md @@ -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 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。 - **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。 + - **PR-030**:路由後的搜尋不再帶 `SQL_CALC_FOUND_ROWS`;總數改由同一查詢的 `COUNT(*)` 回答,存在物件快取,key 綁 `last_changed(posts)`(核心存文、Hook Bus 扁平寫入都會推進)。1M 實測:快取命中時整頁 **8 ms**(原 694 / 363 ms);快取失效後第一次為頁面 8 ms + COUNT 608 / 344 ms,與原本相當。任何 post 寫入都讓全部計數失效(WP last_changed 語意),寫入頻繁的站命中率較低。 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`。 diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 304d02f..91744f6 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -346,6 +346,7 @@ final class TMDO_Hook_Bus { // 清除快取 TMDO_Cache_Orchestrator::invalidate( $type, $entity_id, $group ); + self::touch_posts_last_changed( $type ); // 短路回傳 true(WP 認為寫入成功) return $result !== false; @@ -574,6 +575,8 @@ final class TMDO_Hook_Bus { TMDO_Cache_Orchestrator::invalidate( $type, (int) $object_id, $group ); } + self::touch_posts_last_changed( $type ); + do_action( 'wpdo_after_delete', $type, (int) $object_id, $meta_key, $meta_value, $result, (bool) $delete_all, $before_value ); // 若還要寫 EAV → return null 讓 WP 繼續刪除原生 meta @@ -613,6 +616,20 @@ final class TMDO_Hook_Bus { // 工具方法 // ───────────────────────────────────────────────────────── + /** + * Flat post writes skip update_metadata()'s own hooks in aeav_only, so + * move posts' last_changed here: caches keyed on it (WP query caches, + * routed found-rows counts) must not outlive the write. + * + * @param string $type Entity type. + * @return void + */ + private static function touch_posts_last_changed( string $type ): void { + if ( 'post' === $type ) { + wp_cache_set_posts_last_changed(); + } + } + /** * 從當前 filter 名稱推斷實體類型 * 例:update_user_metadata → user diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 2b486f4..1ba4611 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -27,6 +27,11 @@ * post_date and skips GROUP BY (one row per post), so MySQL can walk * `idx_post_status_date` and stop at the LIMIT. * + * Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every + * match inside the page query); found_posts_query answers from a COUNT of the + * same query, kept in the object cache until posts' last_changed moves (core + * post saves, and flat writes via TMDO_Hook_Bus). + * * 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes * only entity_type='post'. * @@ -53,6 +58,12 @@ class TMDO_Post_Query_Router { /** Query var holding the status / date copy route, when one applies. */ private const COPY_VAR = 'wpdo_post_copy_route'; + /** Query var holding the COUNT for found_posts_query. */ + private const COUNT_VAR = 'wpdo_post_count_sql'; + + /** Object cache group for found-rows counts. */ + private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; + /** * Per-request answer to "are this group's copies usable?". * @@ -71,6 +82,8 @@ class TMDO_Post_Query_Router { 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 ); + add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 ); + add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 ); } /** @@ -272,6 +285,46 @@ class TMDO_Post_Query_Router { return $groupby; } + /** + * Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later. + * + * @param string|null $request Full SELECT. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + public function posts_request( ?string $request, \WP_Query $query ): string { + $request = (string) ( $request ?? '' ); + if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) { + return $request; + } + $request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ); + $matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); + $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); + return $request; + } + + /** + * Answer found rows from the cached COUNT of a routed search. + * + * @param string|null $sql Default 'SELECT FOUND_ROWS()'. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + public function found_posts_query( ?string $sql, \WP_Query $query ): string { + $count_sql = $query->get( self::COUNT_VAR ); + if ( empty( $count_sql ) ) { + return (string) ( $sql ?? '' ); + } + $key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' ); + $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); + if ( false === $total ) { + global $wpdb; + $total = (int) $wpdb->get_var( $count_sql ); + wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS ); + } + return 'SELECT ' . (int) $total; + } + // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8bb4bda..54161d5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -230,6 +230,8 @@ if ( ! function_exists( 'esc_sql' ) ) { return addslashes( $s ); } } +if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } } +if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } } if ( ! function_exists( '_doing_it_wrong' ) ) { function _doing_it_wrong( string $fn, string $msg, string $ver ): void { $GLOBALS['_doing_it_wrong_calls'][] = array( $fn, $msg ); } } diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php new file mode 100644 index 0000000..825f55d --- /dev/null +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -0,0 +1,136 @@ +query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_date DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, fr_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); + $wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type) VALUES (1, 'fr_item'), (2, 'fr_item'), (3, 'fr_item')" ); + $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, fr_price) VALUES (1, 10), (2, 20), (3, 30)' ); + + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cache'] = array(); + $GLOBALS['_wp_last_changed'] = array(); + $GLOBALS['_wp_is_admin'] = false; + $GLOBALS['_wp_current_filter'] = ''; + $GLOBALS['_wp_post_types'] = array( 1 => 'fr_item', 2 => 'fr_item', 3 => 'fr_item' ); + $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', 'fr_core', array( array( 'key' => 'fr_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'fr_item' ) ) ); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $mode->setAccessible( true ); + $mode->setValue( null, null ); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->posts, self::TABLE ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + /** A routed query plus the SQL WordPress would build for it (page size 1). */ + private function routed(): array { + global $wpdb; + $query = new WP_Query(); + $query->set( 'post_type', 'fr_item' ); + $query->set( 'post_status', 'publish' ); + $query->set( 'meta_query', array( array( 'key' => 'fr_price', 'value' => 15, 'compare' => '>=' ) ) ); + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + + $request = "SELECT SQL_CALC_FOUND_ROWS {$wpdb->posts}.ID FROM {$wpdb->posts} " . $router->posts_join( '', $query ) + . " WHERE 1=1 AND {$wpdb->posts}.post_type = 'fr_item' AND (({$wpdb->posts}.post_status = 'publish'))" . $router->posts_where( '', $query ) + . " GROUP BY {$wpdb->posts}.ID ORDER BY {$wpdb->posts}.post_date DESC LIMIT 0, 1"; + return array( $router, $query, $request ); + } + + public function test_routed_request_drops_sql_calc_found_rows(): void { + list( $router, $query, $request ) = $this->routed(); + + $sql = $router->posts_request( $request, $query ); + + $this->assertStringNotContainsString( 'SQL_CALC_FOUND_ROWS', $sql ); + $this->assertStringContainsString( 'LIMIT 0, 1', $sql ); + } + + public function test_unrouted_request_is_left_alone(): void { + $router = new TMDO_Post_Query_Router(); + $request = 'SELECT SQL_CALC_FOUND_ROWS wp_itest_posts.ID FROM wp_itest_posts WHERE 1=1 LIMIT 0, 10'; + + $this->assertSame( $request, $router->posts_request( $request, new WP_Query() ) ); + $this->assertSame( 'SELECT FOUND_ROWS()', $router->found_posts_query( 'SELECT FOUND_ROWS()', new WP_Query() ) ); + } + + public function test_total_is_counted_once_and_served_from_cache_until_posts_change(): void { + global $wpdb; + list( $router, $query, $request ) = $this->routed(); + $router->posts_request( $request, $query ); + + $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) ); + + $wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' ); + $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' ); + + wp_cache_set_posts_last_changed(); + $this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' ); + } + + public function test_flat_writes_and_deletes_move_posts_last_changed(): void { + $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; + $before = wp_cache_get_last_changed( 'posts' ); + WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' ); + $after_write = wp_cache_get_last_changed( 'posts' ); + + $GLOBALS['_wp_current_filter'] = 'delete_post_metadata'; + WPDO_Hook_Bus::intercept_delete( null, 1, 'fr_price', '', false ); + + $this->assertNotSame( $before, $after_write ); + $this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index ea55d9d..0ab1764 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -408,6 +408,8 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) { } $GLOBALS['_wp_cache'] = []; +if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } } +if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } } if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } } if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } } if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } }