voidlearn
 

6.1Общая теория и SQL

Свяжи реляционную модель с тем, что увидишь в запросе: как NULL меняет логику условий, почему JOIN может размножить строки и как физический план влияет на скорость. Это поможет выбирать хранилище по запросам и проверять результат через план, а не по виду SQL.

Реляционная модель и её альтернативы

Реляционная БД хранит данные в виде отношений (таблиц) со строгой схемой, а её движок умеет декларативно отвечать на запросы по этим отношениям и держать транзакционные гарантии. Всё решает слово декларативно: ты пишешь, что хочешь получить, а планировщик решает, как. Поэтому одна и та же строка SQL может выполниться и за 0,3 мс через индекс, и за 40 с через Seq Scan. По той же причине читать план важнее, чем уметь писать сложный SQL.

NoSQL не значит «БД без SQL»: это зонтичный термин для движков, которые сознательно отказались от части реляционных гарантий (обычно от джойнов, строгой схемы или сильной консистентности, в любом сочетании) ради масштабирования, скорости или удобной модели данных. Такой отказ всегда размен: улучшения по всем осям не бывает.

КлассМодель данныхПредставителиСильная сторонаЧем платишь
Реляционныетаблицы + схема + связиPostgreSQL, MySQL, Oracleджойны, транзакции, констрейнты, произвольные запросысложнее горизонтально масштабировать запись
Key-valueключ → непрозрачное значениеRedis, etcd, DynamoDB, AerospikeO(1) доступ, простейшее шардированиезапрос только по ключу, нет джойнов
Документныеключ → JSON-документMongoDB, Couchbase, Elasticsearchгибкая схема, агрегат целиком в одном местедублирование данных, ручная поддержка целостности
Колоночныеданные по колонкам, а не по строкамClickHouse, Vertica, BigQueryаналитика: сканы и агрегаты по миллиардам строкплохо с точечным UPDATE/DELETE и OLTP-нагрузкой
Графовыеузлы и рёбра как объекты первого классаNeo4j, ArangoDB, JanusGraphобход связей произвольной глубиныузкая ниша, слабее на «плоских» выборках
Time-seriesметрика + время + тегиTimescaleDB, InfluxDB, VictoriaMetricsсжатие, ретеншн, оконные агрегаты по временитолько про временные ряды
Формулировка, которую ждут

«Класс БД выбирается под паттерн доступа, а не под объём данных.» Когда запросы известны заранее и всегда по одному ключу, подходит key-value. Произвольные запросы со связями просят реляционную, а если 95 % нагрузки уходит на агрегаты по огромным диапазонам, выбирай колоночную. В конце добавь, что в типичном продукте живут несколько БД одновременно (Postgres как источник истины + Redis как кэш + ClickHouse под аналитику). Это называется polyglot persistence. «Решить всё одним классом» обычно можно, но по одному из измерений потеряешь на порядок.

PostgreSQL против MySQL

Вопрос звучит на каждом втором собесе, и отвечать надо разницей философий, а не списком фич. PostgreSQL вырос как «правильная СУБД академического происхождения»: расширяемость, строгость, соответствие стандарту. MySQL начинался как «быстрое хранилище для веба», и его годами докручивали до нормальной СУБД (докрутили, но следы остались).

АспектPostgreSQLMySQL / InnoDB
Движки храненияодин, встроенныйподключаемые (InnoDB, MyISAM, Memory); транзакции есть только у InnoDB
MVCCстарые версии строк лежат в самой таблице (heap), нужен VACUUMстарые версии - в undo-логе, откуда чистятся purge-потоком
Строгость типовжёсткая: 'abc'::int - ошибка, переполнение - ошибкаисторически «мягкая» (усечение и предупреждения); строгость появилась со strict mode
Уровень по умолчаниюREAD COMMITTEDREPEATABLE READ
Кластеризация таблицыheap: строки лежат в произвольном порядкеclustered index: строки физически лежат в порядке PK
Вторичные индексыхранят ctid - прямой адрес версии строкихранят значение PK → двойной поиск, если индекс не покрывающий
Типы и расширениямассивы, jsonb, диапазоны, PostGIS, свои типы и операторыбеднее; JSON есть, но без индексируемости уровня GIN
DDL в транзакциида: CREATE TABLE откатываетсянет: DDL делает неявный commit
Оконные функции, CTEдавно и полноценно, включая рекурсиюс 8.0
Одно следствие, которым можно добить

Из «heap + ctid» против «clustered index + PK в листьях» растут разные оптимизации. В MySQL выгодно делать PK коротким и монотонным (иначе раздувается каждый вторичный индекс и рвутся страницы при вставке случайного UUID). В PostgreSQL размер PK на вторичные индексы не влияет, зато есть VACUUM, bloat и HOT-обновления, которых нет в MySQL. Это на порядок сильнее ответа «в постгресе лучше JSON».

DDL, DML, DCL, TCL

ГруппаРасшифровкаКомандыЧто делает
DDLData Definition LanguageCREATE, ALTER, DROP, TRUNCATE, COMMENTменяет структуру объектов и системный каталог
DMLData Manipulation LanguageINSERT, UPDATE, DELETE, MERGE, (SELECT)меняет содержимое таблиц построчно
DCLData Control LanguageGRANT, REVOKEправа доступа
TCLTransaction Control LanguageBEGIN, COMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTIONграницы транзакции

SELECT формально относят то к DML, то к отдельной группе DQL (Data Query Language). На собесе хватит фразы «читающая часть DML, в некоторых классификациях выделяют в DQL».

Почему TRUNCATE считается DDL, а не DELETE без WHERE

TRUNCATE не удаляет строки по одной. Он подменяет физическое хранилище таблицы: создаёт новый пустой файл и переписывает relfilenode в системном каталоге, а старый файл выбрасывает целиком. Никакой построчной работы, никаких триггеров BEFORE/AFTER DELETE, никаких записей в WAL на каждую строку. Меняется описание объекта в каталоге, значит, операция относится к определению данных, то есть к DDL. На практике TRUNCATE берёт ACCESS EXCLUSIVE на таблицу (блокирует даже SELECT) и сбрасывает SERIAL-счётчик, если попросить RESTART IDENTITY. В PostgreSQL TRUNCATE при этом транзакционен и откатывается, а в MySQL/Oracle нет: там он делает неявный commit.

DELETETRUNCATEDROP
ГруппаDMLDDLDDL
Что остаётсятаблица, индексы, праватаблица, индексы, праваничего
WHEREданетнет
Скорость на 100 млн строкминуты-часы, WAL на каждую строкудоли секундыдоли секунды
ТриггерыDELETE-триггеры срабатываюттолько TRUNCATE-триггерынет
Место на дискене освобождается сразу (мёртвые версии, нужен VACUUM)освобождается сразуосвобождается сразу
БлокировкапострочнаяACCESS EXCLUSIVE на таблицуACCESS EXCLUSIVE
Откат в PGдадада
FK на таблицуработает по правилам FKошибка, если нет CASCADEошибка, если нет CASCADE
-- построчно, с триггерами, оставит мёртвые версии строк
DELETE FROM events WHERE created_at < NOW() - INTERVAL '30 days';

-- мгновенно, всю таблицу, вместе со связанными по FK
TRUNCATE TABLE events, event_tags RESTART IDENTITY CASCADE;

-- удалить объект целиком
DROP TABLE IF EXISTS events;

Ключи: PRIMARY KEY и FOREIGN KEY

Первичным ключом называют минимальный набор колонок, который однозначно идентифицирует строку. Он автоматически даёт NOT NULL + UNIQUE, а PostgreSQL под капотом создаёт под него уникальный B-tree индекс. Внешний ключ декларирует: «значения этой колонки обязаны существовать в другой таблице». СУБД проверяет это при INSERT/UPDATE дочерней таблицы и при DELETE/UPDATE родительской.

На FK индекс НЕ создаётся автоматически - и это одна из главных причин медленных JOIN

PostgreSQL создаёт индекс под PK и под UNIQUE, но не создаёт под FK. Последствий два. Первое: любой JOIN от родителя к детям идёт по неиндексированной колонке - Seq Scan на каждой выборке. Второе, менее очевидное: при DELETE строки родителя СУБД обязана проверить, нет ли ссылающихся детей, и без индекса это полный скан дочерней таблицы на каждое удаление. На таблице в сотни миллионов строк удаление одной строки родителя может занимать минуты. Правило: под каждый FK заводи индекс руками, если только колонка уже не стоит левым префиксом в существующем составном индексе.

CREATE TABLE orders (
    id          bigserial PRIMARY KEY,               -- индекс создан автоматически
    customer_id bigint NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
    status      text   NOT NULL DEFAULT 'new',
    total       numeric(12,2) NOT NULL CHECK (total >= 0),
    created_at  timestamptz NOT NULL DEFAULT NOW()
);

-- индекса под customer_id нет, создаём сами:
CREATE INDEX orders_customer_id_idx ON orders (customer_id);

-- найти все FK без покрывающего индекса (запрос стоит держать под рукой)
SELECT c.conrelid::regclass AS "таблица", a.attname AS "колонка"
FROM   pg_constraint c
JOIN   LATERAL unnest(c.conkey) k(attnum) ON TRUE
JOIN   pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE  c.contype = 'f'
  AND  NOT EXISTS (
        SELECT 1 FROM pg_index i
        WHERE i.indrelid = c.conrelid
          AND (i.indkey::smallint[])[0] = k.attnum);

NULL и трёхзначная логика

NULL в SQL означает «значение неизвестно», а не «пусто» и не «ноль». Из этого вытекает всё остальное: любое сравнение с неизвестным даёт вместо TRUE или FALSE третье значение, UNKNOWN. А WHERE пропускает строку, только если предикат равен TRUE. Так что WHERE col = NULL формально работает: честно возвращает UNKNOWN для каждой строки, и в результат ничего не попадает.

Трёхзначная логика: TRUE / FALSE / UNKNOWN AND T F U T F U T F U F F F U F U OR T F U T F U T T T T F U T U U T = TRUE, F = FALSE, U = UNKNOWN. В результат WHERE попадает только TRUE. NOT IN + NULL - классическая ловушка WHERE id NOT IN (1, 2, NULL) раскрывается в id <> 1 AND id <> 2 AND id <> NULL последний конъюнкт - всегда UNKNOWN, а «что-то AND UNKNOWN» не бывает TRUE → результат ВСЕГДА пустой, без ошибки col = NULL → UNKNOWN → строка отброшена. Правильно: col IS NULL / col IS NOT NULL.
NULL в предикатах. Достаточно запомнить две строчки таблиц: FALSE AND U = FALSE (поэтому AND может «спасти»), но TRUE AND U = UNKNOWN - поэтому NOT IN с единственным NULL в списке обнуляет весь результат.
ГдеКак ведёт себя NULL
WHERE col = NULLUNKNOWN → строк нет. Нужен IS NULL
col <> 'x'строки с col IS NULL не попадут в результат - почти всегда сюрприз
COUNT(*)считает строки, NULL не важен
COUNT(col)считает только строки, где col IS NOT NULL
SUM/AVG/MIN/MAXигнорируют NULL; AVG делит на число НЕ-NULL. На пустом наборе SUM даёт NULL, а не 0
GROUP BYвсе NULL попадают в одну группу (хотя «неизвестно = неизвестно» логически неверно)
ORDER BYв PostgreSQL NULL по умолчанию «больше всех»: последние при ASC, первые при DESC. Управляется NULLS FIRST/LAST
UNIQUEнесколько NULL допускаются: они «разные». С PG 15 есть UNIQUE NULLS NOT DISTINCT
JOIN ... ON a.x = b.xстроки с NULL не соединяются ни с чем - при LEFT JOIN дадут NULL-хвост
NOT IN (подзапрос)если подзапрос вернул хоть один NULL - результат пуст
NOT EXISTSNULL-безопасен, работает как настоящий anti-join
DISTINCT, UNIONздесь NULL считаются равными друг другу
-- Ловушка «неравенство теряет NULL»
SELECT count(*) FROM users;                        -- 100
SELECT count(*) FROM users WHERE status = 'ban';   -- 10
SELECT count(*) FROM users WHERE status <> 'ban';  -- 60, а не 90 (у 30 status IS NULL)
SELECT count(*) FROM users WHERE status IS DISTINCT FROM 'ban';  -- 90, вот это правильно

-- Безопасные инструменты
COALESCE(col, 0)              -- первое не-NULL значение
NULLIF(a, b)                  -- NULL, если a = b (защита от деления на ноль)
a IS NOT DISTINCT FROM b      -- «равно», где NULL = NULL истинно
count(*) FILTER (WHERE ok)    -- условный подсчёт вместо count(CASE WHEN ...)

Соединения: логика и физика

Логический тип JOIN отвечает на вопрос «какие строки попадут в результат», а физический алгоритм - «как сервер их найдёт». Это два независимых слоя: один и тот же LEFT JOIN может выполниться и как Nested Loop, и как Hash Join.

Исходные данные users u id name 1 Ann 2 Bob 3 Cid orders o id user_id 10 1 11 1 12 4 Bob - нет заказов Cid - нет заказов заказ 12 → нет клиента CROSS JOIN 3 × 3 = 9 строк, декартово произведение, условия нет «Клиенты без заказов» = LEFT JOIN + WHERE o.id IS NULL Что попадёт в результат (u.id = o.user_id) INNER JOIN Ann | 10 Ann | 11 только совпавшие LEFT JOIN Ann | 10 Ann | 11 Bob | NULL Cid | NULL все слева + пары RIGHT JOIN Ann | 10 Ann | 11 NULL | 12 все справа + пары FULL OUTER JOIN Ann | 10 Ann | 11 Bob | NULL Cid | NULL NULL | 12
Пять типов соединения. INNER дал 2 строки из 3+3: соединение умеет и уменьшать число строк, и увеличивать его. Дубли Ann появились, потому что справа нашлись две подходящие строки. Так чаще всего и появляются «внезапно удвоившиеся суммы» в отчётах.
-- «Клиенты без заказов»: три способа, два хороших
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;                                   -- 1) anti-join через LEFT + IS NULL

SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);   -- 2) NULL-безопасно

SELECT c.* FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders);   -- 3) ловушка: один NULL, и ответ пуст
Разница между условием в ON и в WHERE для LEFT JOIN

LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid' оставит всех клиентов, просто там, где заказ не подошёл, будут NULL-хвосты. А LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'paid' уже превратит LEFT JOIN в INNER: WHERE применяется после соединения, NULL = 'paid' даёт UNKNOWN, и все «пустые» клиенты отфильтруются. Пожалуй, это самая частая ошибка в SQL-задачах на собесе.

Nested Loop a1 a2 a3 индекс по B на каждую строку A - поиск в B хорош: A мала, по B есть индекс плох: A велика → N × поиск Hash Join hash(B) build в память A: probe один проход строит хеш по меньшей стороне лучший выбор для больших таблиц только равенство; не влез в work_mem → batches на диск, план проседает Merge Join 1 4 7 1 4 9 обе стороны отсортированы по ключу соединения один проход по обеим, но сортировка стоит денег
Физика соединений. Планировщик выбирает алгоритм по оценке кардинальности и наличию индексов/сортировки. Поэтому «медленный JOIN» почти всегда лечат индексом (чтобы включился Nested Loop с Index Scan) или увеличением work_mem (чтобы Hash Join не уходил на диск), а не переписыванием запроса.
АлгоритмСложностьКогда выбираетсяУсловия соединения
Nested LoopO(N × стоимость поиска)внешняя выборка маленькая, по внутренней есть индекслюбые, включая <, BETWEEN, функции
Hash JoinO(N + M), память под хешобе стороны крупные, нет полезной сортировкитолько эквисоединение (=)
Merge JoinO(N + M) + сортировкаобе стороны уже отсортированы (индексы, ORDER BY)только равенство

Логический порядок выполнения запроса

Логический порядок выполнения - не порядок записи SELECT пишется первым, а выполняется почти последним. Отсюда все правила про алиасы, HAVING и оконные функции. FROM + JOIN WHERE фильтр строк GROUP BY схлопывание HAVING фильтр групп SELECT алиасы, окна DISTINCT дедупликация ORDER BY сортировка LIMIT OFFSET Что из этого следует 1. Алиас из SELECT нельзя использовать в WHERE - SELECT ещё не выполнялся. В ORDER BY можно: он идёт после. 2. WHERE фильтрует строки ДО группировки, HAVING - группы ПОСЛЕ. WHERE дешевле: до GROUP BY доедет меньше строк. 3. Оконные функции считаются на шаге SELECT - их нельзя писать в WHERE и HAVING, нужен подзапрос или CTE.
Порядок выполнения. Это логическая модель: физически планировщик волен переставлять шаги, если результат не изменится (например, протолкнуть WHERE внутрь подзапроса). Но все правила видимости имён выводятся именно из этой цепочки.
-- Неправильно: алиас cnt ещё не существует на шаге WHERE
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE cnt > 5                       -- ERROR: column "cnt" does not exist
GROUP BY customer_id;

-- Правильно: агрегат фильтруют в HAVING
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01'    -- сначала выбросили лишние строки: дёшево
GROUP BY customer_id
HAVING count(*) > 5                 -- потом отфильтровали группы
ORDER BY cnt DESC                   -- а здесь алиас уже виден
LIMIT 10;

Подзапросы, CTE, оконные функции

Скалярный подзапрос возвращает одно значение и может стоять где угодно вместо выражения. Коррелированный ссылается на колонки внешнего запроса, а значит, логически выполняется для каждой строки внешнего запроса. Отсюда и репутация: миллион внешних строк даёт миллион выполнений подзапроса. Планировщик PostgreSQL умеет разворачивать (decorrelate) часть таких конструкций в полноценные соединения, но далеко не все, особенно если внутри есть LIMIT, агрегаты по внешним колонкам или volatile-функции.

-- Коррелированный подзапрос выполняется на каждую строку e
SELECT e.name, e.salary,
       (SELECT avg(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id) AS dept_avg
FROM employees e;

-- То же самое оконной функцией: один проход, одна сортировка
SELECT e.name, e.salary,
       avg(e.salary) OVER (PARTITION BY e.dept_id) AS dept_avg
FROM employees e;

-- Или соединением с агрегатом
SELECT e.name, e.salary, d.dept_avg
FROM employees e
JOIN (SELECT dept_id, avg(salary) AS dept_avg FROM employees GROUP BY dept_id) d
  ON d.dept_id = e.dept_id;
CTE в PostgreSQL: барьер оптимизации до 12 и после

До PostgreSQL 12 любой WITH был барьером оптимизации: CTE всегда материализовался, и условия из внешнего запроса внутрь не проталкивались. Это было и багом (медленно), и фичей (так специально «замораживали» подзапрос). С PG 12 CTE, который используется ровно один раз, не рекурсивен и не содержит побочных эффектов, по умолчанию инлайнится: ведёт себя как обычный подзапрос, и предикаты в него проталкиваются. Поведение задаётся и явно: WITH x AS MATERIALIZED (...) и AS NOT MATERIALIZED (...).

-- Рекурсивный CTE: якорь UNION ALL рекурсивная часть
WITH RECURSIVE subs AS (
    SELECT id, name, manager_id, 1 AS depth      -- якорь
    FROM employees WHERE id = 1
  UNION ALL
    SELECT e.id, e.name, e.manager_id, s.depth + 1   -- шаг
    FROM employees e
    JOIN subs s ON e.manager_id = s.id
    WHERE s.depth < 10                           -- страховка от цикла в данных
)
SELECT * FROM subs;

-- CTE как DML-конвейер: переносим строки в архив одним запросом
WITH moved AS (
    DELETE FROM events WHERE created_at < NOW() - INTERVAL '90 days'
    RETURNING *
)
INSERT INTO events_archive SELECT * FROM moved;

Оконные функции отличаются от агрегатов тем, что не схлопывают строки: каждая строка остаётся в результате, но получает значение, вычисленное по «окну» соседей. PARTITION BY задаёт разбиение окна, ORDER BY внутри OVER - порядок внутри окна, а ROWS/RANGE - рамку.

ФункцияЧто делаетНичьи
ROW_NUMBER()сквозная нумерация 1, 2, 3…ничьи разрываются произвольно
RANK()одинаковый ранг у равных, дальше пропуск1, 2, 2, 4
DENSE_RANK()одинаковый ранг, без пропусков1, 2, 2, 3
LAG(x, n) / LEAD(x, n)значение из строки на n назад/вперёддельты между соседними событиями
SUM(x) OVER (ORDER BY ...)нарастающий итограмка по умолчанию - от начала окна до текущей строки; строки с одинаковым значением ORDER BY получают одну и ту же сумму
NTILE(n)разбиение на n примерно равных корзинперцентили

VIEW, materialized view, нормализация

VIEW хранит только текст запроса, данных в нём нет: при обращении планировщик подставляет тело представления в запрос и оптимизирует всё вместе. Вьюха инкапсулирует логику и права, это плюс. Минус: тяжёлая вьюха тяжела при каждом обращении, а вьюхи поверх вьюх дают нечитаемые планы.

MATERIALIZED VIEW уже хранит результат запроса в физической таблице, и обновлять её надо явно, командой REFRESH MATERIALIZED VIEW. Обычный REFRESH берёт ACCESS EXCLUSIVE, и на время обновления вьюха недоступна. REFRESH ... CONCURRENTLY не блокирует чтение, но требует уникального индекса на вьюхе и работает дольше. По сути, это ручной кэш внутри БД со всеми вопросами кэша: когда инвалидировать и насколько устаревшие данные допустимы.

Форма1НФ2НФ3НФ
Требованиеатомарные значения, нет повторяющихся групп и массивов «в одной ячейке»1НФ + каждый неключевой атрибут зависит от всего ключа, а не от его части2НФ + нет транзитивных зависимостей: неключевые атрибуты не зависят друг от друга
Нарушениеколонка phones = '+7..., +7...'ключ (order_id, product_id), а в строке лежит order_dateв orders лежат city_id и city_name
Лечениеотдельная таблица phonesвынести order_date в ordersоставить city_id, имя брать джойном
Про денормализацию отвечать так

«Нормализация убирает аномалии обновления: один факт хранится в одном месте, поэтому его невозможно обновить наполовину. Денормализация означает сознательный размен целостности на скорость чтения, и он оправдан, когда: (1) план доказывает, что узкое место в джойне, (2) дублируемые данные меняются редко или не меняются вовсе (снапшот цены в заказе вообще не денормализация, а исторический факт), (3) согласованность что-то поддерживает: триггер, материализованная вьюха или пересчёт по расписанию. Без пункта (3) денормализация превращается в будущий баг.»

-- Констрейнты защищают данные декларативно, из приложения их не обойти
CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id bigint NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
    qty        int    NOT NULL CHECK (qty > 0),
    price      numeric(12,2) NOT NULL CHECK (price >= 0),
    PRIMARY KEY (order_id, product_id)               -- составной PK = ещё и UNIQUE
);

-- UNIQUE с условием: email уникален только среди неудалённых
CREATE UNIQUE INDEX users_email_uniq ON users (LOWER(email)) WHERE deleted_at IS NULL;

-- Именованный CHECK легче искать в логах ошибок
ALTER TABLE accounts ADD CONSTRAINT accounts_balance_nonneg CHECK (balance >= 0);

-- NOT VALID: добавить констрейнт мгновенно, проверить старые строки потом
ALTER TABLE orders ADD CONSTRAINT orders_total_pos CHECK (total > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_pos;   -- без ACCESS EXCLUSIVE
Действие FKЧто происходит при удалении родителяКогда уместно
NO ACTION (по умолчанию)ошибка, проверка откладывается до конца операциидефолт, самый безопасный
RESTRICTошибка сразу, без отсрочкикогда удаление родителя - всегда ошибка бизнес-логики
CASCADEдети удаляются автоматическистрогая композиция: order → order_items
SET NULLссылка обнуляетсянеобязательная связь: у сотрудника пропал менеджер
SET DEFAULTставится значение по умолчаниюредко; требует, чтобы дефолт существовал в родителе
ON DELETE CASCADE - мина замедленного действия

Удаление одной строки может каскадом снести миллионы строк в пяти таблицах, взять блокировки на всё это и надолго подвесить соседей. Вдобавок каскад живёт своей жизнью: в MySQL он не вызывает триггеры дочерних таблиц, в PostgreSQL вызывает, и «мягкое удаление» с аудитом легко срабатывают не так, как ждёт бизнес. Поэтому на больших системах чаще берут RESTRICT и явно удаляют батчами в коде, а CASCADE оставляют мелким служебным таблицам.

Вопросы

16
Суть: реляционная БД даёт произвольные запросы и транзакционные гарантии, NoSQL сознательно отказывается от части этого ради масштабирования или удобства модели. Одним классом решить можно почти всё - но с потерей на порядок по одному из измерений.

В реляционной модели данные лежат в отношениях со строгой схемой, а к ним прилагаются декларативный язык запросов и транзакции. Ценны тут не таблицы, а две вещи: ты можешь задать запрос, о котором не думал на этапе проектирования схемы, и сервер сам гарантирует целостность через констрейнты и ACID. За это платишь тем, что запись тяжело масштабировать горизонтально: джойны и транзакции через сеть между узлами стоят дорого.

Зонтичный термин NoSQL на собесе лучше определить так: «NoSQL - это не отсутствие SQL, а отказ от какой-то реляционной гарантии». Redis отказался от запросов не по ключу (встроенный поиск по полям появился в нём только в Redis 8), MongoDB - от строгой схемы и (исторически) от кросс-документных транзакций, ClickHouse - от точечного UPDATE и от OLTP, Cassandra - от сильной консистентности. Взамен каждый получил своё: обычно линейное масштабирование или на порядок большую пропускную способность в своём сценарии.

Как выбирать

  • Смотри на паттерн доступа, а не на объём. 500 ГБ прекрасно живут в PostgreSQL; 10 ГБ, к которым ходят 200 тыс. раз в секунду по одному ключу, - уже не живут.
  • Запросы известны заранее и всегда по ключу → key-value.
  • Запросы произвольные, нужны связи и отчёты → реляционная.
  • Нагрузка состоит из агрегатов по сотням миллионов строк → колоночная.
  • Данные образуют граф, и нужны обходы произвольной глубины → графовая.

Ответ на «можно ли всё одним классом»

Формально да: в PostgreSQL есть jsonb с GIN-индексами (документная модель), hstore и UNLOGGED-таблицы (почти key-value), расширения TimescaleDB и Citus, полнотекстовый поиск, PostGIS. Для 90 % продуктов «просто Postgres» будет правильным дефолтом. Это сильный ответ: он экономит команде операционную сложность. Но на конкретных нагрузках специализированный движок выигрывает на порядок: Redis отдаёт ключ за 50 мкс, ClickHouse считает агрегат по миллиарду строк за секунду. Поэтому зрелый продукт почти всегда держит несколько хранилищ, это и есть polyglot persistence: Postgres как источник истины, Redis как кэш, ClickHouse под аналитику, S3 под файлы.

Чем добить

«Начинал бы с PostgreSQL по умолчанию и выносил бы данные в другое хранилище только под доказанную проблему, с цифрами из мониторинга. Каждое новое хранилище приносит отдельный бэкап, отдельный failover, отдельную точку рассинхронизации и новый класс багов “в базе одно, в кэше другое”.»

Суть: четыре класса различаются тем, что движок знает о структуре значения - от «ничего» (key-value) до «это граф с рёбрами» (графовые).

Key-value

Redis, etcd, DynamoDB, Aerospike. Значение для БД непрозрачно, операция одна: «дай по ключу». Отсюда O(1) доступ и тривиальное шардирование: ключ хешируется, узел находится без координации. Сценарии: кэш, сессии, счётчики, rate limiter, распределённые локи, feature-флаги. А вот «найди все сессии пользователей из Москвы» так не спросишь: вторичный индекс придётся строить руками. В Redis с версии 8 для этого есть встроенный поисковый движок (Redis Query Engine, бывший модуль RediSearch), но он уже надстройка над моделью key-value.

Документные

MongoDB, Couchbase, Elasticsearch. Значение хранится как JSON-документ, и БД умеет заглядывать внутрь: индексировать поля, фильтровать по вложенным путям. Схема гибкая, документы одной коллекции могут отличаться. Годятся для каталогов товаров с разнородными атрибутами, event store, CMS, поисковых индексов. Платишь тем, что целостность между документами поддерживаешь сам, а гибкая схема на второй год превращается в «в этой коллекции восемь разных форматов, и каждый парсер знает про них по-своему».

Колоночные

ClickHouse, Vertica, BigQuery, Druid. Данные лежат по колонкам, значения одной колонки идут подряд. Соседние значения похожи, так что данные сильно сжимаются, а с диска читаются только нужные запросу колонки. SELECT sum(amount) FROM events по миллиарду строк прочитает одну колонку, а не миллиард строк целиком. Сценарии: аналитика, логи, метрики, витрины. Точечный UPDATE строки и OLTP с транзакциями сюда не ложатся.

Графовые

Neo4j, ArangoDB, JanusGraph. Рёбра хранятся как объекты первого класса с физическими указателями, поэтому переход «сосед соседа» стоит константу, а не джойн. Подходят для соцграфа, антифрода («связан ли этот аккаунт с заблокированным через 4 перехода»), рекомендаций, графов прав. В реляционной БД такой обход пишут рекурсивным CTE, и на глубине 5+ он взрывается.

Два класса, о которых часто забывают

Wide-column (Cassandra, HBase, ScyllaDB) часто путают с колоночными, но это другое: строка с динамическим набором колонок, партиционирование по ключу, настраиваемая консистентность через кворумы. Их берут под тяжёлый поток записи, если готов жить с eventual consistency. И time-series (TimescaleDB, VictoriaMetrics, InfluxDB), заточенные под «метрика + время + теги», со сжатием и автоматическим ретеншном.

Суть: главная техническая разница - где живут старые версии строк (heap + VACUUM в PG против undo-лога в InnoDB) и как устроено хранение таблицы (heap против clustered index по PK).

Философия

PostgreSQL вырос из академического проекта: расширяемость (свои типы, операторы, индексные методы, языки функций), строгое следование стандарту, приоритет корректности над скоростью. MySQL создавался как быстрое хранилище для веба и годами доращивался до полноценной СУБД: подключаемые движки, изначально «прощающее» поведение при некорректных данных, огромная экосистема хостингов и репликации.

MVCC - самое важное различие

В PostgreSQL старые версии строк лежат в самой таблице. UPDATE всегда вставляет новую версию кортежа и помечает старую. Отсюда растёт целый пласт эксплуатации, которого в MySQL нет: VACUUM, autovacuum, table bloat, HOT-обновления, transaction ID wraparound. Зато откат транзакции в PG почти бесплатен: новые версии просто никто не увидит.

В InnoDB строка обновляется на месте, а старая версия уезжает в undo-лог. Поэтому чтение «в прошлое» собирает строку по цепочке undo-записей (долгие транзакции удлиняют цепочку и замедляют чтения), а откат большой транзакции стоит дорого: изменения надо физически откатывать. Undo при этом чистит отдельный purge-поток.

Физическое хранение

PostgreSQL хранит таблицу как heap: строки лежат в файле в произвольном порядке, вторичный индекс хранит ctid (номер страницы + смещение) и ведёт прямо к версии. В InnoDB таблица сама и есть clustered index, B-tree по первичному ключу: строки лежат в листьях в порядке PK, а вторичный индекс хранит значение PK. Поэтому поиск по вторичному индексу всегда спускается по дереву дважды. На практике в MySQL PK должен быть коротким и монотонным (случайный UUID раздувает каждый вторичный индекс и рвёт страницы при вставке), а в PostgreSQL размер PK на вторичные индексы не влияет.

Строгость типов и мелочи

  • PG падает с ошибкой на 'abc'::int, на переполнении int и на вставке слишком длинной строки в varchar(10). MySQL исторически усекал и выдавал warning; strict mode (дефолт с 5.7) это исправил, но «мягкое» наследие встречается до сих пор.
  • Уровень изоляции по умолчанию: PG - READ COMMITTED, MySQL/InnoDB - REPEATABLE READ.
  • DDL в транзакции: PG умеет откатывать CREATE TABLE, MySQL - нет (DDL делает неявный commit). Это критично для миграций: в PG упавшая миграция откатывается целиком.
  • Типы: в PG массивы, jsonb с GIN, диапазоны, PostGIS, свои типы.
Не отвечай «в постгресе лучше JSON»

Так отвечают джуны. Сильный ответ строится на MVCC и heap/clustered index, потому что из них выводятся все практические различия в эксплуатации: почему в PG есть autovacuum, почему в MySQL важен порядок PK, почему долгая транзакция вредит обеим базам, но по разным причинам.

Суть: DDL меняет описание объектов в системном каталоге, DML - содержимое таблиц. TRUNCATE не удаляет строки, а подменяет файл таблицы и правит каталог - значит DDL.
  • DDL - CREATE, ALTER, DROP, TRUNCATE, COMMENT: структура и системный каталог.
  • DML - INSERT, UPDATE, DELETE, MERGE: содержимое, построчно. SELECT иногда выделяют в DQL.
  • DCL - GRANT, REVOKE: права.
  • TCL - BEGIN, COMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTION: границы транзакции.

Механика TRUNCATE

DELETE идёт по строкам: на каждую пишет запись в WAL, помечает версию мёртвой, вызывает триггеры. Индексы при этом не трогаются, а место не освобождается: мёртвые версии и записи в индексах потом уберёт VACUUM. TRUNCATE вместо этого создаёт для таблицы новый пустой файл и меняет relfilenode в pg_class, а старый файл выбрасывает целиком. Ни одной построчной операции, ни одной записи в WAL на строку. А раз меняется запись об объекте в каталоге, операция относится к определению данных.

Практические следствия

  • Работает за доли секунды независимо от размера таблицы и сразу освобождает диск.
  • Берёт ACCESS EXCLUSIVE, а она блокирует даже SELECT. На проде это может встать в очередь за долгим чтением и «застопорить» таблицу целиком.
  • Не вызывает BEFORE/AFTER DELETE-триггеры, только TRUNCATE-триггеры. Мягкое удаление и аудит на триггерах будут молча пропущены.
  • Падает с ошибкой, если на таблицу ссылаются по FK: нужен CASCADE.
  • RESTART IDENTITY сбрасывает счётчики последовательностей.
Тонкость про транзакционность

Частый миф: «TRUNCATE нельзя откатить». В PostgreSQL можно: BEGIN; TRUNCATE t; ROLLBACK; вернёт данные, потому что PG умеет транзакционный DDL. В MySQL и Oracle нельзя: там DDL делает неявный commit. Если СУБД не уточнили, так и отвечай: «зависит от движка, и вот почему».

Суть: DELETE - построчно и с условием, TRUNCATE - вся таблица мгновенно, но структура остаётся, DROP - объекта больше нет.
КритерийDELETETRUNCATEDROP
КлассDMLDDLDDL
WHEREданетнет
Стоимость на 100 млн строкминуты-часымиллисекундымиллисекунды
WALзапись на каждую строкуодна запись о смене файлаодна запись
ТриггерыDELETE-триггерытолько TRUNCATE-триггерынет
Диск освобождаетсянет, нужен VACUUMсразусразу
Блокировкапострочная, чтения не мешаетACCESS EXCLUSIVEACCESS EXCLUSIVE
Остаётся послетаблица с данными по фильтрупустая таблица, индексы, праваничего

Как отвечать про выбор

  • Часть строк удаляют только через DELETE, причём батчами: одна транзакция на 10 млн строк держит блокировки, раздувает WAL и оставляет гору мёртвых версий.
  • Очистить таблицу целиком (тестовое окружение, перезалив справочника) - TRUNCATE, но помни про ACCESS EXCLUSIVE.
  • Сущность из схемы убирает DROP, в миграции с IF EXISTS.
  • «Почти всё удалить, чуть-чуть оставить» выгоднее без DELETE: «создать новую таблицу с нужным подмножеством → переименовать» дешевле и не оставляет bloat.
-- Удаляем батчами: транзакции короткие, autovacuum успевает
DELETE FROM events
WHERE ctid IN (
  SELECT ctid FROM events
  WHERE created_at < NOW() - INTERVAL '90 days'
  LIMIT 10000
);
-- в цикле, пока затронуто больше 0 строк, с паузой между итерациями
Ловушка про место на диске

«Удалил половину таблицы через DELETE - почему диск не освободился?» Потому что мёртвые версии остаются в файле; обычный VACUUM пометит место переиспользуемым, но файл не сожмёт - разве что отрежет с конца целиком пустые страницы. Вернуть место операционной системе может только VACUUM FULL (переписывает таблицу, берёт ACCESS EXCLUSIVE) или pg_repack (то же самое, но почти без блокировки).

Суть: PK - идентификация строки (даёт NOT NULL + UNIQUE + автоматический индекс), FK - ссылочная целостность (индекса не даёт, и это одна из главных причин медленных JOIN и медленных DELETE).

PRIMARY KEY задаёт минимальный набор колонок, который однозначно идентифицирует строку, и в таблице такой ключ один. PostgreSQL под него автоматически создаёт уникальный B-tree индекс (иначе проверять уникальность на каждой вставке пришлось бы сканом). UNIQUE отличается от PK тем, что их может быть несколько и они допускают NULL.

FOREIGN KEY требует, чтобы «значение этой колонки существовало в другой таблице». Проверка идёт в обе стороны: при INSERT/UPDATE ребёнка СУБД ищет родителя, при DELETE/UPDATE родителя смотрит, не осиротеют ли дети. Технически это системные триггеры.

Почему отсутствие индекса на FK так больно

  1. JOIN. Соединение от родителя к детям идёт по неиндексированной колонке: планировщик вынужден брать Hash Join с полным сканом дочерней таблицы либо Nested Loop с Seq Scan внутри, а это уже катастрофа.
  2. DELETE родителя. Чтобы проверить ON DELETE RESTRICT/CASCADE, СУБД выполняет SELECT 1 FROM child WHERE fk = $1. Без индекса это полный скан дочерней таблицы на каждую удаляемую строку. Классический инцидент: «удаление одного пользователя занимает 4 минуты».
  3. То же самое при UPDATE ключевой колонки родителя.
CREATE TABLE orders (
    id          bigserial PRIMARY KEY,                     -- индекс есть
    customer_id bigint NOT NULL REFERENCES customers(id)   -- индекса нет
);
CREATE INDEX orders_customer_id_idx ON orders (customer_id);  -- заводим сами

Стоит ли вообще использовать FK

Минусы у FK есть: он замедляет запись (проверка на каждую строку), мешает шардированию (родитель и ребёнок на разных узлах) и усложняет массовые загрузки. Но это единственная гарантия целостности, которую нельзя обойти кривым скриптом, забытым условием или второй версией сервиса. Поэтому в OLTP-монолите FK держат почти всегда, а выключают точечно: в шардированных таблицах, в аналитических витринах и на время массовой загрузки (ALTER TABLE ... DROP CONSTRAINT → загрузка → добавить обратно NOT VALID и потом VALIDATE).

Естественный ключ vs суррогатный

Суррогатный (bigserial, UUID) стабилен и не зависит от бизнеса, это дефолт. Естественный (ИНН, email) даёт бесплатную уникальность, но меняется, а PK меняться не должен. Про UUID стоит знать: uuid_v4 случаен, поэтому в PostgreSQL раздувает B-tree (вставки идут в случайные страницы, WAL растёт из-за full-page writes), а в MySQL ещё и каждый вторичный индекс. Лечится это UUIDv7 (сортируемым по времени): в PG 18 он доступен как uuidv7(), раньше брали расширение или генерировали на стороне приложения.

Суть: NULL - это «неизвестно», сравнение с ним даёт третье логическое значение UNKNOWN, а WHERE пропускает строку только при TRUE. Отсюда выводится всё остальное поведение.

col = NULL честно возвращает UNKNOWN для каждой строки, включая строки, где col IS NULL: неизвестное не равно неизвестному, ведь это могут быть разные неизвестные. Сравнивать нужно через IS NULL, IS NOT NULL, IS [NOT] DISTINCT FROM.

Агрегаты

  • COUNT(*) считает строки, NULL не важен.
  • COUNT(col) считает только строки с col IS NOT NULL. Разница между ними сразу показывает, насколько заполнена колонка.
  • SUM, AVG, MIN, MAX игнорируют NULL. AVG делит на количество НЕ-NULL, а это не то же самое, что «считать NULL нулём».
  • На пустом наборе SUM возвращает NULL, а не 0, и в отчётах вылезают NULL. Лечится COALESCE(SUM(x), 0).
  • GROUP BY собирает все NULL в одну группу; DISTINCT и UNION тоже считают NULL равными друг другу. Это исключения из общего правила.

Неравенство и JOIN

Самая тихая ловушка: WHERE status <> 'ban' не вернёт строки, где status IS NULL. Люди ждут «все, кроме забаненных», а получают «все, у кого статус известен и не ban». Правильно писать WHERE status IS DISTINCT FROM 'ban'. В JOIN ... ON a.x = b.x строки с NULL в ключе не соединяются ни с чем: при INNER они исчезают, при LEFT дают NULL-хвост.

NOT IN - самая опасная конструкция

-- Если подзапрос вернул хотя бы один NULL, результат всегда пустой
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- раскрывается в id <> v1 AND id <> v2 AND ... AND id <> NULL
-- последний конъюнкт = UNKNOWN, а TRUE AND UNKNOWN = UNKNOWN → строка отброшена

-- NULL-безопасно и обычно быстрее (настоящий anti-join)
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Почему это страшнее обычного бага

NOT IN с NULL не падает с ошибкой, а молча возвращает пустой набор. Отчёт показывает ноль, рассылка не уходит, миграция не переносит строки, и никто не видит проблемы месяцами. Правило простое: для anti-join всегда NOT EXISTS.

Инструменты

COALESCE(a, b, 0) - первое не-NULL; NULLIF(a, b) - NULL, если равны (защита от деления на ноль); a IS NOT DISTINCT FROM b - равенство, где NULL = NULL истинно; count(*) FILTER (WHERE cond) - условный подсчёт; ORDER BY x NULLS LAST - в PG по умолчанию NULL «больше всех». И проектное правило: ставь NOT NULL везде, где сомневаешься. Ослабить колонку можно всегда, а вот вычищать накопившиеся NULL больно.

Суть: тип соединения отвечает только на вопрос «что делать со строками, которым не нашлось пары». «Клиенты без заказов» - это anti-join: LEFT JOIN + IS NULL или NOT EXISTS.
  • INNER оставляет только совпавшие пары. Строк может стать и меньше (нет пары), и больше (несколько пар справа).
  • LEFT OUTER - все строки левой таблицы; где пары нет, правая часть заполняется NULL.
  • RIGHT OUTER работает зеркально. На практике его почти не пишут: читается тяжелее, проще переставить таблицы и написать LEFT.
  • FULL OUTER - все строки обеих таблиц. Нужен для сверок «что есть здесь и нет там, и наоборот».
  • CROSS даёт декартово произведение, без условия. Для генерации сеток (все дни × все товары) это законно, а вот случайный CROSS из-за забытого условия легко положит сервер.
  • SELF JOIN - таблица с самой собой (сотрудник → менеджер).
  • LATERAL - правая часть видит колонки левой: подзапрос выполняется для каждой строки слева. Отлично подходит для «top-N на группу».
-- 1) Anti-join через LEFT + IS NULL: соединяем и оставляем только «непришитые»
SELECT c.*
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- 2) NOT EXISTS: NULL-безопасно, планировщик строит настоящий Anti Join,
--    и он умеет останавливаться на первом совпадении
SELECT c.*
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

-- 3) EXCEPT: работает, но требует одинаковых наборов колонок и убирает дубли
SELECT id FROM customers EXCEPT SELECT customer_id FROM orders;

-- 4) NOT IN: ловушка. Один NULL в orders.customer_id, и результат пуст
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
Условие в ON против условия в WHERE

LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid' оставит всех клиентов (у неоплаченных будет NULL-хвост). А тот же фильтр, вынесенный в WHERE o.status = 'paid', превращает LEFT JOIN в INNER: WHERE применяется после соединения, а NULL = 'paid' даёт UNKNOWN. Ровно на этом валится половина SQL-задач на собеседовании. WHERE o.id IS NULL тут исключение: это уже сознательный anti-join.

Про «удвоившиеся суммы»

Если джойнишь orders сразу с order_items и с payments, SUM(o.total) посчитается столько раз, сколько получилось строк после размножения, и сумма вырастет в разы. Агрегируй каждую ветку отдельно (подзапрос или CTE) и только потом джойни. Есть ещё SUM(DISTINCT ...), но он почти всегда намекает, что запрос стоит переписать.

Суть: три алгоритма с разной стоимостью; выбор делает планировщик по оценке кардинальности. «Медленный JOIN» почти всегда лечится не переписыванием SQL, а индексом, статистикой или work_mem.

Nested Loop

Для каждой строки внешней выборки ищем совпадения во внутренней. Стоимость равна N × стоимость_поиска. Хорош, когда внешняя выборка маленькая (десятки строк), а по внутренней есть индекс: получается N быстрых спусков по B-tree. Катастрофа, когда внешних строк много или внутри Seq Scan. Единственный алгоритм, который умеет неэквисоединения (<, BETWEEN, функции) и обязательный для LATERAL.

Hash Join

Фаза build: по меньшей стороне строится хеш-таблица в памяти. Фаза probe: один проход по большей стороне с проверкой по хешу. Сложность O(N + M): лучший выбор для двух крупных таблиц, но только по равенству. Главный риск: если хеш не влезает в отведённую ему память (work_mem, умноженный на hash_mem_multiplier, по умолчанию на 2), PostgreSQL разбивает данные на batches и сбрасывает их на диск. В плане это видно как Batches: 16 вместо Batches: 1 у узла Hash, и запрос проседает в разы.

Merge Join

Обе стороны читаются в отсортированном по ключу порядке и сливаются одним проходом, как merge в сортировке слиянием. Дёшев, если сортировка достаётся даром (index scan по нужному порядку); иначе к стоимости добавляются две сортировки. Часто выигрывает на очень больших наборах, где хеш не влез бы в память.

АлгоритмСтоимостьКогда выбираетсяУсловияЧто ломает
Nested LoopN × поисквнешняя выборка мала, внутри индекслюбыенедооценка кардинальности внешней стороны
Hash JoinO(N+M) + памятьобе стороны крупныетолько =нехватка work_mem → batches на диск
Merge JoinO(N+M) + сортировкисортировка уже естьтолько =нужна явная Sort большого набора

Как это использовать на практике

  1. Nested Loop с loops=200000 и Seq Scan внутри почти всегда просит индекс по колонке соединения внутренней таблицы.
  2. При Hash Join с Batches > 1 подними work_mem (можно локально: SET LOCAL work_mem = '256MB') или уменьши объём данных до соединения.
  3. Если rows и actual rows сильно расходятся, планировщик выбрал алгоритм по неверной оценке. Помогают ANALYZE, повышение default_statistics_target или расширенная статистика CREATE STATISTICS для коррелированных колонок.
Чем добить

«Отключать алгоритмы через enable_hashjoin = off в проде нельзя, но для диагностики приём отличный: если с выключенным hash join запрос стал быстрее, значит, проблема в оценке кардинальности, а не в алгоритме, и чинить надо статистику.» Ещё стоит знать про Memoize (с PG 14): этот узел-кэш над внутренней стороной Nested Loop превращает повторяющиеся поиски по одному ключу в попадания в кэш.

Суть: FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. WHERE фильтрует строки до группировки, HAVING - группы после.

Запрос пишется в одном порядке, а выполняется в другом: SELECT стоит первым, а выполняется почти последним. Из этой цепочки механически выводятся все правила видимости имён, зубрить их не надо.

Что следует

  1. Алиас из SELECT недоступен в WHERE, GROUP BY и HAVING: на момент их выполнения SELECT ещё не отработал. В ORDER BY доступен: он идёт после. (PostgreSQL допускает алиас в GROUP BY как расширение стандарта, но опираться на это не стоит.)
  2. Оконные функции считаются на шаге SELECT, поэтому их нельзя писать ни в WHERE, ни в HAVING: нужен подзапрос или CTE. Так что «топ-3 в группе» всегда пишется в два уровня.
  3. WHERE дешевле HAVING. Всё, что можно отфильтровать до группировки, надо фильтровать до неё: меньше строк доедет до GROUP BY, меньше памяти на хеш-агрегат.
  4. LIMIT применяется последним, после сортировки, так что «взять 10 строк» не значит «прочитать 10 строк».
-- Ошибка: cnt ещё не существует, а агрегат в WHERE запрещён
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE cnt > 5
GROUP BY customer_id;

-- Правильно
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01'   -- фильтр строк: дёшево, до группировки
GROUP BY customer_id
HAVING count(*) > 5                -- фильтр групп: только по агрегату
ORDER BY cnt DESC                  -- здесь алиас уже виден
LIMIT 10;
HAVING без GROUP BY

Работает и означает «весь набор - одна группа»: SELECT count(*) FROM t HAVING count(*) > 100 вернёт либо одну строку, либо ни одной. А вот HAVING с условием на обычную колонку (HAVING status = 'new') почти всегда выдаёт ошибку автора: логически это WHERE, только выполненный позже и дороже.

Логическая модель ≠ физическое выполнение

Планировщик волен переставлять шаги, если результат не меняется: проталкивать предикаты в подзапросы (predicate pushdown), выполнять LIMIT раньше через индексный порядок, схлопывать DISTINCT в группировку. Логический порядок нужен, чтобы рассуждать о семантике, а не о производительности.

Суть: коррелированный подзапрос ссылается на колонки внешнего запроса, поэтому логически выполняется для каждой внешней строки. Переписывается в JOIN с агрегатом или в оконную функцию - один проход вместо N.

Скалярный подзапрос возвращает ровно одно значение и может стоять вместо выражения: SELECT (SELECT max(id) FROM orders). Если он вдруг вернёт больше одной строки, получишь ошибку времени выполнения, а не пустой результат; на этом ловят. Коррелированный использует колонки внешнего запроса, а значит, его нельзя вычислить один раз заранее.

-- Коррелированный: для каждой строки employees считаем среднее по её отделу
SELECT e.name, e.salary,
       (SELECT avg(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id) AS dept_avg
FROM employees e;
-- на 1 млн сотрудников подзапрос выполнится до 1 млн раз

-- Оконная функция: один проход, одна сортировка/хеш
SELECT e.name, e.salary,
       avg(e.salary) OVER (PARTITION BY e.dept_id) AS dept_avg
FROM employees e;

-- Или JOIN с предагрегатом: каждый отдел посчитан ровно один раз
SELECT e.name, e.salary, d.dept_avg
FROM employees e
JOIN (SELECT dept_id, avg(salary) AS dept_avg FROM employees GROUP BY dept_id) d
  ON d.dept_id = e.dept_id;

Когда планировщик спасает, а когда нет

PostgreSQL умеет декоррелировать часть подзапросов: EXISTS и IN обычно превращаются в Semi Join, NOT EXISTS - в Anti Join, и это работает быстро. Но декорреляция не срабатывает, если внутри есть LIMIT, ORDER BY с ограничением, агрегат по внешней колонке, volatile-функция или подзапрос стоит в списке SELECT. В плане это видно как SubPlan с большим loops, и это верный признак, что запрос пора переписать.

Что чем заменять

БылоСталоПочему
скалярный подзапрос в SELECTоконная функцияодин проход вместо N
WHERE x IN (SELECT ...)обычно оставитьпланировщик делает Semi Join
WHERE x NOT IN (SELECT ...)NOT EXISTSNULL-безопасность + Anti Join
подзапрос «последняя строка на группу»LATERAL + LIMIT 1индексный доступ, без сортировки всей таблицы
агрегат по связанной таблице в SELECTJOIN с предагрегатомгруппа считается один раз
-- LATERAL: последний заказ каждого клиента, с попаданием в индекс
SELECT c.id, c.name, o.id AS last_order, o.created_at
FROM customers c
LEFT JOIN LATERAL (
    SELECT o.id, o.created_at
    FROM orders o
    WHERE o.customer_id = c.id
    ORDER BY o.created_at DESC
    LIMIT 1
) o ON TRUE;
-- нужен индекс (customer_id, created_at DESC)
Суть: CTE - именованный подзапрос для читаемости и рекурсии. До PG 12 он всегда был барьером оптимизации (материализовался), с PG 12 однократно используемый CTE по умолчанию инлайнится.

Зачем

  • Читаемость: длинный запрос разбивается на именованные шаги вместо вложенности в пять уровней.
  • Результат можно переиспользовать внутри одного запроса.
  • Рекурсия: без неё иерархии и графы в SQL не обойти.
  • DML-конвейеры: WITH ... AS (DELETE ... RETURNING *) INSERT ... переносит данные одним атомарным запросом.

Что изменилось в PG 12

До PostgreSQL 12 любой WITH был барьером оптимизации: он выполнялся целиком во временный набор, и предикаты внешнего запроса внутрь не проталкивались. Если снаружи стоял WHERE id = 5, а CTE выбирал миллион строк, сначала считался миллион, а потом отбрасывалось всё, кроме одной. С PG 12 CTE, который используется ровно один раз, не рекурсивен и не содержит DML и volatile-функций, по умолчанию инлайнится и ведёт себя как обычный подзапрос. Явно поведение задают через AS MATERIALIZED и AS NOT MATERIALIZED.

-- MATERIALIZED явно - для барьера оптимизации; CTE с двумя и больше ссылками PG 12+ материализует и сам
WITH heavy AS MATERIALIZED (
    SELECT ... FROM big_table WHERE ...
)
SELECT * FROM heavy a JOIN heavy b ON ... ;

-- Рекурсивный CTE: якорь UNION ALL шаг рекурсии
WITH RECURSIVE subs AS (
    SELECT id, name, manager_id, 1 AS depth, ARRAY[id] AS path
    FROM employees WHERE id = 1                  -- якорь
  UNION ALL
    SELECT e.id, e.name, e.manager_id, s.depth + 1, s.path || e.id
    FROM employees e
    JOIN subs s ON e.manager_id = s.id           -- шаг
    WHERE NOT e.id = ANY(s.path)                 -- защита от цикла в данных
      AND s.depth < 50                           -- страховка от бесконечности
)
SELECT * FROM subs ORDER BY depth, id;
Три подвоха рекурсивного CTE

Первый: UNION вместо UNION ALL добавляет дедупликацию на каждом шаге. Это дорого, зато спасает от зацикливания - если в строках нет счётчика глубины или пути: с ними строки всегда разные, и UNION не поможет. Выбор осознанный, а не «пиши UNION ALL всегда». Второй: цикл в данных (A - менеджер B, B - менеджер A) вешает запрос насмерть, если нет ни массива-пути, ни ограничения глубины. В проде ставь и то, и другое. И третий, менее известный: рекурсия в PG работает через очередь, то есть это обход в ширину; порядок строк - не глубина дерева, если явно не сортировать по пути.

CTE не всегда лучше подзапроса

Есть модный антипаттерн: «разбить всё на 12 CTE ради красоты». Если каждый шаг фильтрует только на следующем уровне, планировщик после инлайна справится, но читать план из двенадцати уровней невозможно. Хороший критерий: CTE оправдан, когда у шага есть самостоятельный смысл, который можно назвать словом.

Суть: оконная функция считает значение по «окну» соседних строк, но не схлопывает строки. Топ-N в группе = пронумеровать ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) в подзапросе и снаружи взять rn <= N.

От агрегата окно отличается так: GROUP BY превращает 1000 строк в 10, а окно оставляет все 1000 и каждой приписывает значение, посчитанное по её группе. PARTITION BY режет строки на окна, ORDER BY внутри OVER задаёт порядок внутри окна, ROWS/RANGE задают рамку (если есть ORDER BY, по умолчанию она идёт от начала окна до текущей строки, точнее до последней строки с тем же значением ORDER BY, отсюда «нарастающий итог»).

ФункцияЧто даётНа равных значениях
ROW_NUMBER()сквозная нумерация1, 2, 3, 4 - ничьи разрываются произвольно
RANK()ранг с пропусками1, 2, 2, 4
DENSE_RANK()ранг без пропусков1, 2, 2, 3
LAG(x, n, def) / LEADзначение на n строк назад/вперёддельты между событиями, «предыдущий статус»
FIRST_VALUE / LAST_VALUEкрайние значения окнау LAST_VALUE нужна явная рамка, иначе вернёт текущую строку (при ничьих - последнюю из равных ей)
SUM(x) OVER (ORDER BY t)нарастающий итограмка по умолчанию - до текущей строки, равные по ORDER BY строки получают одну сумму
NTILE(n)разбиение на n корзинперцентили, когорты
-- Топ-3 товара по выручке внутри каждой категории
SELECT category_id, product_id, revenue
FROM (
    SELECT category_id, product_id, sum(amount) AS revenue,
           ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sum(amount) DESC) AS rn
    FROM sales
    GROUP BY category_id, product_id          -- окно считается после агрегации
) t
WHERE rn <= 3
ORDER BY category_id, revenue DESC;

-- Интервал между заказами клиента
SELECT customer_id, created_at,
       created_at - LAG(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS gap
FROM orders;

-- Именованное окно, если оно повторяется
SELECT customer_id, amount,
       sum(amount) OVER w AS running_total,
       ROW_NUMBER()  OVER w AS n
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY created_at);
Три ловушки

1) Окно нельзя писать в WHERE: оно считается на шаге SELECT, поэтому фильтр по rn всегда уходит во внешний запрос. 2) ROW_NUMBER vs RANK для топ-N: если нужны «все, кто попал в топ-3, включая делящих место», бери RANK или DENSE_RANK, а ROW_NUMBER обрежет одного из равных. 3) LAST_VALUE без рамки возвращает текущую строку, потому что рамка по умолчанию заканчивается на ней (а при ничьих по ORDER BY - на последней из равных ей строк); нужно ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

Когда окно проигрывает LATERAL

Окно требует прочитать и отсортировать все строки партиции, даже если нужна одна. Если категорий немного, а товаров в каждой миллионы, вариант «список категорий + LATERAL (... ORDER BY revenue DESC LIMIT 3)» с подходящим индексом отработает на порядки быстрее: он возьмёт по три строки из индекса и не будет сортировать миллионы. На собесе это ответ выше среднего.

Суть: VIEW - сохранённый текст запроса, данных не хранит. MATERIALIZED VIEW - физическая таблица с результатом, которую надо обновлять руками через REFRESH.

VIEW

При обращении планировщик подставляет тело представления в запрос и оптимизирует всё вместе (view expansion). Значит, данные всегда свежие, места не занимают, но каждая выборка стоит столько, сколько стоит исходный запрос. Вьюха инкапсулирует логику, служит слоем совместимости при переименовании колонок и разграничивает права («этой роли - только вьюху без персональных данных»). Но вьюхи поверх вьюх дают планы, которые невозможно читать, а тяжёлая вьюха тяжела на каждом обращении. Простая вьюха обновляема напрямую; сложная - только через INSTEAD OF-триггеры или CREATE RULE.

MATERIALIZED VIEW

Хранит результат физически, поддерживает свои индексы, читается как обычная таблица. Обновляется только явно:

  • REFRESH MATERIALIZED VIEW mv берёт ACCESS EXCLUSIVE: на всё время обновления вьюха недоступна даже для чтения.
  • REFRESH MATERIALIZED VIEW CONCURRENTLY mv не блокирует читателей, но требует уникального индекса на вьюхе и работает заметно дольше (обновление идёт через сравнение со старой версией).
  • С WITH NO DATA вьюха создаётся пустой и до первого REFRESH не читается.

В PostgreSQL нет инкрементального обновления «из коробки»: REFRESH пересчитывает всё. Если данных много, обычно заводят расписание (pg_cron / джоб в приложении) или собственную «сводную таблицу», которую обновляют триггерами или батчами.

Как выбирать

VIEW берут, когда логика повторяется, а исходный запрос дешёвый. MATERIALIZED VIEW - когда запрос дорогой (тяжёлые агрегаты, отчёты), а устаревшие на N минут данные допустимы. Раз материализованная вьюха служит ручным кэшем внутри БД, к ней применимы все вопросы кэша: какой TTL, кто и когда инвалидирует, что показывать, пока идёт refresh. Если данные должны быть строго актуальны, материализация не подходит: нужен либо индекс/переписывание запроса, либо сводная таблица, обновляемая триггерами.

Суть: нормализация - «один факт хранится в одном месте», чтобы его нельзя было обновить наполовину. Денормализация - сознательный размен целостности на скорость чтения, и она обязана идти в комплекте с механизмом поддержания согласованности.

Формы своими словами

  • 1НФ: в ячейке лежит одно атомарное значение, повторяющихся групп нет. Нарушение: phones = '+7999..., +7495...' одной строкой или колонки phone1, phone2, phone3. Лечится отдельной таблицей.
  • 2НФ: 1НФ + каждый неключевой атрибут зависит от всего составного ключа, а не от его части. Нарушение: ключ (order_id, product_id), а в строке лежит order_date, который зависит только от order_id. Актуальна только при составном ключе.
  • 3НФ: 2НФ + нет транзитивных зависимостей: неключевые атрибуты не зависят друг от друга. Нарушение: в orders лежат city_id и city_name, а имя зависит от id, не от заказа. Переименовали город, и надо обновлять миллион строк: часть обновится, а часть нет.

Мнемоника: «каждый неключевой атрибут зависит от ключа, от всего ключа и ни от чего, кроме ключа». Дальше идут НФБК, 4НФ, 5НФ; на собесе достаточно упомянуть, что они существуют и лечат более экзотические аномалии.

Что именно чинит нормализация

Не «красоту», а три аномалии: обновления (одно и то же имя города в миллионе строк обновится не везде), вставки (нельзя завести город, пока нет заказа в нём) и удаления (удалили последний заказ - потеряли факт существования города).

Когда денормализовать

  1. Джойн доказанно стал узким местом: есть план и цифры, а не ощущение.
  2. Дублируемые данные меняются редко или не меняются вовсе.
  3. Есть механизм поддержания согласованности: триггер, материализованная вьюха, пересчёт по расписанию, событие в брокере.
Снапшот - это не денормализация

Цена товара, скопированная в order_items.price на момент заказа, не дублирует справочник, а фиксирует исторический факт: цена в каталоге завтра поменяется, а в заказе должна остаться прежней. То же с адресом доставки и названием компании в счёте. Умение отличать «денормализовали ради скорости» от «зафиксировали состояние на момент события» выдаёт зрелого проектировщика.

Красный флаг

«Мы денормализовали, чтобы было быстрее» без пункта (3) описывает будущий баг. Через полгода счётчик comments_count в постах разъедется с реальным количеством комментариев, потому что кто-то удалил их скриптом мимо приложения.

Суть: констрейнт - гарантия, которую нельзя обойти ни кривым скриптом, ни второй версией сервиса, ни ручным UPDATE из psql. Проверка в приложении - не гарантия, а надежда.
  • NOT NULL - самый дешёвый и самый недооценённый. Убирает целый класс багов с трёхзначной логикой. Ставь по умолчанию, ослабляй осознанно.
  • UNIQUE реализуется уникальным индексом. Помни: несколько NULL допускаются (они «разные»); с PG 15 есть UNIQUE NULLS NOT DISTINCT.
  • CHECK проверяет предикат на каждой строке. Именуй явно: имя приходит в текст ошибки, и по нему удобно ветвиться в коде и искать в логах. Не может ссылаться на другие таблицы и должен вести себя как IMMUTABLE: CHECK (created_at <= now()) PostgreSQL примет, но проверит только при записи, и восстановление из дампа может на нём упасть.
  • EXCLUDE, ещё один недооценённый констрейнт PostgreSQL, требует: «никакие две строки не должны конфликтовать по оператору». Каноничный кейс: запрет пересечения интервалов броней, EXCLUDE USING gist (room_id WITH =, period WITH &&). Оператор = по обычной колонке вроде int GiST понимает только с расширением btree_gist.
-- Частичный уникальный индекс: email уникален только среди неудалённых
CREATE UNIQUE INDEX users_email_uniq ON users (LOWER(email)) WHERE deleted_at IS NULL;

-- Именованный CHECK: имя попадёт в текст ошибки
ALTER TABLE accounts ADD CONSTRAINT accounts_balance_nonneg CHECK (balance >= 0);

-- NOT VALID: добавить мгновенно, старые строки проверить потом без тяжёлой блокировки
ALTER TABLE orders ADD CONSTRAINT orders_total_pos CHECK (total > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_pos;

-- Отложенная проверка: до COMMIT можно временно нарушить (циклические ссылки)
ALTER TABLE a ADD CONSTRAINT a_b_fk FOREIGN KEY (b_id) REFERENCES b(id)
  DEFERRABLE INITIALLY DEFERRED;
ON DELETEЧто происходитКогда уместно
NO ACTION (дефолт)ошибка, проверка может быть отложена до COMMITсамый безопасный дефолт
RESTRICTошибка сразу, отложить нельзяудаление родителя - всегда ошибка бизнес-логики
CASCADEдети удаляются автоматическистрогая композиция: orders → order_items
SET NULLссылка обнуляетсянеобязательная связь: у сотрудника уволился менеджер
SET DEFAULTставится дефолтредко; дефолт обязан существовать в родителе
ON DELETE CASCADE - мина замедленного действия

Удаление одной строки может каскадом снести миллионы строк в пяти таблицах, забрать на всё это блокировки и надолго встать поперёк соседей. Каскад живёт своей жизнью: в MySQL он не вызывает триггеры дочерних таблиц, в PostgreSQL вызывает, и мягкое удаление с аудитом легко срабатывают не так, как ждёт бизнес. На больших системах чаще выбирают RESTRICT плюс явное удаление батчами в коде, а CASCADE оставляют для мелких служебных таблиц. И помни: под каждый FK нужен индекс, иначе каскад превращается в Seq Scan дочерней таблицы.

Как отвечать на «валидация в приложении или в БД»

«И там, и там, но по разным причинам. В приложении - ради понятной ошибки пользователю и раннего отказа. В БД - ради гарантии, потому что в базу пишет не только этот сервис: есть миграции, ручные фиксы, второй инстанс со старым кодом и внешние интеграции. Приложение проверяет удобно, база проверяет надёжно.»

Что стоит запомнить

  • SQL описывает результат, а планировщик выбирает способ его получить; одинаковый запрос может иметь разную стоимость.
  • NULL означает неизвестное: сравнение с ним даёт UNKNOWN, а WHERE оставляет только TRUE.
  • Логический JOIN задаёт состав результата, а Nested Loop, Hash Join и Merge Join описывают способ соединения.

Частые ошибки

  • NOT IN ломает ожидаемый anti-join, если подзапрос вернул NULL; обычно безопаснее NOT EXISTS.
  • JOIN по отношению один-ко-многим увеличивает число строк, поэтому сумма после JOIN может задвоиться.
  • Наличие внешнего ключа в PostgreSQL само по себе не создаёт индекс на дочерней колонке.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Почему запрос ничего не вернул

В таблице orders есть customer_id со значениями 10, 20 и NULL. Объясни, что вернёт запрос и почему.

SELECT id FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
Ответ и разбор

Запрос вернёт пустой результат. Для каждого id проверка раскрывается в набор сравнений через AND, в том числе id <> NULL. Это сравнение даёт UNKNOWN, поэтому итог не становится TRUE. Для поиска клиентов без заказов используй NOT EXISTS с корреляцией по customer_id.

Код-ревью

2. Проверь условие LEFT JOIN

Автор хотел получить всех клиентов и присоединить только оплаченные заказы. Найди ошибку в запросе и предложи исправление.

SELECT c.id, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';
Ответ и разбор

Условие в WHERE отбрасывает строки без заказа: для них o.status равен NULL, а сравнение даёт UNKNOWN. Перенеси фильтр в ON: LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'. Тогда каждый клиент останется в результате, а неподходящий заказ даст NULL-колонки.

Применение

3. Выбери модель хранения

Сервис хранит заказы и регулярно строит отчёты с соединениями клиентов, позиций и платежей. Объясни выбор основного хранилища и назови случай, в котором для этого же продукта оправдано отдельное хранилище.

Ответ и разбор

Для заказов подходит реляционная БД: связи, ограничения целостности, транзакции и запросы с JOIN входят в основной паттерн доступа. Отдельное колоночное хранилище может быть оправдано для тяжёлой аналитики по большим диапазонам; оно дополняет источник истины, а не должно автоматически заменять его.

Применение

4. Найди причину задвоенной суммы

Отчёт суммирует payments.amount после JOIN customers -> orders -> payments. У клиента несколько заказов, у каждого несколько платежей. Как проверить, почему итог выше ожидаемого, и исправить расчёт?

Ответ и разбор

Сначала проверь кардинальность каждой связи и посчитай строки до и после JOIN: связи один-ко-многим размножают строки. Если сумма нужна на уровне клиента, агрегируй платежи на нужном уровне до присоединения к другим размножающим таблицам либо используй отдельный подзапрос с GROUP BY customer_id. DISTINCT по сумме не подходит: одинаковые легитимные платежи тоже схлопнутся.