754f904e3b
- 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
264 lines
12 KiB
PHP
264 lines
12 KiB
PHP
<?php
|
||
/**
|
||
* Scale benchmark: native postmeta vs hot table vs entity table.
|
||
*
|
||
* Runs against the dataset from tests/bench/seed.php:
|
||
* TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php [--iterations=20] [--seed=7]
|
||
*
|
||
* SQL mirrors what the code under test emits: WP_Query meta_query (INNER JOIN
|
||
* per clause, CAST for numerics, SQL_CALC_FOUND_ROWS + FOUND_ROWS()),
|
||
* TMDO_Query_Router (hot) and TMDO_Post_Query_Router (entity, %s-quoted values).
|
||
* Writes run inside a transaction that is rolled back, so the dataset never changes.
|
||
*
|
||
* Output: a Markdown table on stdout (p50 / p95 / max in ms, result rows, index used).
|
||
*
|
||
* @package TMDO\Tests\Bench
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
if ( 'cli' !== PHP_SAPI ) {
|
||
exit( 1 );
|
||
}
|
||
|
||
require_once __DIR__ . '/class-tmdo-bench-seeder.php';
|
||
|
||
const P = 'wp_itest_';
|
||
|
||
$opts = getopt( '', array( 'iterations::', 'seed::' ) );
|
||
$iterations = max( 1, (int) ( $opts['iterations'] ?? 20 ) );
|
||
$warmup = 3;
|
||
$db_name = (string) getenv( 'TMDO_TEST_DB_NAME' );
|
||
|
||
try {
|
||
TMDO_Bench_Seeder::assert_bench_db( $db_name );
|
||
} catch ( InvalidArgumentException $e ) {
|
||
fwrite( STDERR, $e->getMessage() . PHP_EOL );
|
||
exit( 2 );
|
||
}
|
||
|
||
$db = new mysqli(
|
||
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
|
||
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
|
||
(string) getenv( 'TMDO_TEST_DB_PASS' ),
|
||
$db_name
|
||
);
|
||
$db->set_charset( 'utf8mb4' );
|
||
$db->query( 'SET SESSION max_statement_time = 30' ); // MariaDB: cap runaway native queries.
|
||
|
||
$vendors = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_vendor'" )->fetch_row()[0];
|
||
$listings = (int) $db->query( 'SELECT COUNT(*) FROM `' . P . "posts` WHERE post_type = 'hp_listing'" )->fetch_row()[0];
|
||
if ( 0 === $vendors || 0 === $listings ) {
|
||
fwrite( STDERR, "No bench data — run tests/bench/seed.php first.\n" );
|
||
exit( 3 );
|
||
}
|
||
|
||
mt_srand( (int) ( $opts['seed'] ?? 7 ) );
|
||
|
||
// ── SQL builders ──────────────────────────────────────────────────────────
|
||
|
||
/** WP_Query shape with one INNER JOIN per meta clause. */
|
||
function native_search( array $meta_where ): string {
|
||
$joins = '';
|
||
$where = array();
|
||
foreach ( $meta_where as $n => $cond ) {
|
||
$alias = 0 === $n ? P . 'postmeta' : "mt{$n}";
|
||
$joins .= ' INNER JOIN `' . P . 'postmeta`' . ( 0 === $n ? '' : " AS {$alias}" ) . " ON ( p.ID = {$alias}.post_id )";
|
||
$where[] = '( ' . sprintf( $cond, $alias ) . ' )';
|
||
}
|
||
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p{$joins} WHERE 1=1 AND ( " . implode( ' AND ', $where )
|
||
. " ) AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20";
|
||
}
|
||
|
||
/** TMDO router shape: one LEFT JOIN to a flat table plus flat WHERE clauses. */
|
||
function flat_search( string $table, string $alias, string $conditions ): string {
|
||
return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p LEFT 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} 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++ ) {
|
||
$ids[] = $offset + mt_rand( 1, $max );
|
||
}
|
||
return implode( ',', $ids );
|
||
}
|
||
|
||
// ── Scenarios: label => [ variant => fn(): string[] (statements) ] ─────────
|
||
|
||
$price = static function (): array {
|
||
$lo = mt_rand( 100, 80000 );
|
||
return array( $lo, $lo + 20000 ); // ~20% of the 100..100000 range.
|
||
};
|
||
|
||
$scenarios = array(
|
||
'1. 價格區間搜尋(約 20%)' => array(
|
||
'native' => function () use ( $price ) {
|
||
[ $lo, $hi ] = $price();
|
||
return array( native_search( array( "%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'" ) ), 'SELECT FOUND_ROWS()' );
|
||
},
|
||
'hot' => function () use ( $price ) {
|
||
[ $lo, $hi ] = $price();
|
||
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi}" ), 'SELECT FOUND_ROWS()' );
|
||
},
|
||
'entity' => function () use ( $price ) {
|
||
[ $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 ) {
|
||
[ $lo, $hi ] = $price();
|
||
return array(
|
||
native_search(
|
||
array(
|
||
"%1\$s.meta_key = 'hp_price' AND CAST(%1\$s.meta_value AS DECIMAL(10,2)) BETWEEN '{$lo}' AND '{$hi}'",
|
||
"%1\$s.meta_key = 'hp_verified' AND %1\$s.meta_value = '1'",
|
||
)
|
||
),
|
||
'SELECT FOUND_ROWS()',
|
||
);
|
||
},
|
||
'hot' => function () use ( $price ) {
|
||
[ $lo, $hi ] = $price();
|
||
return array( flat_search( 'wpdo_hot_hp_listing', 'h', " AND `h`.`hp_price` BETWEEN {$lo} AND {$hi} AND `h`.`hp_verified` = 1" ), 'SELECT FOUND_ROWS()' );
|
||
},
|
||
'entity' => function () use ( $price ) {
|
||
[ $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()' ),
|
||
'native' => fn() => array( native_search( array( "%1\$s.meta_key = 'hp_vendor' AND %1\$s.meta_value = '" . mt_rand( 1, $vendors ) . "'" ) ), 'SELECT FOUND_ROWS()' ),
|
||
'hot' => fn() => array( flat_search( 'wpdo_hot_hp_listing', 'h', ' AND `h`.`hp_vendor` = ' . mt_rand( 1, $vendors ) ), 'SELECT FOUND_ROWS()' ),
|
||
'entity' => fn() => array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_vendor` = '" . mt_rand( 1, $vendors ) . "'" ), 'SELECT FOUND_ROWS()' ),
|
||
),
|
||
'4. 單筆 listing 讀全部 meta' => array(
|
||
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 1, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
|
||
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
|
||
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id = ' . random_ids( 1, $vendors, $listings ) ),
|
||
),
|
||
'5. 列表頁預載 20 筆 meta' => array(
|
||
'native' => fn() => array( 'SELECT post_id, meta_key, meta_value FROM `' . P . 'postmeta` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ') ORDER BY meta_id ASC' ),
|
||
'hot' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_hot_hp_listing` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
|
||
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_post_hp_listing_core` WHERE post_id IN (' . random_ids( 20, $vendors, $listings ) . ')' ),
|
||
),
|
||
'6. 單一 vendor 讀使用者 meta' => array(
|
||
'native' => fn() => array( 'SELECT user_id, meta_key, meta_value FROM `' . P . 'usermeta` WHERE user_id IN (' . mt_rand( 1, $vendors ) . ') ORDER BY umeta_id ASC' ),
|
||
'entity' => fn() => array( 'SELECT * FROM `' . P . 'wpdo_user_hp_user` WHERE user_id = ' . mt_rand( 1, $vendors ) ),
|
||
),
|
||
'7. 寫入:更新一筆 listing 價格' => array(
|
||
'native' => fn() => array( 'UPDATE `' . P . "postmeta` SET meta_value = '" . mt_rand( 100, 100000 ) . ".00' WHERE post_id = " . random_ids( 1, $vendors, $listings ) . " AND meta_key = 'hp_price'" ),
|
||
'entity' => fn() => array( 'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '" . mt_rand( 100, 100000 ) . "' WHERE post_id = " . random_ids( 1, $vendors, $listings ) ),
|
||
'dual_write' => function () use ( $vendors, $listings ) {
|
||
$id = random_ids( 1, $vendors, $listings );
|
||
$v = mt_rand( 100, 100000 );
|
||
return array(
|
||
'UPDATE `' . P . "postmeta` SET meta_value = '{$v}.00' WHERE post_id = {$id} AND meta_key = 'hp_price'",
|
||
'UPDATE `' . P . "wpdo_post_hp_listing_core` SET hp_price = '{$v}' WHERE post_id = {$id}",
|
||
);
|
||
},
|
||
),
|
||
);
|
||
|
||
// ── Run ───────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* @return array{ms: float, rows: int|string, timeout: bool}
|
||
*/
|
||
function run_statements( mysqli $db, array $statements ): array {
|
||
$is_write = str_starts_with( $statements[0], 'UPDATE' );
|
||
if ( $is_write ) {
|
||
$db->begin_transaction();
|
||
}
|
||
$rows = 0;
|
||
$timeout = false;
|
||
$start = hrtime( true );
|
||
foreach ( $statements as $n => $sql ) {
|
||
try {
|
||
$res = $db->query( $sql );
|
||
} catch ( mysqli_sql_exception $e ) {
|
||
if ( 1969 !== $e->getCode() ) { // ER_STATEMENT_TIMEOUT.
|
||
throw $e;
|
||
}
|
||
$timeout = true;
|
||
break;
|
||
}
|
||
if ( $res instanceof mysqli_result ) {
|
||
$rows = str_starts_with( $sql, 'SELECT FOUND_ROWS' ) ? (int) $res->fetch_row()[0] : ( 0 === $n ? $res->num_rows : $rows );
|
||
$res->free();
|
||
} elseif ( $is_write ) {
|
||
$rows = $db->affected_rows;
|
||
}
|
||
}
|
||
$ms = ( hrtime( true ) - $start ) / 1e6;
|
||
if ( $is_write ) {
|
||
$db->rollback();
|
||
}
|
||
return array( 'ms' => $ms, 'rows' => $rows, 'timeout' => $timeout );
|
||
}
|
||
|
||
function explain_keys( mysqli $db, string $sql ): string {
|
||
if ( ! str_starts_with( $sql, 'SELECT' ) ) {
|
||
return '—';
|
||
}
|
||
$keys = array();
|
||
$res = $db->query( 'EXPLAIN ' . $sql );
|
||
while ( $row = $res->fetch_assoc() ) {
|
||
$keys[] = $row['table'] . ':' . ( $row['key'] ?? 'ALL' );
|
||
}
|
||
return implode( ', ', $keys );
|
||
}
|
||
|
||
function percentile( array $sorted, float $p ): float {
|
||
return $sorted[ (int) min( count( $sorted ) - 1, floor( $p * count( $sorted ) ) ) ];
|
||
}
|
||
|
||
printf( "Dataset: %d vendors, %d listings — %d iterations (+%d warm-up) per variant\n\n", $vendors, $listings, $iterations, $warmup );
|
||
echo "| 情境 | 形式 | p50 ms | p95 ms | max ms | 結果筆數(中位) | 使用索引 |\n|---|---|---:|---:|---:|---:|---|\n";
|
||
|
||
foreach ( $scenarios as $label => $variants ) {
|
||
foreach ( $variants as $variant => $build ) {
|
||
$times = array();
|
||
$rows = array();
|
||
$timeouts = 0;
|
||
$keys = explain_keys( $db, $build()[0] );
|
||
for ( $i = 0; $i < $warmup + $iterations; $i++ ) {
|
||
$r = run_statements( $db, $build() );
|
||
if ( $i < $warmup ) {
|
||
continue;
|
||
}
|
||
$timeouts += (int) $r['timeout'];
|
||
$times[] = $r['ms'];
|
||
$rows[] = $r['rows'];
|
||
}
|
||
sort( $times );
|
||
sort( $rows );
|
||
printf(
|
||
"| %s | %s | %.2f | %.2f | %.2f | %s | %s |\n",
|
||
$label,
|
||
$variant . ( $timeouts ? " ⚠ {$timeouts}× timeout" : '' ),
|
||
percentile( $times, 0.5 ),
|
||
percentile( $times, 0.95 ),
|
||
end( $times ),
|
||
$rows[ intdiv( count( $rows ), 2 ) ],
|
||
$keys
|
||
);
|
||
}
|
||
}
|