Learning Platform
Урок 16.01 · 24 мин
Продвинутый
PerformanceAntipatternsEXPLAINRefactoring

После четырнадцати модулей про storage, MVCC, planner и locks у тебя достаточно бэкграунда, чтобы смотреть на SQL не как на «вот такой текст», а как на инструкцию executor’а: сколько страниц прочитать, сколько кортежей пройти через фильтр, какой join выбрать. С этим бэкграундом многие antipatterns становятся не вопросом «стиля», а конкретными ошибками — каждая из них имеет цену в страницах и миллисекундах.

В этом уроке — каталог из семи antipatterns, которые встречаются чаще остальных. На каждом: что плохо, как diagnose это в EXPLAIN, и какой канонический fix.

Antipattern 1: SELECT *

Самый «безобидный» antipattern. Кажется, что разница между SELECT * и SELECT id, email — это пара лишних колонок в сети. На деле — нет.

Index-only scan
возможен только если все колонки 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 * vs SELECT explicit

Один и тот же предикат: SELECT * читает heap + TOAST; SELECT explicit может остаться в индексе (index-only scan).

SELECT * FROM orders WHERE customer_id=423 шага IO
1. Index scan по orders_customer_idx~3 страницы индекса
2. Heap fetch — за каждой строкой~50 random IO
3. TOAST decode для notes/payload~20 random IO
SELECT id, placed_at1 шаг IO
Index-only scan (id, placed_at) включён в covering index~3 страницы индекса
heap не открыт0 IO
TOAST не тронут0 IO

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 секунд.

PostgreSQL

Тот же запрос, но только нужные колонки. Должен появиться Index Only Scan и Heap Fetches: 0 (если VACUUM прошёл и Visibility Map чистая).

PostgreSQL

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’а:

  1. Перенести функцию на константу. WHERE placed_at >= '2024-01-01' AND placed_at < '2025-01-01' вместо EXTRACT(year FROM placed_at) = 2024. Индекс по placed_at работает.
  2. Функциональный индекс. CREATE INDEX customers_email_lower_idx ON customers (LOWER(email)) — теперь WHERE LOWER(email) = ... сработает. Но: индекс ровно по этому выражению, поменяешь функцию — нужен новый индекс.

LOWER(email) превращает Index Scan в Seq Scan. Сравни план.

PostgreSQL

Fix через functional index. Создаём индекс по выражению — Index Scan возвращается.

PostgreSQL

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_idINT, а c.idBIGINT. 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.

PostgreSQL

Antipattern 6: ORDER BY random()

SELECT * FROM table ORDER BY random() LIMIT 10 — на маленькой таблице безобидно. На большой это:

  1. Полный Seq Scan по всей таблице.
  2. Вычисление random() для каждой строки.
  3. Sort всех результатов по этому случайному числу.
  4. 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.

Сводная таблица

Catalog: antipattern -> diagnosis -> fix

Семь типовых ошибок, признак в EXPLAIN и канонический способ исправить.

SELECT *Heap Fetches > 0; Index Scan вместо Index Only Scan
Fix: явные колонки + INCLUDECovering index
Функция на колонкеSeq Scan; Filter: lower(col) = ...
Fix: перенос на константу или functional index
OR-цепочка > 100Гигантский BitmapOr или Seq Scan
Fix: IN или = ANY(ARRAY[...])
Deep nested subqueryСтранные row-estimates; bad join order
Fix: разверни в JOIN или EXISTS
Type mismatch в JOINЯвный cast в условии; Nested Loop
Fix: align types в DDL
ORDER BY random()Sort over Seq Scan
Fix: TABLESAMPLE или id-range trick
UPDATE без WHERESeq Scan без Filter
Fix: EXPLAIN перед запуском, REVOKE UPDATE

Метод диагностики: общая схема

Когда ловишь медленный запрос, прогон по чек-листу:

  1. EXPLAIN (ANALYZE, BUFFERS). Получи реальный план, не estimated.
  2. Найди самый дорогой узел. Сравни actual time и actual rows с детьми.
  3. Проверь scan type. Если Seq Scan на большой таблице — почему? Где должен быть индекс?
  4. Проверь rows estimates. Если rows=X, actual rows=Y отличается в 10+ раз — статистика устарела или есть корреляция между колонками (нужен CREATE STATISTICS или ANALYZE).
  5. Проверь buffers. shared hit против shared read: если много read — query не помещается в shared_buffers, и это IO-bound.
  6. Сопоставь с каталогом. 90% медленных запросов попадают в один из семи antipatterns выше.
Проверка знанийKnowledge check
Запрос: SELECT id, email FROM users WHERE EXTRACT(year FROM signup_date) = 2024; работает Seq Scan'ом на 10M строк. Индекс есть: CREATE INDEX users_signup_date_idx ON users (signup_date). Что предложишь, чтобы запрос ускорился до Index Range Scan?
ОтветAnswer
Это antipattern «функция на индексированной колонке». EXTRACT(year FROM signup_date) — выражение, и planner не может использовать индекс по голой signup_date. Два варианта fix'а: 1. Перепиши предикат в range: WHERE signup_date >= '2024-01-01' AND signup_date < '2025-01-01'. Индекс users_signup_date_idx сработает как Index Range Scan. 2. Если EXTRACT действительно нужен — создай functional index: CREATE INDEX ON users (EXTRACT(year FROM signup_date)). Но это индекс ровно по этому выражению. Первый вариант предпочтительнее: один индекс работает для любых date-range запросов.

Чек-лист

  • 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 → сопоставить с каталогом.
Антипаттерны: что убивает перфоманс и как чинить Ключевые настройки производительности

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. В EXPLAIN-плане видишь `Filter: (lower(email) = '[email protected]')` над Seq Scan, хотя есть индекс `CREATE INDEX ON users (email)`. Почему planner не использует индекс, и какой минимальный fix?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5