SQLLab
Все статьи

SQL антипаттерны: 12 ошибок которые замедляют запросы

Разбираем типичные SQL антипаттерны: SELECT *, функции в WHERE, NOT IN с NULL, OFFSET, декартово произведение. Каждый антипаттерн — с правильной альтернативой.

17 марта 2026 г.·6 мин чтения·

SQL легко написать, но трудно написать хорошо. Вот 12 антипаттернов, которые встречаются в реальных кодбазах и замедляют производительность или приводят к неверным результатам.


1. SELECT * в продакшне

-- Плохо
SELECT * FROM users;

-- Хорошо
SELECT id, email, name, created_at FROM users;

Проблемы:

  • Передаёт лишние данные по сети
  • Ломается при добавлении/удалении столбцов
  • Мешает index-only scan (PostgreSQL не может использовать покрывающий индекс)

2. Функция на столбце в WHERE — убивает индекс

-- Плохо: индекс на created_at не используется
WHERE YEAR(created_at) = 2026
WHERE DATE(created_at) = '2026-03-15'
WHERE LOWER(email) = 'user@example.com'

-- Хорошо: сравниваем столбец напрямую
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
WHERE created_at::date = '2026-03-15'  -- хуже, но иногда допустимо
WHERE email = LOWER('User@Example.Com')  -- функция на константе — ок

Правило: не применяйте функции к индексированному столбцу в условии WHERE. Используйте функциональные индексы или переписывайте условие.

-- Функциональный индекс — решение
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
WHERE LOWER(email) = 'user@example.com'  -- теперь использует индекс

3. NOT IN с NULL в подзапросе

-- Смертельно опасно: если хоть один user_id = NULL → результат пустой!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

-- Безопасно
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

-- Или с явным исключением NULL
WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);

4. OR вместо IN / UNION ALL

-- Плохо: OR с одним столбцом = план с несколькими сканами
WHERE status = 'active' OR status = 'pending' OR status = 'processing'

-- Хорошо: IN — оптимизатор может использовать Bitmap Index Scan
WHERE status IN ('active', 'pending', 'processing')
-- Плохо: OR по разным столбцам — мешает использованию составного индекса
WHERE (user_id = 1 AND status = 'active')
   OR (user_id = 2 AND status = 'pending')

-- Хорошо: UNION ALL
SELECT * FROM orders WHERE user_id = 1 AND status = 'active'
UNION ALL
SELECT * FROM orders WHERE user_id = 2 AND status = 'pending'

5. OFFSET для пагинации на больших таблицах

-- Плохо: читает и выбрасывает 10000 строк
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;

-- Хорошо: курсорная пагинация (keyset pagination)
SELECT * FROM posts
WHERE created_at < :last_seen_date  -- передаём значение последней страницы
ORDER BY created_at DESC
LIMIT 20;

При OFFSET 10000 PostgreSQL обрабатывает 10020 строк и выбрасывает первые 10000. Курсорный метод работает за константное время.


6. COUNT(*) для проверки существования

-- Плохо: считает все строки даже если нужно просто «есть или нет»
IF (SELECT COUNT(*) FROM orders WHERE user_id = 42) > 0 THEN ...

-- Хорошо: EXISTS останавливается после первого совпадения
IF EXISTS (SELECT 1 FROM orders WHERE user_id = 42) THEN ...

7. Декартово произведение (случайный CROSS JOIN)

-- Плохо: забыли JOIN условие — получили N×M строк!
SELECT u.name, o.amount
FROM users u, orders o;  -- legacy синтаксис JOIN через запятую

-- Хорошо: явный JOIN
SELECT u.name, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id;
-- Скрытый CROSS JOIN с CTE
WITH all_months AS (SELECT generate_series(1, 12) AS month),
     all_products AS (SELECT id FROM products)
SELECT * FROM all_months, all_products;
-- Это тоже CROSS JOIN — возможно умышленный, но проверьте

8. DISTINCT как заплатка для дубликатов

-- Плохо: дубликаты симптом, DISTINCT — не лечение
SELECT DISTINCT u.id, u.name
FROM users u
JOIN orders o ON o.user_id = u.id;

-- Хорошо: понять почему дубли и исправить логику
SELECT u.id, u.name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

9. Сортировка без LIMIT (на больших таблицах)

-- Плохо: сортировка миллиона строк чтобы взять первые 10
SELECT * FROM events ORDER BY created_at DESC;  -- без LIMIT!

-- Хорошо
SELECT * FROM events ORDER BY created_at DESC LIMIT 100;

Сортировка всей таблицы без LIMIT нагружает память и CPU. Всегда добавляйте LIMIT если нужны лишь первые строки.


10. Вычисление в GROUP BY вместо алиаса

-- Плохо: PostgreSQL вычисляет выражение дважды (в SELECT и GROUP BY)
SELECT DATE_TRUNC('month', created_at), COUNT(*)
FROM orders
GROUP BY DATE_TRUNC('month', created_at);  -- дублирование

-- Хорошо: использовать позицию столбца
SELECT DATE_TRUNC('month', created_at), COUNT(*)
FROM orders
GROUP BY 1;

-- Или CTE
WITH monthly AS (SELECT DATE_TRUNC('month', created_at) AS month FROM orders)
SELECT month, COUNT(*) FROM monthly GROUP BY month;

11. N+1 запросов вместо JOIN

-- Плохо (псевдокод): один запрос на пользователя
for user in SELECT * FROM users:
    orders = SELECT * FROM orders WHERE user_id = user.id
    # 1 + N запросов в цикле

-- Хорошо: один запрос с JOIN
SELECT u.id, u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name;

N+1 — типичная болезнь ORM. В SQL её эквивалент — подзапросы в SELECT без корреляции с JOIN:

-- Плохо: коррелированный подзапрос в SELECT = N+1 в SQL
SELECT
    u.id,
    (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;

-- Хорошо: LEFT JOIN
SELECT u.id, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;

12. Сохранение в VARCHAR то что должно быть в нужном типе

-- Плохо: дата как строка
CREATE TABLE events (event_date VARCHAR(10));  -- '2026-03-15'

-- Хорошо: правильный тип
CREATE TABLE events (event_date DATE);
-- Или TIMESTAMPTZ если нужно время с таймзоной

Хранение дат, чисел, JSON как VARCHAR:

  • Нельзя использовать функции дат/чисел без приведения
  • Нельзя создать нормальный индекс
  • Неверная сортировка ('10' < '9' лексикографически)
  • Нет валидации на уровне БД

Итоговая шпаргалка

АнтипаттернПроблемаРешение
SELECT *Лишние данные, ломается схемаПеречислить столбцы
Функция на индексном столбцеИндекс не используетсяПереписать условие
NOT IN с NULLПустой результатNOT EXISTS
OR по разным столбцамПлохой планUNION ALL или IN
OFFSET большойМедленноKeyset pagination
COUNT(*) для EXISTSЛишняя работаEXISTS
CROSS JOIN случайныйВзрыв строкЯвный JOIN ON
DISTINCT как заплаткаСкрывает проблемуИсправить логику
Сортировка без LIMITНагрузка памятиДобавить LIMIT
N+1 запросовМного круговых обращенийJOIN
VARCHAR вместо правильного типаНеверная сортировка, нет индексаПравильный тип данных

Похожие статьи

Попробуй на практике

Тренажёр с реальными задачами — бесплатно и без регистрации

Открыть тренажёр →