Скорость сайта

Медленные запросы MySQL: как найти виновника и ускорить сайт

Сайт тормозит из-за базы. Как включить лог медленных запросов, прочитать EXPLAIN и что делать, если тяжёлый запрос делает плагин.

13 минут чтения 2 Дмитрий Акиндинов
Содержание
  1. Сначала докажите, что виновата база
  2. Включаем лог медленных запросов
  3. Читаем EXPLAIN
  4. Индексы: когда помогают, а когда нет
  5. Частые виновники
  6. Что делать, если тяжёлый запрос делает плагин
  7. Когда база вообще ни при чём
  8. Чек-лист разбора
  9. Сколько это стоит и когда звать человека
  10. Частые вопросы

Виноватость базы доказывают до починки: если статика прилетает за 30-80 мс, а HTML ждёт две-три секунды, тормозит генерация страницы. Дальше включают лог медленных запросов с порогом полсекунды и смотрят, какой из них перебирает полтаблицы.

Страница грузится четыре секунды, картинки при этом легкие, кэш вроде включен, а браузер всё равно долго ждёт первый байт. В половине таких случаев виноват медленный ответ MySQL: один запрос перебирает полтаблицы, база упирается в диск, PHP стоит и ждёт. В статье разберём, как отличить тормоза базы от тормозов кода, как включить лог медленных запросов на shared-хостинге и на VPS, как читать вывод EXPLAIN и что делать, если тяжёлый запрос генерирует плагин, который вам нужен.

Схема разбора медленного запроса к базе данных

Пишу по следам своей практики: за шесть лет абонентки я вытаскивал базы после плагинов статистики, после чужих «оптимизаций» и после трёх лет накопленных транзиентов. Схема разбора почти всегда одна и та же.

Сначала докажите, что виновата база

Самая частая ошибка: сайт тормозит, владелец идёт покупать VPS помощнее, а проблема была в одном запросе без индекса. Прежде чем что-то менять, нужен факт, а не догадка.

Быстрая проверка занимает пять минут. Откройте сайт с отключенным кэшем страниц (в WordPress достаточно залогиниться в админку, кэш-плагины обычно не отдают кэш авторизованным). Посмотрите TTFB в панели разработчика браузера, вкладка Network, первая строка документа. Если статика прилетает за 30-80 мс, а сам HTML ждёт 2-3 секунды, тормозит генерация страницы. Дальше нужно понять, чем именно занят PHP: считает, ждёт внешний API или ждёт базу.

Прямой способ посмотреть, что происходит в базе прямо сейчас:

SHOW FULL PROCESSLIST;

Обновите страницу сайта в соседней вкладке и несколько раз выполните команду. Если в списке регулярно висят запросы в состоянии Sending data или Copying to tmp table с временем 1 секунда и больше, вы уже нашли своего клиента. Заодно посмотрите общую картину:

SHOW GLOBAL STATUS LIKE 'Slow_queries';
SHOW GLOBAL STATUS LIKE 'Threads_running';
SHOW VARIABLES LIKE 'long_query_time';

Slow_queries растёт на десятки в минуту при десятке посетителей: с базой точно беда. Threads_running стабильно выше 4-6 на маленьком сайте: запросы встают в очередь друг за другом.

Со стороны WordPress самый удобный инструмент это плагин Query Monitor (бесплатный, ставится на 20 минут и потом удаляется). Он показывает список всех запросов страницы, время каждого, и главное, стек вызова: какой плагин или тема запрос породили. В Битриксе есть встроенный модуль «Монитор производительности», вкладка с медленными запросами там сразу показывает точку в коде. Если нужен разбор именно по плагинам, у меня про это отдельный текст: какой плагин тормозит сайт.

Если TTFB большой, но база в это время пустая, копайте в другую сторону: лимиты хостинга, внешние виджеты, DNS. Про это подробно разобрано в статье про TTFB больше 600 мс на shared-хостинге.

Включаем лог медленных запросов

Лог медленных запросов (slow query log) это встроенный механизм MySQL: сервер сам пишет в файл каждый запрос, который выполнялся дольше заданного порога. Без него вы гадаете, с ним у вас список подозреваемых с точным временем.

На VPS с полным доступом

Правится конфиг, обычно /etc/mysql/my.cnf или /etc/mysql/mysql.conf.d/mysqld.cnf (Debian и Ubuntu), либо /etc/my.cnf (CentOS, AlmaLinux):

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
log_slow_admin_statements = 1

Порог 0.5 секунды, а не дефолтные 10. При десяти секундах в лог не попадёт ничего, кроме совсем аварийных случаев, а страницу вам убивают тридцать запросов по 200 мс. log_queries_not_using_indexes включайте временно, на пару часов: он быстро раздувает файл, зато сразу показывает, где нет индекса. После правки перезапуск:

sudo systemctl restart mysql
sudo tail -f /var/log/mysql/slow.log

Если рестарт нежелателен (магазин в рабочее время), то же самое включается на лету, без перезапуска, и сбросится при следующем рестарте сервиса:

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

Собирать лог руками не нужно, есть штатный агрегатор. Он группирует одинаковые по структуре запросы и сортирует по суммарному времени:

mysqldumpslow -s t -t 15 /var/log/mysql/slow.log

Если поставите percona-toolkit, pt-query-digest /var/log/mysql/slow.log даст отчёт заметно подробнее: доля каждого запроса в общем времени, распределение по времени выполнения, количество просмотренных строк. Я обычно смотрю не на самый долгий запрос, а на тот, у кого больше суммарное время: запрос на 80 мс, который выполняется 400 раз за страницу, вреднее одного запроса на 2 секунды.

На shared-хостинге

Доступа к my.cnf там нет, и это нормально. Варианты по убыванию удобства:

  1. Панель хостинга. У Timeweb, Beget и Reg.ru в разделе базы данных бывает вкладка с логом медленных запросов или отчёт по нагрузке от MySQL. Первым делом смотрите туда.
  2. Письмо в поддержку. Формулировка простая: «прошу включить slow query log для базы такой-то с порогом 0.5 секунды на сутки и дать файл». Обычно включают, я так делал десятки раз. Отказ бывает у самых дешёвых тарифов.
  3. Логирование со стороны PHP. В WordPress достаточно константы в wp-config.php:
define('SAVEQUERIES', true);

После этого массив $wpdb->queries содержит все запросы страницы с временем выполнения и стеком вызова. Оставлять константу включенной на боевом сайте надолго нельзя: она сама съедает память и время. Включили, сняли данные Query Monitor, выключили.

  1. Если ничего не доступно, остаётся SHOW FULL PROCESSLIST в phpMyAdmin в момент нагрузки. Грубо, но виновника обычно ловит.

Отдельно скажу про лимиты: на shared-хостинге медленный запрос часто не причина, а следствие. Соседи по серверу выели диск, ваша база встала в очередь. Как это отличить, разбирал в тексте про лимиты shared-хостинга по CPU и памяти.

Читаем EXPLAIN

Есть подозреваемый запрос. Дальше нужно понять, почему он медленный. Для этого MySQL показывает план выполнения:

EXPLAIN SELECT p.ID, p.post_title
FROM wp_posts p
INNER JOIN wp_postmeta m ON m.post_id = p.ID
WHERE p.post_type = 'product'
  AND p.post_status = 'publish'
  AND m.meta_key = '_stock_status'
  AND m.meta_value = 'instock'
ORDER BY p.post_date DESC
LIMIT 12;

Вывод это таблица, где каждая строка соответствует одной таблице в запросе. Смотреть надо на четыре колонки.

type показывает способ доступа к таблице. По убыванию качества: const, eq_ref, ref, range, index, ALL. Значение ALL означает полный перебор таблицы, и это ваш главный враг. index тоже перебор, только по индексу целиком: чуть лучше, но всё равно плохо на больших таблицах.

rows это оценка, сколько строк MySQL собирается прочитать. Если вам нужно 12 записей, а в плане стоит 180 000, оптимизатор не нашёл, за что зацепиться.

key показывает выбранный индекс, possible_keys подходящие. Пусто в key при непустых possible_keys бывает, когда статистика устарела: помогает ANALYZE TABLE wp_postmeta;.

Extra содержит самое интересное. Using filesort значит сортировку в памяти или на диске без опоры на индекс. Using temporary значит создание временной таблицы, обычно из-за GROUP BY или DISTINCT по неиндексированному полю. Сочетание Using temporary; Using filesort на таблице в сотни тысяч строк это гарантированные секунды.

На MySQL 8.0.18 и выше есть более честный инструмент, который реально выполняет запрос и показывает фактическое время каждого шага:

EXPLAIN ANALYZE SELECT ...;

На MySQL 5.7, а он до сих пор стоит у половины российских хостеров, используйте EXPLAIN FORMAT=JSON: там есть оценка стоимости и видно, на каком соединении план разваливается.

Индексы: когда помогают, а когда нет

Индекс это отдельная структура, по которой база находит нужные строки, не перебирая таблицу. Ускоряет чтение, замедляет запись и занимает место. Поэтому подход «добавим индексы на все колонки» рабочим не бывает: у сайта с активным приёмом заказов вставка начнёт отставать, а размер базы вырастет в полтора раза.

Классический пример для WordPress. Таблица wp_postmeta в базовой поставке проиндексирована по post_id и по meta_key, но не по паре meta_key плюс meta_value. Магазины на WooCommerce и любые фильтры по произвольным полям бьют именно в эту пару. Лечится составным индексом:

ALTER TABLE wp_postmeta
  ADD INDEX sf_meta_key_value (meta_key(32), meta_value(64));

Числа в скобках это префиксные индексы: индексируются первые 32 и 64 байта колонки. Полностью meta_value тип LONGTEXT проиндексировать нельзя, да и не нужно. Перед ALTER TABLE на боевом сайте обязательно бэкап, а на таблице в миллион строк операция займёт от 30 секунд до нескольких минут, и всё это время таблица будет заблокирована на запись в MySQL 5.7 без онлайн-DDL.

Два правила, которые экономят время.

Первое: в составном индексе важен порядок колонок. Индекс (a, b) работает для условий по a и по a + b, но не работает для условия только по b. Это называется leftmost prefix, и на нём спотыкаются почти все.

Второе: индекс не применяется, если колонка обёрнута в функцию. WHERE DATE(post_date) = '2026-09-01' полностью убивает индекс по post_date. Правильно писать диапазоном:

WHERE post_date >= '2026-09-01 00:00:00'
  AND post_date <  '2026-09-02 00:00:00';

То же самое с поиском LIKE '%слово%': ведущий процент означает полный перебор, никакой индекс тут не спасёт. Если у вас поиск по большому каталогу и он тормозит, решение не в индексе, а в переносе поиска в FULLTEXT или во внешний движок.

Проверить, что реально лежит в таблице:

SHOW INDEX FROM wp_postmeta;

Колонка Cardinality показывает количество уникальных значений. Индекс по колонке с двумя вариантами значений (например, флаг «да/нет») бесполезен: оптимизатор его проигнорирует и пойдёт полным перебором, потому что так дешевле.

Частые виновники

Таблица из моей практики. Слева симптом, справа что чаще всего оказывается причиной.

СимптомОбычная причинаЧто делать
Медленно открывается любая страница, включая главнуюРаздутый wp_options с autoload на 3-5 МБЧистка автозагружаемых опций и мусорных транзиентов
Тормозит каталог с фильтрамиmeta_query по wp_postmeta без индексаСоставной индекс, вынос атрибутов в таксономию
Админка ползёт, фронт нормальныйПлагин статистики, таблица на миллионы строкОтключение сбора, чистка, перенос статистики в Метрику
Сайт встаёт раз в час на 2-3 минутыТяжёлый крон: рассылка, импорт, пересчёт остатковПеренос задачи в системный cron ночью
Заказы оформляются по 10 секундwp_wc_sessions или таблицы очередей WooCommerceЧистка просроченных сессий, wp action-scheduler clean
Битрикс тормозит на разделах каталогаb_iblock_element_prop_s и агрегаты по свойствамИндексация, включение кэша инфоблоков
Скачки нагрузки без посетителейПарсеры и боты бьют в поиск по сайтуОграничение поиска, блокировка ботов на уровне сервера

Отдельно про wp_options: это первое, что я смотрю на любом WordPress старше двух лет. Автозагружаемые опции читаются на каждый запрос страницы, и если там 4 МБ логов плагина, тормозить будет всё и всегда, вне зависимости от индексов. Разбор с командами есть в статье про раздутую базу WordPress и autoload.

Полезные команды для быстрой ревизии базы:

# что автозагружается и сколько весит
wp db query "SELECT ROUND(SUM(LENGTH(option_value))/1024/1024, 2) AS mb
             FROM wp_options WHERE autoload = 'yes';"

# топ таблиц по размеру
wp db query "SELECT table_name, ROUND((data_length+index_length)/1024/1024, 1) AS mb
             FROM information_schema.tables
             WHERE table_schema = DATABASE()
             ORDER BY (data_length+index_length) DESC LIMIT 10;"

# просроченные транзиенты
wp transient delete --expired

Первая команда обычно и объясняет всё. Нормальный autoload это до 1 МБ. Три мегабайта и выше означают, что кто-то из плагинов складывает туда лишнее.

Что делать, если тяжёлый запрос делает плагин

Ситуация, которая случается чаще всего: виновник найден, это плагин, и плагин вам нужен. Правки в его коде переживут ровно до следующего обновления, поэтому решать надо иначе.

Порядок действий, который я применяю.

Проверить настройки самого плагина. У модулей похожих товаров, фильтров и «популярного за неделю» почти всегда есть опция глубины выборки или срока кэша. Сменить «за всё время» на «за 30 дней» бывает достаточно, чтобы запрос перестал перебирать всю таблицу.

Добавить индекс под конкретный запрос. Плагин не трогаем, чиним базу под него. Это самое безопасное решение из всех, потому что обновление плагина его не отменит.

Прикрутить кэш объектов. На VPS ставится Redis или Memcached плюс соответствующий drop-in для WordPress. После этого повторяющиеся запросы уходят в память и до диска не доходят вовсе. На shared-хостинге такой возможности обычно нет, но иногда доступен APCu.

Закэшировать результат руками. Если плагин даёт фильтр или шорткод, результат можно завернуть в транзиент на час. Мой типовой приём:

add_filter('sf_heavy_widget_html', function ($html) {
    $cached = get_transient('sf_heavy_widget');
    if ($cached !== false) {
        return $cached;
    }
    set_transient('sf_heavy_widget', $html, HOUR_IN_SECONDS);
    return $html;
});

Заменить плагин. Виджет «популярные записи», который считает просмотры прямым UPDATE в wp_postmeta на каждый хит, лучше выкинуть совсем: те же цифры видно в Яндекс Метрике, а базе легче на порядок.

Отключить и посмотреть. Простой тест, который экономит часы: выключаем подозреваемого на 10 минут в непиковое время, снимаем TTFB, включаем обратно. Если разница в два раза, вопрос закрыт.

Когда база вообще ни при чём

Не каждый долгий ответ это MySQL. Прежде чем ковырять индексы, отсеките четыре сценария.

Первый: внешние запросы из PHP. Виджет обратного звонка, карта, курсы валют, обращение к CRM в момент рендера страницы. Сервис на той стороне ответил за 3 секунды, ваша страница ждала вместе с ним. В Query Monitor это видно во вкладке HTTP API Calls.

Второй: нехватка памяти. PHP-процесс упирается в лимит, начинается своп, всё замедляется равномерно. Симптом в том, что тормозит вообще всё, включая статические страницы без запросов к базе.

Третий: сам движок таблиц. Если у вас в базе остались таблицы MyISAM (на старых сайтах и на некоторых плагинах статистики это встречается до сих пор), одна запись блокирует всю таблицу целиком. Проверяется одной командой:

SELECT table_name, engine FROM information_schema.tables
WHERE table_schema = DATABASE() AND engine <> 'InnoDB';

Перевод в InnoDB делается через ALTER TABLE ... ENGINE=InnoDB, но только с бэкапом и не в час пик.

Четвёртый: кэш страниц выключен или не работает. Оптимизировать генерацию страницы, которая должна отдаваться из кэша, странно. Сначала проверьте, что анонимный посетитель получает готовый HTML, и только потом занимайтесь запросами.

Чек-лист разбора

Порядок, по которому я прохожу медленную базу у клиента. Занимает от часа до половины дня в зависимости от размера сайта.

  • Снять TTFB на анонимном и на авторизованном заходе, зафиксировать разницу.
  • Посмотреть SHOW FULL PROCESSLIST под нагрузкой, поймать зависшие запросы.
  • Включить slow query log с порогом 0.5 секунды, собрать данные за 2-4 часа реального трафика.
  • Прогнать лог через mysqldumpslow -s t или pt-query-digest, взять топ-5 по суммарному времени.
  • Каждый запрос из топа разобрать через EXPLAIN, найти type = ALL, Using filesort, Using temporary.
  • Проверить размер autoload в wp_options и топ таблиц по объёму.
  • Почистить просроченные транзиенты, логи плагинов, ревизии записей, спам-комментарии.
  • Добавить недостающие индексы по одному, после каждого перезамерять.
  • Проверить движок таблиц, перевести остатки MyISAM в InnoDB.
  • Повторить замер TTFB и зафиксировать результат в отчёте.

Последний пункт важнее, чем кажется. Без замера «до» и «после» любая оптимизация превращается в разговор про ощущения. Я всегда прикладываю к работе два скриншота из панели разработчика и строку из лога.

Сколько это стоит и когда звать человека

Ориентиры по деньгам на 2026 год, чтобы вы понимали порядок цифр. Разовый разбор медленной базы для сайта-визитки или небольшого корпоративного сайта это 4-8 часов работы. Магазин на 3-5 тысяч товаров с фильтрами и интеграциями это 10-20 часов, потому что там кроме базы обычно всплывают крон, очереди и импорт остатков. Если у вас абонентское обслуживание, такая диагностика обычно укладывается в месячный лимит часов, отдельно платить не приходится. Мои тарифы на этот случай посмотреть можно там же.

Своими силами имеет смысл сделать первые три шага чек-листа: замерить TTFB, поставить Query Monitor и почистить транзиенты. Это безопасно и часто даёт заметный эффект без вмешательства в структуру базы. ALTER TABLE на боевом магазине без бэкапа и без понимания, что таблица заблокируется на запись, я делать не советую: восстановление после неудачного захода стоит дороже самой оптимизации.

Если база уже легла или сайт стоит и заказы не проходят, не тратьте время на чтение логов, пишите сразу через форму на главной, в срочных случаях я разбираюсь в тот же день. Другие материалы про скорость собраны в рубрике про производительность.

Частые вопросы

Как понять, что тормозит именно база

Сравнить время отдачи статики и HTML. Картинки летят за десятки миллисекунд, а HTML ждёт пару секунд? Значит время уходит на генерацию, а не на сеть.

Как найти медленный запрос

Включить лог медленных запросов с порогом около полсекунды, а не с дефолтными десятью. Через несколько часов работы сайта в нём будут все виновники с временем выполнения.

Всегда ли помогает индекс

Нет. Индекс ускоряет выборку по условию, но бесполезен там, где запрос всё равно читает большую часть таблицы или сортирует во временной таблице. Иногда правильнее переписать запрос.

Что делать, если тяжёлый запрос делает плагин

Кэшировать результат, ограничить страницы, где плагин работает, или искать замену. Править чужой плагин напрямую нельзя: правки исчезнут при первом же обновлении.

Передайте техническую часть в надёжные руки.

Слежу за хостингом, бэкапами и обновлениями. Когда что-то пойдёт не так – звоните мне, не своему программисту, не в спортзал и не маме.

Короткие вопросы по теме

Все вопросы по теме "Скорость сайта"

Заявка

Обсудим задачу

Напишите имя и телефон, выберите удобный канал. Перезвоню или напишу в течение часа в рабочее время.

Куда удобнее ответить?