После четырнадцати модулей про storage, MVCC, planner и locks у тебя достаточно бэкграунда, чтобы смотреть на SQL не как на «вот такой текст», а как на инструкцию executor’а: сколько страниц прочитать, сколько кортежей пройти через фильтр, какой join выбрать. С этим бэкграундом многие antipatterns становятся не вопросом «стиля», а конкретными ошибками — каждая из них имеет цену в страницах и миллисекундах.
В этом уроке — каталог из семи antipatterns, которые встречаются чаще остальных. На каждом: что плохо, как diagnose это в EXPLAIN, и какой канонический fix.
Antipattern 1: SELECT *
Самый «безобидный» antipattern. Кажется, что разница между SELECT * и SELECT id, email — это пара лишних колонок в сети. На деле — нет.
SELECT есть в индексе. SELECT * мгновенно убивает эту возможность: индекс знает (email, id), но * тянет ещё 15 колонок — придётся идти в heap, и план деградирует до обычного Index Scan. На больших таблицах это в 5-20 раз медленнее.
Второй удар — TOAST. Колонки jsonb, text, bytea могут хранится out-of-line. SELECT * всегда их декодирует, даже если 99% запросов смотрят только на скалярные поля. Это лишний random IO в pg_toast_<oid>.
Один и тот же предикат: SELECT * читает heap + TOAST; SELECT explicit может остаться в индексе (index-only scan).
Diagnosis в EXPLAIN: ищи Heap Fetches: <N> под Index Only Scan’ом, или просто Index Scan (не Index Only Scan) там, где должна быть возможность не ходить в heap.
Fix: перечисляй колонки явно. Если регулярно нужны 3 колонки — заведи CREATE INDEX ... INCLUDE (col1, col2) для covering index’а.
Сравни два плана. Сначала SELECT * — обязательный heap fetch. Затем — covering index и index-only scan. Внимание: датасет инициализируется ~5 секунд.
Тот же запрос, но только нужные колонки. Должен появиться Index Only Scan и Heap Fetches: 0 (если VACUUM прошёл и Visibility Map чистая).
Antipattern 2: функция на индексированной колонке
Индекс на колонке email хранит значения колонки. Если в WHERE написать LOWER(email) = '[email protected]' — индекс не может быть использован: planner видит не email, а выражение LOWER(email), для которого нет ни одного индекса.
То же самое для WHERE DATE(placed_at) = '2024-05-01', WHERE EXTRACT(year FROM placed_at) = 2024, WHERE id::text = '42'. Любая функция, любой cast — индекс по голой колонке не сработает.
Diagnosis: Seq Scan там, где ты ожидал Index Scan. Если в EXPLAIN фильтр выглядит как Filter: (lower(email) = ...) — диагноз ясен.
Два варианта fix’а:
- Перенести функцию на константу.
WHERE placed_at >= '2024-01-01' AND placed_at < '2025-01-01'вместоEXTRACT(year FROM placed_at) = 2024. Индекс поplaced_atработает. - Функциональный индекс.
CREATE INDEX customers_email_lower_idx ON customers (LOWER(email))— теперьWHERE LOWER(email) = ...сработает. Но: индекс ровно по этому выражению, поменяешь функцию — нужен новый индекс.
LOWER(email) превращает Index Scan в Seq Scan. Сравни план.
Fix через functional index. Создаём индекс по выражению — Index Scan возвращается.
Antipattern 3: длинный OR вместо IN
WHERE id = 1 OR id = 2 OR id = 3 OR ... OR id = 1000 — на коротких списках работает, но как только OR-цепочка переваливает за ~100 элементов, planner начинает плодить странные планы: BitmapOr с сотней узлов, иногда деградирует в Seq Scan.
IN (1, 2, 3, ..., 1000) planner понимает лучше и часто превращает в = ANY('{1,2,3,...}'::int[]), а для длинных списков — даже в hash semi-join.
Diagnosis: в EXPLAIN огромное BitmapOr с сотней детей, либо Filter: ((id = 1) OR (id = 2) OR ...) поверх Seq Scan.
Fix: замени на IN (...) или = ANY(ARRAY[...]). Для совсем больших списков — заведи temp table или CTE и сделай JOIN.
Antipattern 4: глубоко вложенные подзапросы
SELECT id FROM a WHERE x IN (
SELECT y FROM b WHERE z IN (
SELECT w FROM c WHERE v IN (
SELECT u FROM d WHERE ...
)
)
)
Каждый уровень вложенности — отдельная задача для планировщика. На трёх уровнях он ещё справляется; на пяти — теряет статистику корреляции и выбирает плохие join orders.
Diagnosis: EXPLAIN показывает странные оценки rows (off на порядки), Nested Loop там, где должен быть Hash Join.
Fix: разверни в JOIN’ы. Каждая IN (SELECT ...) обычно превращается в EXISTS + JOIN или в обычный INNER JOIN. Длинные цепочки CTE с WITH тоже спасают: planner материализует промежуточные результаты и считает их размер.
Antipattern 5: type mismatch в JOIN
JOIN orders o ON o.customer_id = c.id, где customer_id — INT, а c.id — BIGINT. PostgreSQL приведёт типы, но это требует функционального сравнения: индекс по customer_id будет работать, но Hash Join придётся переcчитать хэш для приведённого типа, и в худших случаях planner вообще откажется от Hash в пользу Nested Loop.
Аналогично — TEXT vs VARCHAR(255) (обычно ок, но не всегда), TIMESTAMP vs TIMESTAMPTZ (часто плохо), JSONB vs JSON.
Diagnosis: в EXPLAIN рядом с условием появляется явный cast — (customer_id)::bigint = c.id. Hash Join не используется там, где должен.
Fix: приведи типы при создании таблиц. Если уже поздно — индекс по выражению CREATE INDEX ON orders ((customer_id::bigint)), но это симптоматическое лечение.
Type mismatch в WHERE — индекс не используется. Cast в WHERE превращает план в Seq Scan.
Antipattern 6: ORDER BY random()
SELECT * FROM table ORDER BY random() LIMIT 10 — на маленькой таблице безобидно. На большой это:
- Полный Seq Scan по всей таблице.
- Вычисление
random()для каждой строки. - Sort всех результатов по этому случайному числу.
- LIMIT 10 после полного sort’а.
На 10M строках это десятки секунд против 10 ms ожидаемой случайной выборки.
Diagnosis: EXPLAIN показывает Sort поверх Seq Scan всей таблицы.
Fix: зависит от требований к «случайности»:
- Грубо.
TABLESAMPLE BERNOULLI(1)илиTABLESAMPLE SYSTEM(1)— выбирает ~1% строк или страниц. Очень дёшево, но распределение неравномерное. - Точно для маленьких таблиц.
ORDER BY random() LIMIT N— нормально, если таблица < 100K строк. - Точно для больших. Заранее посчитать
count(*), выбратьNслучайных id в range[1, max_id], сделатьWHERE id IN (...). Требует, чтобы id-пробелов было мало.
Antipattern 7: UPDATE/DELETE без WHERE
UPDATE accounts SET status = 'pending' без WHERE — обновит все строки таблицы. В MVCC-мире это значит: создать новую версию для каждой строки, удвоить размер таблицы, мгновенный bloat в десятки гигабайт.
То же самое — WHERE 1=1 или WHERE true, или WHERE col = col (часто результат ошибки в коде).
Diagnosis: EXPLAIN UPDATE покажет Seq Scan всей таблицы без Filter. До запуска (!).
Fix: обязательное правило — EXPLAIN перед каждым UPDATE/DELETE на больших таблицах. Транзакция с BEGIN; ... ROLLBACK; для проверки. Production-ready инструменты типа pg_hint_plan или просто review.
Защита через permissions: для production users — REVOKE UPDATE ON ALL TABLES, и точечно GRANT UPDATE(status) ON accounts только на нужные колонки. Это не спасёт от WHERE-less, но сужает blast radius.
Сводная таблица
Семь типовых ошибок, признак в EXPLAIN и канонический способ исправить.
Метод диагностики: общая схема
Когда ловишь медленный запрос, прогон по чек-листу:
- EXPLAIN (ANALYZE, BUFFERS). Получи реальный план, не estimated.
- Найди самый дорогой узел. Сравни
actual timeиactual rowsс детьми. - Проверь scan type. Если Seq Scan на большой таблице — почему? Где должен быть индекс?
- Проверь rows estimates. Если
rows=X, actual rows=Yотличается в 10+ раз — статистика устарела или есть корреляция между колонками (нуженCREATE STATISTICSилиANALYZE). - Проверь buffers.
shared hitпротивshared read: если многоread— query не помещается в shared_buffers, и это IO-bound. - Сопоставь с каталогом. 90% медленных запросов попадают в один из семи antipatterns выше.
Чек-лист
- SELECT * — убивает index-only scan и тащит TOAST. Перечисляй колонки явно.
- Функция на колонке — индекс не используется. Перенеси функцию на константу или сделай functional index.
- Длинный OR — заменяй на
IN (...)или= ANY(ARRAY[...]). - Deep nested subquery — разверни в JOIN/EXISTS, иначе planner теряет статистику.
- Type mismatch в JOIN — приводи типы в DDL, иначе теряешь Hash Join.
- ORDER BY random() — sort всей таблицы. Используй TABLESAMPLE или id-range trick.
- UPDATE без WHERE — мгновенный bloat.
EXPLAINперед запуском обязателен. - Общая методика:
EXPLAIN (ANALYZE, BUFFERS)→ найти дорогой узел → проверить scan type, estimates, buffers → сопоставить с каталогом.