💡 Никогда не работал с 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';
Что ломается:
- В columnar DBs (ClickHouse, BigQuery) каждая колонка читается с диска отдельно — лишние = bytes scanned × $. У ClickHouse на 100M строках разница между
SELECT *иSELECT id, amountможет быть 50× по времени. - В row-store (Postgres) меньше критично, но если есть JSONB/BYTEA —
*тащит мегабайты на каждую строку. - Сломает ETL: добавили колонку в источник → padajut downstream-запросы, ожидающие fixed schema.
Правило: в 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:
- Финансовая отчётность, биллинг — только exact.
- DAU/MAU дашборды, A/B-метрики — HLL ок (±2% не влияет на решения).
- В ClickHouse
uniq()по умолчанию HLL,uniqExact()— exact.
Антипаттерн 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_id — BIGINT, а условие 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;.
Почему мой запрос работает быстро локально, но медленно на проде?
- Разные размеры данных (1M vs 100M строк).
- Разные индексы / статистика (
ANALYZEне выполнен). - Разное железо (RAM, дисковая подсистема).
- На проде есть конкурентная нагрузка.
Решение: смотри EXPLAIN ANALYZE на проде с тем же датасетом.
Когда CTE vs subquery vs temp table?
- CTE — для читаемости + повторного использования внутри одного запроса. В старом Postgres (<12) была материализована = медленнее; с PG12+ inlined по умолчанию.
- Subquery — для одноразового использования. Оптимизатор лучше справляется.
- Temp table — для multi-step pipeline или когда CTE используется 5+ раз в одном запросе.
Что делать с медленным GROUP BY?
- Проверь, что есть индекс по группирующим колонкам.
- Уменьши cardinality (например, по дате, а не по timestamp).
- В ClickHouse —
SETTINGS max_threads = 8(parallel aggregation). - Pre-aggregate в materialized view, если запрос частый.
Что дальше
- 🧪 SQL-тренажёр — 480+ задач с проверкой кода (PostgreSQL + SQLite)
- 🧠 3000+ вопросов с собесов — 50+ про SQL-производительность
- 📚 Оконные функции — полный гайд — следующий уровень после JOIN/GROUP BY
- 🪟 ROWS vs RANGE vs GROUPS — глубокий разбор window frames
- 🎯 Когортный анализ retention — практика на реальной задаче
- ⚡ ClickHouse гайд — где SQL для аналитики на 100M+ строк
Источники
- use-the-index-luke.com — настольная книга по SQL-индексам и sargability
- postgresql.org/docs/current/performance-tips — официальные tips
- clickhouse.com/docs/en/sql-reference — ClickHouse SQL reference
10 ошибок — это не топ-10 «самых сложных», а топ-10 «которые мы видим каждую неделю». Открой SQL-тренажёр — там 480+ задач с проверкой, можно отловить эти паттерны на практике.