Руководство по оптимизации баз данных: индексы, кеширование, EXPLAIN и практические техники

Даже самый быстрый сервер с мощным процессором и гигабайтами оперативной памяти может работать медленно, если база данных не оптимизирована. Запросы, которые выполняются секундами, убивают производительность приложения, создают очередь из пользователей и нагружают сервер до предела. Правильная оптимизация базы данных — это искусство, которое сочетает понимание структуры данных, навыки написания запросов и знание внутренних механизмов СУБД.

В этой статье мы подробно разберём:

  • Почему запросы тормозят и как это диагностировать.
  • Что такое индексы, как они работают и как их правильно создавать.
  • Анализ запросов с помощью EXPLAIN.
  • Кеширование на уровне базы данных и приложения.
  • Партиционирование и шардирование.
  • Настройку конфигурации СУБД для производительности.
  • Медленный лог и его анализ.
  • Мониторинг производительности.
  • Практические примеры оптимизации для MySQL и PostgreSQL.

1. Почему запросы тормозят

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

ПричинаОписание
Отсутствие индексовЗапрос сканирует всю таблицу (full scan) вместо быстрого поиска по индексу.
Неправильный индексИндекс создан, но не используется из-за неправильного порядка полей или типа запроса.
Большой объём данныхТаблицы выросли до миллионов строк, а оптимизация не проводилась.
Сложные JOIN-ыОбъединение нескольких больших таблиц без индексов на ключах соединения.
Неоптимальные запросыSELECT *, использование LIKE '%text', отсутствие LIMIT, вложенные подзапросы.
Недостаток памятиКеш базы данных слишком мал, и данные постоянно читаются с диска.
Блокировки (locks)Конкуренция за ресурсы, длительные транзакции.

Задача оптимизации — найти и устранить эти узкие места.

2. Индексы: ускорители запросов

Индекс — это структура данных (чаще всего B-Tree или хеш-таблица), которая позволяет СУБД быстро находить строки по значениям одного или нескольких столбцов, не сканируя всю таблицу.

2.1 Как работают индексы

Представьте книгу без оглавления. Чтобы найти главу, вам нужно перелистать все страницы. Индекс — это оглавление, которое указывает, где находится нужная информация.

Типы индексов:

  • B-Tree (сбалансированное дерево) — стандартный индекс для большинства СУБД (MySQL InnoDB, PostgreSQL). Подходит для точных сравнений, диапазонов (><BETWEEN) и сортировки.
  • Hash (хеш-таблица) — только для точных сравнений (=), очень быстрый, но не поддерживает диапазоны. Используется в движке Memory.
  • Full-text — для полнотекстового поиска по тексту.
  • GiST / GIN — для геоданных и полнотекстового поиска в PostgreSQL.
  • Bitmap — для столбцов с небольшим количеством уникальных значений.

2.2 Когда создавать индексы

  • На столбцах, которые часто используются в WHEREJOINORDER BYGROUP BY.
  • На столбцах с высокой селективностью (много уникальных значений).
  • На внешних ключах (для ускорения JOIN-ов).
  • Для покрывающих индексов (covering index) — когда индекс содержит все нужные поля, и СУБД не обращается к таблице.

2.3 Когда НЕ создавать индексы

  • На маленьких таблицах (менее 1000 записей) — сканирование таблицы быстрее.
  • На столбцах, которые часто обновляются — индекс замедляет вставку и обновление.
  • На столбцах с низкой селективностью (например, gender с значениями M/F) — индекс почти не ускоряет поиск.
  • Если индекс не используется (проверьте через EXPLAIN).

2.4 Правила создания индексов

1. Учитывайте порядок столбцов в составном индексе.

Индексы работают слева направо. Если у вас индекс (a, b, c), то он эффективен для:

  • WHERE a = ...
  • WHERE a = ... AND b = ...
  • WHERE a = ... AND b = ... AND c = ...

Но он НЕ эффективен для:

  • WHERE b = ...
  • WHERE c = ...
  • WHERE a = ... AND c = ... (использует только a)

2. Используйте покрывающие индексы.

Если запрос выбирает только поля, которые есть в индексе, СУБД вообще не обращается к таблице.

SQL
-- Покрывающий индекс (id, username, email)
CREATE INDEX idx_username_email ON users(username, email);

-- Запрос использует только индекс
SELECT username, email FROM users WHERE username = 'john';

ВАЖНО! В InnoDB любой вторичный индекс неявно содержит первичный ключ (обычно id), поэтому индекс (username, email) уже позволяет выполнить запрос без обращения к таблице, если нужны только username, email и id. Это и есть покрывающий индекс.

3. Индексы для LIKE.

  • LIKE 'text%' — использует индекс.
  • LIKE '%text' — НЕ использует индекс.
  • LIKE '%text%' — НЕ использует индекс.

4. Используйте индекс для ORDER BY.

Если поля в ORDER BY совпадают с индексом, сортировка выполняется без дополнительной операции.

SQL
CREATE INDEX idx_created_at ON orders(created_at);
SELECT * FROM orders ORDER BY created_at DESC;

2.5 Примеры создания индексов

MySQL:

SQL
-- Простой индекс
CREATE INDEX idx_username ON users(username);

-- Составной индекс
CREATE INDEX idx_city_age ON users(city, age);

-- Уникальный индекс
CREATE UNIQUE INDEX idx_email ON users(email);

-- Полнотекстовый индекс
CREATE FULLTEXT INDEX idx_content ON articles(content);

-- Индекс для внешнего ключа
CREATE INDEX idx_user_id ON orders(user_id);

PostgreSQL:

SQL
-- Аналогично
CREATE INDEX idx_username ON users(username);
CREATE INDEX idx_city_age ON users(city, age);
CREATE UNIQUE INDEX idx_email ON users(email);

-- Частичный индекс (только для определённых строк)
CREATE INDEX idx_active_users ON users(created_at) WHERE status = 'active';

-- Индекс на выражение
CREATE INDEX idx_email_lower ON users(LOWER(email));

LOWER(email) мешает использованию индекса. Короткий пример, как это обойти:

  • В PostgreSQL: индекс на выражение CREATE INDEX idx_email_lower ON users(LOWER(email));
  • В MySQL: такой индекс на выражение поддерживается, но важно помнить, что запрос должен использовать ту же функцию, иначе индекс не сработает.

3. Анализ запросов с помощью EXPLAIN

EXPLAIN — самый мощный инструмент для анализа производительности запросов. Он показывает план выполнения запроса: какие таблицы сканируются, используются ли индексы, сколько строк обрабатывается, какие операции выполняются.

3.1 Базовое использование EXPLAIN

SQL
EXPLAIN SELECT * FROM users WHERE username = 'john';

MySQL:

Важные поля:

  • type — тип доступа (от лучшего к худшему): const > eq_ref > ref > range > index > ALL (ALL — полное сканирование таблицы, хуже всего).
  • possible_keys — какие индексы мог бы использовать запрос.
  • key — какой индекс фактически использован.
  • key_len — длина используемой части индекса (важно для составных индексов).
  • rows — оценка количества строк, которое нужно обработать.
  • Extra — дополнительная информация: Using index (покрывающий индекс), Using whereUsing filesort (внешняя сортировка), Using temporary (временная таблица).

3.2 Расширенный анализ: EXPLAIN ANALYZE

PostgreSQL:

SQL
EXPLAIN ANALYZE SELECT * FROM users WHERE username = 'john';

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

MySQL (с версии 8.0):

SQL
EXPLAIN ANALYZE SELECT * FROM users WHERE username = 'john';

ВАЖНО! EXPLAIN ANALYZE в MySQL полезен для разовой отладки, но не стоит запускать его на высоконагруженных запросах в продакшене. Для постоянного профилирования используйте медленный лог и мониторинг.

3.3 Как читать EXPLAIN

Плохой запрос (без индекса):

Это означает полное сканирование 100 000 строк. Нужен индекс.

Хороший запрос:

Используется индекс, возвращается одна строка, дополнительного доступа к таблице нет.

Пример с составным индексом:

Запрос:

SQL
SELECT * FROM users WHERE city = 'Moscow' AND age > 25;

Индекс (city, age):

Индекс (age, city):

Первый индекс лучше, потому что city более селективен.

3.4 Частые проблемы, видимые в EXPLAIN

ПроблемаПризнакРешение
Полное сканирование таблицыtype: ALLСоздать индекс на поля в WHERE / JOIN.
Внешняя сортировкаExtra: Using filesortСоздать индекс для ORDER BY.
Временная таблицаExtra: Using temporaryИспользовать индекс для GROUP BY.
Индекс не используетсяpossible_keys не NULL, а key — NULLПроверьте, что тип данных совпадает, и нет функций (LOWER).
Только часть индексаkey_len меньше, чем ожидалосьПроверьте порядок полей в составном индексе.

4. Кеширование на уровне базы данных и приложения

Кеширование — один из самых эффективных способов снизить нагрузку на базу данных.

4.1 Кеш запросов (Query Cache) — устарел, но важен исторически

В старых версиях MySQL (до 8.0) был встроенный кеш запросов. Он кешировал результат SELECT-запроса с точным совпадением текста. Отключён в MySQL 8.0 из-за проблем с масштабированием.

Что вместо него:

  • Используйте внешние кеши (Redis, Memcached).
  • Настройте кеш на уровне приложения.

4.2 Кеш на уровне приложения (Redis / Memcached)

Этот подход мы уже рассматривали в статье про высокую нагрузку. Коротко:

Пример с Redis:

PHP
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$cacheKey = 'user_profile_' . $userId;
$data = $redis->get($cacheKey);

if ($data === false) {
    $data = $db->query("SELECT * FROM users WHERE id = $userId");
    $redis->setex($cacheKey, 3600, serialize($data));
}

echo $data['name'];
?>

Стратегии кеширования:

  • Cache-Aside — приложение проверяет кеш, если нет — идёт в БД и сохраняет в кеш.
  • Write-Through — при записи обновляется и БД, и кеш.
  • Write-Behind — запись сначала в кеш, асинхронная запись в БД.
  • Refresh-Ahead — кеш обновляется до истечения срока жизни (TTL).

ВАЖНО! При использовании Cache‑Aside обязательно реализуйте инвалидацию кеша при изменении данных: при UPDATE/DELETE удаляйте или обновляйте ключ в Redis. Иначе пользователи будут видеть устаревшие данные.

4.3 Кеширование результатов тяжелых запросов

Если какой-то запрос выполняется долго (агрегация, отчёты), можно сохранять его результат в отдельной таблице или в Redis и обновлять раз в минуту/час.

Пример:

SQL
-- Тяжелый запрос
SELECT DATE(created_at), COUNT(*) FROM orders GROUP BY DATE(created_at);

-- Сохранять результат в таблицу-кеш
CREATE TABLE daily_orders_cache (
    day DATE PRIMARY KEY,
    count INT,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

И обновлять через cron или триггер.

Индекс на created_at помогает для сортировки и диапазонов, но для агрегации по DATE(created_at) эффективнее либо хранить дату в отдельном столбце (денормализация), либо использовать отдельную таблицу‑кеш и обновлять её по расписанию.

4.4 Кеширование объектов (ORM кеш)

Популярные ORM (Doctrine, Eloquent, Hibernate) имеют встроенные механизмы кеширования на уровне объектов. Они хранят результат запроса в кеше и не ходят в БД при повторных запросах.

4.5 Промежуточное кеширование через Proxy (ProxySQL)

ProxySQL — это прокси-сервер для MySQL, который умеет кешировать запросы на уровне протокола, переписывать запросы, распределять нагрузку и маршрутизировать трафик на реплики.

Использование ProxySQL — это отдельная тема, но его можно рассматривать как дополнительный уровень кеширования.

5. Партиционирование

Партиционирование — это разделение большой таблицы на более мелкие части (партиции) по определённому критерию (диапазон, список, хеш). Запросы, которые попадают в одну партицию, работают значительно быстрее.

5.1 Типы партиционирования

  • По диапазону (RANGE) — по датам, ID.
  • По списку (LIST) — по списку значений (например, по регионам).
  • По хешу (HASH) — распределение по хешу для равномерной нагрузки.
  • По ключу (KEY) — специфичный для MySQL.

5.2 Пример для MySQL

SQL
-- Создание партиционированной таблицы по дате
CREATE TABLE orders (
    id INT NOT NULL,
    order_date DATE NOT NULL,
    amount DECIMAL(10,2)
)
PARTITION BY RANGE (YEAR(order_date)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

-- Запрос, использующий партицию
SELECT * FROM orders WHERE order_date >= '2023-01-01';

5.3 Пример для PostgreSQL (декларативное партиционирование)

SQL
-- Создание основной таблицы
CREATE TABLE orders (
    id SERIAL,
    order_date DATE NOT NULL,
    amount DECIMAL(10,2)
) PARTITION BY RANGE (order_date);

-- Создание партиций по месяцам
CREATE TABLE orders_2024_01 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE orders_2024_02 PARTITION OF orders
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

CREATE TABLE orders_2024_03 PARTITION OF orders
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');

5.4 Когда использовать партиционирование

  • Таблицы > 10 миллионов строк.
  • Есть естественный критерий для разделения (дата, регион).
  • Большинство запросов используют этот критерий.
  • Нужно быстро удалять старые данные (DROP PARTITION).

5.5 Ограничения

  • Не все индексы работают на партициях.
  • Внешние ключи могут быть сложнее.
  • Партиционирование — это не серебряная пуля. Если запросы не используют ключ партиционирования, они будут сканировать все партиции (что даже хуже).

ВАЖНО! В MySQL внешние ключи не поддерживаются для партиционированных таблиц. Если у вас есть внешние ключи, партиционирование может быть неприменимо. В PostgreSQL поддержка есть, но планируйте миграции и обслуживание заранее.

6. Шардирование

Шардирование — это горизонтальное разделение данных на разные серверы. Каждый шард содержит часть данных (например, пользователи с ID 1–10000 на сервере A, 10001–20000 на сервере B).

Когда нужно шардирование:

  • Данных настолько много, что один сервер не справляется (сотни терабайт).
  • Нагрузка на запись настолько высока, что один мастер не тянет.

Сложности:

  • Перераспределение данных при добавлении новых шардов.
  • JOIN-ы между шардами.
  • Транзакции на разных серверах.

В большинстве проектов до шардирования не доходят, обходясь оптимизацией, индексами и репликацией.

7. Настройка конфигурации СУБД

7.1 Настройка MySQL

Основные параметры в /etc/mysql/mysql.conf.d/mysqld.cnf:

7.2 Настройка PostgreSQL

В /etc/postgresql/15/main/postgresql.conf:

8. Медленный лог (Slow Query Log)

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

8.1 Включение медленного лога в MySQL

SQL
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 2;   -- 2 секунды
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';

Анализ медленного лога с помощью mysqldumpslow:

Bash
mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log

Покажет топ-10 медленных запросов по времени.

Включайте медленный лог только на время диагностики или на тестовом сервере. На продакшене используйте выборочную настройку (например, long_query_time = 2 и ротацию логов), чтобы не получить проблемы с местом на диске.

8.2 Включение медленного лога в PostgreSQL

В postgresql.conf:

После изменения перезапустите PostgreSQL:

Bash
sudo systemctl restart postgresql

8.3 Анализ логов в реальном времени

Bash
tail -f /var/log/mysql/mysql-slow.log
tail -f /var/log/postgresql/postgresql-15-main.log

9. Мониторинг производительности БД

Мы уже настроили Prometheus + Grafana. Добавим мониторинг для базы данных.

9.1 Метрики, за которыми нужно следить

  • Количество запросов в секунду (QPS)
  • Время ответа (latency) — среднее, p95, p99
  • Использование индексов (игнорируются ли индексы)
  • Блокировки (locks) — количество ожидающих транзакций
  • Размер базы данных и рост таблиц
  • Количество активных соединений
  • Нагрузка на InnoDB Buffer Pool (MySQL) / Shared Buffers (PostgreSQL)
  • Медленные запросы в реальном времени

9.2 Инструменты для мониторинга

  • Percona Monitoring and Management (PMM) — бесплатный инструмент для MySQL, PostgreSQL, MongoDB.
  • pgAdmin — встроенный мониторинг для PostgreSQL.
  • MySQL Workbench — мониторинг производительности.
  • Grafana + Prometheus с экспортёрами для MySQL и PostgreSQL (мы установили их ранее).

10. Практические примеры

10.1 Пример: Оптимизация запроса с JOIN

Проблемный запрос:

SQL
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.city = 'Moscow'
ORDER BY o.created_at DESC
LIMIT 20;

Проблемы:

  • Нет индекса на users.city.
  • Нет индекса на orders.user_id.
  • ORDER BY o.created_at без индекса.

Решение:

SQL
CREATE INDEX idx_city ON users(city);
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_created_at ON orders(created_at);

После:

  • users использует индекс idx_city.
  • orders использует индекс idx_user_id для JOIN.
  • ORDER BY использует idx_created_at.

10.2 Пример: Оптимизация поиска по тексту

Проблемный запрос:

SQL
SELECT * FROM articles WHERE content LIKE '%оптимизация%';

Проблема: LIKE '%text%' не использует индекс.

Решение 1: Использовать полнотекстовый поиск.

MySQL:

SQL
ALTER TABLE articles ADD FULLTEXT(content);
SELECT * FROM articles WHERE MATCH(content) AGAINST('оптимизация');

PostgreSQL:

SQL
CREATE INDEX idx_content ON articles USING GIN (to_tsvector('russian', content));
SELECT * FROM articles WHERE to_tsvector('russian', content) @@ to_tsquery('оптимизация');

Решение 2: Использовать внешний поисковик (Elasticsearch, Sphinx).

10.3 Пример: Оптимизация GROUP BY

Проблемный запрос:

SQL
SELECT DATE(created_at), COUNT(*) FROM orders GROUP BY DATE(created_at);

Проблема: GROUP BY без индекса приводит к временной таблице.

Решение: Создать индекс на created_at.

SQL
CREATE INDEX idx_created_at ON orders(created_at);

Теперь GROUP BY использует индекс для сортировки.

Более продвинутое решение: Создать отдельную таблицу для статистики и обновлять через триггер или cron.

10.4 Пример: Оптимизация подзапросов

Плохой запрос:

SQL
SELECT name FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

Оптимизация через JOIN:

SQL
SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.amount > 1000;

Или через EXISTS:

SQL
SELECT name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000);

EXISTS часто быстрее, если подзапрос возвращает мало строк.

11. Частые ошибки и как их избежать

ОшибкаПроявлениеРешение
Индекс на поле, но он не используетсяEXPLAIN показывает type: ALLПроверьте функции (LOWER), преобразования типов, LIKE '%text'.
Слишком много индексовМедленные вставки/обновленияУдалите индексы, которые не используются (проверьте через performance_schema).
SELECT * в больших таблицахТратится много памяти и времениПеречисляйте только нужные поля.
Нет лимита на выборкуЗапрос возвращает миллионы строкВсегда используйте LIMIT для выборок в интерфейсе.
Долгие транзакцииБлокировки, недоступность таблицСтарайтесь делать транзакции короткими, фиксируйте их как можно раньше.
Игнорирование медленного логаПроблемы не видныРегулярно анализируйте медленный лог и оптимизируйте запросы.
Неправильный порядок полей в составном индексеИндекс не используется полностьюСтавьте наиболее селективное поле первым.
Кеширование устаревших данныхПользователи видят старую информациюИспользуйте TTL (время жизни кеша) или инвалидацию при обновлении.

Оптимизация — это не разовое действие, а непрерывный процесс. Регулярно анализируйте запросы, следите за метриками и адаптируйте структуру базы данных под растущие потребности вашего проекта.

Нашли ошибку? Напишите нам!