SQLпроизводительностьPostgreSQLClickHouseоптимизация

10 SQL антипаттернов, которые убивают перформанс аналитика

2026-06-01 12 мин
💡 Никогда не работал с SQL? Сначала пройди серию SQL с нуля для аналитика — 10 связанных частей от «что такое БД» до Window Functions.

Аналитик пишет SQL каждый день. Один криво написанный запрос на витрине 100M строк может зависнуть на час и заблокировать таблицу. Другой — отдать неверные данные из-за NULL в NOT IN, и руководство примет решение по ошибочному отчёту.

Этот гайд — про 10 типичных антипаттернов, которые я регулярно вижу в PR от джунов и даже от мидлов. Каждый — с примером, что ломается, и как поправить. На разных движках (PostgreSQL, ClickHouse, Snowflake).


Антипаттерн 1: SELECT * в production-запросах

-- ПЛОХО
SELECT * FROM orders WHERE created_at >= '2024-01-01';

-- ХОРОШО
SELECT id, user_id, amount, created_at FROM orders WHERE created_at >= '2024-01-01';

Что ломается:

Правило: в production-SQL никаких *. В ad-hoc SELECT * ок, но только с LIMIT 10.


Антипаттерн 2: Функция на колонке в WHERE (non-sargable)

-- ПЛОХО — индекс по created_at НЕ используется
SELECT * FROM orders WHERE DATE(created_at) = '2024-03-15';

-- ХОРОШО — индекс работает
SELECT * FROM orders
WHERE created_at >= '2024-03-15' AND created_at < '2024-03-16';

Почему: обёрнутую в функцию колонку оптимизатор не может сопоставить с индексом — нужно вычислить функцию для КАЖДОЙ строки, потом сравнить.

Другие примеры non-sargable:

WHERE LOWER(email) = 'admin@x.com'  -- → создать functional index или хранить нормализованным
WHERE CAST(user_id AS TEXT) = '123'  -- → user_id = 123 (если column числовой)
WHERE created_at + INTERVAL '1 day' > NOW()  -- → created_at > NOW() - INTERVAL '1 day'

Источник: use-the-index-luke.com — sargability rules.


Антипаттерн 3: OR на разных колонках без UNION

-- ПЛОХО — Postgres часто делает Seq Scan
SELECT * FROM users WHERE email = 'a@b.com' OR phone = '+7900...';

-- ХОРОШО — оптимизатор использует индексы по каждой колонке
SELECT * FROM users WHERE email = 'a@b.com'
UNION
SELECT * FROM users WHERE phone = '+7900...';

Почему: для эффективного OR оптимизатору нужен BitmapOr (Postgres) или Index Merge (MySQL) — не всегда срабатывают. UNION гарантированно отдаст каждую часть через индекс.

Подвох: UNION ALL быстрее UNION (нет дедупа), но даст дубли если запись совпадает по обоим условиям. Если важна уникальность — UNION.


Антипаттерн 4: NOT IN с возможным NULL

-- ПЛОХО — если в blacklist есть хоть один NULL, запрос вернёт 0 строк
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM blacklist);

-- ХОРОШО — NULL-safe через NOT EXISTS
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM blacklist b WHERE b.user_id = u.id);

Почему: x NOT IN (1, 2, NULL) = x != 1 AND x != 2 AND x != NULL = x != 1 AND x != 2 AND UNKNOWN = UNKNOWN → строка не возвращается. Три-значная логика SQL.

Если хоть один user_id в blacklist равен NULL — весь запрос вернёт пусто. Тихий bug, который вылавливают по downstream-несоответствиям.


Антипаттерн 5: COUNT(DISTINCT col) на большом объёме без аппроксимации

-- МЕДЛЕННО — exact distinct на 1B строк требует memory или большой sort
SELECT COUNT(DISTINCT user_id) FROM events WHERE date = '2024-03-15';

-- БЫСТРО — HyperLogLog approximation (ошибка ~2%)
-- ClickHouse:
SELECT uniqHLL12(user_id) FROM events WHERE date = '2024-03-15';
-- BigQuery:
SELECT APPROX_COUNT_DISTINCT(user_id) FROM events WHERE date = '2024-03-15';
-- Postgres + extension postgresql-hll:
SELECT hll_cardinality(hll_add_agg(hll_hash_text(user_id::text))) FROM events;

Когда exact, когда HLL:


Антипаттерн 6: GROUP BY без HAVING + лишний DISTINCT

-- ПЛОХО — DISTINCT после GROUP BY чаще всего бессмысленен
SELECT DISTINCT user_id, COUNT(*) FROM orders GROUP BY user_id;

-- ХОРОШО
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;
GROUP BY уже гарантирует уникальность по группирующим колонкам. DISTINCT поверх — двойная работа.

Связанный антипаттерн: DISTINCT вместо GROUP BY для дедупа:

-- DISTINCT — работает, но не позволяет агрегаты
SELECT DISTINCT user_id, country FROM users;

-- GROUP BY — тоже работает, плюс открывает дверь к COUNT/SUM
SELECT user_id, country FROM users GROUP BY user_id, country;

Оптимизатор обычно одинаково их выполняет, но GROUP BY гибче.


Антипаттерн 7: LIKE '%pattern%' на больших таблицах

-- МЕДЛЕННО — leading wildcard → Seq Scan (B-tree индекс не используется)
SELECT * FROM products WHERE name LIKE '%phone%';

-- БЫСТРО — варианты
-- 1. Полнотекстовый индекс Postgres
CREATE INDEX idx_products_name_fts ON products USING gin(to_tsvector('russian', name));
SELECT * FROM products WHERE to_tsvector('russian', name) @@ to_tsquery('phone');

-- 2. Trigram-индекс pg_trgm
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);
SELECT * FROM products WHERE name ILIKE '%phone%';  -- теперь использует индекс

-- 3. ClickHouse — нативно индексирует через bloom_filter / tokenbf_v1
ALTER TABLE products ADD INDEX name_tokens name TYPE tokenbf_v1(8192, 3, 0) GRANULARITY 4;

Правило: ведущий wildcard %text ломает B-tree. Trailing — text% — работает (Postgres использует index range scan).


Антипаттерн 8: лишние JOIN'ы вместо EXISTS / IN

-- ПЛОХО — INNER JOIN дублирует строки, если в orders есть несколько заказов на user_id
SELECT u.id, u.name
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= '2024-01-01';
-- Получишь user_id повторённым столько раз, сколько у него заказов!

-- ХОРОШО — semi-join через EXISTS (не дублирует)
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.created_at >= '2024-01-01'
);

Правило: если нужна только «существует хоть один match» — EXISTS или IN, не JOIN. JOIN тащит все matched строки + создаёт fan-out (cartesian-like).

Когда JOIN всё-таки ок: если нужны колонки из правой таблицы (e.g., last_order_date).


Антипаттерн 9: CAST в WHERE и в JOIN ON

-- ПЛОХО — CAST на каждой строке + ломает index
SELECT * FROM events
WHERE CAST(user_id AS TEXT) = '12345';

-- ПЛОХО — CAST в JOIN
SELECT * FROM events e
JOIN users u ON CAST(e.user_id AS TEXT) = u.id;

-- ХОРОШО — приведи константу к типу колонки, не наоборот
SELECT * FROM events WHERE user_id = 12345;

Implicit cast тоже ломает. Если user_idBIGINT, а условие WHERE user_id = '12345' (строка) — Postgres может сделать implicit cast, проверь через EXPLAIN что используется индекс.

В ClickHouse implicit cast ещё опаснее — может вернуть unexpected result, типы стрипчее.


Антипаттерн 10: запрос без LIMIT в ad-hoc / dashboard

-- ОПАСНО — ad-hoc на 100M строк, забили весь heap клиента
SELECT user_id, amount FROM orders ORDER BY created_at DESC;

-- ХОРОШО
SELECT user_id, amount FROM orders ORDER BY created_at DESC LIMIT 100;

Правило: в Tableau/Superset/Metabase любой dataset-query → LIMIT 10000 по умолчанию. На очень больших dashboards — pre-aggregated views.

В IDE (DBeaver, DataGrip) — настрой Auto-Limit = 1000 на читах.


Бонусные ошибки (короткие)

SELECT в подзапросе с UNION ALL без выравнивания колонок:

-- падёт если число/типы колонок отличаются
SELECT a, b FROM t1 UNION ALL SELECT a FROM t2;  -- ошибка

ORDER BY 1 в production: магические числа ломаются при добавлении колонки. Лучше ORDER BY column_name.

HAVING вместо WHERE: HAVING работает после агрегации, WHERE до. Если условие на не-агрегированной колонке — это WHERE (фильтруется раньше → быстрее).

-- ПЛОХО — фильтр country работает после group by
SELECT country, COUNT(*) FROM users GROUP BY country HAVING country = 'RU';

-- ХОРОШО — фильтр работает до group by
SELECT country, COUNT(*) FROM users WHERE country = 'RU' GROUP BY country;

FAQ

Как найти медленные запросы в Postgres?

pg_stat_statements — extension. Включи в postgresql.conf (shared_preload_libraries = 'pg_stat_statements'), потом:
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 20;

EXPLAIN ANALYZE vs EXPLAIN?

EXPLAIN — план без выполнения. EXPLAIN ANALYZE — выполняет + показывает реальное время. Для DML (UPDATE/DELETE) оборачивай в BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;.

Почему мой запрос работает быстро локально, но медленно на проде?

Решение: смотри EXPLAIN ANALYZE на проде с тем же датасетом.

Когда CTE vs subquery vs temp table?

Что делать с медленным GROUP BY?


Что дальше

Источники

10 ошибок — это не топ-10 «самых сложных», а топ-10 «которые мы видим каждую неделю». Открой SQL-тренажёр — там 480+ задач с проверкой, можно отловить эти паттерны на практике.

Натренируй SQL до автомата
480+ SQL-задач в браузере с PG/SQLite, 3000+ вопросов с собесов. Первые 5 задач — бесплатно.
Открыть SQL-тренажёр →