Skip to content

Instantly share code, notes, and snippets.

@NeilAlishev
Created December 23, 2025 15:53
Show Gist options
  • Select an option

  • Save NeilAlishev/4bdf89cf74a6d4f5d52e9bd1229e0b13 to your computer and use it in GitHub Desktop.

Select an option

Save NeilAlishev/4bdf89cf74a6d4f5d52e9bd1229e0b13 to your computer and use it in GitHub Desktop.

Критические проблемы

1. posts_per_page => -1 — загрузка ВСЕХ товаров в память

Каждый такой запрос загружает ВСЕ товары из базы данных в память PHP. При 2000 товарах это вызывает:

  • Исчерпание памяти PHP (memory_limit)
  • Блокировку MySQL на время выполнения запроса
  • Таймауты при нескольких одновременных пользователях
Файл Строка Контекст
template-parts/wp_query-product_home.php 34 Главная страница
template-parts/wp_query-product_category.php 89 Страница категории (в цикле!)
template-parts/wp_query-product_category.php 167 Родительская категория
template-parts/wp_query-product_category-today.php 104 Категории "сегодня"
template-parts/wp_query-product_category-today.php 153 Категории "сегодня"
template-parts/wp_query-product_single.php 38 Похожие товары
template-parts/wp_query-product_cart.php 30 Корзина
template-parts/wp_query-product_popular.php 38 Популярные товары
custom_functions.php 919 AJAX фильтр подкатегорий
custom_functions.php 953 AJAX фильтр подкатегорий
custom_functions.php 2824 Другие запросы
custom_functions.php 5712 Другие запросы
functions.php 565 ACF layout
functions.php 1064 Функция копирования
functions.php 1959 Функция поиска

Исправление:

// Было
'posts_per_page' => -1,

// Стало — добавить пагинацию
'posts_per_page' => 24,  // или 48 для каталога
'paged' => get_query_var('paged') ? get_query_var('paged') : 1,

2. cache_results => false — отключённое кеширование

WordPress по умолчанию кеширует результаты WP_Query. Эта настройка отключает кеш, заставляя каждый раз делать новый запрос в БД.

Файл Строка
template-parts/wp_query-product_cart.php 35
template-parts/wp_query-product_home.php 42
template-parts/wp_query-product_single.php 43
template-parts/wp_query-product_popular.php 45

Исправление:

// Было
'cache_results' => false,

// Стало — просто удалить эту строку (по умолчанию true)

3. N+1 проблема в temp_product_loop.php — САМАЯ КРИТИЧНАЯ

Файл: template-parts/temp_product_loop.php

Этот файл вызывается для КАЖДОГО товара в цикле. Внутри него делаются SQL-запросы:

Строки 85-95 (цикл по изображениям):

foreach ($all_images as $attachment_id):
    $image_url = wp_get_attachment_image_url($attachment_id, 'thumbnail');    // SQL запрос
    $image_srcset = wp_get_attachment_image_srcset($attachment_id, 'thumbnail'); // SQL запрос
    $image_sizes = wp_get_attachment_image_sizes($attachment_id, 'thumbnail');   // SQL запрос
    $alt_text = get_post_meta($attachment_id, '_wp_attachment_image_alt', true); // SQL запрос

Строки 117-119:

$discount_percent = get_current_discount_for_product($product_id);  // SQL через ACF
$base_price = get_price_corrected($product, "price", "", true);     // SQL запрос
$total_weight = get_field('weight_discount_data', $product_id);     // SQL через ACF

Строки 160-161:

$unit_step = get_field('unit_step', $product_id);   // SQL через ACF
$unit_label = get_field('unit_label', $product_id); // SQL через ACF

Строка 164:

$pack_terms = get_the_terms($product_id, 'pa_pack'); // SQL запрос

Строка 200:

echo do_shortcode('[product_delivery_date]'); // Ещё SQL внутри шорткода

Расчёт нагрузки:

  • 50 товаров на странице
  • 10+ SQL запросов на товар
  • = 500+ SQL запросов на одну загрузку страницы каталога

4. Вложенный WP_Query в цикле

Файл: template-parts/wp_query-product_category.php Строки: 86-131

foreach ($subcategories as $subcat) {          // Цикл по 10 подкатегориям
    $products = new WP_Query([                  // WP_Query НА КАЖДУЮ подкатегорию
        'posts_per_page' => -1,                 // + загружает ВСЕ товары каждой
        ...
    ]);
    while ($products->have_posts()) {
        include("temp_product_loop.php");       // + N+1 внутри каждого товара
    }
}

Расчёт:

  • 10 подкатегорий × 1 WP_Query = 10 запросов
  • 10 подкатегорий × 50 товаров × 10 запросов = 5000 запросов
  • Итого: 5010+ SQL запросов на страницу категории

5. Битый meta_query (отсутствует value)

Файлы и строки:

  • template-parts/wp_query-product_category.php: 99-105, 146-152
  • template-parts/wp_query-product_category-today.php: аналогичные места
// Текущий код — НЕПРАВИЛЬНО
'meta_query' => [
    [
        'key' => '_stock_status',
        'compare' => '=',      // Сравнение с чем? Нет 'value'!
    ]
]

// Правильно
'meta_query' => [
    [
        'key' => '_stock_status',
        'value' => 'instock',   // Добавить значение
        'compare' => '=',
    ]
]

6. SQL-запросы без prepare (потенциальные SQL-инъекции)

Файл Строка Проблема
functions.php 223 $range и $meta_field не экранированы
functions.php 1442 $post_id не через prepare
functions.php 1913 $filename не экранирован
functions.php 2193, 2212 DELETE без LIMIT — может заблокировать таблицу
includes/class-sms-auth-codes.php 373 DELETE без prepare
includes/class-sms-auth-db.php 59 Переменная в SQL без экранирования

Пример проблемы (functions.php:223):

// Было — SQL инъекция возможна!
$value = $wpdb->get_var("SELECT $range(CAST(meta_value AS UNSIGNED))
    FROM `{$wpdb->prefix}postmeta` WHERE meta_key = '$meta_field'");

// Стало — безопасно
$allowed_ranges = ['MIN', 'MAX', 'AVG'];
$range = in_array($range, $allowed_ranges) ? $range : 'MAX';
$value = $wpdb->get_var($wpdb->prepare(
    "SELECT $range(CAST(meta_value AS UNSIGNED))
    FROM {$wpdb->prefix}postmeta WHERE meta_key = %s",
    $meta_field
));

Высокий приоритет

7. Сортировка по meta_value без индекса в БД

Файлы:

  • wp_query-product_home.php: 36-37
  • wp_query-product_popular.php: аналогично
'orderby' => 'meta_value_num',
'meta_key' => 'total_sales',

MySQL выполняет полный скан таблицы wp_postmeta (может быть миллионы записей).

Исправление — добавить индекс в MySQL:

ALTER TABLE wp_postmeta ADD INDEX idx_meta_key_value (meta_key(191), meta_value(100));

8. get_term_meta в цикле

Файл: template-parts/wp_query-product_category.php Строки: 42-48, 69-70

foreach ($subcategories as $subcat) {
    $count = get_term_meta($subcat->term_id, 'product_count_product_cat', true); // SQL на каждую итерацию!
}

Исправление — предзагрузить:

// Собрать все term_id
$term_ids = wp_list_pluck($subcategories, 'term_id');
// Предзагрузить мету одним запросом
update_termmeta_cache($term_ids);
// Теперь get_term_meta берёт из кеша

9. Дублирование кода

Файл: custom_functions.php Строки: 1186-1193 и 1196-1203

// Код повторяется дважды!
add_action('wp_enqueue_scripts', function () {
    if (is_page('cart') || is_cart()) {
        wp_localize_script('jquery', 'wc_cart_fragments_params', [...]);
    }
});

// ТОТ ЖЕ КОД ещё раз
add_action('wp_enqueue_scripts', function () {
    if (is_page('cart') || is_cart()) {
        wp_localize_script('jquery', 'wc_cart_fragments_params', [...]);
    }
});

Исправление: Удалить дубликат (строки 1196-1203).


Средний приоритет

10. Шорткод в цикле

Файл: template-parts/temp_product_loop.php Строка: 200

echo do_shortcode('[product_delivery_date]');

Шорткоды — это дорогие операции. Лучше вызывать функцию напрямую.


11. Отсутствие Object Cache

WordPress хранит кеш в памяти PHP, которая очищается после каждого запроса. Нужно установить Redis или Memcached.

Решение:

  1. Установить Redis на сервере
  2. Установить плагин Redis Object Cache
  3. Добавить в wp-config.php:
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);

Как исправить

Исправление N+1 для изображений

Добавить предзагрузку ПЕРЕД циклом товаров:

<?php
// ПЕРЕД while ($products->have_posts())

// Собрать все ID товаров
$product_ids = wp_list_pluck($products->posts, 'ID');

// Собрать все ID изображений
$attachment_ids = [];
foreach ($products->posts as $post) {
    $product = wc_get_product($post->ID);
    if ($product) {
        $image_id = $product->get_image_id();
        if ($image_id) $attachment_ids[] = $image_id;
        $gallery = $product->get_gallery_image_ids();
        if ($gallery) $attachment_ids = array_merge($attachment_ids, $gallery);
    }
}
$attachment_ids = array_unique(array_filter($attachment_ids));

// Предзагрузить ВСЕ attachment'ы одним запросом
if (!empty($attachment_ids)) {
    _prime_post_caches($attachment_ids);
    update_meta_cache('post', $attachment_ids);
}

// Предзагрузить ACF поля для всех товаров
if (function_exists('acf_get_field_groups')) {
    foreach ($product_ids as $pid) {
        // ACF кеширует после первого вызова
        get_fields($pid);
    }
}

// Теперь запускаем цикл — все данные уже в кеше
while ($products->have_posts()):
    $products->the_post();
    include("temp_product_loop.php");
endwhile;
?>

Исправление вложенного WP_Query

Вместо отдельного запроса на каждую подкатегорию — один запрос на все:

<?php
// Было: foreach + WP_Query на каждую подкатегорию

// Стало: один запрос
$subcat_ids = wp_list_pluck($subcategories, 'term_id');

$args = [
    'post_type' => 'product',
    'posts_per_page' => 200, // Разумный лимит
    'tax_query' => [
        'relation' => 'AND',
        [
            'taxonomy' => 'product_cat',
            'field' => 'id',
            'terms' => $subcat_ids, // Все подкатегории сразу
        ],
        // + фильтр по городу если нужен
    ],
];

$all_products = new WP_Query($args);

// Группировка по категориям на PHP (быстрее чем N запросов)
$grouped = [];
while ($all_products->have_posts()) {
    $all_products->the_post();
    $cats = wp_get_post_terms(get_the_ID(), 'product_cat', ['fields' => 'ids']);
    foreach ($cats as $cat_id) {
        if (in_array($cat_id, $subcat_ids)) {
            $grouped[$cat_id][] = get_the_ID();
            break;
        }
    }
}

// Вывод по группам
foreach ($subcategories as $subcat) {
    if (empty($grouped[$subcat->term_id])) continue;
    echo '<div class="product-subcategory">';
    echo '<h2>' . $subcat->name . '</h2>';
    foreach ($grouped[$subcat->term_id] as $product_id) {
        $GLOBALS['post'] = get_post($product_id);
        setup_postdata($GLOBALS['post']);
        include("temp_product_loop.php");
    }
    echo '</div>';
}
wp_reset_postdata();
?>

Порядок исправлений

Этап 1 — Критические (сделать первыми)

  1. temp_product_loop.php — добавить предзагрузку изображений и мета-данных
  2. wp_query-product_category.php — убрать вложенный WP_Query, добавить пагинацию
  3. wp_query-product_home.php — заменить posts_per_page => -1 на 24

Этап 2 — Высокий приоритет

  1. Удалить все cache_results => false
  2. Исправить битые meta_query (добавить value)
  3. Исправить остальные posts_per_page => -1

Этап 3 — Оптимизация

  1. Добавить индексы в MySQL
  2. Установить Redis Object Cache
  3. Исправить SQL без prepare

Ожидаемый результат

Метрика До После
SQL запросов на страницу каталога 5000+ 50-100
Время загрузки страницы 5-15 сек 0.5-1 сек
Максимум одновременных пользователей ~50 500+
Потребление памяти PHP 256MB+ 64-128MB

После исправлений 1 нода будет держать 200+ пользователей без проблем.


Дополнительные рекомендации

Мониторинг

Установить плагин Query Monitor для отслеживания количества SQL-запросов на каждой странице.

Профилирование

Включить slow query log в MySQL:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

Кеширование страниц

После исправления кода — добавить page cache:

  • WP Super Cache (бесплатный)
  • WP Rocket (платный, лучший)
  • Nginx FastCGI Cache (на уровне сервера)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment