6.1Общая теория и SQL
Свяжи реляционную модель с тем, что увидишь в запросе: как NULL меняет логику условий, почему JOIN может размножить строки и как физический план влияет на скорость. Это поможет выбирать хранилище по запросам и проверять результат через план, а не по виду SQL.
Реляционная модель и её альтернативы
Реляционная БД хранит данные в виде отношений (таблиц) со строгой схемой, а её движок умеет декларативно отвечать на запросы по этим отношениям и держать транзакционные гарантии. Всё решает слово декларативно: ты пишешь, что хочешь получить, а планировщик решает, как. Поэтому одна и та же строка SQL может выполниться и за 0,3 мс через индекс, и за 40 с через Seq Scan. По той же причине читать план важнее, чем уметь писать сложный SQL.
NoSQL не значит «БД без SQL»: это зонтичный термин для движков, которые сознательно отказались от части реляционных гарантий (обычно от джойнов, строгой схемы или сильной консистентности, в любом сочетании) ради масштабирования, скорости или удобной модели данных. Такой отказ всегда размен: улучшения по всем осям не бывает.
| Класс | Модель данных | Представители | Сильная сторона | Чем платишь |
|---|---|---|---|---|
| Реляционные | таблицы + схема + связи | PostgreSQL, MySQL, Oracle | джойны, транзакции, констрейнты, произвольные запросы | сложнее горизонтально масштабировать запись |
| Key-value | ключ → непрозрачное значение | Redis, etcd, DynamoDB, Aerospike | O(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 начинался как «быстрое хранилище для веба», и его годами докручивали до нормальной СУБД (докрутили, но следы остались).
| Аспект | PostgreSQL | MySQL / InnoDB |
|---|---|---|
| Движки хранения | один, встроенный | подключаемые (InnoDB, MyISAM, Memory); транзакции есть только у InnoDB |
| MVCC | старые версии строк лежат в самой таблице (heap), нужен VACUUM | старые версии - в undo-логе, откуда чистятся purge-потоком |
| Строгость типов | жёсткая: 'abc'::int - ошибка, переполнение - ошибка | исторически «мягкая» (усечение и предупреждения); строгость появилась со strict mode |
| Уровень по умолчанию | READ COMMITTED | REPEATABLE 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
| Группа | Расшифровка | Команды | Что делает |
|---|---|---|---|
| DDL | Data Definition Language | CREATE, ALTER, DROP, TRUNCATE, COMMENT | меняет структуру объектов и системный каталог |
| DML | Data Manipulation Language | INSERT, UPDATE, DELETE, MERGE, (SELECT) | меняет содержимое таблиц построчно |
| DCL | Data Control Language | GRANT, REVOKE | права доступа |
| TCL | Transaction Control Language | BEGIN, COMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTION | границы транзакции |
SELECT формально относят то к DML, то к отдельной группе DQL (Data Query Language).
На собесе хватит фразы «читающая часть DML, в некоторых классификациях выделяют в DQL».
TRUNCATE не удаляет строки по одной. Он подменяет физическое хранилище
таблицы: создаёт новый пустой файл и переписывает relfilenode в системном каталоге,
а старый файл выбрасывает целиком. Никакой построчной работы, никаких триггеров
BEFORE/AFTER DELETE, никаких записей в WAL на каждую строку. Меняется
описание объекта в каталоге, значит, операция относится к определению данных, то есть к DDL.
На практике TRUNCATE берёт ACCESS EXCLUSIVE на таблицу
(блокирует даже SELECT) и сбрасывает SERIAL-счётчик, если попросить
RESTART IDENTITY. В PostgreSQL TRUNCATE при этом
транзакционен и откатывается, а в MySQL/Oracle нет: там он делает неявный commit.
| DELETE | TRUNCATE | DROP | |
|---|---|---|---|
| Группа | DML | DDL | DDL |
| Что остаётся | таблица, индексы, права | таблица, индексы, права | ничего |
| 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 родительской.
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 для каждой строки, и в результат
ничего не попадает.
FALSE AND U = FALSE
(поэтому AND может «спасти»), но TRUE AND U = UNKNOWN - поэтому NOT IN
с единственным NULL в списке обнуляет весь результат.| Где | Как ведёт себя NULL |
|---|---|
WHERE col = NULL | UNKNOWN → строк нет. Нужен 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 EXISTS | NULL-безопасен, работает как настоящий 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.
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, и ответ пуст
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-задачах на собесе.
work_mem (чтобы Hash Join
не уходил на диск), а не переписыванием запроса.| Алгоритм | Сложность | Когда выбирается | Условия соединения |
|---|---|---|---|
| Nested Loop | O(N × стоимость поиска) | внешняя выборка маленькая, по внутренней есть индекс | любые, включая <, BETWEEN, функции |
| Hash Join | O(N + M), память под хеш | обе стороны крупные, нет полезной сортировки | только эквисоединение (=) |
| Merge Join | O(N + M) + сортировка | обе стороны уже отсортированы (индексы, ORDER BY) | только равенство |
Логический порядок выполнения запроса
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;
До 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 | ставится значение по умолчанию | редко; требует, чтобы дефолт существовал в родителе |
Удаление одной строки может каскадом снести миллионы строк в пяти таблицах, взять блокировки
на всё это и надолго подвесить соседей. Вдобавок каскад живёт своей жизнью: в MySQL он не вызывает триггеры дочерних таблиц, в PostgreSQL вызывает, и «мягкое удаление» с аудитом легко срабатывают не так, как ждёт бизнес. Поэтому на больших системах чаще
берут RESTRICT и явно удаляют батчами в коде, а CASCADE оставляют
мелким служебным таблицам.
Вопросы
16В реляционной модели данные лежат в отношениях со строгой схемой, а к ним прилагаются декларативный язык запросов и транзакции. Ценны тут не таблицы, а две вещи: ты можешь задать запрос, о котором не думал на этапе проектирования схемы, и сервер сам гарантирует целостность через констрейнты и 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
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), заточенные под «метрика + время + теги», со сжатием и автоматическим ретеншном.
Философия
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, свои типы.
Так отвечают джуны. Сильный ответ строится на MVCC и heap/clustered index, потому что из них выводятся все практические различия в эксплуатации: почему в PG есть autovacuum, почему в MySQL важен порядок PK, почему долгая транзакция вредит обеим базам, но по разным причинам.
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 - объекта больше нет.| Критерий | DELETE | TRUNCATE | DROP |
|---|---|---|---|
| Класс | DML | DDL | DDL |
| WHERE | да | нет | нет |
| Стоимость на 100 млн строк | минуты-часы | миллисекунды | миллисекунды |
| WAL | запись на каждую строку | одна запись о смене файла | одна запись |
| Триггеры | DELETE-триггеры | только TRUNCATE-триггеры | нет |
| Диск освобождается | нет, нужен VACUUM | сразу | сразу |
| Блокировка | построчная, чтения не мешает | ACCESS EXCLUSIVE | ACCESS 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 (то же самое, но почти без блокировки).
PRIMARY KEY задаёт минимальный набор колонок, который однозначно идентифицирует строку, и в таблице такой ключ один. PostgreSQL под него автоматически создаёт уникальный B-tree индекс (иначе проверять уникальность на каждой вставке пришлось бы сканом). UNIQUE отличается от PK тем, что их может быть несколько и они допускают NULL.
FOREIGN KEY требует, чтобы «значение этой колонки существовало в другой таблице».
Проверка идёт в обе стороны: при INSERT/UPDATE ребёнка СУБД ищет
родителя, при DELETE/UPDATE родителя смотрит, не осиротеют ли дети.
Технически это системные триггеры.
Почему отсутствие индекса на FK так больно
- JOIN. Соединение от родителя к детям идёт по неиндексированной колонке: планировщик вынужден брать Hash Join с полным сканом дочерней таблицы либо Nested Loop с Seq Scan внутри, а это уже катастрофа.
- DELETE родителя. Чтобы проверить
ON DELETE RESTRICT/CASCADE, СУБД выполняетSELECT 1 FROM child WHERE fk = $1. Без индекса это полный скан дочерней таблицы на каждую удаляемую строку. Классический инцидент: «удаление одного пользователя занимает 4 минуты». - То же самое при
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).
Суррогатный (bigserial, UUID) стабилен и не зависит от бизнеса, это дефолт.
Естественный (ИНН, email) даёт бесплатную уникальность, но меняется, а PK меняться не должен.
Про UUID стоит знать: uuid_v4 случаен, поэтому в PostgreSQL раздувает B-tree
(вставки идут в случайные страницы, WAL растёт из-за full-page writes), а в MySQL ещё и
каждый вторичный индекс. Лечится это UUIDv7 (сортируемым по времени): в PG 18 он доступен как
uuidv7(), раньше брали расширение или генерировали на стороне приложения.
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 больно.
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);
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 ...), но он почти всегда намекает, что запрос стоит
переписать.
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 Loop | N × поиск | внешняя выборка мала, внутри индекс | любые | недооценка кардинальности внешней стороны |
| Hash Join | O(N+M) + память | обе стороны крупные | только = | нехватка work_mem → batches на диск |
| Merge Join | O(N+M) + сортировки | сортировка уже есть | только = | нужна явная Sort большого набора |
Как это использовать на практике
- Nested Loop с
loops=200000и Seq Scan внутри почти всегда просит индекс по колонке соединения внутренней таблицы. - При Hash Join с
Batches > 1поднимиwork_mem(можно локально:SET LOCAL work_mem = '256MB') или уменьши объём данных до соединения. - Если
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 стоит
первым, а выполняется почти последним. Из этой цепочки механически выводятся все правила
видимости имён, зубрить их не надо.
Что следует
- Алиас из SELECT недоступен в WHERE, GROUP BY и HAVING: на момент их выполнения
SELECT ещё не отработал. В
ORDER BYдоступен: он идёт после. (PostgreSQL допускает алиас вGROUP BYкак расширение стандарта, но опираться на это не стоит.) - Оконные функции считаются на шаге SELECT, поэтому их нельзя писать ни в
WHERE, ни вHAVING: нужен подзапрос или CTE. Так что «топ-3 в группе» всегда пишется в два уровня. - WHERE дешевле HAVING. Всё, что можно отфильтровать до группировки, надо
фильтровать до неё: меньше строк доедет до
GROUP BY, меньше памяти на хеш-агрегат. 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;
Работает и означает «весь набор - одна группа»:
SELECT count(*) FROM t HAVING count(*) > 100 вернёт либо одну строку, либо
ни одной. А вот HAVING с условием на обычную колонку (HAVING status =
'new') почти всегда выдаёт ошибку автора: логически это WHERE, только
выполненный позже и дороже.
Планировщик волен переставлять шаги, если результат не меняется: проталкивать предикаты
в подзапросы (predicate pushdown), выполнять LIMIT раньше через
индексный порядок, схлопывать DISTINCT в группировку. Логический порядок
нужен, чтобы рассуждать о семантике, а не о производительности.
Скалярный подзапрос возвращает ровно одно значение и может стоять вместо выражения:
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 EXISTS | NULL-безопасность + Anti Join |
| подзапрос «последняя строка на группу» | LATERAL + LIMIT 1 | индексный доступ, без сортировки всей таблицы |
| агрегат по связанной таблице в SELECT | JOIN с предагрегатом | группа считается один раз |
-- 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)
Зачем
- Читаемость: длинный запрос разбивается на именованные шаги вместо вложенности в пять уровней.
- Результат можно переиспользовать внутри одного запроса.
- Рекурсия: без неё иерархии и графы в 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;
Первый: UNION вместо UNION ALL добавляет дедупликацию на каждом
шаге. Это дорого, зато спасает от зацикливания - если в строках нет счётчика глубины или пути: с ними строки всегда разные, и UNION не поможет. Выбор осознанный, а не «пиши UNION ALL всегда». Второй: цикл в данных (A - менеджер B, B - менеджер A) вешает запрос
насмерть, если нет ни массива-пути, ни ограничения глубины. В проде ставь и то,
и другое. И третий, менее известный: рекурсия в PG работает через очередь, то есть это
обход в ширину; порядок строк - не глубина дерева, если явно не сортировать по пути.
Есть модный антипаттерн: «разбить всё на 12 CTE ради красоты». Если каждый шаг фильтрует только на следующем уровне, планировщик после инлайна справится, но читать план из двенадцати уровней невозможно. Хороший критерий: CTE оправдан, когда у шага есть самостоятельный смысл, который можно назвать словом.
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 (... ORDER BY revenue DESC LIMIT 3)» с подходящим индексом отработает
на порядки быстрее: он возьмёт по три строки из индекса и не будет сортировать миллионы.
На собесе это ответ выше среднего.
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НФ; на собесе достаточно упомянуть, что они существуют и лечат более экзотические аномалии.
Что именно чинит нормализация
Не «красоту», а три аномалии: обновления (одно и то же имя города в миллионе строк обновится не везде), вставки (нельзя завести город, пока нет заказа в нём) и удаления (удалили последний заказ - потеряли факт существования города).
Когда денормализовать
- Джойн доказанно стал узким местом: есть план и цифры, а не ощущение.
- Дублируемые данные меняются редко или не меняются вовсе.
- Есть механизм поддержания согласованности: триггер, материализованная вьюха, пересчёт по расписанию, событие в брокере.
Цена товара, скопированная в 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 &&). Оператор=по обычной колонке вродеintGiST понимает только с расширением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 | ставится дефолт | редко; дефолт обязан существовать в родителе |
Удаление одной строки может каскадом снести миллионы строк в пяти таблицах, забрать на всё
это блокировки и надолго встать поперёк соседей. Каскад живёт своей жизнью: в 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 по сумме не подходит: одинаковые легитимные платежи тоже схлопнутся.