Базы данных
Query Tuning: от медленного к быстрому
Команда Uber в 2016 году переехала с PostgreSQL на MySQL - и одной из причин назвали накопившиеся медленные запросы. Но большинство проблем были диагностированы постфактум: N+1 в ORM, implicit casts, устаревшая статистика после bulk-загрузок. Инструменты для поиска этих проблем существовали всегда - просто никто не смотрел в pg_stat_statements.
- **Gitlab** публично задокументировал свой процесс query tuning: каждый MR автоматически запускает EXPLAIN на затронутых таблицах и блокирует мерж если появляется Seq Scan на таблице >10k строк.
- **Notion** обнаружил, что их главная страница делала 47 SQL-запросов из-за N+1 в GraphQL резолверах. Переход на DataLoader сократил это до 3 запросов и ускорил страницу на 400 мс.
- **pg_stat_statements** в связке с Grafana - стандартный стек мониторинга: дашборд обновляется каждые 60 секунд и показывает деградирующие запросы до того как пользователи замечают проблему.
Антипаттерны медленных запросов
90% медленных запросов имеют одну из пяти причин: функция в WHERE по индексированной колонке, LIKE с ведущим wildcard, SELECT *, условие OR вместо UNION, коррелированный подзапрос. Каждый из этих паттернов убивает индексы и вынуждает планировщик делать Seq Scan на всей таблице.
**Убийцы индексов:** `WHERE UPPER(email) = 'TEST@EXAMPLE.COM'` - функция делает индекс на `email` бесполезным (нужен functional index). `WHERE name LIKE '%smith%'` - ведущий wildcard = Seq Scan (нужен pg_trgm + GIN). `WHERE status != 'active'` - негативные условия обычно не используют индексы. `WHERE created_at::date = '2024-01-01'` - cast делает индекс на `created_at` бесполезным.
Запрос `WHERE EXTRACT(YEAR FROM created_at) = 2024` не использует индекс на `created_at`. Как переписать?
N+1: самая дорогая ошибка ORM
N+1 - это когда для загрузки списка из N объектов выполняется N+1 запросов: один для списка и по одному для каждой связанной записи. При N=1000 это 1001 round-trip к базе. На локальной машине незаметно, на продакшне с сетевой задержкой 1 мс - 1 секунда только на сетевые туры.
**Признаки N+1 в логах:** повторяющийся запрос с разными параметрами (`WHERE id = $1` с разными значениями). **Решение в ORM (TypeORM):** `relations: ['author']` или `leftJoinAndSelect`. **Решение на SQL:** JOIN или `WHERE id IN (...)`. **Для глубоких иерархий:** рекурсивный CTE (`WITH RECURSIVE`). **Паттерн DataLoader** (GraphQL): группирует N запросов в один batch за один tick event loop.
В логах TypeORM-приложения - 500 одинаковых запросов `SELECT * FROM users WHERE id = ?` за одну секунду. Причина?
Implicit Casts: когда тип убивает индекс
Неявное приведение типов - коварная ловушка: запрос выглядит правильно, возвращает верные данные, но индекс не используется. PostgreSQL строго типизирован: если колонка `bigint`, а параметр `varchar`, планировщик не может применить B-tree индекс без explicit cast. ORM-библиотеки или драйверы иногда передают параметры неверного типа.
**Типичные случаи:** колонка `user_id bigint`, параметр передан как строка `'42'` - Seq Scan. Колонка `status varchar`, сравнение с `text` - обычно ок. Колонка `phone varchar(20)`, параметр `char(20)` с пробелами - неявный cast. **Диагностика:** `EXPLAIN` покажет `Filter` вместо `Index Cond` - значит индекс не используется в фильтрации. **Решение:** explicit cast в запросе или исправление типа параметра на стороне приложения.
EXPLAIN показывает `Filter: ((user_id)::text = '42')` вместо `Index Cond`. Что происходит?
Статистика: когда планировщик ошибается
Планировщик PostgreSQL работает с оценками, а не с реальными данными. Оценки строятся на статистике - гистограммах распределения значений, собираемых ANALYZE. Устаревшая или неточная статистика - причина 40% проблем с планами запросов в production. После масштабных INSERT/DELETE/UPDATE статистика устаревает мгновенно.
**autovacuum и статистика:** autovacuum автоматически запускает ANALYZE при изменении >20% строк таблицы (threshold). Для горячих таблиц с частыми обновлениями это может быть недостаточно. **Настройка:** `ALTER TABLE hot_table SET (autovacuum_analyze_scale_factor = 0.01)` - запускать при изменении 1% вместо 20%. **Skewed данные:** значение с частотой >5% попадает в Most Common Values - статистика точная. Остаток - гистограмма из N бакетов (default 100). Для skewed колонок - `SET STATISTICS 500`.
После загрузки 5 млн строк в таблицу `events` запросы резко замедлились. EXPLAIN показывает `rows=100` при `actual rows=500000`. Первое действие?
pg_stat_statements: найти самые дорогие запросы
Нельзя оптимизировать то, что не измеряешь. `pg_stat_statements` - расширение PostgreSQL, которое агрегирует статистику по всем выполненным запросам: total_time, calls, mean_time, rows. Это первый инструмент диагностики в любом production PostgreSQL-сервере.
**Что показывает pg_stat_statements:** query (нормализованный - параметры заменены на $1, $2), calls, total_exec_time, mean_exec_time, stddev_exec_time, rows, shared_blks_hit/read (cache hit ratio). **Топ-кандидаты на оптимизацию:** (1) максимальный total_exec_time - запрос тратит больше всего CPU времени суммарно, (2) максимальный mean_exec_time - каждый вызов медленный, (3) низкий cache hit ratio (blks_hit / (blks_hit + blks_read)) - запрос читает много с диска.
Медленный запрос - это всегда тот, у которого нет индекса
Индекс - одна из многих причин. Устаревшая статистика, implicit cast, N+1, неверный work_mem, spillover Hash Join на диск - все это вызывает деградацию без связи с наличием индексов.
Планировщик может иметь индекс и не использовать его (implicit cast, cost model), или использовать неоптимальный join алгоритм (work_mem), или запрашивать базу тысячи раз вместо одного (N+1). Диагностика начинается с EXPLAIN ANALYZE и pg_stat_statements.
pg_stat_statements показывает запрос с `calls=1, total_exec_time=30000ms`. Другой - `calls=100000, total_exec_time=5000ms`. Что оптимизировать первым?
Ключевые идеи
- **Функции в WHERE** по индексированной колонке делают индекс бесполезным - нужен functional index или переписанное условие с range filter.
- **N+1** - архитектурная проблема ORM: использовать JOIN через relations или batch-загрузку через WHERE id IN (...). **Implicit cast** - тип параметра не совпадает с типом колонки = Seq Scan вместо Index Scan.
- **pg_stat_statements** - первый инструмент диагностики: сортировать по total_exec_time, брать топ-3, запускать EXPLAIN ANALYZE, исправлять. Повторять каждую неделю.
Связанные темы
Query Tuning применяет знания о планах запросов и индексах:
- EXPLAIN ANALYZE: читаем план запроса — Базовые инструменты анализа плана, используемые при tuning
- Транзакции и изоляция — MVCC и bloat таблицы влияют на производительность запросов
Вопросы для размышления
- Как обнаружить N+1 проблему в production без доступа к коду приложения?
- Когда implicit cast не является проблемой и PostgreSQL всё равно использует индекс?
- Каков оптимальный порог для autovacuum_analyze_scale_factor для таблицы с 100 млн строк и 10 000 INSERT в секунду?