Каталог із фільтрами за ціною, наявністю і парою атрибутів впирається в базу раніше за все інше в проєкті. Причина майже завжди одна й та сама, і її добре видно в SQL, який генерує WP_Query.
Увімкніть define('SAVEQUERIES', true) або поставте Query Monitor, відкрийте сторінку фільтра і подивіться на найповільніший запит. Виглядатиме він приблизно так:
SELECT wp_posts.ID FROM wp_posts
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
INNER JOIN wp_postmeta AS mt1 ON ( wp_posts.ID = mt1.post_id )
WHERE wp_posts.post_type = 'product'
AND wp_posts.post_status = 'publish'
AND ( ( wp_postmeta.meta_key = '_stock_status' AND wp_postmeta.meta_value = 'instock' )
AND ( mt1.meta_key = '_price'
AND CAST(mt1.meta_value AS DECIMAL(10,2)) BETWEEN 100 AND 500 ) )
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
LIMIT 0, 24
Кожна умова в meta_query дає окремий JOIN до wp_postmeta з аліасом mt1, mt2 і далі. GROUP BY wp_posts.ID WP додає автоматично, щойно в запиті є join з мета або таксономією, бо інакше пости дублюються. А коли GROUP BY іде по одній таблиці, а ORDER BY по іншій, в EXPLAIN з'являється Using temporary; Using filesort.
Чому postmeta не індексується так, як хочеться
Схема таблиці незмінна з давніх версій: meta_id, post_id, meta_key varchar(255), meta_value longtext. Індексів три: PRIMARY по meta_id, KEY post_id (post_id) і KEY meta_key (meta_key(191)).
Довжина 191 тут не випадкова. Після переходу на utf8mb4 у WP 4.2 повний varchar(255) перестав влазити в ліміт ключа InnoDB зі старим форматом рядка, і індекс обрізали до 191 символу. Практичного значення це майже не має, бо ключі мета коротші.
Значення не індексується взагалі. Ви не можете побудувати повний індекс на longtext, а префіксний (meta_value(64)) не працює для діапазонів і не рятує від CAST. Саме CAST забирає решту надій: щойно ви вказали 'type' => 'NUMERIC' або 'DECIMAL(10,2)', умова перестає бути sargable, і навіть якби індекс на значенні існував, оптимізатор його б не взяв.
Що залишається MySQL? Взяти пости за type_status_date, а далі для кожного відібраного ID зробити пробіг по post_id в postmeta, двічі, і відфільтрувати вже в пам'яті. Поки постів сотні, це непомітно. Коли їх десятки тисяч, а фільтрів чотири, ви отримуєте чотири join'и і сортування по полю, якого в жодному індексі немає.
Окремо про 'compare' => 'LIKE': WP формує %значення%, тобто з провідним wildcard. Це завжди повний перебір, незалежно від індексів.
Компромісний індекс, який іноді допомагає:
ALTER TABLE wp_postmeta ADD INDEX meta_key_value (meta_key(32), meta_value(64));
Він закриває точні збіги на кшталт _stock_status = 'instock' і зменшує кількість рядків, які доведеться читати. Він нічим не допоможе діапазону за ціною з CAST і нічим не допоможе сортуванню. Плюс ви платите за нього на кожному update_post_meta, а postmeta зазвичай найактивніша таблиця на запис у всій базі. Перед релізом, як тимчасовий захід, згодиться. Архітектурним рішенням він не стане.
Що реально економить у WP_Query
Перш ніж переписувати щось на SQL, приберіть очевидні втрати.
'no_found_rows' => true для всіх запитів без пагінації. У WP 6.0 ядро відмовилося від SQL_CALC_FOUND_ROWS і робить окремий COUNT(*); цей прапорець пропускає його повністю. Для віджета «останні п'ять новин» другий важкий запит не потрібен нікому.
'fields' => 'ids' повертає масив ID і виходить з get_posts() до блоку кешування, тобто не праймить ні пости, ні мета. Якщо ви далі робите get_post_meta() у циклі, це стане гіршим варіантом, не кращим.
'update_post_meta_cache' => false і 'update_post_term_cache' => false доречні, коли в шаблоні ви справді не торкаєтесь мета і термінів. Інакше залиште як є: один update_meta_cache() на всю вибірку дешевший за окремий запит на кожен пост.
Тепер про самі фільтри. Булеві фасети (бренд, колір, розмір, «є в наявності») майже завжди краще жити таксономіями, а не мета-полями. wp_term_relationships має PRIMARY KEY (object_id, term_taxonomy_id) і окремий ключ по term_taxonomy_id, тому tax_query join'иться по індексу в обидва боки. Мета-поля лишіть для того, чим ви не фільтруєте: описи, службові прапорці, кешовані значення.
Ціна, дата, рейтинг і будь-яке сортування за числом у postmeta не лікуються взагалі.
$wpdb без дірок
Коли ви вже пишете SQL руками, діють три правила.
prepare() завжди, навіть якщо значення «точно int». Плейсхолдери: %d, %f, %s, і з WP 6.2 %i для ідентифікаторів (імена таблиць і колонок). Імена таблиць беріть з $wpdb->prefix, а не з конфіга плагіна.
global $wpdb;
$ids = $wpdb->get_col($wpdb->prepare(
"SELECT post_id FROM {$wpdb->prefix}shop_product_index
WHERE stock_status = %s AND price BETWEEN %f AND %f
ORDER BY price ASC
LIMIT %d",
'instock', $min, $max, 100
));
Для списків IN генеруйте плейсхолдери за кількістю елементів, інакше ви або клеїте рядок вручну, або отримуєте помилку prepare:
$placeholders = implode(',', array_fill(0, count($post_ids), '%d'));
$sql = $wpdb->prepare(
"SELECT post_id, price FROM {$wpdb->prefix}shop_product_index WHERE post_id IN ($placeholders)",
$post_ids
);
Для LIKE спершу $wpdb->esc_like(), потім %s. Функції like_escape() немає з WP 4.0.
І головне: не відтворюйте wp_posts вручну. Права доступу, статуси, стікі-пости, кеш об'єктів, фільтри тем: усе це живе в WP_Query. Поділ праці має бути такий: ID знаходить швидкий SQL, а показує їх WP_Query:
$query = new WP_Query([
'post_type' => 'product',
'post__in' => $ids ?: [0],
'orderby' => 'post__in',
'posts_per_page' => 24,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
]);
$ids ?: [0] тут обов'язковий: порожній post__in WP просто ігнорує і повертає всі пости.
Помилки $wpdb тихі. get_results() поверне порожній масив і на порожній вибірці, і на синтаксичній помилці, тому після запиту, від якого щось залежить, перевіряйте $wpdb->last_error.
Власна таблиця
Момент, коли її пора заводити, впізнається за симптомами: більше двох фільтрів за мета одночасно, сортування за числовим мета-полем, або запит, який доводиться кешувати, бо інакше сторінка не відкривається. Прецедент в екосистемі є: WooCommerce тримає wp_wc_product_meta_lookup саме для фільтрів і сортування, а в HPOS переніс замовлення з wp_posts/wp_postmeta у власні таблиці.
postmeta лишається джерелом істини, редактор працює як працював, а поруч живе плоска таблиця, де кожен запитуваний атрибут отримує окрему типізовану колонку з індексом під конкретні запити.
function shop_install_index_table(): void
{
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = $wpdb->prefix . 'shop_product_index';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
post_id bigint(20) unsigned NOT NULL,
price decimal(12,2) NOT NULL DEFAULT 0,
stock_status varchar(20) NOT NULL DEFAULT '',
brand_id bigint(20) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (post_id),
KEY stock_price (stock_status,price),
KEY brand_price (brand_id,price)
) {$charset_collate};";
dbDelta($sql);
update_option('shop_index_schema_version', 3, false);
}
dbDelta() капризна до форматування: одне поле на рядок, два пробіли після PRIMARY KEY, KEY замість INDEX, у кожного індексу має бути ім'я. Порушите форматування, і на кожному запуску вона намагатиметься створити дубль індексу.
Викликати цю функцію лише з register_activation_hook() недостатньо: деплой без перевмикання плагіна схему не оновить. Надійніше тримати версію схеми в опції й перевіряти її на plugins_loaded.
Синхронізація вішається на мета-хуки, з фільтром за ключем, щоб не смикати переіндексацію на кожен запис:
add_action('added_post_meta', 'shop_sync_index', 10, 3);
add_action('updated_post_meta', 'shop_sync_index', 10, 3);
add_action('deleted_post_meta', 'shop_sync_index', 10, 3);
function shop_sync_index(int|array $meta_id, int $post_id, string $meta_key): void
{
if (!in_array($meta_key, ['_price', '_stock_status'], true)) {
return;
}
global $wpdb;
$wpdb->replace(
$wpdb->prefix . 'shop_product_index',
[
'post_id' => $post_id,
'price' => (float) get_post_meta($post_id, '_price', true),
'stock_status' => (string) get_post_meta($post_id, '_stock_status', true),
'brand_id' => shop_resolve_brand_id($post_id),
],
['%d', '%f', '%s', '%d']
);
}
replace() робить DELETE плюс INSERT, тому підходить, коли ви пишете рядок цілком. Часткові оновлення робіть через $wpdb->update(). На before_delete_post не забудьте $wpdb->delete(), інакше індекс обросте сиротами.
Бекфіл існуючих товарів робиться командою WP-CLI батчами по кілька сотень ID, а не одним posts_per_page => -1. І передбачте ручний перезапуск: рано чи пізно індекс розійдеться з реальністю після імпорту, який писав мета в обхід ваших хуків.
Далі таблицю можна підмішати прямо в WP_Query через posts_clauses, замість того щоб тягнути ID окремим запитом:
add_filter('posts_clauses', static function (array $clauses, WP_Query $query): array {
if (!$query->get('shop_index_price')) {
return $clauses;
}
global $wpdb;
[$min, $max] = $query->get('shop_index_price');
$table = $wpdb->prefix . 'shop_product_index';
$clauses['join'] .= " INNER JOIN {$table} spi ON spi.post_id = {$wpdb->posts}.ID ";
$clauses['where'] .= $wpdb->prepare(' AND spi.price BETWEEN %f AND %f ', $min, $max);
$clauses['orderby'] = 'spi.price ASC';
return $clauses;
}, 10, 2);
Обов'язково з власною query var як запобіжником: posts_clauses спрацьовує на кожному запиті постів, включно з адмінкою і меню.
Одна пастка наостанок. З WP 6.1 WP_Query кешує результати запитів, і ключ кешу будується з аргументів та фінального SQL, тож ваш join у posts_clauses у ключ потрапляє. А от інвалідація прив'язана до last_changed групи posts, який рухається при зміні постів. Якщо ви оновили індексну таблицю в обхід збереження поста (бекфіл з CLI, синк із зовнішньої системи), кеш про це не дізнається і віддаватиме стару вибірку. У такому сценарії бампайте wp_cache_set_posts_last_changed() наприкінці синку самі.