Семь ошибок индексации БД, которые убивают производительность SaaS на корню

Индексы — не волшебная таблетка. Неправильные индексы тормозят запросы, раздувают хранилище и превращают INSERT в катастрофу. Разбираем семь типичных ошибок и показываем, как их избежать.

Обложка: Семь ошибок индексации БД, которые убивают производительность SaaS на корню

Ваш код чист, архитектура продумана, а запросы на staging укладываются в 12 мс. Но при 500 000 записей всё встаёт: панели мониторинга тормозят, пользователи жалуются, а дежурный инженер в полночь разглядывает план выполнения запроса и не понимает, что пошло не так. В девяти случаях из десяти причина — не отсутствие индексов, а неправильные индексы.

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

В этой статье разберём семь самых разрушительных ошибок индексации в production-SaaS: от параноидального создания индексов «на всякий случай» до игнорирования раздутия и особенностей мультитенантных схем. Каждая ошибка — с примерами SQL, метриками и конкретным фиксом.

Ключевые выводы

Индексы — это налог на запись: каждый INSERT, UPDATE и DELETE обновляет все индексы таблицы.

Индекс на колонке с низкой селективностью (boolean, статус) почти бесполезен — используйте частичные (partial) индексы.

В составном индексе порядок колонок критичен: PostgreSQL может использовать (a, b, c) только если условие начинается с a.

Раздутые индексы на высокоизменяемых таблицах могут занимать в 8 раз больше места, чем нужно — планируйте REINDEX CONCURRENTLY.

В мультитенантных системах индекс по tenant_id один часто недостаточен: добавляйте диапазон времени или тип события.

Внешние ключи в PostgreSQL не индексируются автоматически — после каждого FOREIGN KEY создавайте индекс вручную.

Без EXPLAIN ANALYZE индексы создаются вслепую. Проверяйте реальный план выполнения перед деплоем.

Ошибка 1. Индексировать всё подряд «на всякий случай»

Самая распространённая ошибка — не недостаток индексов, а их избыток из тревожности. Особенно часто в неё попадают junior-разработчики: добавляют индекс на каждую колонку, которая встречается в WHERE, «на всякий случай». Кажется ответственным, но на самом деле вредно.

Каждый индекс — это налог на запись. При INSERT, UPDATE и DELETE PostgreSQL (или MySQL) вынуждена обновлять все индексы таблицы. На таблице с 8 индексами каждая запись трогает 8 структур данных. При низкой нагрузке это невидимо, но при 10 000 записей в минуту это становится узким местом.

Аудит неиспользуемых индексов в PostgreSQL:
Запрос к pg_stat_user_indexes покажет, сколько раз каждый индекс применялся с момента сброса статистики. Если idx_scan = 0 — индекс кандидат на удаление.
			SELECT schemaname, tablename, indexname,
       idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC;
		

Не удаляйте индекс сразу — сначала убедитесь, что он не нужен для редких, но критичных отчётов. Но если он месяцами не используется, смело избавляйтесь.

Ошибка 2. Не понимать селективность

Индекс на булеву колонку — почти всегда бесполезен. Селективность измеряет, сколько различных значений содержится относительно общего числа строк. У boolean всего два значения. Если 95% строк имеют is_active = true, планировщик запросов проигнорирует индекс и сделает Seq Scan — и будет прав.

			-- Бесполезный индекс на низкоселективной колонке
CREATE INDEX idx_users_is_active ON users(is_active);

-- Правильный вариант: частичный индекс только по активным
CREATE INDEX idx_users_active_created
ON users(created_at)
WHERE is_active = true;
		

Правило большого пальца: если у колонки меньше 10–20 уникальных значений относительно размера таблицы, простой индекс по ней один не справится. Используйте частичные или составные индексы.

Ошибка 3. Неверный порядок колонок в составном индексе

Составные индексы мощны, но часто неправильно понимаются. PostgreSQL может использовать индекс (a, b, c) для фильтрации по a, a и b, a, b и c. Но не может эффективно использовать его, если запрос фильтрует только по b или c — ведущая колонка пропущена.

			-- Создали индекс
CREATE INDEX idx_orders_user_status_date
ON orders(user_id, status, created_at);

-- Использует индекс эффективно
SELECT * FROM orders
WHERE user_id = 123 AND status = 'pending';

-- НЕ использует индекс эффективно (пропущен user_id)
SELECT * FROM orders
WHERE status = 'pending' AND created_at > '2025-01-01';
		

Решение: первыми ставьте колонки с equality-условиями, затем — по селективности, и проектируйте индексы вокруг реальных паттернов запросов, а не вокруг схемы таблицы. Также учитывайте ORDER BY и необходимость покрывающего индекса. Перед созданием обязательно запускайте EXPLAIN ANALYZE.

Ошибка 4. Игнорировать раздутие индексов

Индексы деградируют со временем. Многие инженеры воспринимают их как «поставил и забыл», но это заблуждение. В PostgreSQL при обновлении или удалении строки старые записи в индексе устаревают и накапливаются как раздутие (bloat); их физическое удаление происходит при выполнении VACUUM. На высокоизменяемых таблицах (заказы, события, логи, сессии) раздутие накапливается стремительно.

Таблица с 1 млн живых строк может иметь индекс, раздутый до размеров 8 млн записей. Каждый запрос через такой индекс делает в 8 раз больше работы, чем должен.

			SELECT tablename, indexname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
       idx_scan
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
ORDER BY pg_relation_size(indexrelid) DESC;
		
REINDEX CONCURRENTLY — ключевое слово. Обычный REINDEX блокирует таблицу, а в production-SaaS это прямой путь к инциденту. CONCURRENTLY перестраивает индекс без блокировок, хотя и медленнее.
			REINDEX INDEX CONCURRENTLY idx_orders_user_status_date;
		

Также убедитесь, что autovacuum настроен под вашу реальную нагрузку на запись. Значения по умолчанию в PostgreSQL консервативны и часто недостаточны для SaaS с высокой интенсивностью записи.

Ошибка 5. Индексы на колонках с малым числом уникальных значений в мультитенантных системах

В мультитенантной архитектуре почти каждый запрос фильтрует по tenant_id. Естественное желание — проиндексировать эту колонку. Но для крупных тенантов индекс по tenant_id вернёт слишком много строк, и планировщик предпочтёт Seq Scan. Для маленьких тенантов отдельный индекс по tenant_id может быть полезен.

			-- Недостаточно для крупных тенантов
CREATE INDEX idx_events_tenant ON events(tenant_id);

-- Лучше: тенант + временной диапазон
CREATE INDEX idx_events_tenant_created
ON events(tenant_id, created_at DESC);

-- Ещё лучше для конкретных паттернов
CREATE INDEX idx_events_tenant_type_created
ON events(tenant_id, event_type, created_at DESC)
WHERE event_type IN ('purchase', 'refund', 'signup');
		

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

Ошибка 6. Не индексировать внешние ключи

В PostgreSQL внешние ключи не индексируются автоматически. При удалении родительской строки СУБД должна проверить все дочерние таблицы на наличие ссылающихся записей — и без индекса на внешнем ключе это Seq Scan по каждой дочерней таблице. На таблице orders с 50 млн строк удаление пользователя вызывает полное сканирование.

			-- Внешний ключ есть, а индекса нет
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id);

-- Создайте индекс вручную сразу после FK
CREATE INDEX idx_orders_user_id ON orders(user_id);
		

Сделайте это командным соглашением: в чек-листе миграций обязательный пункт «после каждого FOREIGN KEY — CREATE INDEX».

Ошибка 7. Не использовать EXPLAIN ANALYZE перед деплоем

Большинство решений об индексах принимаются интуитивно. Интуиция ошибается достаточно часто, чтобы это стало проблемой. EXPLAIN ANALYZE показывает, что именно делает планировщик: какие индексы использует, какие игнорирует, сколько строк реально прочитал против оценки, где тратится время.

			EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.tenant_id = 'abc-123'
  AND o.status = 'pending'
  AND o.created_at > NOW() - INTERVAL '7 days'
ORDER BY o.created_at DESC
LIMIT 50;
		

На что обращать внимание:
Seq Scan на большой таблице при выборке малой доли строк — возможно, пропущен индекс или планировщик не может использовать существующий индекс.
Rows Removed by Filter в десятки тысяч — индекс есть, но неправильные колонки или низкая селективность.
Buffers: shared hit=0 read=45000 — данные не закэшированы (cold cache), страницы читаются с диска. Для диагностики раздутия смотрите общее число буферов и сравнивайте размер индекса с ожидаемым.
• Высокое actual time — проверьте раздутие, актуальность статистики, количество буферов, дисковую подсистему и наличие блокировок. Запустите ANALYZE tablename, чтобы обновить статистику планировщика.

Чек-лист индексации для SaaS

  • У каждой колонки внешнего ключа есть индекс?
  • Составные индексы упорядочены по селективности, а не по удобству?
  • Булевы и низкокардинальные фильтры используют частичные индексы вместо полных?
  • Вы запускали EXPLAIN ANALYZE на 10 самых медленных запросов за неделю?
  • Есть процесс поиска и удаления неиспользуемых индексов?
  • Высокоизменяемые таблицы регулярно проходят REINDEX CONCURRENTLY?
  • Autovacuum настроен под реальный объём записи, а не под значения по умолчанию PostgreSQL?
  • В мультитенантных таблицах индексы начинаются с tenant_id и включают диапазон времени?
Часто задаваемые вопросы
1
Что такое селективность индекса?

Селективность — это доля уникальных значений колонки от общего числа строк. Чем выше селективность, тем меньше строк возвращает индекс при одном значении, и тем эффективнее он работает. Например, индекс по email почти уникален (высокая селективность), а индекс по статусу заказа с тремя вариантами — низкая.

2
Как часто нужно запускать REINDEX CONCURRENTLY?

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

3
Почему PostgreSQL не индексирует FOREIGN KEY автоматически?

Внешний ключ — это ограничение целостности, а не структура доступа. PostgreSQL проверяет целостность при DELETE и UPDATE родительской таблицы, но не предполагает, что вы будете часто искать по этой колонке. Поэтому индекс создавать нужно вручную — и это стандартная практика в production.

4
Можно ли слишком много индексов?

Да. Каждый индекс замедляет INSERT, UPDATE и DELETE, занимает диск и RAM. Оптимальное количество — не абсолютная цифра, а баланс между скоростью чтения и записи. Регулярно проверяйте pg_stat_user_indexes и удаляйте те, что не используются.

5
Какой инструмент использовать для анализа планов запросов?

В PostgreSQL — встроенный EXPLAIN ANALYZE. Для визуализации используйте explain.depesz.com, pgAdmin или pghero. В MySQL — EXPLAIN FORMAT=JSON. Для автоматического мониторинга медленных запросов подойдут pg_stat_statements (PostgreSQL) или Performance Schema (MySQL).

Выводы

Индексы — не фича производительности, которую добавляют, когда всё начинает тормозить. Это проектное решение, которое принимается вместе со схемой, и пересматривается по мере эволюции паттернов запросов. Команды, которые уверенно масштабируются, — не те, у кого больше всего индексов, а те, кто понимает, что каждый индекс стоит, что даёт и когда его пора убирать.

База данных, которая быстра на 10 000 строках и быстра на 50 миллионах, — не случайность. Это результат того, что кто-то считал планирование запросов первоклассной инженерной задачей, а не рутинным дополнением.
Авторская редакцияTproger

Проверьте свои самые медленные запросы этой недели — возможно, одна из этих семи ошибок уже сидит в вашей production-базе.

Источник и материалы:
Database Indexing Mistakes That Kill SaaS Performance at Scale — оригинальная статья OutworkTech
PostgreSQL Index Types — официальная документация
REINDEX — документация по перестроению индексов