Каждый такой запрос загружает ВСЕ товары из базы данных в память 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,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)Файл: template-parts/temp_product_loop.php
Этот файл вызывается для КАЖДОГО товара в цикле. Внутри него делаются SQL-запросы:
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 запрос$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$unit_step = get_field('unit_step', $product_id); // SQL через ACF
$unit_label = get_field('unit_label', $product_id); // SQL через ACF$pack_terms = get_the_terms($product_id, 'pa_pack'); // SQL запросecho do_shortcode('[product_delivery_date]'); // Ещё SQL внутри шорткодаРасчёт нагрузки:
- 50 товаров на странице
- 10+ SQL запросов на товар
- = 500+ SQL запросов на одну загрузку страницы каталога
Файл: 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 запросов на страницу категории
Файлы и строки:
template-parts/wp_query-product_category.php: 99-105, 146-152template-parts/wp_query-product_category-today.php: аналогичные места
// Текущий код — НЕПРАВИЛЬНО
'meta_query' => [
[
'key' => '_stock_status',
'compare' => '=', // Сравнение с чем? Нет 'value'!
]
]
// Правильно
'meta_query' => [
[
'key' => '_stock_status',
'value' => 'instock', // Добавить значение
'compare' => '=',
]
]| Файл | Строка | Проблема |
|---|---|---|
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
));Файлы:
wp_query-product_home.php: 36-37wp_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));Файл: 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 берёт из кешаФайл: 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).
Файл: template-parts/temp_product_loop.php
Строка: 200
echo do_shortcode('[product_delivery_date]');Шорткоды — это дорогие операции. Лучше вызывать функцию напрямую.
WordPress хранит кеш в памяти PHP, которая очищается после каждого запроса. Нужно установить Redis или Memcached.
Решение:
- Установить Redis на сервере
- Установить плагин
Redis Object Cache - Добавить в
wp-config.php:
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_REDIS_PORT', 6379);Добавить предзагрузку ПЕРЕД циклом товаров:
<?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;
?>Вместо отдельного запроса на каждую подкатегорию — один запрос на все:
<?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();
?>- temp_product_loop.php — добавить предзагрузку изображений и мета-данных
- wp_query-product_category.php — убрать вложенный WP_Query, добавить пагинацию
- wp_query-product_home.php — заменить
posts_per_page => -1на24
- Удалить все
cache_results => false - Исправить битые
meta_query(добавитьvalue) - Исправить остальные
posts_per_page => -1
- Добавить индексы в MySQL
- Установить Redis Object Cache
- Исправить 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 (на уровне сервера)