Медленная база данных — это бич 90% сайтов, с которыми я работаю. На прошлой неделе разбирал интернет-магазин на Bitrix, где одна страница каталога грузилась 8 секунд из-за неоптимизированной MySQL. После настройки время сократилось до 1.2 секунды.
Почему база данных тормозит ваш сайт
Честно говоря, большинство разработчиков забывают про оптимизацию MySQL до тех пор, пока сайт не начнёт откровенно виснуть. А ведь база данных — это сердце любого динамического сайта. В ней хранятся товары, пользователи, контент, логи.
На моей практике встречаются одни и те же проблемы. Первая — отсутствие индексов. У меня был клиент с WordPress-сайтом на 50 000 записей, где поиск по wp_posts выполнялся за 12 секунд. Почему? Потому что поле meta_value в wp_postmeta не было проиндексировано, а плагин делал по нему выборку.
Вторая частая проблема — медленные запросы с JOIN'ами через несколько таблиц. Особенно это касается Битрикса, где одна страница товара может генерировать 150+ запросов к базе. И если каждый запрос выполняется по 50-100 мс, то вся страница грузится секунд пять.
Третий момент — неправильная конфигурация MySQL. По умолчанию MySQL 8.0 настроена под сервер с 1-2 ГБ RAM. А если у вас выделенный сервер на 16 ГБ, то база использует лишь малую часть ресурсов. Результат — постоянные обращения к диску вместо работы с кешем в памяти.
Аудит производительности базы данных
Перед любой оптимизацией нужно понять, где именно узкие места. Я всегда начинаю с включения медленных запросов (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';
После этого оставляем сайт поработать час-два под обычной нагрузкой, а затем анализируем логи. Я использую утилиту mysqldumpslow:
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
Эта команда покажет 10 самых частых медленных запросов. Обычно картина такая: 5-6 запросов отжирают 80% времени выполнения. Именно их и нужно оптимизировать в первую очередь.
Второй инструмент — команда EXPLAIN. Она показывает, как MySQL выполняет запрос: использует ли индексы, сколько строк просматривает, какой тип соединения применяет. Вот пример плохого запроса:
EXPLAIN SELECT * FROM wp_posts p
LEFT JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE pm.meta_key = 'price' AND pm.meta_value > 1000;
Если в выводе EXPLAIN видим type = ALL и rows = 50000, то запрос сканирует всю таблицу. Это плохо. Хорошо, когда type = ref и rows меньше 100.
Третий момент аудита — анализ загрузки сервера. Команда mysqladmin processlist покажет текущие запросы и их статус. А SHOW ENGINE INNODB STATUS даст детальную информацию о блокировках, буферах и производительности InnoDB.
У меня был случай, когда сайт тормозил из-за одного «висящего» запроса UPDATE, который заблокировал половину таблицы. Processlist это сразу показал — запрос висел в статусе «Waiting for table metadata lock» уже 10 минут.
Оптимизация индексов — основа быстрой работы
Индексы — это альфа и омега оптимизации MySQL. Без правильных индексов даже простой SELECT может выполняться минуты. На практике я вижу две крайности: либо индексов нет вообще, либо их слишком много.
Начнём с основ. Индекс нужен на каждом поле, по которому идёт поиск в WHERE, JOIN или ORDER BY. Классический пример — таблица пользователей:
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255),
login VARCHAR(100),
created_at DATETIME,
status ENUM('active', 'inactive')
);
-- Добавляем нужные индексы
ALTER TABLE users ADD INDEX idx_email (email);
ALTER TABLE users ADD INDEX idx_login (login);
ALTER TABLE users ADD INDEX idx_status_created (status, created_at);
Обратите внимание на последний индекс — он составной. Если часто делаем выборку активных пользователей за период, то индекс по двум полям будет работать гораздо быстрее двух отдельных.
А вот типичная ошибка в WordPress — отсутствие индекса по meta_key в таблице wp_postmeta. Эта таблица может содержать миллионы записей, а поиск без индекса превращается в кошмар:
-- Плохо: поиск без индекса
SELECT post_id FROM wp_postmeta WHERE meta_key = 'price' AND meta_value > '1000';
-- Добавляем составной индекс
ALTER TABLE wp_postmeta ADD INDEX idx_meta_key_value (meta_key, meta_value(100));
Здесь я ограничил длину индекса по meta_value до 100 символов. Это экономит место и ускоряет работу, если в поле хранятся длинные тексты.
В Битриксе особое внимание уделите таблицам b_iblock_element, b_iblock_element_property, b_sale_order. Вот мой стандартный набор индексов для каталога:
-- Для b_iblock_element
ALTER TABLE b_iblock_element ADD INDEX idx_iblock_active (IBLOCK_ID, ACTIVE);
ALTER TABLE b_iblock_element ADD INDEX idx_code (CODE);
-- Для b_iblock_element_property
ALTER TABLE b_iblock_element_property ADD INDEX idx_element_property (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID);
ALTER TABLE b_iblock_element_property ADD INDEX idx_property_value (IBLOCK_PROPERTY_ID, VALUE(50));
Результат такой оптимизации — страница каталога с 50 товарами начинает грузиться за 200-300 мс вместо 2-3 секунд. Проверено на десятках проектов.
Настройка конфигурации MySQL под нагрузку
Стандартная конфигурация MySQL рассчитана на «игрушечные» нагрузки. А реальный сайт с трафиком 1000+ посетителей в день требует серьёзной настройки. Я всегда начинаю с файла /etc/mysql/my.cnf (или my.ini на Windows).
Первый параметр — innodb_buffer_pool_size. Это объём памяти для кеширования данных и индексов InnoDB. По умолчанию стоит 128MB, что смехотворно мало. На сервере с 8 ГБ RAM я ставлю 4-5 ГБ:
[mysqld]
# Основные параметры производительности
innodb_buffer_pool_size = 4G
innodb_buffer_pool_instances = 4
innodb_log_file_size = 256M
innodb_log_buffer_size = 64M
Параметр innodb_buffer_pool_instances разделяет буферный пул на несколько частей. Это снижает конкуренцию за блокировки при высокой нагрузке. Правило простое: 1 инстанс на каждый гигабайт buffer pool.
Второй важный момент — размер лог-файлов InnoDB. Маленькие лог-файлы приводят к частым checkpoint'ам и тормозам. Я обычно ставлю 256-512 MB:
# Настройки логирования
innodb_log_file_size = 256M
innodb_log_files_in_group = 2
innodb_flush_log_at_trx_commit = 2
Параметр innodb_flush_log_at_trx_commit = 2 немного снижает надёжность, но значительно ускоряет записи. При значении 1 (по умолчанию) каждая транзакция ждёт записи на диск. При значении 2 запись идёт раз в секунду.
Для сайтов с большим количеством соединений важны параметры подключений:
# Настройки соединений
max_connections = 200
max_connect_errors = 10000
connect_timeout = 10
wait_timeout = 300
interactive_timeout = 300
Для WordPress и других CMS с активными запросами на чтение добавлю настройки кеша запросов:
# Кеш запросов (только для MySQL 5.7 и ниже)
query_cache_type = 1
query_cache_size = 256M
query_cache_limit = 2M
Но учтите: в MySQL 8.0 кеш запросов убрали из-за проблем с производительностью при высокой нагрузке. Вместо него лучше использовать Redis или Memcached на уровне приложения.
Последний момент — настройка tmp_table_size и max_heap_table_size. Эти параметры определяют, какие временные таблицы создаются в памяти, а какие на диске:
# Временные таблицы
tmp_table_size = 256M
max_heap_table_size = 256M
У меня был клиент с отчётами в Битриксе, которые генерировались по 5 минут. Проблема была в том, что временные таблицы создавались на диске из-за маленького tmp_table_size. После увеличения до 512M отчёты стали строиться за 30 секунд.
Оптимизация медленных запросов
Даже с правильными индексами и настройками MySQL некоторые запросы могут тормозить. Чаще всего проблема в сложных JOIN'ах, подзапросах или неправильном использовании функций в WHERE.
Типичный пример плохого запроса — поиск товаров по нескольким свойствам в Битриксе:
-- Плохо: множественные JOIN'ы
SELECT e.ID, e.NAME
FROM b_iblock_element e
LEFT JOIN b_iblock_element_property p1 ON e.ID = p1.IBLOCK_ELEMENT_ID AND p1.IBLOCK_PROPERTY_ID = 15
LEFT JOIN b_iblock_element_property p2 ON e.ID = p2.IBLOCK_ELEMENT_ID AND p2.IBLOCK_PROPERTY_ID = 16
WHERE e.IBLOCK_ID = 2
AND p1.VALUE = 'Red'
AND p2.VALUE > 1000;
Этот запрос будет сканировать огромное количество строк. Лучше переписать его через EXISTS или использовать агрегацию:
-- Хорошо: используем EXISTS
SELECT e.ID, e.NAME
FROM b_iblock_element e
WHERE e.IBLOCK_ID = 2
AND EXISTS (
SELECT 1 FROM b_iblock_element_property p1
WHERE p1.IBLOCK_ELEMENT_ID = e.ID
AND p1.IBLOCK_PROPERTY_ID = 15
AND p1.VALUE = 'Red'
)
AND EXISTS (
SELECT 1 FROM b_iblock_element_property p2
WHERE p2.IBLOCK_ELEMENT_ID = e.ID
AND p2.IBLOCK_PROPERTY_ID = 16
AND p2.VALUE > 1000
);
Или ещё лучше — через группировку:
-- Ещё лучше: одно обращение к таблице свойств
SELECT e.ID, e.NAME
FROM b_iblock_element e
WHERE e.IBLOCK_ID = 2
AND e.ID IN (
SELECT p.IBLOCK_ELEMENT_ID
FROM b_iblock_element_property p
WHERE (p.IBLOCK_PROPERTY_ID = 15 AND p.VALUE = 'Red')
OR (p.IBLOCK_PROPERTY_ID = 16 AND p.VALUE > 1000)
GROUP BY p.IBLOCK_ELEMENT_ID
HAVING COUNT(*) = 2
);
Второй момент — избегайте функций в WHERE. Запрос вида WHERE DATE(created_at) = '2026-01-15' не сможет использовать индекс по created_at. Правильно писать так:
-- Плохо: функция в WHERE
WHERE DATE(created_at) = '2026-01-15'
-- Хорошо: диапазон значений
WHERE created_at >= '2026-01-15 00:00:00'
AND created_at < '2026-01-16 00:00:00'
Третья частая проблема — запросы с ORDER BY по неиндексированному полю. Особенно болезненно это для пагинации:
-- Плохо: сортировка без индекса
SELECT * FROM products ORDER BY price LIMIT 1000, 20;
-- Добавляем индекс
ALTER TABLE products ADD INDEX idx_price (price);
-- Или используем курсорную пагинацию
SELECT * FROM products WHERE price > 1500 ORDER BY price LIMIT 20;
Курсорная пагинация работает гораздо быстрее обычной при больших OFFSET'ах. Вместо «показать страницу 1000» мы говорим «показать товары дороже 1500 рублей».
Кеширование на уровне базы данных
Правильное кеширование может снизить нагрузку на MySQL в разы. Я использую несколько уровней кеширования: встроенный кеш MySQL (для версий до 5.7), Redis для результатов запросов и кеширование на уровне приложения.
Начнём с Redis. Это быстрое хранилище ключ-значение в памяти, которое отлично подходит для кеширования результатов тяжёлых запросов. Вот как я организую кеширование в PHP:
redis = new Redis();
$this->redis->connect('127.0.0.1', 6379);
}
public function getProducts($categoryId, $filters = []) {
$cacheKey = 'products_' . $categoryId . '_' . md5(serialize($filters));
// Пытаемся получить из кеша
$cached = $this->redis->get($cacheKey);
if ($cached) {
return json_decode($cached, true);
}
// Если нет в кеше - делаем запрос к БД
$sql = "SELECT id, name, price FROM products WHERE category_id = ? AND active = 1";
// ... выполняем запрос ...
// Сохраняем в кеш на 1 час
$this->redis->setex($cacheKey, 3600, json_encode($result));
return $result;
}
}
Для WordPress я часто использую встроенный object cache с Redis backend. Он автоматически кеширует результаты get_posts, WP_Query и других функций:
get_results("
SELECT p.ID, p.post_title, COUNT(pm.meta_id) as views
FROM wp_posts p
LEFT JOIN wp_postmeta pm ON p.ID = pm.post_id AND pm.meta_key = 'views'
WHERE p.post_status = 'publish'
GROUP BY p.ID
ORDER BY views DESC
LIMIT 10
");
wp_cache_set($cache_key, $posts, '', 3600);
}
return $posts;
}
В Битриксе есть встроенный механизм тегированного кеша. Он позволяет инвалидировать кеш при изменении связанных данных:
initCache($cacheTime, $cacheId, $cacheDir)) {
return $cache->getVars();
}
$cache->startDataCache();
// Тяжёлый запрос к базе
$result = \CIBlockSection::GetList(
['SORT' => 'ASC'],
['IBLOCK_ID' => 1, 'SECTION_ID' => $sectionId, 'ACTIVE' => 'Y'],
false,
['ID', 'NAME', 'ELEMENT_CNT']
);
$sections = [];
while ($section = $result->GetNext()) {
$sections[] = $section;
}
// Тегируем кеш для автоматической инвалидации
$cache->endDataCache(['sections' => $sections]);
return ['sections' => $sections];
}
Ещё один полезный трюк — кеширование на уровне веб-сервера. Nginx может кешировать ответы PHP-скриптов в файловой системе:
# В nginx.conf
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=WORDPRESS:100m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
server {
# ... основные настройки ...
location ~ \.php$ {
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# ... остальные fastcgi настройки ...
}
}
Этот кеш работает даже быстрее Redis, потому что nginx отдаёт закешированный ответ, не запуская PHP вообще. Но нужно аккуратно настроить исключения для административных страниц и авторизованных пользователей.
Мониторинг и профилирование производительности
Оптимизация — это не разовая процедура, а постоянный процесс. Нагрузка на сайт растёт, добавляются новые функции, накапливаются данные. Поэтому важно настроить постоянный мониторинг производительности MySQL.
Первый инструмент — Performance Schema в MySQL 5.7+. Это встроенная система мониторинга, которая собирает детальную статистику по запросам, блокировкам, использованию памяти:
-- Включаем Performance Schema
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME LIKE '%statement/%' OR NAME LIKE '%stage/%';
-- Смотрим топ медленных запросов
SELECT query, exec_count, avg_timer_wait/1000000000 as avg_time_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY avg_timer_wait DESC
LIMIT 10;
Второй полезный запрос — анализ блокировок. Deadlock'и и долгие блокировки могут серьёзно замедлить сайт:
-- Текущие блокировки
SELECT r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
Для автоматического мониторинга я использую связку Prometheus + Grafana. mysqld_exporter собирает метрики MySQL и отправляет в Prometheus, а Grafana строит красивые графики:
# docker-compose.yml
version: '3'
services:
mysqld-exporter:
image: prom/mysqld-exporter
environment:
- DATA_SOURCE_NAME=exporter:password@(mysql:3306)/
ports:
- "9104:9104"
depends_on:
- mysql
На графиках Grafana я слежу за ключевыми метриками: QPS (queries per second), время выполнения запросов, использование буферного пула, количество медленных запросов. Если что-то идёт не так — сразу видно.
Для WordPress есть отличный плагин Query Monitor, который показывает все SQL-запросы прямо в админке. Очень удобно для разработки и отладки:
0.1) { // Логируем запросы дольше 100мс
error_log("Slow query ($time sec): $query");
}
return $result;
});
В продакшене я обязательно настраиваю ротацию логов MySQL. Slow query log может быстро вырасти до гигабайтов, особенно на высоконагруженных сайтах. В logrotate добавляю такую конфигурацию:
# /etc/logrotate.d/mysql
/var/log/mysql/*.log {
daily
missingok
rotate 52
compress
delaycompress
notifempty
create 0640 mysql mysql
postrotate
/usr/bin/mysqladmin flush-logs
endscript
}
Оптимизация структуры таблиц и типов данных
Правильный выбор типов данных может существенно повлиять на производительность. Я часто вижу таблицы, где для хранения булевых значений используется VARCHAR(255), а для ID — BIGINT. Это расточительство места и снижение скорости.
Основные принципы выбора типов данных: используйте самый маленький тип, который может вместить ваши данные. Для ID в большинстве случаев хватает INT (до 4 млрд записей). BIGINT нужен только для действительно больших таблиц:
-- Плохо: расточительство места
CREATE TABLE users (
id BIGINT AUTO_INCREMENT,
name VARCHAR(500),
email VARCHAR(500),
is_active VARCHAR(10),
created_at VARCHAR(50)
);
-- Хорошо: оптимальные типы
CREATE TABLE users (
id INT AUTO_INCREMENT,
name VARCHAR(100),
email VARCHAR(255),
is_active TINYINT(1),
created_at TIMESTAMP,
PRIMARY KEY (id)
);
Для булевых значений используйте TINYINT(1) или BOOLEAN (это алиас для TINYINT(1)). Для дат — TIMESTAMP или DATETIME, но не VARCHAR. Для денежных сумм — DECIMAL, а не FLOAT.
Особое внимание уделите кодировке. Для русскоязычных сайтов я использую utf8mb4 (поддержка эмодзи) с коллацией utf8mb4_unicode_ci:
-- Создаём таблицу с правильной кодировкой
CREATE TABLE products (
id INT AUTO_INCREMENT,
name VARCHAR(255),
description TEXT,
price DECIMAL(10,2),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Второй момент — нормализация данных. Но не переусердствуйте. Иногда денормализация может ускорить запросы. У меня был проект интернет-магазина, где цена товара дублировалась в таблице корзины. Это избавило от JOIN'а с таблицей товаров при отображении корзины.
SELECT table_name, round(((data_length + index_length) / 1024 / 1024), 2) as MB FROM information_schema.tables. Это поможет выявить «раздувшиеся» таблицы.Третий аспект — партиционирование больших таблиц. Если у вас таблица логов на сотни миллионов записей, разбейте её по датам:
-- Партиционирование по месяцам
CREATE TABLE access_logs (
id INT AUTO_INCREMENT,
user_id INT,
action VARCHAR(100),
created_at TIMESTAMP,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
PARTITION p202601 VALUES LESS THAN (202602),
PARTITION p202602 VALUES LESS THAN (202603),
PARTITION p202603 VALUES LESS THAN (202604),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
MySQL будет читать только нужные партиции при запросах с условием по дате. Запрос логов за январь 2026 будет работать только с партицией p202601, игнорируя остальные.
Последний момент структуры — архивирование старых данных. Не храните в основных таблицах данные, которые не нужны для работы сайта. Старые заказы, логи, удалённых пользователей лучше переносить в архивные таблицы или отдельную базу.
У меня был клиент с CRM, где таблица звонков содержала 50 млн записей за 10 лет. Запросы выполнялись по 30 секунд. После переноса записей старше 2 лет в архивную таблицу время запросов сократилось до 1-2 секунд. А архивные данные остались доступны для отчётности через отдельный интерфейс.
Оптимизация MySQL — это искусство баланса между скоростью чтения и записи, использованием памяти и дискового пространства. Не существует универсальных рецептов, каждый случай уникален. Но правильные индексы, настройка конфигурации и мониторинг производительности дадут заметный результат на любом проекте. Если нужна помощь с оптимизацией базы данных на вашем сайте — обращайтесь к нам в поддержку Битрикс или поддержку WordPress. Разберём именно ваш случай и настроим всё под конкретную нагрузку.
Нужна помощь с оптимизацией базы данных MySQL?
Обратитесь к нашим экспертам для профессиональной настройки и ускорения работы вашего сайта.