Это мой первый разобранный кейс по производительности — с него, по сути, и начался этот блог. Клиент заказал платные административные работы на своём облачном сервере. Все данные обезличены: названия сайта, сервера и базы изменены.

Исходные данные

ПараметрЗначение
Сервероблачный VPS, 2 vCPU, 8 ГБ RAM
ОСUbuntu 22.04
ПанельISPmanager
Стекnginx → Apache (prefork) → PHP → MySQL 8
КэшRedis, OPcache
Сайтновостной портал на DLE, десятки тысяч публикаций с аудио

Жалоба клиента: в момент сохранения новости резко растёт число процессов, CPU и память уходят в потолок, сайт отдаёт 504 Gateway Time-Out, помогает только принудительная перезагрузка.

Клиент сначала обратился к разработчикам CMS. Ответ был логичным, но бесполезным: «при сохранении DLE сбрасывает свои кэши, нагрузка растёт, а настройка сервера — это к хостингу». Так задача пришла ко мне.

Шаг 1. Воспроизвести проблему

Без воспроизведения можно бесконечно крутить настройки вслепую. Я попросил клиента создать тестовую новость и передать файлы, с которыми обычно работают редакторы. Результат: любое редактирование новости с аудио запускало лавину — число процессов Apache росло без остановки, пока сервер не зависал.

Теперь было что измерять.

Цепочка запроса: nginx, Apache, PHP, MySQL — и где возникал затор на каждом уровне

Шаг 2. nginx: не хватает соединений

Первым в цепочке стоит nginx, с его логов и начал:

[alert] 768 worker_connections are not enough

Стандартных 768 соединений на воркер не хватало. Увеличил лимит и настроил буферизацию ответов от бэкенда:

events {
    worker_connections 4096;
}

# в блоке http / server
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 32 4k;
proxy_max_temp_file_size 0;

Ошибки ушли, но сайт всё равно ложился — nginx просто стал передавать затор дальше.

Шаг 3. Apache: процессы упираются в лимит

Следующий уровень — Apache в режиме prefork: каждый запрос занимает отдельный процесс. Клиент видел более 150 процессов apache2 — ровно столько, сколько разрешал лимит. Новые запросы вставали в очередь, nginx не дожидался ответа и отдавал 504.

Поднял лимиты в /etc/apache2/mods-available/mpm_prefork.conf:

StartServers            10
MinSpareServers         10
MaxSpareServers         20
ServerLimit             2000
MaxRequestWorkers       1500
MaxConnectionsPerChild  10000

MaxConnectionsPerChild перезапускает процесс после 10 000 запросов — это защищает от утечек памяти в PHP.

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

Шаг 4. MySQL: очередь из блокировок

SHOW PROCESSLIST показал картину, которая объяснила всё:

| Id   | db     | Command | Time | State                        | Info                          |
| 6294 | site_db | Query  |   46 | Waiting for table level lock | SELECT p.id, p.autor, ... |
| 6295 | site_db | Query  |   29 | Waiting for table level lock | SELECT p.id, p.autor, ... |
| 6296 | site_db | Query  |   26 | Waiting for table level lock | SELECT p.id, p.autor, ... |
| 6303 | site_db | Query  |   73 | executing                    | SELECT p.id, p.autor, ... |
...

Десятки одинаковых SELECT к таблице публикаций висят в состоянии Waiting for table level lock по 30–70 секунд. Механика затора:

  1. Редактор сохраняет новость — CMS пишет в таблицу публикаций и сбрасывает свой кэш.
  2. Запись блокирует таблицу целиком.
  3. Кэш сброшен, поэтому каждый посетитель сайта идёт прямо в базу — и встаёт в очередь за блокировкой.
  4. Каждый ждущий запрос держит процесс Apache — процессы плодятся, память кончается, сервер падает.

Чтобы понять, какие запросы виноваты, включил лог медленных запросов и разобрал его утилитой pt-query-digest из Percona Toolkit:

# Overall: 811 total, 23 unique, 1.08 QPS, 88.99x concurrency
# Attribute          total     min     max     avg     95%
# Exec time         66652s      2s    168s     82s    159s
# Lock time         44917s       0    159s     55s    151s

# Rank Response time     Calls R/Call   Item
#    1 31009.2  46.5%      282 109.96   SELECT dle_post dle_post_extras
#    2 18449.6  27.7%      167 110.48   SELECT dle_post dle_post_extras dle_post_extras_cats
#    3  7528.1  11.3%       93  80.95   SELECT dle_post dle_post_extras_cats dle_post_extras

Ключевые цифры: средний запрос выполнялся 82 секунды, из них 55 секунд — ожидание блокировки. Конкурентность 89x означает, что одновременно в очереди стояло около 90 запросов. Проблема была не в «тяжёлых» запросах, а в том, что они ждали друг друга.

Шаг 5. Конфигурация MySQL

Конфиг MySQL был почти стандартным, рассчитанным на минимальное железо. Привёл его в соответствие с сервером на 8 ГБ, где база — главный потребитель памяти:

[mysqld]
# InnoDB: данные и индексы — в памяти
innodb_buffer_pool_size      = 4G
innodb_buffer_pool_instances = 4
innodb_log_file_size         = 1G
innodb_flush_method          = O_DIRECT
innodb_read_io_threads       = 8
innodb_write_io_threads      = 8
innodb_lock_wait_timeout     = 50

# Кэши и буферы
key_buffer_size    = 64M
table_open_cache   = 2000
sort_buffer_size   = 4M
thread_cache_size  = 100

# Соединения
max_connections     = 300
wait_timeout        = 120
interactive_timeout = 120

# Диагностика
slow_query_log                = 1
slow_query_log_file           = /var/log/mysql/mysql-slow.log
long_query_time               = 1
log_queries_not_using_indexes = 1

Что здесь главное:

ПараметрЗачем
innodb_buffer_pool_size = 4Gполовина RAM под кэш данных и индексов: запросы читают из памяти, а не с диска
innodb_flush_method = O_DIRECTнет двойного кэширования в InnoDB и в кэше ОС
innodb_log_file_size = 1Gреже сбросы журнала при записи
table_open_cache, thread_cache_sizeменьше накладных расходов на открытие таблиц и создание потоков
max_connections = 300лимит, согласованный с памятью: каждое соединение тоже ест RAM
slow_query_logпостоянный мониторинг, чтобы следующая проблема была видна сразу

Попутно из конфига убрал параметры query_cache_* — в MySQL 8 query cache удалён, и такие строки мешают серверу стартовать.

Результат

После изменений лавина при сохранении новости исчезла. SHOW PROCESSLIST в момент редактирования — несколько спящих служебных соединений и ни одного ожидания блокировки. Повторный разбор медленного лога:

Сравнение до и после: среднее время запроса, время ожидания блокировки и конкурентность

МетрикаДоПосле
Среднее время запроса82 с13 мс
Среднее ожидание блокировки55 с2 мкс
Конкурентность89x0,01x
Запросов в медленном логе за ~10 минут811278

Большая часть запросов теперь выполняется за 100 мкс – 1 мс. Медленные запросы остались — например, выборка с NOT IN (подзапрос) и сортировкой по счётчику просмотров, которая просматривает 228 тысяч строк, — но они больше не блокируют весь сайт.

Что я сделал бы иначе сегодня

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

  • Сразу проверить движок таблиц. Waiting for table level lock — классический признак таблиц MyISAM: они блокируются целиком на любую запись. InnoDB блокирует только нужные строки. Проверка занимает одну команду:

    SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES
    WHERE TABLE_SCHEMA = 'site_db' AND ENGINE <> 'InnoDB';
    

    Перевод старых таблиц на InnoDB (ALTER TABLE … ENGINE=InnoDB, после бэкапа) решает именно эту причину, а не её симптомы.

  • Осторожнее с лимитами Apache. MaxRequestWorkers 1500 на сервере с 8 ГБ — это ставка на то, что процессы будут лёгкими. Один процесс prefork с PHP занимает десятки мегабайт, и при новом заторе такой лимит может съесть всю память. Правильнее считать лимит от памяти, а ещё лучше — перейти на PHP-FPM и mpm_event.

  • Не забывать про кэш приложения. Затор начинался с того, что после сохранения новости все посетители одновременно шли в базу. Кэширование страниц на уровне nginx или Redis сглаживает такие всплески.

  • Добавить индексы под медленные запросы. Оставшиеся медленные выборки — кандидаты на индекс и разбор через EXPLAIN.

Выводы

  1. Сначала воспроизвести, потом настраивать. Тестовая новость от клиента сэкономила дни догадок.
  2. Идти по цепочке запроса. nginx → Apache → PHP → MySQL: каждый уровень показывает симптом, причина — на последнем.
  3. Поднятие лимитов переносит проблему, а не решает её. Зато показывает, где настоящее узкое место.
  4. Метрики до и после. Без pt-query-digest «стало лучше» было бы просто ощущением.

Похожие проблемы хостинга — переполненные логи, почтовые очереди, зависшие задачи — я собрал в шпаргалке по Plesk.