<? phpukraine СТАТТІ
Пошук по платформі
WORDPRESS 14 вересня 2026 · 8 хв читання

База даних у WordPress: коли WP_Query достатньо, а коли потрібен $wpdb і власна таблиця

Схема `wp_postmeta` з коробки має індекс на `post_id` і префіксний індекс на `meta_key`. Індексу на `meta_value` немає і не буде: це `longtext`. Тому будь-який фільтр за значенням мета-поля - це JOIN на кожну умову, повний перебір відібраних рядків і сортування у тимчасовій таблиці. Розбираємо, що видно в EXPLAIN, які параметри WP_Query реально економлять запити, як писати прямий SQL через $wpdb без дірок, і в якій точці чесніше завести власну таблицю з нормальними індексами замість того, щоб рятувати postmeta.

РP
Редакція phpukraine
Редакція платформи

Каталог із фільтрами за ціною, наявністю і парою атрибутів впирається в базу раніше за все інше в проєкті. Причина майже завжди одна й та сама, і її добре видно в 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() наприкінці синку самі.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 15 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті