hardening 2.0: 2meet-data-optimizer (staged rollout, dev22 first) #3

Merged
wpdev merged 30 commits from hardening/2.0 into main 2026-09-26 10:20:24 +08:00
2 changed files with 319 additions and 0 deletions
Showing only changes of commit d3b72bd1af - Show all commits
+70
View File
@@ -0,0 +1,70 @@
# 規模效能基準:原生 postmeta vs Hot 表 vs Entity 表
**量測日期**:2026-09-25 **分支**:`hardening/2.0` **目標規模**:vendor 100,000、listing 1,000,000
## 如何重現
```bash
# DB 帳密沿用整合測試的 TMDO_TEST_DB_* 環境變數;資料庫名稱必須以 _bench 結尾
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000 # 約 7.5 分鐘
TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/run.php --iterations=20 # 約 20 分鐘
```
## 環境與資料
| 項目 | 值 |
|---|---|
| 資料庫 | MariaDB 10.11.14(本機,InnoDB 預設設定) |
| 主機 | 8 核、31 GB RAM、NVMe |
| 資料 | 100,000 vendor(users + hp_vendor posts)、1,000,000 listing(90% publish) |
| 原生 meta | postmeta 9,549,796 筆(951 MB)、usermeta 600,000 筆 |
| Entity 表 | `wpdo_post_hp_listing_core` 1,000,000 筆(379 MB) |
| Hot 表 | `wpdo_hot_hp_listing` 1,000,000 筆(229 MB) |
SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個條件一個 INNER JOIN、數值用 CAST、`SQL_CALC_FOUND_ROWS`),Hot 是 `TMDO_Query_Router`,Entity 是 `TMDO_Post_Query_Router`。每個情境 20 次,另有 3 次暖機不計入,參數隨機但可重現(`--seed=7`)。寫入在交易內執行後 rollback。
> 20 次取樣下 p95 等於最大值,僅供參考。
## 結果
| 情境 | 形式 | p50 ms | p95 ms | 結果筆數 | 使用索引 |
|---|---|---:|---:|---:|---|
| 1. 價格區間搜尋(命中約 20%) | 原生 | **14,695** | 14,890 | 180,292 | postmeta:post_id |
| | Hot | 558 | 3,605 | 180,329 | h:ALL |
| | Entity | 652 | 3,817 | 180,300 | e:ALL |
| 2. 價格區間 + verified=1 | 原生 | **17,094** | 17,250 | 54,197 | postmeta:post_id ×2 |
| | Hot | 277 | 299 | 54,109 | `idx_hp_verified_hp_price`(複合) |
| | Entity | 337 | 340 | 54,180 | e:ALL |
| 3. 某 vendor 名下的 listing | `post_parent`(HivePress 實際做法) | 0.19 | 0.25 | 7 | posts:post_parent |
| | 原生 meta `hp_vendor` | **14,432** | 14,599 | 8 | postmeta:post_id |
| | Hot | **3,405** | 3,651 | 10 | 無可用索引 |
| | Entity | 0.28 | 0.33 | 8 | `idx_hp_vendor` |
| 4. 單筆 listing 讀全部 meta | 原生 | 0.12 | 0.15 | 9 列 | postmeta:post_id |
| | Hot / Entity | 0.09 | 0.11–0.14 | 1 列 | 主鍵 |
| 5. 列表頁預載 20 筆 meta | 原生 | 0.59 | 0.61 | 181 列 | postmeta:post_id |
| | Hot / Entity | 0.21–0.22 | 0.23–0.26 | 20 列 | 主鍵 |
| 6. 單一 vendor 讀使用者 meta | 原生 | 0.11 | 0.14 | 6 列 | usermeta:user_id |
| | Entity | 0.08 | 0.11 | 1 列 | 主鍵 |
| 7. 寫入:更新一筆價格 | 原生 | 0.12 | 0.16 | — | — |
| | Entity | 0.10 | 0.13 | — | — |
| | dual_write(兩邊都寫) | 0.22 | 0.26 | — | — |
## 結論
1. **原生 meta_query 在百萬規模下無法使用。** 任何以 meta 為條件的搜尋都要 14–17 秒,扁平化是必要的,不是錦上添花。
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 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。
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)。
6. **dual_write 的寫入成本約為兩倍(0.22 ms 對 0.12 ms)**,絕對值很小,不是瓶頸。
7. **Hot 表和 Entity 表儲存了同一份 listing 資料**(合計約 608 MB),兩者功能重疊。
## 優化候選(待使用者確認後排入 backlog)
| # | 候選 | 針對 | 預期效果 |
|---|---|---|---|
| O-1 | Entity 表把 `post_status`、`post_date` 一併存進來,並建 `(hp_status, post_date)` 等複合索引,讓搜尋不必 JOIN 回 `posts` 排序 | 情境 1、2 | 寬範圍搜尋改為走索引排序,避免 filesort |
| O-2 | Entity 表支援群組層級的複合索引宣告,例如 `(hp_verified, hp_price)`、`(hp_featured, hp_price)` | 情境 2 | 追上或超過 Hot 表 |
| O-3 | Hot 表的索引規則補上外鍵型 bigint 欄位(例如 `hp_vendor`) | 情境 3 | 3.4 秒降到 1 ms 以下 |
| O-4 | 提供總數快取,或讓路由後的查詢可以不跑 `SQL_CALC_FOUND_ROWS`(MySQL 8 已棄用) | 情境 1、2 | 省去每次搜尋都要算出全部符合列的成本 |
| O-5 | 評估 Hot 表與 Entity 表的整併,避免同一份資料存兩份 | 容量、寫入 | 省下約 229 MB 並減少一次寫入 |
+249
View File
@@ -0,0 +1,249 @@
<?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";
}
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()' );
},
),
'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()' );
},
),
'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
);
}
}