SQL

SQL #

1. Уровни изоляции транзакций в БД #

Статья на хабре про уровни изоляции

От менее строгого к самому строгому:

  • Read uncommited (незащищённое чтение) - транзакции могут читать данные, которые еще не зафиксированы другими транзакциями (использовать, когда все транзакции на чтение)
  • Read commited (защищённое чтение) - транзакции могут читать только те данные, которые уже зафиксированы другими данными (решает потерянное обновление и грязное чтение)
  • Repeatable read (повторяющееся чтение) - в рамках одной транзакции многократный запрос данных всегда возвращает одинаковый набор данных, независимо от изменений, сделанных другими транзакциями (решает потерянное обновление, грязное чтение и неповторяющееся чтение)
  • Serializable - самый строгий уровень изоляции. Транзакции выполняются так, как будто происходят последовательно одна за другой (решает всё, но бьёт по производительности)

Какой уровень изолированности стоит по умолчанию в MySQL? В MySQL - Repeatable read, в PostgreSQL - Read commited

Зависимость скорости работы БД и уровня изоляции

Уровень изоляцииОписаниеВлияние на производительность
Read UncommittedТранзакции могут читать данные, которые ещё не зафиксированыСамый быстрый, но данные могут быть неконсистентными
Read CommittedТранзакции читают только зафиксированные данныеСредняя производительность, баланс между скоростью и консистентностью
Repeatable ReadГарантирует, что данные, прочитанные транзакцией, не изменятся, пока транзакция не завершенаСнижает производительность из-за блокировок, но обеспечивает более высокую консистентность
SerializableСамый высокий уровень изоляции, предотвращает все виды аномалийСамый медленный, так как использует полные блокировки


2. Что такое реляционные базы данных? #

Что такое реляционные базы данных #

Реляционная база данных — это база данных, в которой данные хранятся в виде связанных таблиц.

Каждая таблица описывает отдельную сущность: пользователей, заказы, товары, платежи и так далее.

Пример:

Таблица users:

idnameemail
1Ivanivan@mail.com
2Annaanna@mail.com

Таблица orders:

iduser_idtotal
125000
211200

Здесь orders.user_id ссылается на users.id. То есть заказ связан с конкретным пользователем.

Основная идея #

Реляционная БД строится на отношениях между таблицами.

Связи обычно задаются через:

Primary Key — первичный ключ, уникально идентифицирует запись в таблице.

Foreign Key — внешний ключ, указывает на запись в другой таблице.

Пример:

users.id        -- primary key
orders.user_id  -- foreign key на users.id

Так база понимает, как данные связаны между собой.

Зачем нужны реляционные БД #

Они позволяют:

хранить структурированные данные;

избегать дублирования данных;

связывать сущности между собой;

делать сложные выборки через SQL;

обеспечивать целостность данных;

использовать транзакции и ACID.

Примеры реляционных СУБД #

Самые известные:

PostgreSQL;

MySQL;

MariaDB;

SQLite;

Oracle Database;

Microsoft SQL Server.

Кратко #

Реляционная база данных — это база данных, где информация хранится в таблицах, а связи между таблицами задаются через ключи. Основная работа с такими базами выполняется с помощью SQL. Реляционные БД хорошо подходят для структурированных данных, где важны целостность, связи между сущностями и транзакционность.


3. Как повысить эффективность выборки записей из большой таблицы? | Опыт оптимизации запросов. #

Как повысить эффективность выборки из большой таблицы #

Главная идея: база должна читать как можно меньше строк и страниц с диска/памяти.

1. Добавить правильные индексы #

Индекс нужен на поля, которые часто используются в:

WHERE
JOIN ON
ORDER BY
GROUP BY

Пример:

CREATE INDEX idx_orders_user_id ON orders(user_id);

Запрос:

SELECT *
FROM orders
WHERE user_id = 10;

Без индекса БД может просканировать всю таблицу. С индексом она быстрее найдёт нужные строки. PostgreSQL прямо указывает, что индексы позволяют серверу быстрее находить строки и повышают производительность запросов.

2. Использовать составные индексы #

Если часто фильтруешь по нескольким полям:

SELECT *
FROM orders
WHERE user_id = 10 AND status = 'paid';

лучше создать составной индекс:

CREATE INDEX idx_orders_user_status
ON orders(user_id, status);

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

3. Не выбирать лишние колонки #

Плохо:

SELECT *
FROM users;

Лучше:

SELECT id, username, email
FROM users;

Чем меньше данных нужно прочитать и передать, тем быстрее работает запрос.

4. Использовать пагинацию #

Для больших таблиц нельзя без ограничения вытаскивать все записи:

SELECT *
FROM logs
ORDER BY created_at DESC
LIMIT 100;

Для глубоких страниц OFFSET может становиться дорогим:

LIMIT 100 OFFSET 100000;

Часто лучше использовать keyset pagination:

SELECT *
FROM logs
WHERE created_at < '2026-06-29 10:00:00'
ORDER BY created_at DESC
LIMIT 100;

Индекс:

CREATE INDEX idx_logs_created_at ON logs(created_at);

5. Проверять запрос через EXPLAIN / EXPLAIN ANALYZE #

Нельзя оптимизировать вслепую. Нужно смотреть реальный план выполнения:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 10;

EXPLAIN показывает, какой план выполнения выбрала БД, а EXPLAIN ANALYZE фактически выполняет запрос и показывает реальные времена выполнения.

Особенно важно смотреть:

Seq Scan
Index Scan
Index Only Scan
Bitmap Index Scan

Если на большой таблице виден Seq Scan, возможно, не хватает индекса или запрос написан так, что индекс не используется.

6. Использовать частичные индексы #

Если часто выбирается только часть таблицы, можно индексировать не всю таблицу, а только нужные строки.

Пример:

CREATE INDEX idx_orders_unpaid
ON orders(created_at)
WHERE status = 'unpaid';

Это полезно, когда, например, 95% заказов уже оплачены, а запросы часто ищут только неоплаченные. PostgreSQL описывает partial index как индекс только по подмножеству таблицы, заданному условием.

7. Использовать партиционирование #

Если таблица очень большая, например логи за несколько лет, её можно разделить на части:

logs_2024
logs_2025
logs_2026

Или декларативно:

PARTITION BY RANGE (created_at)

Тогда запрос за конкретный месяц или год сможет читать только нужные партиции, а не всю таблицу. PostgreSQL поддерживает разделение таблицы на партиции по выбранному ключу.

8. Следить за статистикой таблиц #

Оптимизатор выбирает план запроса на основе статистики. Если статистика устарела, БД может выбрать плохой план.

В PostgreSQL используют:

ANALYZE table_name;

или:

VACUUM ANALYZE table_name;

9. Избегать функций над индексируемыми колонками #

Плохо:

SELECT *
FROM users
WHERE LOWER(email) = 'test@mail.com';

Обычный индекс по email может не помочь, потому что применяется функция LOWER.

Варианты решения:

CREATE INDEX idx_users_lower_email
ON users(LOWER(email));

или хранить email в нормализованном виде.

10. Оптимизировать JOIN #

Для больших таблиц важно, чтобы поля соединения были индексированы:

SELECT *
FROM orders o
JOIN users u ON u.id = o.user_id;

Обычно нужны индексы:

users.id
orders.user_id

Первичный ключ обычно индексируется автоматически, но внешний ключ не всегда создаёт индекс автоматически — зависит от СУБД.

Кратко #

Эффективность выборки из большой таблицы повышают за счёт правильных индексов, анализа запроса через EXPLAIN ANALYZE, ограничения количества выбираемых данных, пагинации, оптимизации JOIN, использования составных и частичных индексов, партиционирования больших таблиц и актуальной статистики. Важно не просто добавлять индексы, а проверять, использует ли их база в реальном плане выполнения.


4. Виды JOIN. Как работает каждый JOIN? #

Оператор, позволяющий связать данные из двух таблиц. Бывают:

  • (INNER) JOIN - выдаст общее для левой и правой таблицы (может быть заменён WHERE)
  • LEFT (OUTER) JOIN - выдаст все значения с левой таблицы и все подходящие с правой
  • RIGHT (OUTER) JOIN - выдаст все значения с правой таблицы и все подходящие с левой
  • FULL (OUTER) JOIN - выдаст все записи, которые присутствуют в таблицах
  • CROSS JOIN - создаст декартово произведение двух таблиц (если в первой таблице 5 строк, а во второй 3, то вернет 5 x 3 = 15)
  • NATURAL JOIN - объединяет таблицы по столбцам с одинаковыми именами, если значения в этих столбцах совпадают
  • SELF JOIN - соединяет таблицу с самой собой


5. Нормализация и денормализация. Перечислите формы #

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

  • 1NF (1-я нормальная форма). Одна ячейка - одно значение.
  • 2NF (2-я нормальная форма). Все не ключевые колонки зависят от всего ключа.
  • 3NF (3-я нормальная форма). Все не ключевые колонки не зависят друг от друга.

Денормализация - процесс обратный нормализации. Обычно это необходимо для повышения производительности и скорости извлечения данных, за счет увеличения избыточности данных (влечёт уменьшение сложности запросов)


6. Индексы в SQL. Что это и для чего используются? #

Индексы в БД - это инструмент оптимизации/ускорения запросов путём создания упорядоченного набора указателей на строки таблицы. Ускорение происходит за счёт того, что структура индекса оптимизирована под поиск (например, b-tree). При этом, чем больше дубликатов в столбце - тем хуже работает индекс

В индексе ссылка на данные или в самом индексе кешируем данные? В индексе хранятся ссылки на реальные данные, которые находятся в таблице базы данных. Эти ссылки могут быть представлены в виде:

  • указателей на строки в таблице, где хранятся данные
  • идентификаторов записей, которые позволяют найти данные в таблице

В Postgres в индексе хранятся ссылки на реальные данные, которые находятся в таблице базы данных. Например, в B-tree:

  • промежуточные узлы дерева содержат только ключи для направления поиска
  • в листьях дерева хранятся не сами данные, а ссылки на данные в таблице (или другие индексы)

Однако, можно создать покрывающий индекс - индекс, который включает не только ключи, но и значение колонки(ок). Такие индексы позволяют извлекать данные не обращаясь к таблице. Пример:

CREATE INDEX idx_price ON products(price) INCLUDE (name);
-- Теперь индекс хранит значения price + колонку name, что позволяет выполнить запрос
SELECT price, name FROM products WHERE price > 50;
-- не обращаясь к таблице

В MySQL (InnoDB) поведение индексов отличается в зависимости от их типа:

  1. PRIMARY KEY (кластерный индекс) - индекс содержит сами данные
  2. Обычный (SECONDARY INDEX) индекс хранит ссылки (ключи PRIMARY KEY) на данные

Какие индексы по умолчанию в разных СУБД? По-умолчанию в большинстве CУБД стоит B-tree

Сравнение сложности поиска

Тип поискаСтруктураСложностьКогда применять?
Без индексаПолный переборO(N)Маленькие таблицы
B-Tree индексСбалансированное деревоO(log N)Частый поиск по диапазонам
Hash-индексХеш-таблицаO(1)Быстрый поиск по =

Как добавить новый индекс в большую, активно используемую таблицу в продакшене, не блокируя доступ и не приостанавливая работу приложения, и какие стратегии можно применить для минимизации влияния на систему?

  • PostgreSQLCREATE INDEX CONCURRENTLY / pg_repack / gh-ost
  • MySQLALTER TABLE … ALGORITHM=INPLACE, LOCK=NONE / pt-online-schema-change

Как определить, какой индекс будет использован в запросе с фильтрацией по столбцу с низкой селективностью (например, пол), и как селективность влияет на эффективность индексов?

  1. EXPLAIN ANALYZE покажет, будет ли Index Scan или Seq Scan
  2. pg_stats.n_distinct даёт оценку числа уникальных значений


7. Какие основные типы/виды индексов существуют и в каких случаях они применяются? #

Тип индексаОписаниеИспользование
B-дерево (B-tree)Стандартный индекс, используемый в большинстве СУБД. Данные хранятся в сбалансированном деревеДля поиска, сортировки, диапазонных запросов
Хеш-индекс (Hash Index)Использует хеш-функцию для поиска точных совпаденийДля быстрого поиска по уникальным значениям (например, ID)
Индекс с несколькими столбцами (Composite Index)Индекс, включающий несколько столбцов. Ускоряет запросы, использующие несколько фильтровКогда запросы фильтруют по нескольким столбцам одновременно
Индекс с уникальными значениями (Unique Index)Индекс, обеспечивающий уникальность значений в столбцеДля обеспечения уникальности значений (например, email)
Индекс полного текста (Full-Text Index)Индекс для быстрого поиска по тексту, включая ключевые слова и фразыПоиск по длинным текстовым полям, например, в статьях или документах
Индекс обратного списка (Bitmap Index)Индекс с битовыми картами для колонок с малым числом уникальных значенийДля столбцов с ограниченным числом уникальных значений, например, пол
Инкрементный (Clustered Index)Индекс, при котором данные таблицы физически упорядочены в соответствии с индексомКогда необходимо хранить данные в порядке индекса, например, по ID
Индекс по выражению (Functional Index)Индекс, основанный на вычисленных значениях или функциях над столбцамиДля запросов, использующих функции или выражения в фильтрах
Индекс на основе дерева (GiST — Generalized Search Tree)Общий тип дерева, используемый для индексации сложных типов данныхДля индексации географических данных или других сложных типов
Индекс на основе префикса (Prefix Index)Индекс, созданный на части строки, например, на первых символахДля текстовых полей, где важно индексировать только часть строки (например, первые несколько символов)
GIN (Generalized Inverted Index)Инвертированная структура: GIN создает карту, связывающую значения (ключи) с идентификаторами строк (TID), в которых эти значения встречаются. B-tree под капотомИдеален дляJSONB (операторы @>, ?), ARRAY (операторы <@, @>, &&)
Покрывающий индекс (Covering Index)Схож с композитным, но INCLUDE-колонки не участвуют в поиске/фильтрацииCREATE INDEX idx_users_coverON users(email)INCLUDE(name, age)Для определённого набора часто запрашиваемых полей, не участвующих в фильтрации
Тип индексаКогда использоватьГде доступен
B-TreeУниверсальный, для =<>ORDER BYВезде
HashТолько = (поиск по точному значению)PostgreSQL, MySQL MEMORY
BitmapКолонка с малым числом уникальных значений (status)Oracle, Postgres ext
Full-text/GIN/GiSTПоиск по тексту, JSON, массивам, гео-даннымPostgreSQL, MySQL
ClusteredPrimary key, диапазоныSQL Server, MySQL InnoDB
Non-clusteredДополнительные индексыSQL Server, MySQL
PartialИндекс только по условию WHEREPostgreSQL, SQL Server


8. В каких случаях целесообразно использовать hash-индекс в базе данных? #

Когда целесообразно использовать hash-индекс #

Hash-индекс имеет смысл использовать, когда запросы ищут строки только по точному совпадению значения.

Типичный пример:

SELECT *
FROM users
WHERE email = 'test@example.com';

Или:

SELECT *
FROM sessions
WHERE token = 'abc123';

В PostgreSQL hash-индексы хранят хеш-код значения и могут использоваться только для простых сравнений на равенство через оператор =.

Хорошие случаи для hash-индекса #

Hash-индекс может быть уместен, если:

  1. Запросы почти всегда используют =
WHERE user_id = 100
WHERE email = 'a@mail.com'
WHERE token = '...'
WHERE uuid = '...'
  1. Не нужны диапазонные условия

Hash-индекс не подходит для:

WHERE created_at > '2026-01-01'
WHERE price BETWEEN 100 AND 500
WHERE id >= 1000

Для таких запросов обычно нужен B-tree индекс.

  1. Не нужна сортировка по этому индексу

Hash-индекс не помогает для:

ORDER BY created_at

Потому что hash-индекс не хранит данные в отсортированном порядке.

  1. Колонка имеет высокую селективность

Хорошие кандидаты:

id
uuid
email
username
token
session_id
api_key

Плохие кандидаты:

status
is_active
gender
type

Например, если в колонке status всего 3 значения, hash-индекс может быть бесполезен: база всё равно найдёт слишком много строк.

Пример в PostgreSQL #

CREATE INDEX idx_users_email_hash
ON users USING hash (email);

Запрос:

SELECT *
FROM users
WHERE email = 'test@example.com';

Но важно: в PostgreSQL обычный B-tree индекс тоже умеет работать с =, а также дополнительно поддерживает диапазоны, сортировку и больше сценариев. Поэтому hash-индекс не является универсальной заменой B-tree. PostgreSQL указывает, что B-tree поддерживает операторы <, <=, =, >=, >, а hash-индекс — только equality-сравнения.

Когда hash-индекс не подходит #

Hash-индекс не стоит использовать, если запросы выглядят так:

WHERE age > 18
WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'
ORDER BY created_at DESC
WHERE name LIKE 'Alex%'
WHERE status IN ('new', 'pending')

Для этих случаев чаще подходит B-tree индекс.

В MySQL #

В MySQL hash-индексы особенно связаны с MEMORY storage engine. Документация MySQL указывает, что hash-индексы используются только для equality-сравнений через = или <=>, но не подходят для range-поиска.

То есть хорошо:

WHERE id = 10

Плохо:

WHERE id > 10

Кратко #

Hash-индекс целесообразно использовать для точечного поиска по точному совпадению, когда запросы используют только = по высокоселективной колонке: id, uuid, email, token, session_id. Он не подходит для диапазонов, сортировки, ORDER BY, LIKE 'prefix%' и условий больше/меньше. На практике чаще сначала используют B-tree индекс, а hash-индекс рассматривают только после проверки через EXPLAIN ANALYZE, если workload действительно состоит из equality-запросов.


9. Как организован B-tree индекс и по какому принципу он работает? #

Что такое B-tree индекс #

B-tree индекс — это сбалансированное многопутевое дерево, где ключи хранятся в отсортированном порядке. “Многопутевое” означает, что один узел содержит не один ключ, как в бинарном дереве, а много ключей и много ссылок на дочерние узлы. PostgreSQL прямо описывает B-tree как стандартную структуру “multi-way balanced tree” для данных, которые можно отсортировать в линейный порядок.

Упрощённо структура такая:

                 [ 30 | 60 ]
                /     |      \
        < 30         30-60        > 60

       [10|20]     [35|45|55]    [70|80|90]

Корневой узел говорит:
ключи меньше 30 ищи слева, от 30 до 60 — посередине, больше 60 — справа.

Как он организован #

B-tree индекс состоит из нескольких уровней:

root node
  └── internal nodes
        └── leaf nodes

Внутренние узлы хранят ключи-разделители и ссылки на следующие узлы. Листовые узлы хранят реальные индексные записи: значение ключа и указатель на строку таблицы либо на первичный ключ, в зависимости от СУБД и типа индекса. Например, в InnoDB первичный ключ является clustered index, то есть данные строки хранятся вместе с кластерным индексом; вторичные индексы содержат свои ключи и значения первичного ключа строки.

Главная идея: индекс не хранит строки “как попало”. Он хранит ключи в отсортированной древовидной структуре, чтобы СУБД не читала всю таблицу подряд.

Как выполняется поиск #

Допустим, есть индекс по колонке age, и запрос:

SELECT *
FROM users
WHERE age = 35;

СУБД идёт сверху вниз:

  1. Смотрит в корневой узел.

  2. Определяет, в какой диапазон попадает 35.

  3. Переходит в нужный дочерний узел.

  4. Повторяет процесс, пока не дойдёт до листа.

  5. В листе находит 35.

  6. По ссылке из индекса получает нужную строку таблицы.

То есть вместо полного сканирования таблицы:

проверь строку 1
проверь строку 2
проверь строку 3
...
проверь строку 10 000 000

СУБД делает короткий путь по дереву:

root → internal node → leaf node → row

Высота B-tree обычно небольшая, потому что в каждом узле хранится много ключей. Поэтому даже для миллионов строк путь может состоять из нескольких переходов.

Почему B-tree хорошо подходит для диапазонов #

B-tree сохраняет порядок ключей. Поэтому он хорошо работает не только с =, но и с диапазонными условиями:

WHERE age > 30
WHERE age BETWEEN 20 AND 40
WHERE created_at >= '2026-01-01'
ORDER BY created_at

PostgreSQL использует B-tree для сравнений <, <=, =, >=, >, а также может использовать такой индекс для получения данных в отсортированном порядке. ( PostgreSQL) MySQL также указывает, что B-tree индекс применим для =, >, >=, <, <=, BETWEEN и некоторых LIKE 'prefix%' условий.

Например:

SELECT *
FROM orders
WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31';

СУБД сначала быстро находит начало диапазона в дереве, а потом последовательно проходит по соседним ключам, пока диапазон не закончится.

Как происходит вставка #

При вставке новой строки СУБД должна добавить новый ключ в индекс.

Например, вставляется age = 42.

  1. СУБД ищет листовой узел, куда должен попасть ключ 42.

  2. Если в узле есть место — просто вставляет ключ в правильную позицию.

  3. Если узел переполнен — он делится на два узла.

  4. Средний ключ поднимается выше, чтобы родительский узел знал о новом разделении.

  5. Если переполнен родитель — процесс может подняться выше, вплоть до корня.

За счёт этого дерево остаётся сбалансированным: путь от корня до любого листа примерно одинаковый. Это важно, потому что поиск не деградирует в длинный линейный проход.

Почему индекс ускоряет чтение, но замедляет запись #

B-tree ускоряет SELECT, потому что позволяет быстро найти нужные строки. Но при INSERT, UPDATE, DELETE СУБД должна обновлять не только таблицу, но и все связанные индексы. PostgreSQL отдельно подчёркивает, что индексы ускоряют поиск, но добавляют накладные расходы, поэтому их нужно использовать разумно.

Например, если есть таблица:

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_age ON users(age);
CREATE INDEX idx_users_created_at ON users(created_at);

То при вставке пользователя СУБД обновит:

таблицу users
индекс по email
индекс по age
индекс по created_at

Чем больше индексов, тем тяжелее операции записи.

Главное #

B-tree индекс — это отсортированное сбалансированное дерево, которое позволяет быстро сузить область поиска. Он особенно полезен для:

WHERE column = value
WHERE column > value
WHERE column BETWEEN a AND b
ORDER BY column
LIKE 'abc%'

Но он хуже помогает, когда условие не использует начало индексируемого значения, например:

LIKE '%abc'

В таком случае порядок B-tree почти бесполезен, потому что неизвестно, с какого места дерева начинать поиск.


10. Что хранится в узлах B-дерева и как по нему выполняется поиск? #

Что хранится в узлах B-дерева #

В B-tree/B+tree индексе узел — это страница индекса. В PostgreSQL B-tree индекс состоит из внутренних страниц и листовых страниц; листовые страницы находятся на нижнем уровне дерева, а все уровни выше — внутренние. В листовых страницах PostgreSQL хранятся индексные кортежи, которые указывают на строки таблицы.

Упрощённо:

B-tree индекс

                 [ 30 | 60 ]
                /     |      \
        [10|20]   [35|45|55]   [70|80|90]

Внутренний узел хранит:

ключи-разделители
ссылки на дочерние узлы

Например:

[ 30 | 60 ]

означает:

меньше 30        → идти в левый дочерний узел
от 30 до 60      → идти в средний дочерний узел
больше 60        → идти в правый дочерний узел

Листовой узел хранит уже сами индексные записи:

значение индексируемого ключа
указатель на строку таблицы / идентификатор строки

Например, для индекса по email лист может логически выглядеть так:

["a@mail.com"] → ссылка на строку #101
["b@mail.com"] → ссылка на строку #205
["c@mail.com"] → ссылка на строку #317

В реальной СУБД детали зависят от движка. Например, в MySQL InnoDB вторичные индексы хранят ключ вторичного индекса и значение первичного ключа, по которому затем можно найти строку в clustered index.

Как выполняется поиск #

Допустим, есть индекс:

CREATE INDEX idx_users_age ON users(age);

И запрос:

SELECT *
FROM users
WHERE age = 45;

СУБД не перебирает всю таблицу. Она идёт по дереву сверху вниз.

                 [ 30 | 60 ]
                /     |      \
        [10|20]   [35|45|55]   [70|80|90]

Поиск 45:

1. Смотрим корневой узел: [30 | 60]
2. 45 больше 30, но меньше 60
3. Значит, идём в средний дочерний узел
4. Попадаем в лист: [35 | 45 | 55]
5. Находим ключ 45
6. По ссылке из индекса получаем строку таблицы

То есть путь такой:

root node → internal node → leaf node → table row

Если дерево выше, процесс тот же самый: на каждом уровне СУБД сравнивает искомое значение с ключами-разделителями и выбирает нужную ветку.

Почему поиск быстрый #

B-tree не бинарное дерево. Один узел может содержать много ключей и ссылок, поэтому у дерева маленькая высота.

Условно, вместо такого поиска:

проверить 1-ю строку
проверить 2-ю строку
проверить 3-ю строку
...
проверить миллионную строку

получается такой:

прочитать корень
прочитать нужный внутренний узел
прочитать нужный лист
найти ссылку на строку

Поэтому B-tree хорошо подходит для точечного поиска:

WHERE id = 100
WHERE email = 'test@mail.com'

и для диапазонов:

WHERE age BETWEEN 20 AND 40
WHERE created_at >= '2026-01-01'

MySQL указывает, что B-tree индекс может использоваться для сравнений =, >, >=, <, <=, BETWEEN, а также для LIKE, если шаблон не начинается с wildcard, например LIKE 'abc%'. ( MySQL)

Как выполняется диапазонный поиск #

Для диапазона принцип немного другой.

Запрос:

SELECT *
FROM users
WHERE age BETWEEN 30 AND 50;

СУБД:

1. Через B-tree быстро находит первое значение >= 30
2. Попадает в нужный листовой узел
3. Дальше идёт по соседним индексным записям в отсортированном порядке
4. Останавливается, когда значение стало больше 50

Именно поэтому B-tree полезен для ORDER BY, BETWEEN, <, >, >=, <=: данные внутри индекса упорядочены.

Коротко #

В узлах B-дерева хранятся не сами “объекты” таблицы, а индексная структура:

внутренние узлы:
  ключи-разделители + ссылки на дочерние страницы

листовые узлы:
  индексируемые значения + ссылки на строки таблицы

Поиск выполняется так:

сравнить ключ в корне
выбрать нужную ветку
спуститься ниже
дойти до листа
найти ключ
получить строку таблицы

Главный смысл B-tree индекса — быстро сузить область поиска за счёт отсортированной и сбалансированной структуры.


11. Недостатки индексов | В каких ситуациях использование индексов в базе данных снижает эффективность системы или приводит к ухудшению производительности? | Почему много индексов замедляет INSERT, UPDATE, DELETE? #

  • Усложнение работы планировщика - СУБД может не выбрать лучший индекс для запроса, если их слишком много. Некоторые индексы могут быть избыточными, что не только не ускоряет запросы, но и увеличивает нагрузку на систему
  • Утилизация места на жёстком диске - индексы занимают дополнительное место на диске пропорционально объёму данных и кол-ву выбранных столбцов
  • Увеличение времени записи - при вставке, обновлении или удалении данных индексы нужно обновлять, что замедляет операции записи
  • Усложнение управления - управление большим количеством индексов становится сложным, включая их отслеживание и обслуживании


12. Что такое SQL-инъекция? #

Что такое SQL-инъекция #

SQL-инъекция — это уязвимость, при которой пользовательский ввод попадает внутрь SQL-запроса как часть команды, а не как обычное значение.

Иными словами: приложение ожидало данные, но из-за неправильной сборки SQL-запроса пользователь может передать кусок SQL-кода, который база данных выполнит. OWASP относит SQL Injection к распространённым и опасным уязвимостям, потому что база часто содержит чувствительные данные.

Простой пример плохого кода:

username = request.GET["username"]

sql = f"SELECT * FROM users WHERE username = '{username}'"

Ожидался обычный ввод:

alex

Тогда получится запрос:

SELECT * FROM users WHERE username = 'alex'

Но атакующий может передать:

' OR '1'='1

И итоговый запрос станет:

SELECT * FROM users WHERE username = '' OR '1'='1'

Условие '1'='1' всегда истинно, поэтому база может вернуть не одного пользователя, а все подходящие строки.

В чём суть проблемы #

Проблема не в самой базе данных, а в том, что приложение смешивает:

данные пользователя
SQL-команду

То есть пользовательский ввод становится частью SQL-синтаксиса.

Плохо:

SELECT * FROM users WHERE username = '<ввод пользователя>'

Потому что ввод пользователя напрямую встраивается в запрос.

Правильно — передавать значение отдельно от SQL-команды:

cursor.execute(
    "SELECT * FROM users WHERE username = %s",
    [username]
)

В этом случае база понимает: username — это значение, а не SQL-код. OWASP прямо указывает parameterized queries / prepared statements как основной способ защиты от SQL-инъекций.

Что может сделать SQL-инъекция #

В зависимости от прав приложения и типа запроса SQL-инъекция может позволить:

прочитать чужие данные
обойти авторизацию
изменить данные
удалить данные
получить структуру таблиц
в некоторых случаях выполнить более опасные действия через возможности СУБД

PortSwigger описывает SQL injection как уязвимость, позволяющую вмешиваться в запросы приложения к базе; часто это даёт доступ к данным, которые пользователь не должен видеть, а иногда позволяет изменять или удалять данные.

Пример с авторизацией #

Плохой вариант:

sql = f"""
SELECT *
FROM users
WHERE username = '{username}'
AND password = '{password}'
"""

Обычный запрос:

SELECT *
FROM users
WHERE username = 'admin'
AND password = '12345'

Атакующий может ввести в поле username:

admin' --

Тогда запрос станет примерно таким:

SELECT *
FROM users
WHERE username = 'admin' --'
AND password = 'anything'

-- в SQL означает комментарий. Всё после него игнорируется. В итоге проверка пароля может быть выключена.

Почему экранирования недостаточно #

Иногда пытаются защищаться так: удалить кавычки, заменить спецсимволы, запретить слова SELECT, DROP, OR.

Это слабый подход, потому что:

у разных СУБД разные правила синтаксиса
можно обойти фильтр альтернативной записью
можно ошибиться в обработке кодировок
легко забыть обработать одно из мест

OWASP указывает, что allow-list валидация может быть полезна, но параметризованные SQL-запросы обычно требуют меньше сопровождения и дают более надёжные гарантии безопасности.

Как выглядит правильная защита #

Основной способ — параметризованные запросы:

cursor.execute(
    "SELECT * FROM users WHERE username = %s AND password_hash = %s",
    [username, password_hash]
)

Или через ORM:

User.objects.get(username=username)

ORM обычно сам формирует параметризованный запрос, если не использовать опасные ручные вставки SQL.

Также важно:

не собирать SQL через f-string / конкатенацию
использовать prepared statements / bind parameters
ограничивать права пользователя БД
валидировать входные данные
не показывать пользователю подробные SQL-ошибки
логировать подозрительные запросы

Коротко #

SQL-инъекция — это когда ввод пользователя попадает в SQL-запрос как исполняемый код.

Плохая идея:

sql = f"SELECT * FROM users WHERE id = {user_id}"

Нормальная идея:

cursor.execute(
    "SELECT * FROM users WHERE id = %s",
    [user_id]
)

Главное правило: SQL-команда и пользовательские данные должны передаваться отдельно.


13. Как защититься от SQL-инъекций? #

Главная защита — параметризованные запросы #

Нельзя собирать SQL через строки:

username = request.GET["username"]

sql = f"SELECT * FROM users WHERE username = '{username}'"

Так пользовательский ввод становится частью SQL-кода.

Правильно — передавать SQL и значения отдельно:

cursor.execute(
    "SELECT * FROM users WHERE username = %s",
    [username]
)

В этом случае база воспринимает username как значение, а не как SQL-команду. OWASP называет prepared statements / parameterized queries основным способом защиты: SQL-код задаётся отдельно, а пользовательские параметры передаются позже как данные.

Что делать на практике #

  1. Использовать ORM вместо ручного SQL, когда это возможно.

Например, в Django:

User.objects.get(username=username)

Вместо:

User.objects.raw(
    f"SELECT * FROM users WHERE username = '{username}'"
)

Django прямо рекомендует сначала использовать ORM, а при raw SQL обязательно передавать пользовательские значения через params, чтобы защищаться от SQL-инъекций.

  1. Если нужен raw SQL — использовать параметры.

Django:

User.objects.raw(
    "SELECT * FROM users WHERE username = %s",
    [username]
)

psycopg:

cur.execute(
    "SELECT * FROM users WHERE username = %s",
    (username,)
)

В psycopg параметры передаются вторым аргументом в execute(). Документация отдельно предупреждает не склеивать значения с SQL-строкой вручную.

SQLAlchemy:

from sqlalchemy import text

stmt = text("SELECT * FROM users WHERE id = :user_id")
result = session.execute(stmt, {"user_id": user_id})

В SQLAlchemy для текстового SQL есть text() с bind parameters через :name; документация также предупреждает не подставлять недоверенный ввод прямо внутрь SQL-строки.

Чего нельзя делать #

Опасно:

sql = "SELECT * FROM users WHERE id = " + user_id

Опасно:

sql = f"SELECT * FROM users WHERE email = '{email}'"

Опасно:

sql = "SELECT * FROM users WHERE name = '%s'" % name

Даже если кажется, что значение безопасное. Например, id пришёл как число, но из HTTP-запроса всё изначально приходит как внешние данные.

Правильно:

cursor.execute(
    "SELECT * FROM users WHERE id = %s",
    [user_id]
)

Валидация не заменяет параметризацию #

Валидация нужна, но она не должна быть основной защитой от SQL-инъекции.

Например, для id можно проверять:

user_id = int(user_id)

Для сортировки можно использовать allow-list:

allowed_sort_fields = {
    "created_at": "created_at",
    "username": "username",
    "email": "email",
}

sort_by = allowed_sort_fields.get(request.GET.get("sort"))

Но это дополнение. Главное — не вставлять пользовательские значения прямо в SQL.

Особенно важно: параметры защищают значения, но не подходят для подстановки имён таблиц, колонок, направлений сортировки и SQL-операторов.

Например, так делать нельзя:

cursor.execute(
    "SELECT * FROM users ORDER BY %s",
    [sort_by]
)

Для таких частей запроса лучше использовать allow-list:

allowed_sort = {
    "name": "username",
    "date": "created_at",
}

column = allowed_sort.get(sort_by, "created_at")

sql = f"SELECT * FROM users ORDER BY {column}"

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

Ограничить права пользователя БД #

Даже если SQL-инъекция где-то появится, ущерб можно уменьшить правами.

Не стоит подключать приложение к БД под суперпользователем.

Для обычного backend-приложения лучше отдельный пользователь БД с минимальными правами:

GRANT SELECT, INSERT, UPDATE, DELETE ON users TO app_user;

А не:

GRANT ALL PRIVILEGES ON DATABASE main_db TO app_user;

И тем более не использовать postgres, root или владельца всей БД в приложении.

Не показывать SQL-ошибки наружу #

Плохо, когда пользователь видит:

syntax error at or near ...
relation users does not exist
column password_hash does not exist

Такие ошибки помогают понять структуру базы.

Нормально:

{
  "detail": "Invalid request"
}

А подробности должны уходить в логи, доступные только разработчикам.

Проверять опасные места в коде #

Искать нужно всё, где SQL собирается вручную:

f"SELECT ..."
"SELECT ..." + value
"SELECT ... %s" % value
.format(...)
raw()
RawSQL()
extra()
text()
execute()

ORM-запросы обычно безопаснее, но raw SQL, кастомные выражения и динамические ORDER BY, WHERE, LIMIT, имена таблиц и колонок нужно проверять отдельно.

Коротко #

Нормальный набор защиты:

1. Не собирать SQL через f-string, +, %, .format().
2. Использовать ORM.
3. Для raw SQL — только параметризованные запросы.
4. Для имён колонок/таблиц/sort — allow-list.
5. Валидировать входные данные.
6. Ограничивать права пользователя БД.
7. Не показывать SQL-ошибки пользователю.
8. Логировать подозрительные запросы и ошибки.

Главное правило: пользовательский ввод никогда не должен становиться частью SQL-синтаксиса. Он должен передаваться отдельно как значение.


14. Что такое транзакция в базе данных и какими свойствами ACID она обладает? #

Транзакция - это набор логически связанных запросов, выполняемых атомарно (либо выполняются все - commit транзакции, либо откат всего - rollback)


15. Расскажите про ACID #

ACID - это набор свойств, гарантирующих надёжность транзакции:

  • Atomicity (атомарность/неделимость операций) - все операции внутри транзакции либо выполняются полностью, либо не выполняются совсем
  • Consistency (согласованность данных) - после транзакции данные соблюдают все правила целостности (правила целостности определяются бизнес-логикой)
  • Isolation (изоляция) - во время выполнения транзакции параллельные транзакции не должны оказывать влияние на ее результат
  • Durability (долговечность/живучесть) - гарантирует, что закоммиченные изменения не могут быть потеряны ни при каких сбоях (поломка сервера, отключение электроэнергии и т.д.)


16. Какими инструментами вы пользовались для анализа плана выполнения (Профилирования) запроса (EXPLAIN/EXPLAIN ANALYZE)? #

  • EXPLAIN - показывает теоретический план выполнения запроса, что позволяет увидеть, как СУБД планирует извлекать данные
  • EXPLAIN ANALYZE - выполняет запрос и показывает реальный план выполнения, включая затраченные ресурсы (время, память)
  • Indexes - индексы ускоряют доступ к данным, особенно при использовании в условиях WHERE
  • pg_stat_statements (для PostgreSQL) - модуль для сбора статистики выполнения запросов

SQL-запрос у тебя в норме, но выполнение всё равно долгое. Твои действия?

  • Оптимизация плана выполнения - воспользоваться EXPLAIN и EXPLAIN ANALYZE для анализа плана выполнения запроса и поиска проблемных мест
  • Использование индексов - проверить, правильно ли используются существующие индексы. При необходимости создать или изменить текущие
  • Рефакторинг запроса - изменить структуру запроса, если есть более эффективный способ получить те же данные. Например, объединение нескольких подзапросов или использование более простых JOIN
  • Ресурсные ограничения - проверить отсутствие ограничений по железу (нехватка CPU, памяти, диска)


17. Какую информацию о выполнении SQL-запроса позволяет получить EXPLAIN/EXPLAIN ANALYZE? #

EXPLAIN и EXPLAIN ANALYZE показывают, как СУБД планирует выполнить SQL-запрос и где могут быть узкие места.

Главное отличие #

EXPLAIN показывает предполагаемый план выполнения, но сам запрос не выполняет.

EXPLAIN ANALYZE реально выполняет запрос и показывает не только план, но и фактические метрики: реальное время, реальное количество строк, количество повторов узлов плана и т.д. В PostgreSQL EXPLAIN ANALYZE выполняет запрос полностью, поэтому для INSERT, UPDATE, DELETE, MERGE изменения действительно произойдут, если не обернуть запрос в транзакцию и не сделать ROLLBACK.

Какой план выполнения выбран #

Можно увидеть, какие операции использует база:

Seq Scan
Index Scan
Index Only Scan
Bitmap Index Scan
Bitmap Heap Scan
Nested Loop
Hash Join
Merge Join
Sort
Aggregate
Limit

Например, по плану видно, читает ли PostgreSQL всю таблицу через Seq Scan или использует индекс через Index Scan. План представлен как дерево узлов: нижние узлы обычно читают данные из таблиц или индексов, а верхние узлы выполняют join, sort, aggregate и другие операции.

Оценочная стоимость выполнения #

В обычном EXPLAIN выводятся оценки планировщика:

(cost=0.00..445.00 rows=10000 width=244)

Где:

cost=0.00..445.00 — примерная стоимость выполнения узла плана. Это не миллисекунды, а внутренние условные единицы PostgreSQL.

rows=10000 — сколько строк планировщик ожидает получить из этого узла.

width=244 — средний ожидаемый размер строки в байтах.

Важно: cost — это не реальное время. Это оценка, по которой планировщик сравнивает разные варианты выполнения запроса.

Реальное время выполнения #

В EXPLAIN ANALYZE появляются фактические данные:

(actual time=0.017..0.051 rows=10 loops=1)

Где:

actual time=0.017..0.051 — реальное время начала и завершения работы узла, в миллисекундах.

rows=10 — сколько строк реально вернул этот узел.

loops=1 — сколько раз этот узел был выполнен.

Если loops больше 1, например при Nested Loop, то actual time и rows показываются как средние значения за один запуск узла. Чтобы примерно понять общее время узла, нужно умножать на loops. ( PostgreSQL)

Насколько планировщик ошибся #

Одна из самых важных вещей — сравнить:

rows=100

из оценки планировщика

с

actual rows=10000

из реального выполнения.

Если планировщик ожидал 100 строк, а реально получил 10000, это может означать:

данные плохо проанализированы;

устарела статистика;

нужен ANALYZE;

условие плохо оценивается;

индекс не помогает так, как ожидалось;

запрос написан неоптимально.

PostgreSQL прямо указывает, что при EXPLAIN ANALYZE особенно важно смотреть, насколько оценочное количество строк близко к реальному.

Используются ли индексы #

По плану видно:

используется ли индекс;

какой именно индекс используется;

применяется ли условие как Index Cond;

какие условия применяются уже после чтения строк через Filter;

сколько строк было отброшено фильтром.

Пример:

Index Cond: (unique1 < 100)
Filter: (stringu1 = 'xxx')
Rows Removed by Filter: 3000

Index Cond означает, что условие помогло при поиске по индексу.

Filter означает, что строки сначала были прочитаны, а потом отфильтрованы.

Rows Removed by Filter показывает, сколько строк было отброшено после чтения.

Затраты на сортировку, хеширование и память #

Для некоторых операций PostgreSQL показывает дополнительные детали:

Sort Method: quicksort  Memory: 74kB
Hash Cond: ...
Buckets: 1024  Batches: 1  Memory Usage: 35kB

Это помогает понять:

сортировка была в памяти или ушла на диск;

сколько памяти заняла сортировка;

как строился hash join;

не было ли слишком большого hash/sort этапа.

Буферы и I/O #

С опцией BUFFERS, а в актуальных версиях PostgreSQL при ANALYZE она может включаться автоматически, можно увидеть работу с буферами:

Buffers: shared hit=36 read=6

Это показывает:

hit — данные были найдены в shared buffers, то есть уже в памяти PostgreSQL;

read — данные пришлось читать с диска/ОС;

dirtied — страницы были изменены;

written — страницы были записаны.

Это полезно, когда запрос медленный не из-за CPU, а из-за чтения большого объёма данных.

Время планирования и выполнения #

В конце EXPLAIN ANALYZE обычно есть:

Planning Time: 0.485 ms
Execution Time: 0.073 ms

Planning Time — сколько времени ушло на построение плана.

Execution Time — сколько времени ушло на выполнение запроса.

Иногда сам запрос быстрый, но планирование сложное. Например, при большом количестве join, партиций или сложных условий.

Что можно диагностировать через EXPLAIN/EXPLAIN ANALYZE #

С помощью этих команд можно понять:

почему запрос медленный;

используется ли индекс;

почему база выбрала Seq Scan;

какой join используется: Nested Loop, Hash Join, Merge Join;

сколько строк реально проходит через каждый этап;

где планировщик ошибается в оценках;

есть ли лишняя сортировка;

есть ли тяжелая агрегация;

читает ли запрос много данных с диска;

какой узел плана занимает больше всего времени;

насколько LIMIT, ORDER BY, WHERE, JOIN, GROUP BY влияют на выполнение.

Пример чтения #

EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'test@example.com';

Возможный результат:

Seq Scan on users
  (cost=0.00..1200.00 rows=1 width=80)
  (actual time=0.030..45.000 rows=1 loops=1)
  Filter: (email = 'test@example.com')
  Rows Removed by Filter: 999999
Execution Time: 45.100 ms

Это значит: PostgreSQL просканировал таблицу целиком, нашёл 1 строку, но отбросил почти миллион строк. Вероятная проблема — нет индекса по email.

Лучше было бы увидеть:

Index Scan using users_email_idx on users
  Index Cond: (email = 'test@example.com')

Тогда база ищет строку через индекс, а не перебирает всю таблицу.

Итог #

EXPLAIN показывает что PostgreSQL собирается сделать.

EXPLAIN ANALYZE показывает что PostgreSQL реально сделал.

Самое важное в выводе:

Seq Scan / Index Scan — как читаются данные;

cost — оценка планировщика;

rows — ожидаемые строки;

actual rows — реальные строки;

actual time — реальное время;

loops — сколько раз выполнялся узел;

Filter / Index Cond — где применяются условия;

Rows Removed by Filter — сколько строк зря прочитано;

Buffers — работа с памятью и диском;

Planning Time / Execution Time — время планирования и выполнения.


18. EXPLAIN vs EXPLAIN ANALYZE #

EXPLAIN показывает план выполнения запроса до его фактического выполнения. Он выводит, как СУБД собирается получить данные. Это включает:

  • какой тип сканирования будет применяться (Seq Scan, Index Scan)
  • сортировки и объединения (Nested Loop, Hash Join)
  • предполагаемое количество строк и стоимость выполнения на каждом этапе

Explain analyze. Как устроен? На что обращать внимание? EXPLAIN ANALYZE не только показывает план выполнения, но и выполняет запрос, выводя фактическое время выполнения каждой операции. Полезные моменты:

  • фактическое время (Actual Time) - сколько времени заняла операция
  • ROWS - реальное количество строк, прошедших через каждый этап
  • LOOPS - количество раз, когда этот шаг был выполнен (важно для вложенных циклов)
EXPLAIN ANALYZE SELECT * FROM employees WHERE salary > 100000;


19. Что такое сбор статистики в PostgreSQL? | Для чего нужен ANALYZE в PostgreSQL? #

Что такое сбор статистики в PostgreSQL #

В контексте ANALYZE сбор статистики — это процесс, при котором PostgreSQL изучает содержимое таблицы и собирает примерные сведения о данных. Эти сведения потом использует планировщик запросов, чтобы выбрать более выгодный план выполнения. ANALYZE сохраняет результат в системном каталоге pg_statistic; официальная документация PostgreSQL прямо указывает, что эти данные использует query planner.

Важно: это не статистика “сколько запросов было выполнено”. Это статистика о содержимом таблиц и колонок.

Например, PostgreSQL собирает информацию примерно такого типа:

сколько строк в таблице
сколько страниц занимает таблица
какие значения встречаются чаще всего
примерное распределение значений
сколько уникальных значений в колонке
сколько NULL-значений
корреляция физического порядка строк и порядка значений

Эти данные нужны не пользователю напрямую, а оптимизатору.

Для чего нужен ANALYZE #

ANALYZE нужен, чтобы PostgreSQL понимал, какие данные лежат в таблице, и мог оценивать стоимость разных вариантов выполнения запроса.

Например, есть запрос:

SELECT *
FROM users
WHERE city = 'Baku';

PostgreSQL должен решить, что выгоднее:

прочитать всю таблицу последовательно
или использовать индекс по city

Для этого ему нужно примерно понимать:

сколько всего строк в users
сколько строк имеют city = 'Baku'
насколько избирательное это условие

Если city = 'Baku' встречается у 1% строк — индекс может быть выгоден.

Если city = 'Baku' встречается у 80% строк — индекс может быть хуже, потому что придётся сходить по индексу и потом всё равно прочитать большую часть таблицы.

Пример #

Есть таблица:

CREATE TABLE orders (
    id bigserial PRIMARY KEY,
    status text,
    created_at timestamp
);

CREATE INDEX idx_orders_status ON orders(status);

В таблице 10 миллионов заказов.

Распределение такое:

paid      — 9 000 000 строк
new       —   900 000 строк
failed    —   100 000 строк

Запрос:

SELECT *
FROM orders
WHERE status = 'failed';

Для failed индекс может быть полезен, потому что таких строк мало.

А для:

SELECT *
FROM orders
WHERE status = 'paid';

индекс может быть невыгоден, потому что таких строк почти вся таблица.

Чтобы принять такое решение, PostgreSQL использует статистику, собранную через ANALYZE.

Что будет, если статистика устарела #

Если в таблице сильно изменились данные, а статистика старая, PostgreSQL может выбрать плохой план.

Например, раньше в таблице было мало строк:

orders: 10 000 строк

Потом стало много:

orders: 10 000 000 строк

Но статистика ещё старая. Планировщик может думать, что таблица маленькая, и выбрать план, который уже не подходит.

Типичные последствия устаревшей статистики:

выбирается Seq Scan вместо Index Scan
выбирается Index Scan вместо Seq Scan
неудачный порядок JOIN
неподходящий алгоритм JOIN: Nested Loop / Hash Join / Merge Join
неверная оценка количества строк

Главная проблема обычно видна в EXPLAIN ANALYZE: PostgreSQL ожидал одно количество строк, а реально получил совсем другое.

Например:

estimated rows: 100
actual rows: 500000

Это признак, что планировщик сильно ошибся в оценке.

Как запускается ANALYZE #

Можно запустить вручную:

ANALYZE;

Это обновит статистику по всем таблицам текущей базы данных.

Можно по конкретной таблице:

ANALYZE users;

Можно по конкретным колонкам:

ANALYZE users(email, city);

Также можно совместить с VACUUM:

VACUUM ANALYZE users;

VACUUM ANALYZE выполняет VACUUM, а затем ANALYZE; PostgreSQL описывает это как обновление статистики, которую планировщик использует для выбора эффективного плана запроса.

Автоматический ANALYZE #

Обычно вручную постоянно запускать ANALYZE не нужно. В PostgreSQL этим занимается autovacuum daemon: он не только чистит мёртвые версии строк через VACUUM, но и автоматически запускает ANALYZE для таблиц, где данные заметно изменились. Документация указывает, что в стандартной конфигурации autovacuum автоматически анализирует таблицы при загрузке данных и при изменениях в процессе работы.

Проверить, когда таблица последний раз анализировалась, можно так:

SELECT
    schemaname,
    relname,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
ORDER BY last_autoanalyze DESC NULLS LAST;

Где посмотреть собранную статистику #

Удобное представление:

SELECT *
FROM pg_stats
WHERE tablename = 'users';

Там можно увидеть статистику по колонкам: долю NULL, частые значения, гистограммы и другие данные.

Системный каталог низкого уровня — pg_statistic, но напрямую с ним обычно работают редко. PostgreSQL указывает, что pg_statistic хранит статистические данные о содержимом базы, а записи создаются командой ANALYZE; при этом статистика является приблизительной.

ANALYZE не выполняет запрос #

Важно не путать:

ANALYZE users;

и

EXPLAIN ANALYZE
SELECT * FROM users;

Это разные вещи.

ANALYZE users; — собирает статистику по таблице.

EXPLAIN ANALYZE SELECT ... — реально выполняет запрос и показывает фактический план выполнения с реальным временем и количеством строк.

Коротко #

ANALYZE в PostgreSQL нужен, чтобы обновить статистику о данных в таблицах.

Эта статистика помогает планировщику решать:

использовать индекс или читать таблицу целиком
в каком порядке соединять таблицы
какой тип JOIN выбрать
какой план будет дешевле
сколько строк примерно вернёт условие

Без актуальной статистики PostgreSQL может выбрать плохой план, даже если индексы созданы правильно.


20. Что делает команда VACUUM в PostgreSQL и зачем она нужна? #

Что делает VACUUM #

VACUUM в PostgreSQL очищает таблицы от “мёртвых” версий строк, которые появились после UPDATE и DELETE.

PostgreSQL работает через MVCC: когда строку обновляют, старая версия не удаляется физически сразу. Вместо этого появляется новая версия строки, а старая остаётся в таблице, пока она ещё может быть нужна старым транзакциям. После того как старая версия уже никому не нужна, её можно убрать. Именно этим занимается VACUUM: он освобождает место, занятое dead tuples — мёртвыми строковыми версиями. В официальной документации PostgreSQL указано, что удалённые или устаревшие после UPDATE строки физически не удаляются из таблицы до выполнения VACUUM.

Пример:

UPDATE users
SET email = 'new@mail.com'
WHERE id = 10;

Логически кажется, что строка просто изменилась. Но внутри PostgreSQL может быть примерно так:

старая версия строки: id=10, email='old@mail.com'  ← стала мёртвой
новая версия строки:  id=10, email='new@mail.com'  ← актуальная

VACUUM потом приходит и очищает старую версию, когда она уже не нужна активным транзакциям.

Зачем он нужен #

Без VACUUM таблицы и индексы постепенно раздуваются.

Это называется bloat — лишнее место внутри таблиц и индексов. Запросы начинают читать больше страниц, чем реально нужно, из-за чего падает производительность.

VACUUM нужен для нескольких вещей:

1. Убирать мёртвые версии строк после UPDATE и DELETE.
2. Освобождать место внутри таблицы для повторного использования.
3. Чистить мёртвые записи в индексах.
4. Поддерживать нормальную производительность запросов.
5. Помогать предотвращать transaction ID wraparound.

Обычный VACUUM не всегда возвращает место операционной системе. Чаще он делает это место доступным для повторного использования внутри той же таблицы. PostgreSQL отдельно указывает: обычный VACUUM освобождает место для повторного использования, может работать параллельно с обычным чтением и записью, но в большинстве случаев не возвращает лишнее место ОС.

То есть после обычного VACUUM размер файла таблицы на диске может почти не уменьшиться, но новые строки смогут занять уже очищенное место.

Простая аналогия #

Таблица после множества UPDATE и DELETE похожа на файл с пометками:

строка 1 — актуальная
строка 2 — удалена, но физически ещё лежит
строка 3 — старая версия после UPDATE
строка 4 — актуальная
строка 5 — удалена, но физически ещё лежит

VACUUM проходит и говорит:

строка 2 больше никому не нужна → место можно переиспользовать
строка 3 больше никому не нужна → место можно переиспользовать
строка 5 больше никому не нужна → место можно переиспользовать

Но он не обязательно “сжимает файл” на диске. Он скорее наводит порядок внутри таблицы.

VACUUM и VACUUM FULL #

Есть обычный VACUUM:

VACUUM users;

Он очищает мёртвые строки и освобождает место для повторного использования внутри таблицы. Обычно он не блокирует обычные SELECT, INSERT, UPDATE, DELETE так жёстко.

Есть VACUUM FULL:

VACUUM FULL users;

Он физически переписывает таблицу в новый файл без лишнего свободного места. Поэтому после него размер таблицы на диске может реально уменьшиться. Но он намного тяжелее: PostgreSQL указывает, что VACUUM FULL медленнее и требует ACCESS EXCLUSIVE lock на таблицу, то есть блокирует нормальную работу с ней на время выполнения.

Разница:

VACUUM:
  очищает мёртвые строки
  место остаётся внутри таблицы для переиспользования
  обычно не требует полной блокировки таблицы

VACUUM FULL:
  переписывает таблицу заново
  реально уменьшает размер файла на диске
  требует сильную блокировку
  работает дольше

VACUUM FULL не стоит запускать просто “для профилактики” на живой большой таблице. Его используют, когда таблица сильно раздулась и нужно именно вернуть место операционной системе.

Что такое autovacuum #

Обычно вручную постоянно запускать VACUUM не нужно. В PostgreSQL есть autovacuum — фоновый механизм, который сам запускает VACUUM и ANALYZE, когда таблицы достаточно изменились. В документации PostgreSQL указано, что autovacuum daemon в каждом цикле проверяет базу и выполняет VACUUM и ANALYZE для таблиц, где это нужно.

Проверить, когда autovacuum последний раз работал по таблицам:

SELECT
    schemaname,
    relname,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

Особенно полезно смотреть на n_dead_tup — примерное количество мёртвых строк.

VACUUM и ANALYZE — не одно и то же #

VACUUM чистит мёртвые строки.

ANALYZE собирает статистику для планировщика запросов.

Можно запустить вместе:

VACUUM ANALYZE users;

Это сначала выполняет VACUUM, а потом ANALYZE. В PostgreSQL документации указано, что VACUUM ANALYZE выполняет обе операции для выбранных таблиц, а ANALYZE обновляет статистику, которую планировщик использует для выбора эффективного плана запроса.

То есть:

VACUUM        → чистит физический мусор после UPDATE/DELETE
ANALYZE       → обновляет статистику о данных
VACUUM ANALYZE → делает и то, и другое

Почему VACUUM важен для индексов #

После UPDATE и DELETE мёртвые записи могут оставаться не только в таблице, но и в индексах. Если их долго не чистить, индексы тоже раздуваются, становятся тяжелее, и индексные сканы могут работать хуже. PostgreSQL указывает, что если index cleanup не выполняется регулярно, производительность может ухудшаться, потому что индексы накапливают мёртвые записи.

То есть проблема не только в размере таблицы, но и в размере индексов.

Когда вручную запускать VACUUM #

Обычно autovacuum достаточно. Но вручную VACUUM может быть полезен после массовых операций:

DELETE FROM logs
WHERE created_at < now() - interval '1 year';

или:

UPDATE orders
SET status = 'archived'
WHERE created_at < now() - interval '2 years';

После таких операций появляется много мёртвых строк. Тогда можно выполнить:

VACUUM logs;

или, если нужно ещё обновить статистику:

VACUUM ANALYZE logs;

А если после огромного удаления нужно именно уменьшить размер файла на диске:

VACUUM FULL logs;

Но VACUUM FULL надо применять осторожно из-за блокировки.

Коротко #

VACUUM — это механизм уборки в PostgreSQL.

Он нужен, потому что PostgreSQL не удаляет старые версии строк сразу после UPDATE и DELETE. Сначала они остаются в таблице из-за MVCC, а потом VACUUM освобождает занятое ими место.

Главное:

VACUUM чистит мёртвые строки.
VACUUM уменьшает bloat.
VACUUM помогает таблицам и индексам не раздуваться.
Обычный VACUUM обычно не возвращает место ОС, а делает его доступным внутри таблицы.
VACUUM FULL реально сжимает таблицу на диске, но блокирует её.
Autovacuum обычно делает эту работу автоматически.
VACUUM ANALYZE = очистка + обновление статистики.


21. Что делает команда TRUNCATE и чем она отличается от DELETE? #

  • DELETE - оператор DML, удаляет записи из таблицы, которые удовлетворяют условиям WHERE. Ведёт журналирование. Не сбрасывает последовательности. Медленнее. Есть возможность восстановить данные

  • TRUNCATE - DDL оператор, удаляет все строки из таблицы, быстро высвобождая занятую память. Не ведёт журналирование, не сбрасывает последовательности. Нет возможности восстановить данные


22. Какие решения по конфигурации базы данных обычно принимает разработчик на проекте? #

Что обычно решает backend-разработчик #

Разработчик обычно не “настраивает всю PostgreSQL как DBA”, но принимает решения, которые напрямую влияют на работу приложения с БД: схема данных, индексы, транзакции, пул соединений, таймауты, миграции и настройки ORM.

1. Как приложение подключается к БД #

Разработчик задаёт конфигурацию подключения:

DB_HOST=postgres
DB_PORT=5432
DB_NAME=app_db
DB_USER=app_user
DB_PASSWORD=...
DB_SSLMODE=require

Обычно это выносится в .env, Kubernetes secrets, Docker Compose env или настройки CI/CD.

Также решается, будут ли разные подключения для:

локальной разработки
тестов
staging
production
read replica

В Django это обычно настраивается через DATABASES, а время жизни persistent database connection — через CONN_MAX_AGE; документация Django описывает его как максимальное время жизни подключения к БД.

2. Размер пула соединений #

Если приложение использует SQLAlchemy/FastAPI, разработчик часто настраивает пул:

engine = create_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
    pool_recycle=1800,
)

Здесь решается:

сколько постоянных соединений держать
сколько временных соединений можно открыть сверх лимита
проверять ли соединение перед использованием
когда пересоздавать старые соединения

SQLAlchemy описывает pool_size как количество соединений, поддерживаемых в пуле, а max_overflow — как дополнительные соединения сверх pool_size, которые можно открыть при нехватке свободных подключений.

Это важно, потому что если каждый backend-инстанс открывает слишком много соединений, можно упереться в лимит PostgreSQL. max_connections в PostgreSQL определяет максимальное количество одновременных подключений к серверу.

3. Структура таблиц и типы данных #

Разработчик решает, как будут выглядеть таблицы:

CREATE TABLE users (
    id bigserial PRIMARY KEY,
    email text NOT NULL,
    username text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

Здесь принимаются решения:

какой primary key использовать: serial/bigserial/uuid
какие поля обязательные
какие типы данных выбрать
использовать ли jsonb, uuid, numeric, timestamptz
как хранить даты и время
как нормализовать связи между сущностями

Например, для времени в backend-проектах чаще безопаснее использовать timestamptz, если дата связана с реальным моментом времени, а не просто календарной датой.

4. Ограничения целостности #

Разработчик определяет ограничения:

ALTER TABLE users
ADD CONSTRAINT users_email_unique UNIQUE (email);

или:

ALTER TABLE orders
ADD CONSTRAINT orders_user_id_fk
FOREIGN KEY (user_id) REFERENCES users(id);

Обычно решаются такие вещи:

PRIMARY KEY
UNIQUE
FOREIGN KEY
NOT NULL
CHECK
ON DELETE CASCADE / RESTRICT / SET NULL

Это не просто “красота схемы”. Это защита данных на уровне БД. Например, если email должен быть уникальным, лучше закрепить это UNIQUE-ограничением, а не только проверкой в коде.

PostgreSQL автоматически создаёт уникальный индекс при создании UNIQUE constraint или PRIMARY KEY.

5. Индексы #

Разработчик обычно решает, какие индексы нужны под реальные запросы приложения.

Например:

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);
CREATE INDEX idx_orders_status_created_at ON orders(status, created_at);

Индекс имеет смысл, если по колонке часто выполняются:

WHERE user_id = ?
WHERE status = ?
ORDER BY created_at DESC
JOIN orders ON orders.user_id = users.id

PostgreSQL описывает индексы как способ повысить производительность поиска, но также предупреждает, что неправильное использование индексов может ухудшать производительность.

Отдельно важный момент: PostgreSQL не всегда автоматически создаёт индекс на колонке, где объявлен foreign key. Документация указывает, что при DELETE или UPDATE родительской строки БД должна проверять дочернюю таблицу, поэтому часто имеет смысл индексировать referencing columns — то есть колонки, где лежит внешний ключ.

6. Миграции #

Разработчик решает, как изменения схемы будут доставляться в БД:

Django migrations
Alembic
ручные SQL-миграции
миграции через CI/CD

Обычно нужно решить:

как именовать миграции
кто создаёт миграции
как решать конфликты миграций
можно ли делать destructive changes
как откатывать миграции
как безопасно менять большие таблицы

Например, опасная миграция:

ALTER TABLE users ADD COLUMN age integer NOT NULL;

Если таблица уже большая и в ней есть данные, такая миграция может упасть или заблокировать таблицу. Обычно делают осторожнее:

ALTER TABLE users ADD COLUMN age integer;
-- заполнить данные
-- потом добавить NOT NULL

7. Границы транзакций #

Разработчик решает, какие операции должны выполняться атомарно.

Например:

with transaction.atomic():
    order = Order.objects.create(user=user)
    Payment.objects.create(order=order, amount=amount)
    StockReservation.objects.create(order=order, item=item)

Здесь важно определить:

что должно быть в одной транзакции
где транзакция должна начинаться и заканчиваться
можно ли держать транзакцию во время внешнего HTTP-запроса
какой уровень изоляции нужен
нужны ли SELECT FOR UPDATE / advisory locks

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

8. Таймауты #

Разработчик часто задаёт таймауты на уровне подключения, роли или сессии:

SET statement_timeout = '30s';
SET lock_timeout = '5s';
SET idle_in_transaction_session_timeout = '60s';

Смысл:

statement_timeout — ограничить длительность запроса
lock_timeout — не ждать блокировку бесконечно
idle_in_transaction_session_timeout — прибить зависшую открытую транзакцию

В PostgreSQL statement_timeout ограничивает максимальное время выполнения SQL-команды, а idle_in_transaction_session_timeout завершает сессию, которая слишком долго простаивает внутри открытой транзакции.

Для backend-приложения это важно: один зависший запрос или забытая транзакция не должны держать БД в плохом состоянии бесконечно.

9. Настройки read/write split и реплик #

Если есть реплики, разработчик может решать:

какие запросы идут в master
какие запросы идут в read replica
можно ли читать устаревшие данные
что делать после записи, если нужно сразу прочитать свежие данные

Например:

POST /orders       → primary database
GET /orders/list   → read replica

Но тут есть проблема replica lag. После записи данные на реплике могут появиться не мгновенно. Поэтому нельзя бездумно отправлять все SELECT на реплики.

10. Настройки тестовой БД #

Разработчик обычно решает:

использовать SQLite или тот же PostgreSQL
создавать отдельную test database
откатывать тесты транзакциями или пересоздавать схему
запускать ли миграции в тестах
чем наполнять тестовые данные

Для серьёзного backend-проекта лучше тестировать на той же СУБД, что и production. SQLite и PostgreSQL по-разному работают с типами, блокировками, JSON, индексами, ограничениями и SQL-синтаксисом.

11. Расширения PostgreSQL #

Иногда разработчик решает, какие расширения нужны:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS postgis;

Примеры:

uuid-ossp / pgcrypto — генерация UUID
pg_trgm — ускорение поиска по похожести / ILIKE
citext — case-insensitive text
postgis — геоданные

Это уже влияет не только на код, но и на инфраструктуру: расширения должны быть доступны в окружении БД.

12. Логирование и диагностика запросов #

Разработчик обычно настраивает или просит включить:

логирование медленных запросов
EXPLAIN / EXPLAIN ANALYZE для проблемных запросов
метрики количества соединений
метрики длительности запросов
контроль deadlocks

На уровне приложения это может быть логирование SQL-запросов ORM, middleware для slow queries, OpenTelemetry, APM.

Что чаще не решает обычный разработчик один #

Обычно это зона DBA/DevOps/SRE, но разработчик может участвовать:

shared_buffers
work_mem
maintenance_work_mem
wal_level
checkpoint_timeout
max_wal_size
autovacuum thresholds
replication slots
backup policy
HA/failover
physical storage
partitioning strategy для очень больших таблиц

Например, shared_buffers и другие параметры памяти относятся к resource consumption settings PostgreSQL и обычно требуют понимания нагрузки и ресурсов сервера.

Коротко #

Обычно backend-разработчик принимает решения по таким вещам:

подключение к БД
пул соединений
структура таблиц
типы данных
constraints
индексы
миграции
границы транзакций
таймауты запросов
настройки ORM
тестовая БД
read/write routing
расширения PostgreSQL
логирование медленных запросов

А низкоуровневый тюнинг PostgreSQL, репликация, бэкапы, WAL, память и autovacuum обычно решаются вместе с DBA/DevOps, потому что там ошибка может повлиять на всю БД, а не только на один сервис.


23. Как в PostgreSQL работает блокировка строки (row-level lock)? #

Что такое row-level lock #

Row-level lock — это блокировка конкретной строки, а не всей таблицы.

Она нужна, чтобы две транзакции не могли одновременно конфликтно изменить одну и ту же строку. В PostgreSQL row-level lock не мешает обычному чтению данных: он блокирует только другие операции записи или другие попытки взять конфликтующую блокировку на ту же строку. Официальная документация PostgreSQL прямо указывает: row-level locks не влияют на обычный запрос данных, они блокируют только writers and lockers для той же строки.

То есть обычный SELECT обычно не ждёт row-level lock:

SELECT *
FROM accounts
WHERE id = 1;

Но UPDATE, DELETE или SELECT ... FOR UPDATE по той же строке могут ждать.


Простой пример #

Есть таблица:

CREATE TABLE accounts (
    id bigint PRIMARY KEY,
    balance numeric NOT NULL
);

Первая транзакция:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

-- транзакция пока не завершена

Когда PostgreSQL выполняет этот UPDATE, он берёт блокировку на строку accounts.id = 1.

Вторая транзакция в это время пытается изменить ту же строку:

BEGIN;

UPDATE accounts
SET balance = balance - 50
WHERE id = 1;

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

COMMIT;

или:

ROLLBACK;

После этого PostgreSQL продолжит выполнение второй транзакции.


Что именно блокируется #

Блокируется не вся таблица accounts, а конкретная строка:

accounts.id = 1

Другая строка той же таблицы может обновляться параллельно:

UPDATE accounts
SET balance = balance - 50
WHERE id = 2;

Если первая транзакция заблокировала только id = 1, то id = 2 не конфликтует.


Обычный SELECT не блокируется #

В PostgreSQL работает MVCC. Поэтому читающий запрос обычно видит согласованную версию данных, а не ждёт, пока завершится чужой UPDATE.

Пример:

-- Transaction 1
BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

Пока первая транзакция не сделала COMMIT, другой запрос:

-- Transaction 2
SELECT balance
FROM accounts
WHERE id = 1;

обычно прочитает старую зафиксированную версию строки.

Это важное отличие PostgreSQL: чтение и запись не обязательно блокируют друг друга. Документация PostgreSQL описывает concurrency control как механизм, который должен позволять нескольким сессиям одновременно работать с одними и теми же данными, сохраняя целостность данных.


SELECT FOR UPDATE #

SELECT ... FOR UPDATE используется, когда нужно заранее заблокировать строки, которые потом будут изменяться.

Пример:

BEGIN;

SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

FOR UPDATE блокирует выбранные строки так, как будто они будут обновляться. Другие транзакции не смогут изменить, удалить или взять конфликтующую блокировку на эти строки до завершения текущей транзакции.

Это нужно, например, при работе с балансом:

1. прочитать текущий баланс
2. проверить, хватает ли денег
3. списать деньги

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


Что происходит при конфликте #

Допустим:

-- Transaction 1
BEGIN;

SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE;

Теперь строка id = 1 заблокирована.

Вторая транзакция:

-- Transaction 2
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

Она будет ждать завершения первой транзакции.

Если первая транзакция сделает:

COMMIT;

вторая продолжит работу уже с учётом актуального состояния строки.

Если первая сделает:

ROLLBACK;

блокировка освободится, и вторая транзакция сможет выполнить свой UPDATE.


Блокировка держится до конца транзакции #

Row-level lock обычно удерживается до завершения транзакции:

COMMIT;

или:

ROLLBACK;

PostgreSQL указывает, что row-level locks освобождаются в конце транзакции или при rollback к savepoint, если блокировка была взята после этого savepoint.

Поэтому опасно держать транзакцию открытой долго:

BEGIN;

SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE;

-- здесь приложение ждёт внешний API, ввод пользователя или долгую операцию

COMMIT;

Пока транзакция открыта, другие операции по этой строке могут стоять в ожидании.


Основные режимы row-level lock #

В PostgreSQL есть несколько режимов строковых блокировок:

FOR UPDATE
FOR NO KEY UPDATE
FOR SHARE
FOR KEY SHARE

FOR UPDATE — самый строгий режим для обычных сценариев. Он нужен, когда строку планируют менять или удалять.

FOR NO KEY UPDATE слабее: PostgreSQL обычно использует его для UPDATE, который не меняет ключевые колонки, важные для foreign key.

FOR SHARE позволяет другим транзакциям тоже брать shared lock, но мешает изменять или удалять строку.

FOR KEY SHARE ещё слабее: он защищает ключевые значения строки, например когда нужно не дать удалить родительскую строку, пока на неё ссылается foreign key. PostgreSQL описывает эти режимы и их конфликты в разделе Row-Level Lock Modes.

На практике чаще всего вручную используют:

SELECT ... FOR UPDATE;

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


NOWAIT и SKIP LOCKED #

По умолчанию, если строка уже заблокирована, PostgreSQL будет ждать.

Иногда ждать не нужно. Тогда используют NOWAIT:

SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE NOWAIT;

Если строка уже заблокирована, запрос сразу завершится ошибкой, а не будет висеть в ожидании.

Для очередей задач часто используют SKIP LOCKED:

SELECT *
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Смысл: взять свободные строки, а уже заблокированные другими воркерами пропустить.

Так несколько воркеров могут параллельно разбирать задачи из одной таблицы:

worker 1 забрал jobs 1-10
worker 2 пропустил заблокированные 1-10 и забрал 11-20
worker 3 забрал 21-30

Как увидеть блокировки #

Для диагностики используют pg_locks.

Пример:

SELECT
    locktype,
    relation::regclass,
    page,
    tuple,
    transactionid,
    pid,
    mode,
    granted,
    waitstart
FROM pg_locks
WHERE NOT granted;

pg_locks показывает активные блокировки и ожидания блокировок. В документации PostgreSQL указано, что одна строка в pg_locks соответствует lockable object, режиму блокировки и процессу; поле granted = false означает, что процесс ждёт блокировку.

Важный нюанс: row-level locks обычно хранятся на диске, а не в памяти, поэтому сами строковые блокировки не всегда видны в pg_locks как tuple. Если процесс ждёт строковую блокировку, он часто отображается как ожидание transaction ID владельца этой блокировки.


Deadlock на строках #

Row-level locks могут привести к deadlock.

Пример:

-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

Параллельно:

-- Transaction 2
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;

Что происходит:

Transaction 1 заблокировала строку id = 1
Transaction 2 заблокировала строку id = 2

Transaction 1 ждёт id = 2
Transaction 2 ждёт id = 1

Получается deadlock. PostgreSQL умеет автоматически обнаруживать deadlock и завершает одну из транзакций, чтобы вторая могла продолжить. В документации также указано, что deadlock может возникать из-за row-level locks, и лучшая защита — брать блокировки в одинаковом порядке во всех транзакциях.

Правильнее:

-- всегда блокировать счета по возрастанию id
SELECT *
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

Типичный backend-сценарий #

Например, нужно безопасно списать деньги:

BEGIN;

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

-- приложение проверяет balance

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

Это защищает от ситуации, когда два параллельных запроса одновременно читают один и тот же баланс и оба пытаются его списать.

Но важно не держать транзакцию дольше необходимого. Нельзя внутри такой транзакции делать долгие внешние HTTP-запросы, ждать пользователя или выполнять тяжёлую бизнес-логику.


Коротко #

Row-level lock в PostgreSQL — это блокировка конкретной строки.

Главное:

UPDATE и DELETE автоматически блокируют изменяемые строки.
SELECT FOR UPDATE блокирует выбранные строки явно.
Обычный SELECT обычно не ждёт row-level lock из-за MVCC.
Другой UPDATE/DELETE по той же строке будет ждать.
Блокировка держится до COMMIT или ROLLBACK.
Долгие транзакции усиливают проблему блокировок.
Deadlock возможен, если разные транзакции блокируют строки в разном порядке.

Практический смысл: row-level lock нужен, чтобы безопасно выполнять конкурентные изменения одной и той же строки без потери данных и гонок.


24. Что такое блокировки в базе данных? Как они работают и какие бывают? #

Блокировки (или локи) в базе данных - это механизм, управляющий конкурентным доступом к данным, чтобы избежать конфликтов и ошибок. Есть два типа блокировок

Оптимистическая блокировка - при завершении транзакции сверяется текущая версия данных и версия, полученная в начале транзакции. Если они не совпадают, то транзакция откатывается. Подразумевает, что конфликты будут происходить нечасто

SELECT version  
FROM orders  
WHERE id = 1;  
  
-- Обновляем, если версия не изменилась  
UPDATE orders  
SET status = 'processed', version = version + 1  
WHERE id = 1 AND version = 5;  

Пессимистическая блокировка - при начале транзакции изменяемые данные блокируются для всех остальных, пока текущая транзакция не закончится

SELECT *  
FROM orders  
WHERE id = 1  
FOR UPDATE;  

Сравнение

ХарактеристикаОптимистичные локиПессимистичные локи
КонфликтыРедкиеЧастые
МеханизмПроверка версии данныхБлокировка ресурса
ПроизводительностьВыше при редких конфликтахНиже из-за блокировок
Обработка конфликтовТребует доп. кодаНе требуется
ПрименимостьЧтение превалирует над записьюВысококонкурентная запись


25. Что обычно используют в качестве Primary Key в реляционной базе данных (Какие типы данных чаще всего выбирают)? #

В реляционных БД чаще всего используют суррогатный Primary Key — отдельное поле id, которое не несёт бизнес-смысла.

Самые частые варианты #

ТипГде используютПример
BIGINT / BIGSERIAL / IDENTITYСамый частый вариант для обычных backend-приложенийid BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
INTEGER / SERIALМаленькие и средние таблицы, где точно не будет миллиардов записейid SERIAL PRIMARY KEY
UUIDРаспределённые системы, микросервисы, генерация id на стороне приложения, публичные idid UUID PRIMARY KEY
Составной ключСвязующие таблицы, например many-to-manyPRIMARY KEY (user_id, role_id)
Natural keyРедко, когда бизнес-поле стабильно и реально уникальноcountry_code, passport_number, isbn

Что обычно выбирают на практике #

Для большинства проектов:

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

Это хороший дефолт: компактный, быстрый для индексов, хорошо работает с B-tree, автоматически генерируется базой.

В PostgreSQL IDENTITY — современный способ автоинкрементного ключа через неявную sequence; документация прямо показывает пример с id bigint GENERATED ... AS IDENTITY. При этом сама IDENTITY-колонка не гарантирует уникальность без PRIMARY KEY или UNIQUE. ( PostgreSQL)

Почему часто берут BIGINT, а не INTEGER #

INTEGER занимает 4 байта, BIGINT — 8 байт. Но INTEGER ограничен примерно 2.1 млрд положительных значений, а для долгоживущих таблиц, логов, заказов, событий, уведомлений и аудита это может стать проблемой. В PostgreSQL serial создаёт integer, а bigserial создаёт bigint; документация рекомендует bigserial, если ожидается больше 2^31 идентификаторов за жизнь таблицы.

UUID как Primary Key #

UUID используют, когда id нужно генерировать независимо от БД или не хочется раскрывать последовательные номера наружу:

id UUID PRIMARY KEY

В PostgreSQL есть нативный тип uuid, то есть его не нужно хранить как varchar или text.

Минус UUID: он больше по размеру, хуже для локальности вставок в B-tree, особенно если используется случайный UUID v4. Поэтому для внутренних таблиц обычный BIGINT IDENTITY часто эффективнее.

AUTO_INCREMENT в MySQL #

В MySQL типичный вариант:

id BIGINT AUTO_INCREMENT PRIMARY KEY

MySQL автоматически назначает последовательные значения для AUTO_INCREMENT, если значение явно не передано. Документация также рекомендует выбирать минимальный целочисленный тип, которого достаточно для ожидаемого диапазона значений.

Итог #

Лучший стандартный выбор:

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

Для PostgreSQL это предпочтительнее старого SERIAL, потому что IDENTITY явно описывает авто生成ируемую колонку.
UUID стоит брать, когда нужен распределённый id, внешний публичный id или генерация идентификаторов вне базы.
Составной Primary Key уместен в связующих таблицах.
Natural key лучше использовать осторожно, потому что бизнес-значения могут меняться.


26. Как работает автоинкрементный id через sequence в PostgreSQL? #

Суть #

В PostgreSQL автоинкрементный id обычно работает не как специальное свойство самой колонки, а через отдельный объект — sequence.

Sequence — это генератор чисел. PostgreSQL хранит его отдельно от таблицы и при вставке новой строки берёт следующее значение через функцию nextval().

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

Когда выполняется:

INSERT INTO users (username) VALUES ('alex');

PostgreSQL фактически получает новый id из внутренней sequence, связанной с колонкой users.id. В документации PostgreSQL identity column прямо описана как колонка, которая автоматически генерируется из неявной sequence.

Что происходит внутри #

Упрощённо:

CREATE SEQUENCE users_id_seq;

CREATE TABLE users (
    id BIGINT NOT NULL DEFAULT nextval('users_id_seq'),
    username TEXT NOT NULL
);

ALTER SEQUENCE users_id_seq OWNED BY users.id;

То есть у колонки id стоит значение по умолчанию:

DEFAULT nextval('users_id_seq')

Когда ты вставляешь строку и не указываешь id, PostgreSQL вызывает nextval(). Эта функция атомарно увеличивает sequence и возвращает новое значение. Даже если несколько транзакций одновременно делают INSERT, каждая получит отдельное значение.

Пример по шагам #

INSERT INTO users (username) VALUES ('A');
-- nextval() вернул 1

INSERT INTO users (username) VALUES ('B');
-- nextval() вернул 2

INSERT INTO users (username) VALUES ('C');
-- nextval() вернул 3

В таблице получится:

id | username
---+---------
1  | A
2  | B
3  | C

Важный момент: sequence не откатывается при rollback #

Например:

BEGIN;

INSERT INTO users (username) VALUES ('bad_user');
-- id = 4

ROLLBACK;

Строка откатится, но значение 4 уже было выдано sequence. Следующая вставка получит уже 5, а не 4.

Поэтому пропуски в id — это нормально:

1, 2, 3, 5, 6

id через sequence гарантирует не «идеальную нумерацию без дыр», а безопасную выдачу уникальных значений при конкурентных вставках. nextval() в PostgreSQL атомарен и безопасен для одновременных вызовов из разных сессий.

Чем отличаются SERIAL и IDENTITY #

Старый способ:

id BIGSERIAL PRIMARY KEY

Современный способ:

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

SERIAL / BIGSERIAL — это псевдотипы. Они создают sequence и ставят nextval() как DEFAULT для колонки. А IDENTITY — более современный SQL-стандартный синтаксис, где PostgreSQL сам создаёт связанную sequence для identity-колонки. Связанную sequence можно получить через pg_get_serial_sequence(), причём функция работает и для serial, и для identity-колонок.

Как посмотреть sequence у колонки #

SELECT pg_get_serial_sequence('users', 'id');

Результат будет примерно:

public.users_id_seq

Потом можно посмотреть текущее состояние:

SELECT last_value, is_called
FROM users_id_seq;

Но last_value — это не всегда «следующий id». Это последнее значение, уже выделенное sequence. PostgreSQL отдельно отмечает, что last_value может устареть к моменту просмотра, если другие сессии параллельно вызывают nextval().

Как вручную получить следующий id #

SELECT nextval('users_id_seq');

Это сразу сдвинет sequence вперёд.

SELECT currval('users_id_seq');

currval() вернёт последнее значение sequence, полученное через nextval() в текущей сессии. То есть сначала в этой же сессии должен быть вызван nextval().

Как сбросить или синхронизировать sequence #

После ручной загрузки данных может возникнуть проблема:

INSERT INTO users (id, username) VALUES (100, 'manual');

Если sequence всё ещё думает, что последний id был 5, следующая авто-вставка может попытаться вставить 6, а не 101.

Обычно sequence синхронизируют так:

SELECT setval(
    pg_get_serial_sequence('users', 'id'),
    COALESCE(MAX(id), 1)
)
FROM users;

После этого следующий nextval() пойдёт дальше от текущего максимального id.

Главное #

Автоинкрементный id в PostgreSQL — это связка:

колонка id
DEFAULT nextval(...)
sequence
следующее число

Sequence нужна, чтобы быстро и безопасно выдавать уникальные числа при параллельных INSERT. Она не предназначена для красивой непрерывной нумерации без пропусков.


27. Можно ли создать таблицу без Primary Key? #

Да, таблицу можно создать без Primary Key. В PostgreSQL это разрешено.

Пример:

CREATE TABLE logs (
    message TEXT,
    created_at TIMESTAMP
);

Такая таблица будет работать: в неё можно делать INSERT, SELECT, UPDATE, DELETE.

Но на практике для обычных бизнес-таблиц это почти всегда плохая идея.

Что теряется без Primary Key #

Primary Key даёт сразу несколько вещей:

  1. Уникальную идентификацию строки

Например, в таблице пользователей каждая строка должна иметь однозначный идентификатор:

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

Без него сложнее точно обратиться к конкретной записи.

  1. NOT NULL + UNIQUE

В PostgreSQL добавление PRIMARY KEY автоматически создаёт уникальный B-tree индекс и делает колонку или набор колонок NOT NULL. То есть Primary Key не может быть NULL и не может повторяться.

  1. Связи через Foreign Key

Обычно другие таблицы ссылаются именно на Primary Key:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT REFERENCES users(id)
);

Без ключа таблицу сложнее нормально использовать в связях.

  1. Удобную работу ORM

Django ORM, SQLAlchemy ORM и большинство других ORM ожидают, что у модели есть стабильный уникальный идентификатор. Без Primary Key начинаются ограничения: сложнее обновлять, удалять и однозначно отслеживать объект.

Когда таблица без Primary Key допустима #

Так делают в отдельных случаях:

логи,
временные staging-таблицы,
таблицы для импорта сырых данных,
одноразовые промежуточные таблицы,
аналитические таблицы, где строки не обновляются по одной.

Например:

CREATE TABLE raw_import_events (
    payload JSONB,
    imported_at TIMESTAMP DEFAULT now()
);

Если это просто временная таблица для загрузки данных, Primary Key может быть не нужен.

Когда Primary Key лучше обязательно делать #

Для обычных таблиц приложения:

users,
orders,
products,
notifications,
files,
payments,
comments,
posts

Primary Key лучше делать всегда.

Обычный вариант:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

Важный нюанс про MySQL #

В MySQL InnoDB таблица тоже может быть создана без явного Primary Key, но в новых версиях есть механизм Generated Invisible Primary Key: при включённой настройке sql_generate_invisible_primary_key MySQL может автоматически добавить скрытый первичный ключ для InnoDB-таблицы без явного PK.

То есть в некоторых СУБД или конфигурациях отсутствие явного Primary Key может обрабатываться особым образом.

Итог #

Технически — да, таблицу можно создать без Primary Key.

Но для нормальной прикладной таблицы лучше почти всегда добавлять:

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

Без Primary Key таблица допустима в основном для логов, импорта, временных данных и некоторых аналитических сценариев.


28. Чем отличается суррогатный ключ от натурального ключа? #

Суррогатный ключ — искусственный технический идентификатор строки.

id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

Натуральный ключ — реальное бизнес-значение, которое уже существует в предметной области и само по себе уникально.

email TEXT UNIQUE NOT NULL

PostgreSQL определяет PRIMARY KEY как колонку или набор колонок, которые уникально идентифицируют строки; значения Primary Key должны быть UNIQUE и NOT NULL.

Пример #

Таблица пользователей:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    username TEXT NOT NULL
);

Здесь:

id    -> суррогатный ключ
email -> натуральный ключ-кандидат

id не имеет смысла вне базы. Это просто технический номер строки.

email имеет бизнес-смысл: пользователь реально использует этот адрес, по нему его можно идентифицировать.

Главное отличие #

КритерийСуррогатный ключНатуральный ключ
Имеет бизнес-смыслНетДа
Примерid, uuidemail, phone, passport_number, sku, country_code
Генерируется системойОбычно даОбычно приходит из предметной области
Может изменитьсяПочти никогдаМожет измениться
Удобен для Foreign KeyДаНе всегда
РазмерОбычно маленький: BIGINTМожет быть длинным или составным
Риск изменения бизнес-правилНизкийВыше

IBM описывает натуральные ключи как осмысленные значения, идентифицирующие записи, например номера соцстрахования, календарные даты или SKU товара. Суррогатные ключи, по IBM и Kimball Group, используются как искусственные/замещающие ключи, особенно когда натуральные ключи могут меняться, переиспользоваться или быть неудобными для связей.

Почему чаще используют суррогатный ключ #

Потому что он стабильный.

Например, если сделать email Primary Key:

CREATE TABLE users (
    email TEXT PRIMARY KEY,
    username TEXT NOT NULL
);

И на него ссылается другая таблица:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_email TEXT REFERENCES users(email)
);

Если пользователь сменит email, придётся обновлять связи. Это можно сделать через ON UPDATE CASCADE, но сама модель становится более хрупкой.

С суррогатным ключом проще:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT UNIQUE NOT NULL
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT REFERENCES users(id)
);

Теперь email можно менять, а связь остаётся через стабильный user_id.

Натуральный ключ всё равно часто нужен #

Суррогатный id не отменяет бизнес-уникальность.

Плохой вариант:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL
);

Так можно случайно создать двух пользователей с одинаковым email.

Правильнее:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT UNIQUE NOT NULL
);

То есть:

id    -> Primary Key для связей
email -> UNIQUE constraint для бизнес-уникальности

PostgreSQL автоматически создаёт уникальный индекс для PRIMARY KEY и UNIQUE constraints, и именно этот индекс обеспечивает проверку уникальности.

Когда натуральный ключ можно использовать как Primary Key #

Когда значение:

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

Примеры:

CREATE TABLE countries (
    code CHAR(2) PRIMARY KEY,
    name TEXT NOT NULL
);

Здесь code = 'AZ', TR, US может быть нормальным натуральным ключом.

Но для пользователей, заказов, файлов, платежей, комментариев, уведомлений обычно лучше суррогатный id.

Итог #

В прикладной разработке чаще делают так:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    username TEXT NOT NULL
);

id — суррогатный ключ, стабильный технический идентификатор.
email — натуральный бизнес-идентификатор, но не основной ключ для связей.

Это самый практичный подход: связи строятся по стабильному id, а бизнес-уникальность защищается отдельным UNIQUE.


29. Что такое GIN index в PostgreSQL? #

Что такое GIN index #

GIN index в PostgreSQL — это индекс для поиска внутри составных значений: массивов, jsonb, документов полнотекстового поиска и похожих структур.

GIN расшифровывается как Generalized Inverted Index — обобщённый инвертированный индекс. PostgreSQL описывает его как индекс для случаев, когда значение состоит из нескольких элементов, а запрос ищет наличие конкретного элемента внутри этого значения. Например: документ содержит слова, массив содержит элементы, jsonb содержит ключи и значения.

Простая идея #

Обычный B-tree индекс хорошо работает с простыми значениями:

WHERE id = 10
WHERE email = 'a@test.com'
WHERE created_at > '2026-01-01'

А GIN нужен, когда в одной ячейке лежит много внутренних элементов.

Например:

tags = ARRAY['python', 'postgresql', 'backend']

GIN индексирует не всю ячейку как одно значение, а отдельные элементы:

python     -> строки, где есть python
postgresql -> строки, где есть postgresql
backend    -> строки, где есть backend

То есть это похоже на словарь:

элемент -> список строк, где он встречается

Поэтому он и называется инвертированным индексом.

Где используют GIN #

Чаще всего GIN используют для:

массивов,
jsonb,
полнотекстового поиска,
поиска по trigram через расширение pg_trgm.

Пример с массивом #

Таблица:

CREATE TABLE articles (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title TEXT,
    tags TEXT[]
);

Индекс:

CREATE INDEX articles_tags_gin_idx
ON articles
USING GIN (tags);

Запрос:

SELECT *
FROM articles
WHERE tags @> ARRAY['postgresql'];

Оператор @> означает: массив слева содержит массив справа.

Без GIN PostgreSQL часто должен просматривать много строк. С GIN он может быстро найти строки, где внутри массива есть нужный элемент. В документации PostgreSQL прямо сказано, что GIN подходит для значений с несколькими компонентами, например массивов, и эффективно обрабатывает проверки наличия конкретных компонентов.

Пример с JSONB #

CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    data JSONB
);

Пример данных:

{
  "brand": "Samsung",
  "color": "black",
  "storage": 256
}

GIN индекс:

CREATE INDEX products_data_gin_idx
ON products
USING GIN (data);

Запрос:

SELECT *
FROM products
WHERE data @> '{"brand": "Samsung"}';

Здесь GIN помогает искать документы jsonb, которые содержат нужный ключ или пару ключ-значение.

Пример с полнотекстовым поиском #

CREATE TABLE posts (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    body TEXT
);

Индекс:

CREATE INDEX posts_body_fts_idx
ON posts
USING GIN (to_tsvector('russian', body));

Запрос:

SELECT *
FROM posts
WHERE to_tsvector('russian', body) @@ plainto_tsquery('russian', 'postgresql индекс');

Здесь PostgreSQL разбивает текст на лексемы и GIN хранит связь:

слово/лексема -> строки, где оно встречается

Чем GIN отличается от B-tree #

B-treeGIN
Для простых скалярных значенийДля составных значений
id, email, created_at, pricejsonb, массивы, tsvector, trigram
Хорош для =, <, >, ORDER BY, диапазоновХорош для поиска «содержит элемент»
Индексирует значение целикомИндексирует внутренние элементы значения

B-tree подходит для данных, которые можно упорядочить в линейном порядке. PostgreSQL документация описывает B-tree как индекс для типов, которые можно отсортировать.

GIN не про сортировку. Он про быстрый поиск по внутренним компонентам.

Когда GIN не нужен #

GIN обычно не нужен для таких запросов:

WHERE id = 100
WHERE email = 'test@example.com'
WHERE price > 500
WHERE created_at BETWEEN '2026-01-01' AND '2026-02-01'
ORDER BY created_at DESC

Для этого обычно используют B-tree.

Минусы GIN #

GIN индекс может быть тяжёлым:

занимает больше места,
медленнее обновляется при INSERT/UPDATE/DELETE,
может быть избыточным на маленьких таблицах,
плохо подходит для частых массовых изменений.

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

Итог #

GIN index — это индекс для поиска внутри сложных значений.

Его используют, когда запросы выглядят примерно так:

-- массив содержит элемент
WHERE tags @> ARRAY['python']

-- jsonb содержит ключ/значение
WHERE data @> '{"status": "active"}'

-- полнотекстовый поиск
WHERE search_vector @@ to_tsquery('postgresql')

-- поиск по похожести/подстрокам через pg_trgm
WHERE name ILIKE '%phone%'

Для обычных полей вроде id, email, created_at, price чаще нужен не GIN, а обычный B-tree индекс.


30. LEFT JOIN, RIGHT JOIN #

LEFT JOIN #

LEFT JOIN возвращает:

  1. Все строки из левой таблицы.

  2. Совпавшие строки из правой таблицы.

  3. Если совпадения справа нет — PostgreSQL подставляет NULL в поля правой таблицы.

LEFT JOIN и LEFT OUTER JOIN — одно и то же. Слово OUTER можно не писать. PostgreSQL указывает, что LEFT, RIGHT и FULL являются outer join-типами.

Пример:

SELECT users.id, users.name, orders.id AS order_id
FROM users
LEFT JOIN orders ON orders.user_id = users.id;

Допустим, есть таблицы:

users
id | name
---+------
1  | Alex
2  | Bob
3  | Tom
orders
id | user_id | amount
---+---------+-------
10 | 1       | 500
11 | 1       | 300
12 | 2       | 700

Результат LEFT JOIN:

user_id | name | order_id
--------+------+---------
1       | Alex | 10
1       | Alex | 11
2       | Bob  | 12
3       | Tom  | NULL

Пользователь Tom попал в результат, хотя у него нет заказов. Поэтому поля из orders стали NULL.

RIGHT JOIN #

RIGHT JOIN работает наоборот:

  1. Все строки из правой таблицы.

  2. Совпавшие строки из левой таблицы.

  3. Если совпадения слева нет — поля левой таблицы будут NULL.

SELECT users.id AS user_id, users.name, orders.id AS order_id
FROM users
RIGHT JOIN orders ON orders.user_id = users.id;

Результат будет содержать все строки из orders, даже если для какого-то заказа не найден пользователь.

Пример:

users
id | name
---+------
1  | Alex
2  | Bob
orders
id | user_id | amount
---+---------+-------
10 | 1       | 500
11 | 2       | 700
12 | 99      | 300

Результат RIGHT JOIN:

user_id | name | order_id
--------+------+---------
1       | Alex | 10
2       | Bob  | 11
NULL    | NULL | 12

Заказ 12 попал в результат, потому что orders стоит справа, а RIGHT JOIN сохраняет все строки правой таблицы.

Главное отличие #

users LEFT JOIN orders

означает:

Покажи всех пользователей, даже если у них нет заказов.
users RIGHT JOIN orders

означает:

Покажи все заказы, даже если для них нет пользователя.

LEFT JOIN обычно используют чаще #

RIGHT JOIN почти всегда можно переписать через LEFT JOIN, просто поменяв таблицы местами.

Вот это:

SELECT *
FROM users
RIGHT JOIN orders ON orders.user_id = users.id;

эквивалентно этому:

SELECT *
FROM orders
LEFT JOIN users ON users.id = orders.user_id;

На практике LEFT JOIN читается проще, потому что основная таблица стоит первой. Поэтому в backend-разработке чаще пишут LEFT JOIN, а RIGHT JOIN используют редко.

В MySQL прямо указано, что на этапе парсинга RIGHT OUTER JOIN преобразуется в эквивалентный LEFT JOIN. Это не значит, что в PostgreSQL обязательно происходит точно так же, но хорошо показывает, что логически RIGHT JOIN можно выразить через LEFT JOIN.

Частый кейс LEFT JOIN: найти записи без связей #

Например, найти пользователей без заказов:

SELECT users.id, users.name
FROM users
LEFT JOIN orders ON orders.user_id = users.id
WHERE orders.id IS NULL;

Логика:

Берём всех пользователей.
Пытаемся присоединить заказы.
Оставляем только тех, у кого заказ не найден.

Это типичный паттерн:

LEFT JOIN ... WHERE right_table.id IS NULL

Важный нюанс с WHERE #

Ошибка:

SELECT users.id, users.name, orders.id AS order_id
FROM users
LEFT JOIN orders ON orders.user_id = users.id
WHERE orders.amount > 500;

На первый взгляд это LEFT JOIN, но условие в WHERE отфильтрует строки, где orders.amount IS NULL. В результате пользователи без заказов пропадут, и запрос начнёт вести себя ближе к INNER JOIN.

Правильнее переносить условие в ON, если нужно сохранить строки из левой таблицы:

SELECT users.id, users.name, orders.id AS order_id
FROM users
LEFT JOIN orders 
    ON orders.user_id = users.id
   AND orders.amount > 500;

Теперь пользователи без подходящих заказов останутся в результате, а поля orders будут NULL.

Итог #

LEFT JOIN:

Оставляет все строки слева.

RIGHT JOIN:

Оставляет все строки справа.

В реальном коде чаще используют LEFT JOIN, потому что его легче читать и почти любой RIGHT JOIN можно переписать через LEFT JOIN, поменяв порядок таблиц.


31. Как работает FULL OUTER JOIN? #

Что делает FULL OUTER JOIN #

FULL OUTER JOIN возвращает все строки из обеих таблиц:

  1. Если строки совпали по условию ON — они объединяются в одну строку результата.

  2. Если строка есть только в левой таблице — поля правой таблицы будут NULL.

  3. Если строка есть только в правой таблице — поля левой таблицы будут NULL.

В PostgreSQL FULL OUTER JOIN сначала работает как обычный INNER JOIN для совпавших строк, а затем добавляет несовпавшие строки с обеих сторон, заполняя отсутствующую сторону NULL. FULL JOIN и FULL OUTER JOIN — одно и то же, слово OUTER можно не писать.

Пример #

Есть таблица пользователей:

users
id | name
---+------
1  | Alex
2  | Bob
3  | Tom

И таблица заказов:

orders
id | user_id | amount
---+---------+-------
10 | 1       | 500
11 | 2       | 700
12 | 99      | 300

Запрос:

SELECT
    users.id AS user_id,
    users.name,
    orders.id AS order_id,
    orders.user_id AS order_user_id,
    orders.amount
FROM users
FULL OUTER JOIN orders ON orders.user_id = users.id;

Результат:

user_id | name | order_id | order_user_id | amount
--------+------+----------+---------------+-------
1       | Alex | 10       | 1             | 500
2       | Bob  | 11       | 2             | 700
3       | Tom  | NULL     | NULL          | NULL
NULL    | NULL | 12       | 99            | 300

Что здесь произошло:

Alex и Bob имеют заказы — строки объединились.
Tom есть в users, но заказов нет — поля orders стали NULL.
Заказ с user_id = 99 есть в orders, но пользователя нет — поля users стали NULL.

Отличие от LEFT JOIN и RIGHT JOIN #

LEFT JOIN сохраняет все строки только из левой таблицы:

users LEFT JOIN orders ON orders.user_id = users.id

Смысл:

Показать всех пользователей, даже если у них нет заказов.

RIGHT JOIN сохраняет все строки только из правой таблицы:

users RIGHT JOIN orders ON orders.user_id = users.id

Смысл:

Показать все заказы, даже если для них нет пользователя.

FULL OUTER JOIN сохраняет обе стороны:

users FULL OUTER JOIN orders ON orders.user_id = users.id

Смысл:

Показать всех пользователей и все заказы, даже если связи между ними нет.

Упрощённая формула #

Логически FULL OUTER JOIN можно воспринимать как:

INNER JOIN
+
несовпавшие строки слева
+
несовпавшие строки справа

Или как объединение идеи LEFT JOIN и RIGHT JOIN.

Где используют FULL OUTER JOIN #

Чаще всего — для сверки данных.

Например, есть две таблицы:

payments_from_bank
payments_from_system

Нужно найти:

платежи, которые есть и там, и там;
платежи, которые есть только в банке;
платежи, которые есть только в системе.

Запрос:

SELECT
    bank.payment_id AS bank_payment_id,
    system.payment_id AS system_payment_id,
    bank.amount AS bank_amount,
    system.amount AS system_amount
FROM payments_from_bank AS bank
FULL OUTER JOIN payments_from_system AS system
    ON system.payment_id = bank.payment_id;

Такой запрос покажет полную картину расхождений.

Как найти только несовпавшие строки #

Если нужны только строки, которые есть только с одной стороны:

SELECT
    users.id AS user_id,
    users.name,
    orders.id AS order_id,
    orders.user_id AS order_user_id
FROM users
FULL OUTER JOIN orders ON orders.user_id = users.id
WHERE users.id IS NULL
   OR orders.id IS NULL;

Результат будет содержать:

пользователей без заказов;
заказы без существующего пользователя.

То есть совпавшие строки будут исключены.

Важный нюанс с WHERE #

Как и с LEFT JOIN, фильтрация в WHERE может случайно убрать строки с NULL.

Например:

SELECT *
FROM users
FULL OUTER JOIN orders ON orders.user_id = users.id
WHERE orders.amount > 500;

Этот запрос уберёт пользователей без заказов, потому что у них orders.amount = NULL, а условие NULL > 500 не считается истинным.

Если нужно сохранить строки без заказов, условие лучше писать аккуратно:

SELECT *
FROM users
FULL OUTER JOIN orders
    ON orders.user_id = users.id
   AND orders.amount > 500;

Или явно учитывать NULL в WHERE, если это нужно по логике запроса.

Итог #

FULL OUTER JOIN нужен, когда важно не потерять данные ни с одной стороны.

SELECT *
FROM table_a
FULL OUTER JOIN table_b ON table_b.a_id = table_a.id;

Он возвращает:

совпавшие строки из обеих таблиц;
строки только из левой таблицы;
строки только из правой таблицы.

В обычной backend-разработке он используется реже, чем LEFT JOIN, но очень полезен для сверки, поиска расхождений, аудита и сравнения двух наборов данных.


32. Что такое обычный JOIN в SQL? #

Обычный JOIN — это INNER JOIN #

Когда пишут просто:

SELECT *
FROM users
JOIN orders ON orders.user_id = users.id;

это то же самое, что:

SELECT *
FROM users
INNER JOIN orders ON orders.user_id = users.id;

То есть обычный JOIN возвращает только те строки, для которых нашлось совпадение в обеих таблицах. В PostgreSQL JOIN, INNER JOIN и CROSS JOIN описаны в разделе joined tables; для INNER JOIN результат содержит строки, где условие соединения истинно.

Пример #

Таблица users:

id | name
---+------
1  | Alex
2  | Bob
3  | Tom

Таблица orders:

id | user_id | amount
---+---------+-------
10 | 1       | 500
11 | 1       | 300
12 | 2       | 700

Запрос:

SELECT
    users.id AS user_id,
    users.name,
    orders.id AS order_id,
    orders.amount
FROM users
JOIN orders ON orders.user_id = users.id;

Результат:

user_id | name | order_id | amount
--------+------+----------+-------
1       | Alex | 10       | 500
1       | Alex | 11       | 300
2       | Bob  | 12       | 700

Tom не попал в результат, потому что у него нет заказа. Обычный JOIN не сохраняет строки без совпадений.

Как он работает логически #

Упрощённо:

Берётся строка из users.
PostgreSQL ищет строки из orders, где orders.user_id = users.id.
Если совпадение есть — строка попадает в результат.
Если совпадения нет — строка отбрасывается.

То есть условие:

ON orders.user_id = users.id

говорит базе, какие строки из двух таблиц считаются связанными. PostgreSQL документация описывает join как объединение строк из двух таблиц по выражению, которое определяет, какие строки должны быть сопоставлены.

Главное отличие от LEFT JOIN #

JOIN / INNER JOIN:

Показывает только совпавшие строки.

LEFT JOIN:

Показывает все строки слева, даже если справа совпадения нет.

Пример:

-- Только пользователи с заказами
SELECT *
FROM users
JOIN orders ON orders.user_id = users.id;

-- Все пользователи, даже без заказов
SELECT *
FROM users
LEFT JOIN orders ON orders.user_id = users.id;

Где используют обычный JOIN #

Обычный JOIN используют, когда нужны только связанные данные.

Например:

пользователи, у которых есть заказы;
товары, которые входят в заказ;
комментарии вместе с авторами;
уведомления вместе с пользователем;
файлы вместе с владельцем.

Пример:

SELECT
    orders.id,
    users.name,
    orders.amount
FROM orders
JOIN users ON users.id = orders.user_id;

Смысл:

Показать заказы вместе с пользователями.

Если заказ не связан с существующим пользователем, он не попадёт в результат.

Важный момент #

JOIN может размножать строки.

Если у одного пользователя несколько заказов:

Alex -> order 10
Alex -> order 11

то в результате будет две строки с Alex:

Alex | 10
Alex | 11

Это нормально. JOIN возвращает не «одну строку на пользователя», а все найденные пары строк, которые удовлетворяют условию ON.

Итог #

Обычный JOIN в SQL — это INNER JOIN.

Он возвращает только строки, которые имеют совпадение в обеих таблицах:

SELECT *
FROM table_a
JOIN table_b ON table_b.a_id = table_a.id;

Если совпадения нет — строка не попадает в результат. Для сохранения строк без совпадений используют LEFT JOIN, RIGHT JOIN или FULL OUTER JOIN.


33. Что обычно должно быть условием соединения в JOIN? #

Главное правило #

В обычном JOIN условием соединения чаще всего должно быть сравнение:

дочерняя_таблица.foreign_key = родительская_таблица.primary_key

Например:

SELECT
    orders.id,
    users.username,
    orders.amount
FROM orders
JOIN users ON users.id = orders.user_id;

Здесь условие соединения:

users.id = orders.user_id

То есть заказ связывается с пользователем через внешний ключ orders.user_id, который ссылается на первичный ключ users.id.

PostgreSQL описывает ON как условие, которое определяет, какие строки из двух таблиц считаются совпавшими. Для INNER JOIN в результат попадают пары строк, где это условие истинно.

Типичный вариант #

Есть таблица пользователей:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

Есть таблица заказов:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT REFERENCES users(id),
    amount NUMERIC NOT NULL
);

Правильный JOIN:

SELECT *
FROM orders
JOIN users ON users.id = orders.user_id;

Смысл:

Соедини заказ с тем пользователем, чей users.id равен orders.user_id.

Что обычно стоит в ON #

Чаще всего:

ON child_table.parent_id = parent_table.id

Примеры:

-- Комментарий + его автор
comments.user_id = users.id

-- Заказ + пользователь
orders.user_id = users.id

-- Файл + владелец
files.owner_id = users.id

-- Товар в заказе + сам товар
order_items.product_id = products.id

-- Уведомление + пользователь
notifications.user_id = users.id

То есть условие должно отражать реальную связь между сущностями.

Если связь составная #

Иногда внешний ключ состоит из нескольких колонок. Тогда в JOIN нужно сравнить все части ключа.

Например:

CREATE TABLE order_items (
    order_id BIGINT,
    product_id BIGINT,
    quantity INTEGER NOT NULL,
    PRIMARY KEY (order_id, product_id)
);

Если другая таблица ссылается на такую пару, условие будет составным:

ON a.order_id = b.order_id
AND a.product_id = b.product_id

PostgreSQL поддерживает foreign key по группе колонок; в таком случае набор колонок ссылается на соответствующий набор колонок другой таблицы.

Можно ли соединять не по Primary Key #

Да, можно. JOIN не обязан использовать только PRIMARY KEY.

Технически ON может быть любым булевым выражением:

ON table_a.some_column = table_b.some_column

Но логически это должно быть поле, по которому строки действительно связаны.

Например, можно соединить по уникальному email:

SELECT *
FROM users
JOIN profiles ON profiles.email = users.email;

Но в реальных схемах лучше связывать таблицы через стабильный id, а email оставить как UNIQUE, потому что email может измениться.

Плохие условия соединения #

Плохой пример:

SELECT *
FROM orders
JOIN users ON users.username = orders.comment;

Формально SQL может это выполнить, но связи между username и comment нет. Такой JOIN даст бессмысленные или случайные совпадения.

Ещё частая ошибка:

SELECT *
FROM orders
JOIN users ON orders.id = users.id;

Это почти всегда неверно. orders.id — id заказа, users.id — id пользователя. Они могут случайно совпасть численно, но это не означает, что заказ принадлежит этому пользователю.

Правильно:

SELECT *
FROM orders
JOIN users ON orders.user_id = users.id;

JOIN через USING #

Если в обеих таблицах колонка называется одинаково, можно использовать USING.

Например:

SELECT *
FROM orders
JOIN users USING (user_id);

Но это сработает только если в обеих таблицах есть колонка user_id.

Обычно в нормализованной схеме родительская таблица имеет id, а дочерняя — user_id, поэтому чаще пишут явно:

ON users.id = orders.user_id

PostgreSQL указывает, что условие соединения задаётся через ON, USING или неявно через NATURAL; при этом ON — самый общий вариант.

NATURAL JOIN лучше избегать #

NATURAL JOIN сам соединяет таблицы по колонкам с одинаковыми именами.

Например:

SELECT *
FROM users
NATURAL JOIN orders;

Проблема в том, что если позже в обеих таблицах появится новая колонка с одинаковым названием, смысл запроса может неожиданно измениться.

Поэтому в backend-разработке лучше писать условие явно:

JOIN orders ON orders.user_id = users.id

Итог #

Обычно условие соединения в JOIN должно быть таким:

ON дочерняя_таблица.foreign_key = родительская_таблица.primary_key

Например:

ON orders.user_id = users.id

Это самое правильное и частое условие: оно соединяет строки не по случайному совпадению значений, а по реальной связи между таблицами.


34. Можно ли написать JOIN ON 1=1 и что будет результатом такого соединения? #

Да, можно #

SELECT *
FROM users
JOIN orders ON 1 = 1;

Такой JOIN будет работать. Условие 1 = 1 всегда истинно, поэтому каждая строка из users соединится с каждой строкой из orders.

То есть результатом будет декартово произведение таблиц.

В PostgreSQL CROSS JOIN эквивалентен INNER JOIN ... ON TRUE, а если одна таблица содержит N строк, другая M строк, результат содержит N * M строк.

Пример #

Таблица users:

id | name
---+------
1  | Alex
2  | Bob

Таблица orders:

id | amount
---+-------
10 | 500
11 | 700
12 | 300

Запрос:

SELECT
    users.name,
    orders.id AS order_id,
    orders.amount
FROM users
JOIN orders ON 1 = 1;

Результат:

name | order_id | amount
-----+----------+-------
Alex | 10       | 500
Alex | 11       | 700
Alex | 12       | 300
Bob  | 10       | 500
Bob  | 11       | 700
Bob  | 12       | 300

Было:

2 пользователя
3 заказа

Стало:

2 * 3 = 6 строк

Это то же самое, что CROSS JOIN #

Эти запросы логически эквивалентны:

SELECT *
FROM users
JOIN orders ON 1 = 1;
SELECT *
FROM users
JOIN orders ON TRUE;
SELECT *
FROM users
CROSS JOIN orders;

PostgreSQL также указывает, что CROSS JOIN эквивалентен INNER JOIN ON (TRUE), то есть строки не отбрасываются условием соединения.

Почему это обычно опасно #

Если в одной таблице 100 000 строк, а в другой 50 000 строк:

100 000 * 50 000 = 5 000 000 000 строк

Такой запрос может быть очень тяжёлым: много памяти, много CPU, большой временный результат, медленное выполнение.

Когда это может быть нужно #

JOIN ON 1=1 или CROSS JOIN используют редко, но осознанно:

сгенерировать все комбинации товаров и размеров;
составить все пары дат и категорий;
получить матрицу вариантов;
подмешать одну строку с настройками ко всем строкам результата.

Пример нормального использования:

SELECT
    products.name,
    sizes.size
FROM products
CROSS JOIN sizes;

Если есть 10 товаров и 5 размеров, получится 50 комбинаций.

Итог #

JOIN ON 1=1 разрешён, но это не обычное соединение по связи между таблицами.

JOIN orders ON orders.user_id = users.id

соединяет связанные строки.

JOIN orders ON 1 = 1

соединяет каждую строку с каждой.

По смыслу это CROSS JOIN. В обычных прикладных запросах такое условие почти всегда означает ошибку или забытое условие связи.


35. Как PostgreSQL обеспечивает консистентность данных при репликации? #

PostgreSQL обеспечивает консистентность при репликации через WAL — Write-Ahead Log.

Все изменения сначала записываются в WAL на primary-сервере, затем WAL передаётся на standby/replica, и реплика воспроизводит эти записи в том же порядке. За счёт этого реплика приходит к тому же состоянию данных, что и primary, но с возможной задержкой.

В PostgreSQL параметр wal_level = replica записывает достаточно информации в WAL для архивации, streaming replication и read-only запросов на standby. Для logical replication нужен уровень logical.

Как это работает по шагам #

  1. Клиент выполняет INSERT, UPDATE, DELETE на primary.

  2. PostgreSQL формирует WAL-записи для этих изменений.

  3. При commit запись о транзакции фиксируется в WAL.

  4. Реплика получает WAL по streaming replication или через архив WAL.

  5. Реплика последовательно применяет WAL-записи.

  6. После replay commit-записи изменения становятся видимыми на standby.

PostgreSQL прямо указывает, что после replay commit record на standby изменения этой транзакции становятся видимыми для новых snapshot на standby.

Важный момент: реплика не «сама пересчитывает» данные #

При physical replication standby не выполняет заново SQL-запросы вида:

UPDATE users SET balance = balance - 100 WHERE id = 1;

Она получает уже записанные WAL-изменения и воспроизводит их на уровне хранения данных. Поэтому physical replication ближе к копированию изменений файлов/страниц БД, а не к повторному выполнению бизнес-операций.

В документации PostgreSQL logical replication противопоставляется physical replication: logical работает с объектами данных и их изменениями, а physical использует точные адреса блоков и побайтовую репликацию.

Асинхронная репликация #

По умолчанию streaming replication обычно асинхронная.

Это значит:

primary подтвердил COMMIT клиенту,
но реплика ещё может не успеть получить или применить WAL.

Итог:

на primary данные уже есть;
на replica эти данные появятся чуть позже.

Такой режим даёт лучшую производительность, но при аварии primary возможна потеря последних транзакций, которые ещё не дошли до реплики.

Для чтения с реплики это означает eventual consistency: реплика со временем догоняет primary, но не обязана показывать самый свежий результат прямо сейчас. PostgreSQL прямо пишет, что между primary и standby есть измеримая задержка, поэтому один и тот же запрос почти одновременно на primary и standby может вернуть разные результаты; данные на standby являются eventually consistent относительно primary.

Синхронная репликация #

В синхронном режиме primary может ждать подтверждения от standby перед тем, как окончательно подтвердить commit клиенту.

Для этого настраивают, например:

synchronous_standby_names = 'standby1'
synchronous_commit = on

PostgreSQL указывает, что при такой конфигурации commit ждёт подтверждения, что standby записал commit record в durable storage. Это повышает гарантию, что изменения не будут потеряны при сбое primary, но увеличивает время ответа транзакции минимум на сетевой round-trip до standby.

Есть более строгий вариант:

synchronous_commit = remote_apply

В этом случае standby подтверждает не просто получение/запись WAL, а то, что commit record уже воспроизведён, и транзакция стала видимой на реплике.

Консистентность чтения на реплике #

Standby в режиме Hot Standby может принимать подключения и выполнять read-only запросы. Но запись на такой реплике запрещена: INSERT, UPDATE, DELETE, MERGE, TRUNCATE, COPY FROM не разрешены.

Это важно для консистентности:

primary принимает изменения;
standby только воспроизводит WAL;
пользовательские записи на standby не смешиваются с WAL primary.

То есть реплика не расходится с primary из-за локальных изменений.

Что происходит при конфликте чтения и применения WAL #

На standby могут выполняться долгие SELECT. Одновременно реплика должна применять WAL с primary. Иногда WAL-изменение конфликтует с долгим read-only запросом на standby.

Например, primary удалил строки или выполнил VACUUM, а standby ещё держит snapshot, которому нужны старые версии строк.

В таких случаях PostgreSQL должен выбрать:

или задержать применение WAL;
или отменить конфликтующий запрос на standby.

За это отвечают параметры вроде:

max_standby_streaming_delay
max_standby_archive_delay

Документация указывает, что администратор должен выбирать эти параметры в зависимости от приоритета: высокая доступность и малый replication lag либо более долгие аналитические запросы на standby.

Роль replication slots #

Replication slot помогает не потерять WAL, который ещё нужен реплике.

Без slot primary может удалить старые WAL-сегменты, если реплика сильно отстала. Тогда реплика уже не сможет догнать primary без пересоздания.

С replication slot primary знает, что конкретной реплике ещё нужны определённые WAL-записи, и удерживает их. В настройках PostgreSQL max_replication_slots определяет максимальное число replication slots, а wal_level должен быть replica или выше для их использования.

Минус: если реплика долго не работает, WAL может накапливаться и занять много места на диске.

Logical replication #

При logical replication PostgreSQL реплицирует не весь кластер поблочно, а изменения выбранных таблиц через модель publication/subscription.

Механизм консистентности там другой:

сначала снимается snapshot исходных данных;
таблица копируется на subscriber;
после этого изменения с publisher непрерывно отправляются и применяются на subscriber.

PostgreSQL указывает, что subscriber применяет данные в том же порядке, что и publisher, поэтому transactional consistency гарантируется для publications внутри одной subscription.

Для UPDATE и DELETE в logical replication таблице нужна replica identity — обычно primary key. Она нужна, чтобы subscriber мог понять, какую строку обновлять или удалять.

Итог #

PostgreSQL держит консистентность репликации за счёт нескольких механизмов:

WAL фиксирует все изменения в строгом порядке;
реплика применяет WAL последовательно;
standby не принимает пользовательские записи;
snapshot isolation даёт согласованные read-only запросы;
synchronous replication может ждать подтверждения от standby;
replication slots защищают нужный WAL от преждевременного удаления;
logical replication применяет изменения в порядке publisher внутри subscription.

Главное различие:

асинхронная репликация — быстрее, но возможен lag и потеря последних транзакций при аварии primary;

синхронная репликация — надёжнее по потере данных, но медленнее из-за ожидания подтверждения от standby.


36. Какие связи между таблицами бывают в реляционных БД? (one to one, one to many, many to many) #

Общая идея #

В реляционных БД связь между таблицами обычно строится через:

FOREIGN KEY

То есть одна таблица хранит ссылку на строку из другой таблицы.

Обычно связь выглядит так:

child_table.parent_id REFERENCES parent_table(id)

parent_table.id — это обычно PRIMARY KEY, а child_table.parent_id — внешний ключ. PostgreSQL описывает foreign key как constraint, который указывает, что значения в одной таблице должны соответствовать значениям в другой таблице. Это и есть механизм ссылочной целостности.

Основные типы связей:

one-to-one      -> один к одному
one-to-many     -> один ко многим
many-to-many    -> многие ко многим

One-to-one — один к одному #

Связь one-to-one означает:

одна строка в таблице A соответствует максимум одной строке в таблице B;
одна строка в таблице B соответствует максимум одной строке в таблице A.

Пример:

users
user_profiles

Один пользователь имеет один профиль. Один профиль принадлежит одному пользователю.

Схема:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

CREATE TABLE user_profiles (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT UNIQUE NOT NULL REFERENCES users(id),
    first_name TEXT,
    last_name TEXT,
    bio TEXT
);

Ключевой момент:

user_id BIGINT UNIQUE NOT NULL REFERENCES users(id)

Здесь REFERENCES users(id) говорит: профиль связан с пользователем.

А UNIQUE говорит: один и тот же user_id не может повториться в user_profiles.

Без UNIQUE это была бы уже не one-to-one, а one-to-many.

Пример данных:

users
id | username
---+---------
1  | alex
2  | bob
user_profiles
id | user_id | first_name
---+---------+-----------
1  | 1       | Alex
2  | 2       | Bob

Нельзя вставить второй профиль для пользователя 1:

INSERT INTO user_profiles (user_id, first_name)
VALUES (1, 'Another Alex');

Потому что user_id в user_profiles уникальный.

PostgreSQL UNIQUE constraint гарантирует, что значение в колонке или группе колонок не повторяется среди строк таблицы.

Когда используют one-to-one #

One-to-one используют, когда часть данных логически относится к основной сущности, но её хотят отделить.

Например:

users -> user_profiles
users -> user_settings
users -> user_security_settings
employees -> employee_passports
products -> product_seo_info

Причины выделения в отдельную таблицу:

данные необязательные;
данные редко используются;
данные имеют отдельные права доступа;
данные слишком большие;
нужно разгрузить основную таблицу;
нужно логически отделить разные части модели.

Например, users может хранить только данные для авторизации:

id
username
email
password_hash
created_at

А user_profiles — публичный профиль:

avatar_url
bio
first_name
last_name
birth_date

Альтернативный вариант one-to-one #

Иногда в дочерней таблице user_id делают одновременно PRIMARY KEY и FOREIGN KEY:

CREATE TABLE user_profiles (
    user_id BIGINT PRIMARY KEY REFERENCES users(id),
    first_name TEXT,
    last_name TEXT,
    bio TEXT
);

Тогда в user_profiles нет отдельного id. Первичный ключ профиля — это user_id.

Это тоже one-to-one.

Плюс такого варианта: невозможно создать два профиля на одного пользователя, потому что user_id сам является PRIMARY KEY.

Минус: иногда в ORM удобнее иметь отдельный id.

One-to-many — один ко многим #

Связь one-to-many означает:

одна строка в таблице A может соответствовать многим строкам в таблице B;
каждая строка в таблице B относится к одной строке из таблицы A.

Самый частый тип связи в backend-разработке.

Примеры:

один пользователь -> много заказов;
один автор -> много постов;
одна категория -> много товаров;
один клиент -> много платежей;
одна папка -> много файлов;
один пользователь -> много уведомлений.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    amount NUMERIC NOT NULL
);

Здесь:

orders.user_id REFERENCES users(id)

Означает:

каждый заказ принадлежит какому-то пользователю.

Но orders.user_id не уникальный. Поэтому один пользователь может иметь много заказов.

Пример данных:

users
id | username
---+---------
1  | alex
2  | bob
orders
id | user_id | amount
---+---------+-------
10 | 1       | 500
11 | 1       | 700
12 | 2       | 300

Пользователь alex имеет два заказа:

orders.user_id = 1
orders.user_id = 1

Это нормально для one-to-many.

Как читать one-to-many #

Сторона, где стоит FOREIGN KEY, обычно является стороной many.

В примере:

orders.user_id REFERENCES users(id)

Значит:

users  -> one
orders -> many

То есть:

один user может иметь много orders;
один order принадлежит одному user.

Как получить данные через JOIN #

SELECT
    users.id AS user_id,
    users.username,
    orders.id AS order_id,
    orders.amount
FROM users
JOIN orders ON orders.user_id = users.id;

Результат:

user_id | username | order_id | amount
--------+----------+----------+-------
1       | alex     | 10       | 500
1       | alex     | 11       | 700
2       | bob      | 12       | 300

Здесь строка пользователя alex повторяется два раза, потому что у него два заказа.

Это нормальное поведение JOIN при one-to-many.

Many-to-many — многие ко многим #

Связь many-to-many означает:

одна строка в таблице A может быть связана со многими строками в таблице B;
одна строка в таблице B может быть связана со многими строками в таблице A.

Примеры:

студенты -> курсы;
пользователи -> роли;
посты -> теги;
товары -> заказы;
книги -> авторы;
фильмы -> актёры.

Напрямую через одну колонку такую связь нормально не сделать.

Нужна третья таблица — связующая таблица.

Её ещё называют:

junction table;
association table;
link table;
through table;
промежуточная таблица;
таблица связей.

Пример:

users
roles
user_roles

Один пользователь может иметь много ролей.
Одна роль может быть у многих пользователей.

Схема:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

CREATE TABLE roles (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE user_roles (
    user_id BIGINT NOT NULL REFERENCES users(id),
    role_id BIGINT NOT NULL REFERENCES roles(id),
    PRIMARY KEY (user_id, role_id)
);

Таблица user_roles хранит пары:

user_id | role_id

Пример данных:

users
id | username
---+---------
1  | alex
2  | bob
roles
id | name
---+-------
1  | admin
2  | editor
3  | viewer
user_roles
user_id | role_id
--------+--------
1       | 1
1       | 2
2       | 3

Смысл:

alex имеет роли admin и editor;
bob имеет роль viewer.

Почему в many-to-many нужен составной Primary Key #

В таблице user_roles обычно делают:

PRIMARY KEY (user_id, role_id)

Это запрещает дубликаты связей.

Без такого ограничения можно было бы случайно вставить:

user_id | role_id
--------+--------
1       | 2
1       | 2
1       | 2

То есть один и тот же пользователь получил бы одну и ту же роль несколько раз.

PRIMARY KEY в PostgreSQL означает, что выбранные колонки должны быть уникальными и не NULL; PostgreSQL также автоматически создаёт уникальный B-tree индекс для primary key.

JOIN для many-to-many #

Например, получить пользователей и их роли:

SELECT
    users.username,
    roles.name AS role_name
FROM users
JOIN user_roles ON user_roles.user_id = users.id
JOIN roles ON roles.id = user_roles.role_id;

Результат:

username | role_name
---------+----------
alex     | admin
alex     | editor
bob      | viewer

Здесь связь проходит через промежуточную таблицу:

users -> user_roles -> roles

Many-to-many с дополнительными полями #

Иногда связующая таблица хранит не только user_id и role_id, но и дополнительные данные.

Например, пользователь записался на курс:

CREATE TABLE students (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE courses (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title TEXT NOT NULL
);

CREATE TABLE student_courses (
    student_id BIGINT NOT NULL REFERENCES students(id),
    course_id BIGINT NOT NULL REFERENCES courses(id),
    enrolled_at TIMESTAMP NOT NULL DEFAULT now(),
    grade NUMERIC,
    PRIMARY KEY (student_id, course_id)
);

Здесь student_courses — не просто техническая таблица. Она хранит сам факт записи студента на курс:

когда записался;
какую оценку получил;
статус обучения;
кто добавил запись;
дата завершения.

В таких случаях связующая таблица становится полноценной бизнес-сущностью.

Разница между тремя связями #

Тип связиКак устроенаГде стоит FOREIGN KEYНужен UNIQUE
One-to-one1 строка к 1 строкеВ одной из таблицДа, на FK
One-to-many1 строка к многим строкамНа стороне manyНет
Many-to-manyМногие к многимВ промежуточной таблицеОбычно PRIMARY KEY (fk1, fk2)

Простое правило определения связи #

Смотри, может ли значение foreign key повторяться.

One-to-one #

user_profiles.user_id UNIQUE REFERENCES users(id)

user_id не может повторяться.

Значит:

один user -> максимум один profile

One-to-many #

orders.user_id REFERENCES users(id)

user_id может повторяться.

Значит:

один user -> много orders

Many-to-many #

user_roles.user_id REFERENCES users(id)
user_roles.role_id REFERENCES roles(id)

Обе стороны могут повторяться по отдельности:

user_id = 1 может быть много раз;
role_id = 2 может быть много раз;
но пара (user_id, role_id) должна быть уникальной.

Связь необязательная и обязательная #

Связь может быть обязательной или необязательной.

Обязательная связь:

user_id BIGINT NOT NULL REFERENCES users(id)

Это значит:

заказ не может существовать без пользователя.

Необязательная связь:

user_id BIGINT REFERENCES users(id)

Здесь user_id может быть NULL.

Это значит:

строка может быть не привязана к пользователю.

Например, комментарий может быть оставлен удалённым пользователем, или файл может временно быть без владельца. Но для большинства бизнес-сущностей связь обычно делают NOT NULL.

Что происходит при удалении связанной строки #

Foreign key может иметь правило удаления:

ON DELETE CASCADE
ON DELETE SET NULL
ON DELETE RESTRICT
ON DELETE NO ACTION

Пример:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    amount NUMERIC NOT NULL
);

ON DELETE CASCADE означает:

если удалить пользователя, его заказы тоже удалятся.

Для заказов это часто опасно: исторические заказы обычно не хотят удалять вместе с пользователем.

Для чего-то вроде user_sessions или user_tokens это нормально:

удалили пользователя -> удалили его сессии и токены.

ON DELETE SET NULL:

user_id BIGINT REFERENCES users(id) ON DELETE SET NULL

Означает:

если пользователь удалён, user_id станет NULL.

ON DELETE RESTRICT / NO ACTION обычно не дают удалить родительскую строку, пока на неё есть ссылки.

PostgreSQL поддерживает действия foreign key при удалении/обновлении, включая CASCADE, SET NULL, RESTRICT, NO ACTION и SET DEFAULT.

Примеры из backend-разработки #

Пользователь и уведомления #

users -> notifications

Тип связи:

one-to-many

Один пользователь может иметь много уведомлений.

CREATE TABLE notifications (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    type TEXT NOT NULL,
    text TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Пользователь и профиль #

users -> user_profiles

Тип связи:

one-to-one
CREATE TABLE user_profiles (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT UNIQUE NOT NULL REFERENCES users(id),
    avatar_url TEXT,
    bio TEXT
);

Пользователь и роли #

users -> roles

Тип связи:

many-to-many
CREATE TABLE user_roles (
    user_id BIGINT NOT NULL REFERENCES users(id),
    role_id BIGINT NOT NULL REFERENCES roles(id),
    PRIMARY KEY (user_id, role_id)
);

Посты и теги #

posts -> tags

Тип связи:

many-to-many
CREATE TABLE posts (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title TEXT NOT NULL
);

CREATE TABLE tags (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE post_tags (
    post_id BIGINT NOT NULL REFERENCES posts(id),
    tag_id BIGINT NOT NULL REFERENCES tags(id),
    PRIMARY KEY (post_id, tag_id)
);

Один пост может иметь много тегов.
Один тег может быть у многих постов.

Важный практический момент #

Связь между таблицами — это не просто JOIN в запросе.

Правильная связь в схеме БД обычно должна быть закреплена constraint-ами:

PRIMARY KEY;
FOREIGN KEY;
UNIQUE;
NOT NULL;
ON DELETE / ON UPDATE правила.

Без foreign key таблицы можно соединять через JOIN, но база сама не будет защищать данные от нарушения ссылочной целостности.

Например, без FOREIGN KEY можно вставить заказ на несуществующего пользователя:

INSERT INTO orders (user_id, amount)
VALUES (999, 1000);

Если пользователя 999 нет, это будет «битая ссылка».

С FOREIGN KEY база не даст вставить такую строку.

Итог #

В реляционных БД обычно выделяют три основные связи.

One-to-one:

одна запись связана максимум с одной записью;
реализуется через FOREIGN KEY + UNIQUE.

Пример:

users -> user_profiles

One-to-many:

одна запись связана со многими;
FOREIGN KEY стоит на стороне many.

Пример:

users -> orders
users -> notifications
categories -> products

Many-to-many:

многие записи одной таблицы связаны со многими записями другой;
реализуется через промежуточную таблицу.

Пример:

users -> user_roles -> roles
posts -> post_tags -> tags
students -> student_courses -> courses

Самое важное правило: тип связи определяется не названием таблиц, а ограничениями в схеме — где стоит FOREIGN KEY, есть ли UNIQUE, и есть ли промежуточная таблица.


37. Что такое репликация и шардирование в базах данных? #

Общая идея #

Репликация и шардирование — это два разных способа масштабировать и повышать надёжность базы данных.

Они решают разные задачи:

репликация  -> копирует одни и те же данные на несколько серверов;
шардирование -> делит данные на части и раскладывает их по разным серверам.

То есть:

Репликация = копии.
Шардирование = разделение.

Репликация #

Репликация — это механизм, при котором данные с одного сервера БД передаются на другой сервер или несколько серверов.

Обычно есть:

primary / master  -> основной сервер, принимает записи;
replica / standby -> копия, получает изменения с primary.

В PostgreSQL серверы, которые отслеживают изменения primary, называются standby или secondary servers. Standby может использоваться для высокой доступности, восстановления после сбоя и распределения read-only нагрузки.

Простая схема:

             ┌──────────────┐
WRITE ------>│   Primary    │
             └──────┬───────┘
                    │ WAL / changes
        ┌───────────┴───────────┐
        ↓                       ↓
 ┌──────────────┐        ┌──────────────┐
 │  Replica 1   │        │  Replica 2   │
 └──────────────┘        └──────────────┘
     READ                    READ

Что даёт репликация #

Репликация нужна для нескольких целей.

1. Отказоустойчивость #

Если primary-сервер упал, одну из реплик можно повысить до нового primary.

Например:

Primary сломался
Replica становится новым Primary
Приложение продолжает работать

Это не всегда происходит само по себе. Часто нужен отдельный механизм failover: Patroni, repmgr, pg_auto_failover, Kubernetes-operator, облачный managed PostgreSQL и т.д.

Но сама база предоставляет фундамент: standby-серверы и передачу WAL.

2. Масштабирование чтения #

Можно отправлять тяжёлые SELECT-запросы на реплики, а записи оставлять на primary.

Например:

INSERT / UPDATE / DELETE -> primary
SELECT для API          -> primary или replica
SELECT для аналитики    -> replica
отчёты                 -> replica

В PostgreSQL Hot Standby позволяет подключаться к standby-серверу и выполнять read-only запросы во время recovery/standby-режима.

3. Резервная копия почти в реальном времени #

Реплика может быть почти актуальной копией primary.

Но важно: репликация не заменяет backup.

Если на primary случайно удалить таблицу:

DROP TABLE users;

это удаление тоже уйдёт на реплики.

Поэтому нужны отдельно:

backup;
point-in-time recovery;
архив WAL;
защита от человеческих ошибок.

Как работает репликация в PostgreSQL #

В PostgreSQL основа физической репликации — WAL, Write-Ahead Log.

Все изменения сначала попадают в WAL, затем реплика получает эти WAL-записи и воспроизводит их у себя.

Упрощённо:

1. Клиент делает INSERT/UPDATE/DELETE на primary.
2. PostgreSQL записывает изменение в WAL.
3. Primary подтверждает COMMIT.
4. WAL передаётся на standby.
5. Standby применяет WAL у себя.
6. Реплика догоняет primary.

PostgreSQL поддерживает standby через file-based log shipping, streaming replication или их комбинацию.

Асинхронная репликация #

В асинхронном режиме primary не ждёт, пока реплика применит изменения.

Клиент сделал COMMIT.
Primary подтвердил успех.
Replica получит изменение чуть позже.

Плюсы:

быстрее запись;
меньше задержка для клиента;
проще выдерживать высокую нагрузку.

Минусы:

реплика может отставать;
после COMMIT на primary данные могут ещё не быть видны на replica;
при аварии primary можно потерять последние транзакции, которые не успели попасть на replica.

Это важный практический момент. Если приложение после записи сразу читает с реплики, оно может не увидеть только что созданную запись.

Например:

POST /orders -> запись на primary успешна
GET /orders/123 -> чтение ушло на replica
replica ещё не догнала primary
приложение получило 404

Это называется replication lag.

Синхронная репликация #

В синхронном режиме primary ждёт подтверждения от standby.

Упрощённо:

Клиент делает COMMIT.
Primary отправляет WAL на standby.
Standby подтверждает получение/запись/применение.
Primary подтверждает COMMIT клиенту.

В PostgreSQL синхронная репликация настраивается через synchronous_standby_names и synchronous_commit. Документация указывает, что синхронная репликация позволяет ждать подтверждения от standby, но увеличивает время commit как минимум на сетевую задержку до standby.

Плюсы:

меньше риск потери подтверждённых транзакций;
лучше консистентность между primary и standby;
подходит для критичных данных.

Минусы:

запись медленнее;
при проблемах с репликой primary может начать тормозить;
нужна аккуратная настройка.

Physical replication и logical replication #

В PostgreSQL есть два важных подхода.

Physical replication #

Физическая репликация копирует изменения на уровне WAL/страниц хранения.

Особенности:

реплицируется весь кластер или база на физическом уровне;
standby обычно является точной копией primary;
хорошо подходит для failover и read-only реплик;
реплика обычно не принимает записи от приложения.

Это типичный вариант для standby-сервера.

Logical replication #

Logical replication работает на уровне таблиц и изменений строк.

В PostgreSQL logical replication использует модель publication/subscription: publisher публикует изменения, subscriber подписывается на них.

Пример:

Publisher:
  таблица users
  таблица orders

Subscriber:
  получает изменения этих таблиц

Publication — это набор изменений из одной или нескольких таблиц.

Logical replication используют, когда нужно:

реплицировать не всё, а только часть таблиц;
переносить данные между разными версиями PostgreSQL;
строить интеграции;
передавать изменения в аналитическую БД;
делать миграции с минимальным downtime.

Но logical replication сложнее: нужно думать о primary key / replica identity, конфликтах, DDL-изменениях, последовательностях, ограничениях.

Шардирование #

Шардирование — это разделение данных на части, где каждая часть хранится на отдельном сервере или узле.

Эти части называются:

shard;
шард;
партиция на уровне кластера;
кусок данных.

Простая схема:

              ┌──────────────┐
              │  Application │
              └──────┬───────┘
              ┌──────────────┐
              │ Router/Logic │
              └──────┬───────┘
        ┌────────────┼────────────┐
        ↓            ↓            ↓
 ┌──────────┐  ┌──────────┐  ┌──────────┐
 │ Shard 1  │  │ Shard 2  │  │ Shard 3  │
 │ users 1k │  │ users 2k │  │ users 3k │
 └──────────┘  └──────────┘  └──────────┘

Например, таблица users слишком большая. Её можно разделить:

Shard 1: users с id 1–10_000_000
Shard 2: users с id 10_000_001–20_000_000
Shard 3: users с id 20_000_001–30_000_000

Или по хэшу:

hash(user_id) % 3 = 0 -> shard 1
hash(user_id) % 3 = 1 -> shard 2
hash(user_id) % 3 = 2 -> shard 3

Что даёт шардирование #

Шардирование нужно, когда один сервер уже не справляется по:

объёму данных;
количеству записей;
CPU;
RAM;
I/O;
размеру индексов;
количеству подключений;
пропускной способности диска/сети.

Репликация помогает масштабировать чтение, но не решает полностью проблему записи.

Если все записи идут на один primary, то он остаётся бутылочным горлышком:

100 реплик помогают читать;
но INSERT/UPDATE/DELETE всё равно идут в один primary.

Шардирование позволяет распределить записи:

user_id 1 пишет в shard 1;
user_id 2 пишет в shard 2;
user_id 3 пишет в shard 3.

То есть нагрузка на запись распределяется между несколькими серверами.

Пример шардирования по user_id #

Допустим, есть таблица:

CREATE TABLE notifications (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    text TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Если уведомлений очень много, можно шардировать по user_id.

Логика:

notifications пользователя 10 -> shard A
notifications пользователя 11 -> shard B
notifications пользователя 12 -> shard C

Приложение или роутер вычисляет, куда идти:

shard = hash(user_id) % shard_count

Плюс такого подхода:

все уведомления конкретного пользователя лежат на одном шарде;
запрос "покажи уведомления пользователя" идёт только в один shard;
записи распределены между несколькими серверами.

Минус:

запрос "покажи последние 100 уведомлений всех пользователей" должен идти во все шарды.

Виды шардирования #

1. Range-based sharding #

Данные делятся по диапазонам.

Например:

users.id 1 - 1_000_000       -> shard 1
users.id 1_000_001 - 2_000_000 -> shard 2
users.id 2_000_001 - 3_000_000 -> shard 3

Плюсы:

просто понять;
удобно для диапазонных запросов;
легче архивировать старые диапазоны.

Минусы:

может быть перекос нагрузки;
новые записи часто попадают в последний shard;
один shard может стать горячим.

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

2. Hash-based sharding #

Данные распределяются через хэш.

shard = hash(user_id) % N

Плюсы:

нагрузка обычно распределяется равномернее;
меньше риск горячего последнего shard.

Минусы:

сложнее делать range-запросы;
сложнее менять количество шардов;
нужно аккуратно выбирать shard key.

3. List-based / tenant-based sharding #

Данные делятся по конкретным значениям.

Например:

tenant_id = 1, 2, 3     -> shard A
tenant_id = 4, 5, 6     -> shard B
tenant_id = 7, 8, 9     -> shard C

Часто используется в multi-tenant системах:

один клиент/организация/tenant хранится на конкретном shard.

Плюс:

данные одного tenant локализованы;
проще изолировать крупных клиентов;
удобно переносить tenant между shard.

Минус:

один крупный tenant может перегрузить свой shard;
нужен механизм ребалансировки.

Shard key #

Самое важное решение при шардировании — выбор shard key.

Shard key — это колонка, по которой база или приложение решает, на каком шарде хранить строку.

Примеры:

user_id;
tenant_id;
account_id;
organization_id;
region_id;
created_at;
order_id.

Хороший shard key должен:

равномерно распределять данные;
совпадать с частыми запросами;
минимизировать cross-shard операции;
быть стабильным;
не меняться часто.

Например, если почти все запросы такие:

SELECT *
FROM notifications
WHERE user_id = 123
ORDER BY created_at DESC;

то user_id — хороший кандидат для shard key.

Если же часто нужны глобальные запросы:

SELECT *
FROM notifications
ORDER BY created_at DESC
LIMIT 100;

то шардирование по user_id усложнит жизнь: придётся ходить во все шарды, собирать результаты и сортировать глобально.

В Citus, расширении для распределённого PostgreSQL, distributed tables раскладываются по шардам на worker-узлах, а distribution column используется для назначения строк таблицы шардам.

Разница между partitioning и sharding #

Важно не путать.

Partitioning — это разделение таблицы на части внутри одной логической БД, часто на одном сервере.

Например в PostgreSQL:

orders_2025
orders_2026
orders_2027

Но все они могут находиться в одном PostgreSQL-инстансе.

Sharding — это распределение данных между разными серверами/узлами.

Shard 1 -> server A
Shard 2 -> server B
Shard 3 -> server C

PostgreSQL поддерживает declarative partitioning внутри БД, но это само по себе не равно полноценному автоматическому шардированию между серверами. Для распределения данных по нескольким PostgreSQL-узлам обычно используют дополнительные решения: Citus, application-level sharding, FDW-подходы, cloud distributed databases и т.д.

postgres_fdw позволяет обращаться к данным на внешних PostgreSQL-серверах как к foreign tables. Это может использоваться как строительный блок для распределённых схем, но само по себе не превращает обычный PostgreSQL в полностью автоматическую шардированную СУБД.

Репликация vs шардирование #

КритерийРепликацияШардирование
Что делаетКопирует данныеДелит данные
Где лежит одна и та же строкаНа primary и репликахОбычно только на одном shard
Главная цельНадёжность, read scaling, failoverГоризонтальное масштабирование объёма и записи
ЗаписьОбычно на primaryНа разные shard
ЧтениеМожно читать с репликНужно знать, в каком shard данные
СложностьСредняяВысокая
Типичная проблемаreplication lagcross-shard queries/transactions
Примерprimary + standbyusers разбиты по user_id

Простое сравнение #

Репликация:

Server A: users 1..100
Server B: users 1..100
Server C: users 1..100

Шардирование:

Server A: users 1..33
Server B: users 34..66
Server C: users 67..100

Репликация повышает доступность и помогает читать быстрее.

Шардирование позволяет хранить и обрабатывать больше данных, чем помещается на один сервер.

Можно ли использовать вместе #

Да. В больших системах репликацию и шардирование часто используют вместе.

Например:

Shard 1:
  primary_1
  replica_1a
  replica_1b

Shard 2:
  primary_2
  replica_2a
  replica_2b

Shard 3:
  primary_3
  replica_3a
  replica_3b

То есть каждый shard может иметь свои реплики.

Схема:

                Application
              Shard Router
        ┌───────────┼───────────┐
        ↓           ↓           ↓
     Shard 1     Shard 2     Shard 3
     Primary     Primary     Primary
       │           │           │
       ↓           ↓           ↓
    Replica     Replica     Replica

Так достигают сразу двух целей:

шардирование распределяет объём данных и запись;
репликация повышает отказоустойчивость каждого shard и помогает читать.

Пример на практике #

Допустим, есть сервис уведомлений.

Пока проект маленький:

один PostgreSQL;
таблица users;
таблица notifications;
обычные индексы.

Потом нагрузка выросла.

Первый шаг обычно не шардирование, а оптимизация:

индексы;
EXPLAIN ANALYZE;
оптимизация запросов;
партиционирование по дате;
архивирование старых данных;
увеличение ресурсов сервера;
connection pooling;
кэширование;
read replica для тяжёлых SELECT.

Потом можно добавить реплику:

primary принимает записи;
replica обслуживает отчёты и read-only запросы.

Если и этого мало, и один primary не справляется с объёмом записей, тогда думают о шардировании:

notifications шардируются по user_id;
каждый shard хранит уведомления своей группы пользователей;
запросы по конкретному пользователю идут в один shard.

Основные проблемы репликации #

Replication lag #

Реплика может отставать.

Пример:

на primary пользователь создал заказ;
на replica заказ появится через 100 мс, 1 секунду или больше;
если читать сразу с replica, можно получить старые данные.

Решения:

после записи читать с primary;
использовать sticky reads;
отслеживать lag;
для критичных операций использовать synchronous replication;
не отправлять read-after-write запросы на отстающие реплики.

Failover не всегда мгновенный #

При падении primary нужно:

выбрать новую primary;
убедиться, что она самая свежая;
перенастроить клиентов;
избежать split-brain;
поднять новую реплику.

Split-brain — ситуация, когда два узла считают себя primary и принимают записи. Это опасно, потому что данные расходятся.

Репликация не защищает от логических ошибок #

Если приложение массово испортило данные:

UPDATE users SET balance = 0;

эта ошибка тоже уйдёт на реплики.

Поэтому нужны backup и возможность восстановления на момент времени.

Основные проблемы шардирования #

Cross-shard queries #

Если запрос затрагивает один shard — всё хорошо.

SELECT *
FROM notifications
WHERE user_id = 123;

Если user_id — shard key, запрос идёт в один shard.

Но если запрос глобальный:

SELECT *
FROM notifications
WHERE created_at > now() - interval '1 day'
ORDER BY created_at DESC
LIMIT 100;

он может требовать обхода всех shard.

Это сложнее:

выполнить запрос на каждом shard;
собрать результаты;
отсортировать;
применить LIMIT;
вернуть клиенту.

Cross-shard transactions #

Транзакция внутри одного PostgreSQL-сервера относительно проста:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

Но если account_id = 1 находится на shard A, а account_id = 2 на shard B, нужна распределённая транзакция.

Это уже сложнее:

координация между shard;
двухфазный commit;
риск частичных сбоев;
больше задержка;
сложнее восстановление.

Поэтому при шардировании стараются проектировать модель так, чтобы большинство операций происходило внутри одного shard.

Сложность JOIN #

Обычный JOIN между таблицами на одном сервере прост:

SELECT *
FROM orders
JOIN users ON users.id = orders.user_id;

В шардированной системе может оказаться, что orders и users лежат на разных shard. Тогда JOIN становится распределённым.

Поэтому часто стараются:

шардировать связанные таблицы по одному ключу;
держать данные одного пользователя/tenant на одном shard;
реплицировать маленькие справочники на все shard;
денормализовать часть данных.

Citus, например, использует distributed tables и reference tables: distributed tables шардируются по worker-узлам, а reference tables реплицируются на все узлы для join и foreign key-сценариев.

Ребалансировка #

Если shard стал слишком большим, данные нужно переносить.

Например:

Shard 1: 900 GB
Shard 2: 300 GB
Shard 3: 280 GB

Нужно перенести часть данных с shard 1 на другие shard. Это называется rebalance.

Проблемы:

перенос больших объёмов данных;
сохранение консистентности;
минимальный downtime;
обновление маршрутизации;
контроль нагрузки во время миграции.

Когда нужна репликация #

Репликация нужна почти всегда в production, если важна доступность данных.

Типичные причины:

нужен failover;
нужно читать с копии;
нужны отчёты без нагрузки на primary;
нужно повысить отказоустойчивость;
нужна географическая копия данных.

Для обычного backend-проекта репликация появляется значительно раньше, чем шардирование.

Когда нужно шардирование #

Шардирование нужно не сразу.

Оно оправдано, когда:

один сервер уже не выдерживает объём данных;
вертикальное масштабирование стало слишком дорогим;
индексы стали слишком большими;
запись упёрлась в один primary;
партиционирование и оптимизация уже не помогают;
данные естественно делятся по tenant/user/region.

Шардирование — дорогая архитектурная мера. Его не стоит делать заранее «на всякий случай», потому что оно сильно усложняет разработку.

Частая ошибка #

Считать, что репликация и шардирование — одно и то же.

Это разные вещи.

Репликация отвечает на вопрос:

Как сделать копии данных?

Шардирование отвечает на вопрос:

Как разделить данные между несколькими узлами?

Ещё одна частая ошибка #

Считать, что read replica решает проблему любой нагрузки.

Реплика помогает, если проблема в чтении:

много SELECT;
тяжёлые отчёты;
аналитические запросы;
нагрузка на чтение выше нагрузки на запись.

Но если проблема в записи:

очень много INSERT;
очень много UPDATE;
огромный поток событий;
один primary не успевает писать WAL;
индексы слишком тяжёлые;

то реплики не разгрузят primary по записи. Здесь могут понадобиться:

оптимизация схемы;
batch insert;
очереди;
партиционирование;
архивирование;
шардирование;
вынос части нагрузки в другую систему.

Итог #

Репликация — это когда одна и та же база или часть данных копируется на другие серверы.

Она нужна для:

отказоустойчивости;
failover;
масштабирования чтения;
резервной копии почти в реальном времени;
географического распределения.

Шардирование — это когда данные делятся на части и каждая часть хранится на своём сервере.

Оно нужно для:

горизонтального масштабирования;
распределения объёма данных;
распределения нагрузки на запись;
уменьшения размера отдельных таблиц и индексов;
работы с очень большими объёмами данных.

Главное различие:

Репликация: каждый сервер хранит копию одних и тех же данных.
Шардирование: каждый сервер хранит свою часть данных.

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


38. Какие бывают способы репликации в базах данных? #

Общая классификация #

Способы репликации можно классифицировать по нескольким признакам:

1. По уровню данных:
   physical replication;
   logical replication.

2. По моменту подтверждения записи:
   asynchronous replication;
   synchronous replication;
   semi-synchronous replication.

3. По направлению записи:
   single-primary / primary-replica;
   multi-primary / multi-master;
   peer-to-peer.

4. По способу передачи изменений:
   WAL/binlog/log-based replication;
   statement-based replication;
   row-based replication;
   snapshot replication;
   trigger-based replication.

5. По топологии:
   primary -> replica;
   primary -> many replicas;
   cascading replication;
   bidirectional replication;

На практике чаще всего в backend-разработке встречается схема:

один primary принимает запись;
одна или несколько replica принимают чтение;
изменения передаются через WAL/binlog;
режим чаще асинхронный или синхронный.

1. Physical replication #

Physical replication — это репликация на физическом уровне хранения данных.

В PostgreSQL такой подход обычно связан с WAL — Write-Ahead Log. Primary записывает изменения в WAL, а standby получает и воспроизводит эти WAL-записи. Реплика получается почти точной копией primary на уровне файлов/страниц хранения.

PostgreSQL описывает streaming replication так: primary-серверы отправляют данные, standby-серверы получают их; при cascading replication standby может быть одновременно получателем и отправителем.

Схема:

Client
  |
  v
Primary PostgreSQL
  |
  | WAL stream
  v
Standby PostgreSQL

Особенности physical replication:

реплицируется весь кластер/инстанс или большая физическая часть данных;
реплика обычно read-only;
хорошо подходит для failover;
хорошо подходит для read replica;
реплика должна быть совместимой с primary по версии и формату хранения;
нельзя удобно выбрать только одну таблицу для репликации.

Пример использования:

production primary;
standby для аварийного переключения;
read replica для отчётов;
резервная копия почти в реальном времени.

В PostgreSQL это один из самых типичных вариантов для отказоустойчивости.


2. Logical replication #

Logical replication — это репликация не физических страниц, а логических изменений данных.

То есть передаются не «изменения блоков на диске», а смысловые операции над строками:

вставлена строка;
обновлена строка;
удалена строка.

В PostgreSQL logical replication строит поток логических изменений из WAL и позволяет реплицировать изменения на уровне отдельных таблиц. Документация PostgreSQL также указывает, что сервер может публиковать свои изменения и одновременно подписываться на изменения другого сервера, что позволяет организовывать поток данных в нескольких направлениях.

Схема:

Publisher
  |
  | logical changes
  v
Subscriber

В PostgreSQL это обычно модель:

publication -> subscription

Пример:

CREATE PUBLICATION app_publication
FOR TABLE users, orders;
CREATE SUBSCRIPTION app_subscription
CONNECTION 'host=primary dbname=app user=repl password=secret'
PUBLICATION app_publication;

Когда полезна logical replication:

нужно реплицировать не всю базу, а конкретные таблицы;
нужно мигрировать данные между версиями PostgreSQL;
нужно передавать данные в аналитическую БД;
нужно построить интеграцию между системами;
нужно делать zero/minimal downtime migration;
нужно реплицировать данные между разными схемами или частично разными структурами.

Минусы:

сложнее physical replication;
DDL не всегда реплицируется так же просто, как DML;
нужно думать о primary key / replica identity;
могут быть конфликты;
последовательности sequence требуют отдельного внимания;
не всегда подходит как полноценная аварийная копия всего кластера.

3. Asynchronous replication #

Асинхронная репликация — это режим, при котором primary подтверждает транзакцию клиенту, не дожидаясь, пока replica получит или применит изменения.

Схема:

1. Клиент отправляет INSERT.
2. Primary записывает изменение.
3. Primary подтверждает COMMIT клиенту.
4. Replica получает изменение позже.

Плюсы:

быстрая запись;
меньше задержка для клиента;
реплика может временно отставать без остановки primary;
простая и распространённая схема.

Минусы:

есть replication lag;
после записи чтение с replica может вернуть старые данные;
при аварии primary можно потерять последние подтверждённые транзакции, если они не успели попасть на replica.

Пример проблемы:

POST /orders
  -> запись ушла на primary
  -> клиент получил 201 Created

GET /orders/123
  -> запрос ушёл на replica
  -> replica ещё не получила WAL/binlog
  -> клиент получил 404

Это не ошибка SQL как таковая. Это нормальное следствие асинхронной репликации.

MySQL документация описывает исходную модель репликации как one-way asynchronous replication: один server source, один или несколько replicas. Там же указано, что MySQL дополнительно поддерживает semisynchronous replication.


4. Synchronous replication #

Синхронная репликация — это режим, при котором primary подтверждает commit только после подтверждения со стороны replica.

Упрощённо:

1. Клиент делает COMMIT.
2. Primary записывает изменение.
3. Primary отправляет изменение на replica.
4. Replica подтверждает получение/запись/применение.
5. Primary подтверждает COMMIT клиенту.

Плюсы:

меньше риск потери подтверждённых транзакций;
лучше гарантия консистентности между primary и replica;
подходит для критичных данных.

Минусы:

запись медленнее;
latency зависит от сети между primary и replica;
проблемы с replica могут тормозить primary;
нужна аккуратная настройка quorum/standby.

PostgreSQL в разделе High Availability указывает, что некоторые решения являются synchronous: data-modifying transaction не считается committed, пока все серверы не зафиксировали транзакцию.

В PostgreSQL есть разные уровни ожидания через synchronous_commit, например ожидание записи на удалённой стороне или ожидание применения. В практическом смысле это позволяет выбирать баланс между скоростью и надёжностью.


5. Semi-synchronous replication #

Полусинхронная репликация — промежуточный вариант между asynchronous и fully synchronous.

Идея:

primary ждёт подтверждения хотя бы от одной replica,
что та получила и записала события,
но не обязательно ждёт, что replica полностью применит изменения.

MySQL документация описывает semisynchronous replication именно как промежуточный режим: source ждёт, пока хотя бы одна replica получит и залогирует events, но не ждёт подтверждения от всех replicas и не требует, чтобы события были полностью выполнены и committed на стороне replica.

Плюсы:

надёжнее, чем чисто асинхронный режим;
меньше риск потери данных при падении primary;
обычно быстрее, чем полностью синхронная схема.

Минусы:

всё равно возможен lag применения на replica;
не всегда даёт строгую консистентность чтения;
при проблемах с replica может деградировать или замедлять запись.

6. Statement-based replication #

Statement-based replication — репликация SQL-выражений.

Источник записывает в лог не сами изменённые строки, а SQL-команды:

UPDATE products
SET price = price * 1.1
WHERE category_id = 5;

Replica потом выполняет этот statement у себя.

MySQL документация выделяет Statement Based Replication, Row Based Replication и Mixed Based Replication как основные форматы репликации. Statement Based Replication реплицирует SQL statements, Row Based Replication — изменённые строки.

Плюсы statement-based:

лог может быть компактнее;
понятнее, какие SQL-команды выполнялись;
иногда меньше объём передаваемых данных.

Минусы:

недетерминированные функции могут дать разный результат;
запрос может выполниться иначе из-за отличий окружения;
сложные UPDATE/DELETE должны быть переисполнены на replica;
выше риск расхождений при небезопасных statements.

Пример потенциально опасной логики:

UPDATE users
SET token = random()
WHERE id = 1;

Если на replica заново выполнить random(), результат может отличаться. Поэтому statement-based replication требует осторожности.


7. Row-based replication #

Row-based replication — репликация изменений конкретных строк.

Источник пишет в лог примерно такую информацию:

в таблице products строка id=10:
старое price = 100;
новое price = 110.

MySQL документация описывает row-based logging так: source записывает в binary log events, которые показывают, как изменились отдельные строки таблиц; replica копирует эти events. В MySQL row-based logging является default method.

Плюсы:

более детерминированно;
replica не должна заново вычислять весь SQL;
лучше для сложных или недетерминированных изменений;
меньше риск разных результатов между source и replica.

Минусы:

лог может быть намного больше;
массовый UPDATE может породить много row events;
сложнее читать глазами.

Пример:

UPDATE users SET is_active = false;

Если затронуто 5 миллионов строк, row-based replication может записать изменения по миллионам строк, а не одну SQL-команду.


8. Mixed replication #

Mixed replication — гибрид statement-based и row-based.

Идея:

обычно использовать statement-based;
для небезопасных случаев переключаться на row-based.

MySQL документация прямо выделяет Mixed Based Replication как третий формат помимо SBR и RBR.

На практике это компромисс:

где statement безопасен — лог компактнее;
где statement опасен — используется row-based.

9. Snapshot replication #

Snapshot replication — это репликация снимка данных целиком или выбранной части данных на определённый момент.

Идея:

сделать снимок таблиц;
передать снимок получателю;
периодически повторять полное обновление или использовать как начальную загрузку.

SQL Server документация указывает, что snapshot replication используется для предоставления начального набора данных для transactional и merge replication, а также подходит, когда уместны полные обновления данных.

Когда полезно:

маленькие справочники;
редко меняющиеся данные;
периодическая синхронизация;
начальная загрузка перед дальнейшей репликацией изменений.

Пример:

раз в сутки копировать справочник городов;
раз в час обновлять каталог товаров в read-only хранилище;
первично загрузить subscriber перед transactional replication.

Минусы:

не подходит для частых real-time изменений;
может быть тяжёлой при больших таблицах;
между снимками данные устаревают.

10. Transactional replication #

Transactional replication — репликация изменений транзакций после начального snapshot.

Идея:

сначала делается начальная копия;
затем изменения транзакций передаются подписчикам.

В SQL Server это отдельный тип репликации. Документация Microsoft выделяет snapshot, transactional и merge replication как основные типы SQL Server replication.

Когда полезно:

нужно близкое к реальному времени распространение изменений;
нужны read-only копии для отчётов;
нужно передавать изменения из OLTP в reporting database;
нужно синхронизировать выбранные таблицы.

В PostgreSQL близкую по идее роль часто играет logical replication, хотя терминология и реализация отличаются.


11. Merge replication #

Merge replication — это репликация, при которой изменения могут происходить на нескольких узлах, а потом эти изменения синхронизируются и объединяются.

SQL Server документация описывает merge replication так: она обычно начинается со snapshot, а последующие изменения данных и схемы на Publisher и Subscribers отслеживаются с помощью triggers; Subscriber синхронизируется с Publisher при подключении и обменивается изменёнными строками с момента последней синхронизации.

Где используется:

мобильные клиенты;
офлайн-режим;
филиалы, которые временно работают без связи;
распределённые приложения, где изменения могут появляться в разных местах.

Плюсы:

узлы могут работать автономно;
после восстановления связи изменения можно объединить;
подходит для offline-first сценариев.

Минусы:

сложное разрешение конфликтов;
сложнее модель консистентности;
нужно отслеживать изменения;
могут быть конфликтующие UPDATE одной строки на разных узлах.

Пример конфликта:

Филиал A изменил phone клиента на 111.
Филиал B изменил phone того же клиента на 222.
При синхронизации нужно решить, какое значение победит.

12. Single-primary replication #

Single-primary replication — один основной сервер принимает запись, остальные являются репликами.

Это самый частый вариант:

Primary:
  INSERT / UPDATE / DELETE

Replicas:
  SELECT
  failover candidates

Схема:

        writes
          |
          v
      Primary
      /  |   \
     v   v    v
Replica Replica Replica

Плюсы:

простая модель записи;
меньше конфликтов;
проще поддерживать консистентность;
хорошо подходит для большинства backend-сервисов.

Минусы:

primary остаётся узким местом для записи;
при failover нужна смена primary;
read-after-write с replica требует осторожности.

13. Multi-primary / multi-master replication #

Multi-primary или multi-master replication — несколько узлов могут принимать запись.

Схема:

Primary A <----> Primary B <----> Primary C

Плюсы:

можно писать в разные регионы;
меньше задержка для географически распределённых пользователей;
выше доступность записи при отказе одного узла.

Минусы:

сложные конфликты;
нужна координация;
сложнее транзакции;
сложнее уникальные ключи;
сложнее гарантировать строгую консистентность.

Пример проблемы:

Node A создал пользователя с email test@example.com.
Node B почти одновременно создал пользователя с тем же email.
Оба локально считают операцию успешной.
При синхронизации возникает конфликт UNIQUE.

Поэтому multi-master обычно применяют осторожно и только когда есть реальная потребность.


14. Cascading replication #

Cascading replication — это топология, где replica получает изменения не напрямую от primary, а от другой replica.

Схема:

Primary
  |
  v
Replica 1
  |
  v
Replica 2
  |
  v
Replica 3

PostgreSQL документация указывает, что при cascading replication standby-серверы могут быть не только получателями, но и отправителями данных.

Зачем это нужно:

снизить нагрузку на primary;
удобно строить реплики в удалённых регионах;
можно сделать промежуточный узел-распространитель WAL;
полезно при большом количестве replica.

Минусы:

увеличивается задержка на дальних replica;
если промежуточная replica упала, нижние replica могут перестать получать изменения;
топология становится сложнее.

15. Trigger-based replication #

Trigger-based replication — изменения отслеживаются через триггеры в БД.

Идея:

на INSERT/UPDATE/DELETE срабатывает trigger;
trigger пишет изменение в служебную таблицу;
отдельный процесс забирает эти изменения и отправляет в другую БД.

Такой подход встречается в некоторых системах синхронизации, CDC-инструментах, legacy-интеграциях.

Плюсы:

можно гибко выбирать, что реплицировать;
можно добавлять бизнес-логику;
можно использовать там, где нет нормального доступа к WAL/binlog.

Минусы:

триггеры замедляют запись;
можно ошибиться в логике;
сложнее обслуживание;
не всегда видны все низкоуровневые изменения;
нагрузка ложится на основную БД.

16. Log-based replication / CDC #

Log-based replication — это репликация через журнал изменений БД:

PostgreSQL WAL;
MySQL binary log;
SQL Server transaction log;
Oracle redo log.

Это основа многих CDC-подходов — Change Data Capture.

Смысл:

база пишет изменения в свой журнал;
репликационный процесс читает этот журнал;
изменения передаются в другую БД, Kafka, DWH, search engine и т.д.

Плюсы:

меньше вмешательства в приложение;
обычно меньше overhead, чем у trigger-based подхода;
хорошо подходит для потоковой передачи изменений;
можно строить near real-time интеграции.

Минусы:

нужно понимать формат логов;
нужна настройка retention/slots/binlog;
можно потерять изменения при неправильном хранении логов;
есть сложности со схемой, DDL и ordering.

Сравнительная таблица #

СпособЧто передаётсяГлавная пользаГлавный риск
PhysicalWAL/страницы/физические измененияFailover, read replicaРеплицируется почти всё, мало гибкости
LogicalЛогические изменения строкЧастичная репликация, интеграцииКонфликты, DDL, replica identity
AsyncИзменения без ожидания replicaБыстрая записьLag, возможная потеря последних транзакций
SyncCommit ждёт replicaВыше надёжностьМедленнее запись
Semi-syncЖдёт ACK от части replicaКомпромиссНе всегда строгое чтение с replica
Statement-basedSQL-командыКомпактностьНедетерминированность
Row-basedИзменённые строкиНадёжнее и детерминированнееБольшой лог
SnapshotСнимок данныхПростая периодическая копияДанные устаревают между снимками
MergeИзменения с разных узловOffline/multi-siteКонфликты
CascadingРеплика от репликиМеньше нагрузка на primaryБольше lag и сложнее топология

Что чаще встречается в PostgreSQL #

В PostgreSQL чаще всего говорят о таких вариантах:

physical streaming replication;
logical replication;
synchronous replication;
asynchronous replication;
cascading replication;
replication slots;
hot standby read-only replica.

Для обычного production backend-проекта типичная схема:

PostgreSQL primary
  -> physical streaming replica
  -> async mode
  -> read-only SELECT на replica
  -> failover через Patroni/repmgr/cloud managed service

Если нужна передача части таблиц в другую систему:

PostgreSQL logical replication
или CDC через WAL

Что чаще встречается в MySQL #

В MySQL часто обсуждают:

source-replica replication;
asynchronous replication;
semisynchronous replication;
statement-based replication;
row-based replication;
mixed replication;
binary log replication;
Group Replication.

MySQL replication работает через события в binary log. В зависимости от формата это могут быть statements, row changes или mixed-подход.


Что чаще встречается в SQL Server #

В SQL Server классически выделяют:

snapshot replication;
transactional replication;
merge replication.

Microsoft документация указывает именно эти типы как основные варианты SQL Server replication.

Также в SQL Server отдельно есть Always On Availability Groups, log shipping и другие HA/DR-механизмы, но это уже не всегда называют replication в том же смысле, что snapshot/transactional/merge replication.


Что важно понимать на собеседовании или в практике #

Репликацию нельзя описывать одним словом, потому что «репликация» может означать разные вещи:

копировать весь сервер для failover;
копировать отдельные таблицы;
передавать SQL-команды;
передавать изменённые строки;
ждать подтверждения replica;
не ждать подтверждения replica;
разрешать запись только на primary;
разрешать запись на нескольких узлах.

Самое практичное разделение:

physical vs logical
async vs sync
single-primary vs multi-primary
statement-based vs row-based
snapshot vs streaming/log-based

Итог #

Основные способы репликации в базах данных:

Physical replication — копирование изменений на уровне хранения/WAL.

Logical replication — передача логических изменений строк/таблиц.

Asynchronous replication — primary не ждёт replica.

Synchronous replication — commit ждёт подтверждения replica.

Semi-synchronous replication — компромисс: ждём подтверждение получения от части replica.

Statement-based replication — передаются SQL-команды.

Row-based replication — передаются изменения конкретных строк.

Mixed replication — гибрид statement-based и row-based.

Snapshot replication — периодическая или начальная копия снимка данных.

Transactional replication — передача транзакционных изменений после начального snapshot.

Merge replication — изменения могут быть на разных узлах и потом объединяются.

Cascading replication — replica может реплицировать данные дальше другим replica.

Для PostgreSQL в обычной backend-практике самые важные: physical streaming replication, logical replication, asynchronous/synchronous replication и replication lag.


39. Что такое constraints в SQL? | Какие ограничения бывают в реляционных базах данных? #

Что такое constraints в SQL #

Constraints — это ограничения на данные в таблице.

Они задают правила, которым должны соответствовать строки в БД. Если данные нарушают constraint, база не даст выполнить INSERT, UPDATE или иногда DELETE.

Простыми словами:

constraint = правило целостности данных на уровне базы данных

Например:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    age INTEGER CHECK (age >= 0)
);

Здесь сразу несколько ограничений:

id      -> PRIMARY KEY
email   -> NOT NULL + UNIQUE
age     -> CHECK

PostgreSQL хранит constraints как объекты схемы; в системном каталоге pg_constraint хранятся CHECK, NOT NULL, PRIMARY KEY, UNIQUE, FOREIGN KEY и EXCLUSION constraints.

Зачем нужны constraints #

Constraints нужны не только для «красивой схемы», а для защиты данных от некорректного состояния.

Например, без ограничений можно случайно получить:

пользователя без email;
два аккаунта с одинаковым username;
заказ на несуществующего пользователя;
товар с отрицательной ценой;
платёж без суммы;
две активные подписки там, где должна быть только одна;

Валидация в приложении тоже нужна, но она не заменяет constraints.

Причина простая: к БД могут обращаться разные части системы:

backend API;
админка;
миграции;
скрипты;
ETL;
ручные SQL-запросы;
другие сервисы;
очереди/воркеры.

Если правило важно для целостности данных, его лучше закреплять в самой базе.


Основные виды constraints #

В реляционных БД чаще всего встречаются:

NOT NULL
UNIQUE
PRIMARY KEY
FOREIGN KEY
CHECK
DEFAULT
EXCLUSION

Строго говоря, DEFAULT обычно не считают constraint в том же смысле, что PRIMARY KEY или FOREIGN KEY, но на практике его часто обсуждают рядом с ограничениями, потому что он тоже влияет на допустимое/автоматическое значение колонки.

В PostgreSQL в разделе constraints официально описаны CHECK, NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY и EXCLUSION.


NOT NULL #

NOT NULL запрещает хранить NULL в колонке.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL,
    email TEXT NOT NULL
);

Теперь нельзя вставить пользователя без username:

INSERT INTO users (email)
VALUES ('test@example.com');

База вернёт ошибку, потому что username обязателен.

NOT NULL используют для полей, без которых строка не имеет смысла:

username;
email;
created_at;
order.amount;
payment.status;
notification.user_id;

Пример:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    amount NUMERIC NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Если amount может быть NULL, потом начинаются проблемы:

SELECT SUM(amount) FROM orders;

NULL — это не ноль. Это отсутствие значения. Поэтому обязательные бизнес-поля лучше явно делать NOT NULL.


UNIQUE #

UNIQUE запрещает дубликаты в колонке или группе колонок.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT UNIQUE NOT NULL
);

Теперь нельзя создать двух пользователей с одинаковым email:

INSERT INTO users (email) VALUES ('test@example.com');
INSERT INTO users (email) VALUES ('test@example.com');

Вторая вставка упадёт.

PostgreSQL создаёт уникальный B-tree индекс для UNIQUE constraint, и именно он обеспечивает проверку уникальности.

Составной UNIQUE #

UNIQUE может быть не только на одной колонке, но и на комбинации колонок.

Например, пользователь не может иметь две папки с одинаковым именем:

CREATE TABLE folders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    name TEXT NOT NULL,
    UNIQUE (user_id, name)
);

Это не запрещает одинаковые имена папок у разных пользователей:

user_id = 1, name = 'docs'  -> можно
user_id = 2, name = 'docs'  -> можно

Но запрещает дубликат внутри одного пользователя:

user_id = 1, name = 'docs'
user_id = 1, name = 'docs'  -> нельзя

Это очень частый кейс в backend-разработке.

Примеры составного UNIQUE:

UNIQUE (user_id, filename)
UNIQUE (tenant_id, slug)
UNIQUE (order_id, product_id)
UNIQUE (chat_id, user_id)
UNIQUE (provider, external_id)

PRIMARY KEY #

PRIMARY KEY — это главный идентификатор строки в таблице.

Обычно это колонка id:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

PRIMARY KEY означает:

значение не может быть NULL;
значение должно быть уникальным;
по нему можно однозначно найти строку;
на него обычно ссылаются FOREIGN KEY из других таблиц.

В PostgreSQL PRIMARY KEY фактически накладывает те же ограничения на данные, что UNIQUE + NOT NULL, но дополнительно несёт смысл: это основной идентификатор строки, на который могут опираться другие таблицы.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT UNIQUE NOT NULL
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    amount NUMERIC NOT NULL
);

Здесь:

users.id  -> PRIMARY KEY
orders.user_id -> FOREIGN KEY на users.id

Составной PRIMARY KEY #

Primary key тоже может состоять из нескольких колонок.

Частый пример — таблица many-to-many:

CREATE TABLE user_roles (
    user_id BIGINT NOT NULL REFERENCES users(id),
    role_id BIGINT NOT NULL REFERENCES roles(id),
    PRIMARY KEY (user_id, role_id)
);

Такой ключ запрещает дубликаты связей:

user_id | role_id
--------+--------
1       | 2      -> можно
1       | 2      -> нельзя, такая пара уже есть

FOREIGN KEY #

FOREIGN KEY связывает одну таблицу с другой и защищает ссылочную целостность.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id)
);

Теперь нельзя создать заказ на пользователя, которого нет:

INSERT INTO orders (user_id)
VALUES (999);

Если в users нет строки с id = 999, база вернёт ошибку.

PostgreSQL указывает, что foreign key должен ссылаться на колонки, которые являются primary key, unique constraint или подходящим unique index; это гарантирует, что ссылка ведёт на однозначно идентифицируемую строку.

Зачем нужен FOREIGN KEY #

Без foreign key можно получить «битые ссылки»:

orders.user_id = 123
но users.id = 123 не существует

В приложении потом будет непонятно:

кому принадлежит заказ;
можно ли его показывать;
можно ли удалить пользователя;
почему JOIN не находит данные.

С foreign key база сама не даст нарушить связь.

ON DELETE #

Foreign key может задавать поведение при удалении родительской строки.

Например:

CREATE TABLE notifications (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    text TEXT NOT NULL
);

ON DELETE CASCADE означает:

если удалить пользователя,
его уведомления тоже удалятся.

Другие частые варианты:

ON DELETE CASCADE
ON DELETE SET NULL
ON DELETE RESTRICT
ON DELETE NO ACTION

ON DELETE CASCADE #

user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE

Удалили пользователя — удалились зависимые строки.

Хорошо подходит для:

user_sessions;
refresh_tokens;
temporary_codes;
user_settings;
notification_drafts;

Но опасно для исторических данных:

orders;
payments;
invoices;
audit_logs;

Заказы и платежи обычно не хотят физически удалять вместе с пользователем.

ON DELETE SET NULL #

author_id BIGINT REFERENCES users(id) ON DELETE SET NULL

Удалили пользователя — поле author_id стало NULL.

Так делают, когда строку нужно сохранить, но связь с удалённой сущностью можно потерять.

Пример:

пост остаётся;
автор удалён;
author_id становится NULL.

ON DELETE RESTRICT / NO ACTION #

Обычно означает: нельзя удалить родительскую строку, пока на неё есть ссылки.

Например:

user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT

Смысл:

нельзя удалить пользователя, пока у него есть заказы.

CHECK #

CHECK задаёт произвольное логическое условие для значений строки.

Пример:

CREATE TABLE products (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC NOT NULL CHECK (price >= 0)
);

Теперь нельзя вставить товар с отрицательной ценой:

INSERT INTO products (name, price)
VALUES ('Laptop', -100);

База отклонит такую строку.

Примеры CHECK:

CHECK (age >= 0)

CHECK (price > 0)

CHECK (discount >= 0 AND discount <= 100)

CHECK (status IN ('new', 'paid', 'cancelled'))

CHECK (start_date <= end_date)

CHECK (quantity >= 1)

CHECK хорош для простых бизнес-правил, которые можно выразить через значения одной строки.

Например:

CREATE TABLE subscriptions (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    starts_at TIMESTAMP NOT NULL,
    ends_at TIMESTAMP NOT NULL,
    CHECK (starts_at < ends_at)
);

Это защитит от подписки, которая заканчивается раньше, чем начинается.

Важный нюанс CHECK #

CHECK обычно проверяет значения текущей строки.

Он не подходит для правил вида:

у пользователя не должно быть больше 5 активных заказов;
сумма всех платежей не должна превышать лимит;
в таблице должно быть не больше одной активной записи по сложному условию.

Для таких правил часто нужны:

UNIQUE / partial unique index;
триггеры;
транзакции;
блокировки;
проверка в приложении + constraint в БД.

DEFAULT #

DEFAULT задаёт значение по умолчанию, если при вставке оно не передано.

Пример:

CREATE TABLE notifications (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    text TEXT NOT NULL,
    is_read BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Теперь можно вставить:

INSERT INTO notifications (text)
VALUES ('New message');

База сама поставит:

is_read = false
created_at = now()

DEFAULT не столько запрещает плохие данные, сколько помогает создавать корректные данные без лишнего кода в приложении.

Частые значения по умолчанию:

created_at TIMESTAMP NOT NULL DEFAULT now()
is_active BOOLEAN NOT NULL DEFAULT true
status TEXT NOT NULL DEFAULT 'new'
attempts INTEGER NOT NULL DEFAULT 0

EXCLUSION constraint #

EXCLUSION constraint — более специфичное ограничение, особенно полезное в PostgreSQL.

Оно запрещает строки, которые конфликтуют по заданному оператору.

Классический пример: нельзя создать две брони одной комнаты на пересекающиеся интервалы времени.

Пример в PostgreSQL:

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE room_bookings (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id INTEGER NOT NULL,
    period TSRANGE NOT NULL,
    EXCLUDE USING gist (
        room_id WITH =,
        period WITH &&
    )
);

Смысл:

для одной и той же room_id
нельзя иметь пересекающиеся period

Оператор && для range-типов означает пересечение диапазонов.

То есть можно:

room_id = 1, 10:00-11:00
room_id = 1, 11:00-12:00

Но нельзя:

room_id = 1, 10:00-11:00
room_id = 1, 10:30-11:30

PostgreSQL официально выделяет exclusion constraints как отдельный вид ограничений.

В обычной backend-разработке EXCLUSION встречается реже, чем PRIMARY KEY, FOREIGN KEY, UNIQUE, NOT NULL и CHECK, но это мощный инструмент для задач бронирования, расписаний и пересекающихся диапазонов.


Column constraint и table constraint #

Constraint можно записать на уровне колонки или на уровне таблицы.

Column constraint #

Ограничение написано прямо возле колонки:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    age INTEGER CHECK (age >= 0)
);

Table constraint #

Ограничение написано отдельно после списка колонок:

CREATE TABLE users (
    id BIGINT,
    email TEXT,
    age INTEGER,
    PRIMARY KEY (id),
    UNIQUE (email),
    CHECK (age >= 0)
);

Для ограничений на одну колонку оба варианта часто равнозначны.

Но для составных ограничений нужен table-level синтаксис:

CREATE TABLE folders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    name TEXT NOT NULL,
    UNIQUE (user_id, name)
);

Здесь нельзя написать UNIQUE только у user_id или только у name, потому что уникальной должна быть именно пара.


Именованные constraints #

Constraints лучше явно именовать, особенно в продакшн-схемах и миграциях.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    email TEXT NOT NULL,
    age INTEGER NOT NULL,

    CONSTRAINT users_pk PRIMARY KEY (id),
    CONSTRAINT users_email_unique UNIQUE (email),
    CONSTRAINT users_age_non_negative CHECK (age >= 0)
);

Плюсы именования:

понятнее ошибки;
легче удалять constraint в миграции;
легче читать схему;
проще поддерживать проект командой.

Например:

ALTER TABLE users
DROP CONSTRAINT users_email_unique;

Если имя сгенерировала сама БД, оно может быть менее удобным.


Constraints и индексы #

Не все constraints — это индексы, но некоторые constraints создают индексы автоматически.

В PostgreSQL:

PRIMARY KEY -> создаёт уникальный B-tree индекс;
UNIQUE      -> создаёт уникальный B-tree индекс;
FOREIGN KEY -> не всегда создаёт индекс на дочерней колонке автоматически;
CHECK       -> не создаёт индекс;
NOT NULL    -> не создаёт индекс;

Важный момент: для FOREIGN KEY индекс на referenced columns обычно уже есть, потому что ссылка идёт на PRIMARY KEY или UNIQUE. Но на дочерней колонке индекс часто нужно создать самому.

Например:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id)
);

Полезно добавить:

CREATE INDEX orders_user_id_idx ON orders(user_id);

Зачем:

быстрее JOIN orders -> users;
быстрее поиск заказов пользователя;
быстрее проверка при удалении/изменении users.id.

PostgreSQL документация прямо отмечает: foreign key должен ссылаться на primary/unique columns, поэтому на referenced side индекс есть; при этом для referencing columns часто полезно создать индекс отдельно.


Constraints и бизнес-правила #

Не каждое бизнес-правило удобно выражать constraint-ом.

Хорошие кандидаты для constraints:

email должен быть уникальным;
цена не может быть отрицательной;
заказ должен принадлежать существующему пользователю;
created_at обязателен;
статус должен быть из допустимого набора;
у пользователя не может быть двух папок с одинаковым именем.

Плохие или сложные кандидаты:

скидка зависит от сложной бизнес-логики;
лимит зависит от текущего тарифа пользователя;
нельзя создать заказ, если складской остаток меньше суммы всех резервов;
одновременно можно иметь только N активных задач;
правило зависит от внешнего сервиса.

Для сложной логики обычно используют комбинацию:

валидация в приложении;
транзакции;
блокировки;
constraints;
уникальные индексы;
триггеры;
очереди;
идемпотентность.

Пример полной таблицы с constraints #

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    username TEXT NOT NULL,
    email TEXT NOT NULL,
    age INTEGER,
    status TEXT NOT NULL DEFAULT 'active',
    created_at TIMESTAMP NOT NULL DEFAULT now(),

    CONSTRAINT users_pk PRIMARY KEY (id),
    CONSTRAINT users_username_unique UNIQUE (username),
    CONSTRAINT users_email_unique UNIQUE (email),
    CONSTRAINT users_age_check CHECK (age IS NULL OR age >= 0),
    CONSTRAINT users_status_check CHECK (status IN ('active', 'blocked', 'deleted'))
);

Что здесь защищается:

id уникально идентифицирует пользователя;
username не может быть пустым и не повторяется;
email не может быть пустым и не повторяется;
age либо NULL, либо >= 0;
status только из разрешённого набора;
created_at автоматически ставится при создании.

Пример связанной схемы #

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    amount NUMERIC NOT NULL,
    status TEXT NOT NULL DEFAULT 'new',
    created_at TIMESTAMP NOT NULL DEFAULT now(),

    CONSTRAINT orders_user_fk
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE RESTRICT,

    CONSTRAINT orders_amount_positive
        CHECK (amount > 0),

    CONSTRAINT orders_status_check
        CHECK (status IN ('new', 'paid', 'cancelled'))
);

Эта схема защищает сразу несколько вещей:

заказ не может быть без пользователя;
заказ не может ссылаться на несуществующего пользователя;
сумма заказа должна быть положительной;
статус заказа должен быть валидным;
нельзя удалить пользователя, если на него есть заказы.

Constraints vs валидация в приложении #

Валидация в приложении нужна для удобства пользователя и понятных ошибок.

Например backend может вернуть:

{
  "error": "Email already exists"
}

Но база всё равно должна иметь:

email TEXT NOT NULL UNIQUE

Почему нельзя полагаться только на приложение:

два запроса могут прийти одновременно;
может быть баг в backend;
может быть другой сервис;
может быть ручной SQL;
может быть импорт данных;
может быть миграция.

Классический пример гонки:

Запрос A проверил: email свободен.
Запрос B проверил: email свободен.
Запрос A вставил пользователя.
Запрос B вставил пользователя.

Без UNIQUE в БД появятся два одинаковых email.

С UNIQUE один insert пройдёт, второй получит ошибку. Это правильная защита на уровне данных.


DEFERRABLE constraints #

В PostgreSQL некоторые constraints можно сделать отложенными — DEFERRABLE.

Это значит, что constraint может проверяться не сразу после каждой команды, а в конце транзакции.

Примерная идея:

BEGIN;

-- временно внутри транзакции данные могут быть в промежуточном состоянии
-- к COMMIT всё должно стать корректным

COMMIT;

В PostgreSQL SET CONSTRAINTS влияет на UNIQUE, PRIMARY KEY, REFERENCES и EXCLUDE constraints, если они объявлены как deferrable; NOT NULL и CHECK всегда проверяются немедленно.

Это нужно реже, но полезно в сложных случаях:

циклические foreign key;
перестановка уникальных значений;
сложные batch-операции;
импорт взаимосвязанных данных.

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

1. Делать поле обязательным только в приложении #

Плохо:

email TEXT

И потом надеяться, что backend всегда проверит email.

Лучше:

email TEXT NOT NULL

Если email должен быть уникальным:

email TEXT NOT NULL UNIQUE

2. Не ставить FOREIGN KEY #

Плохо:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL
);

Так база не знает, что user_id должен ссылаться на users.

Лучше:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id)
);

3. Путать UNIQUE и PRIMARY KEY #

UNIQUE может быть несколько в таблице:

email TEXT UNIQUE,
username TEXT UNIQUE

А PRIMARY KEY обычно один:

id BIGINT PRIMARY KEY

PRIMARY KEY — главный идентификатор строки.

UNIQUE — дополнительное правило уникальности.

Например:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    username TEXT NOT NULL UNIQUE
);

Здесь:

id       -> технический идентификатор строки;
email    -> бизнес-уникальность;
username -> бизнес-уникальность.

4. Использовать CHECK вместо нормальной справочной таблицы #

Например:

CHECK (status IN ('new', 'paid', 'cancelled'))

Это нормально, если статусов мало и они редко меняются.

Но если статусы управляются бизнесом, имеют переводы, порядок, настройки, права переходов, лучше отдельная таблица:

CREATE TABLE order_statuses (
    code TEXT PRIMARY KEY,
    title TEXT NOT NULL
);

И потом:

status_code TEXT NOT NULL REFERENCES order_statuses(code)

5. Забывать про составные ограничения #

Например, нужно запретить одинаковые имена файлов внутри одной папки пользователя.

Плохо:

filename TEXT UNIQUE

Так одинаковое имя файла нельзя будет использовать вообще никому.

Лучше:

UNIQUE (user_id, folder_id, filename)

Тогда уникальность будет в правильной области.


Итог #

Constraints в SQL — это ограничения, которые база применяет к данным, чтобы они оставались корректными.

Основные виды:

NOT NULL    -> значение обязательно;
UNIQUE      -> значение или комбинация значений не повторяется;
PRIMARY KEY -> главный уникальный идентификатор строки;
FOREIGN KEY -> ссылка на строку в другой таблице;
CHECK       -> произвольное условие для значений строки;
DEFAULT     -> значение по умолчанию;
EXCLUSION   -> запрет конфликтующих строк по операторам, например пересечений диапазонов.

Для нормальной схемы БД constraints — это базовый уровень защиты. Они не заменяют бизнес-логику приложения, но фиксируют критически важные правила там, где данные реально хранятся.


40. Для чего используются LIMIT и OFFSET в SQL? #

Что делают LIMIT и OFFSET #

LIMIT и OFFSET используются, чтобы вернуть не все строки результата, а только нужную часть.

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 20;

Смысл:

OFFSET 20 -> пропустить первые 20 строк
LIMIT 10  -> вернуть следующие 10 строк

То есть запрос вернёт строки с 21-й по 30-ю, если считать после сортировки.

PostgreSQL описывает LIMIT и OFFSET как конструкции, которые позволяют получить только часть строк из результата запроса. При этом документация отдельно подчёркивает: чтобы получать предсказуемую часть строк, нужно использовать ORDER BY.


LIMIT #

LIMIT ограничивает количество строк, которые вернёт запрос.

Пример:

SELECT *
FROM users
LIMIT 5;

Этот запрос вернёт максимум 5 строк.

Если в таблице 1000 пользователей, вернутся только 5.
Если в таблице 3 пользователя, вернутся 3.

То есть LIMIT — это не «номер страницы», а именно ограничение сверху:

верни не больше N строк

OFFSET #

OFFSET пропускает указанное количество строк перед началом возврата результата.

Пример:

SELECT *
FROM users
OFFSET 10;

Смысл:

пропусти первые 10 строк,
верни остальные.

Но на практике OFFSET почти всегда используют вместе с LIMIT:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 10;

Смысл:

пропусти первые 10 строк,
верни следующие 10.

Использование для пагинации #

Самый частый кейс — пагинация.

Например, в API нужно отдавать пользователей страницами по 10 записей.

Первая страница:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 0;

Вторая страница:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 10;

Третья страница:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 20;

Формула:

OFFSET = (page - 1) * page_size
LIMIT  = page_size

Если:

page = 3
page_size = 10

то:

OFFSET = (3 - 1) * 10 = 20
LIMIT = 10

Запрос:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 20;

Почему нужен ORDER BY #

Очень важный момент: LIMIT и OFFSET почти всегда должны использоваться вместе с ORDER BY.

Плохо:

SELECT *
FROM users
LIMIT 10 OFFSET 20;

Почему плохо: без ORDER BY база не обязана возвращать строки в стабильном порядке.

Сегодня она может вернуть одни 10 строк, завтра другие. Это зависит от плана запроса, индексов, физического расположения данных, статистики и других факторов.

PostgreSQL прямо указывает: если не указать ORDER BY, строки возвращаются в неопределённом порядке; гарантировать конкретный порядок можно только через явную сортировку.

Правильно:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 20;

Теперь порядок понятен:

сначала сортируем по id,
потом пропускаем 20 строк,
потом берём 10 строк.

Как это работает логически #

Запрос:

SELECT *
FROM users
WHERE is_active = true
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;

Логически можно представить так:

1. Найти всех активных пользователей.
2. Отсортировать их по created_at DESC.
3. Пропустить первые 20 строк.
4. Вернуть следующие 10 строк.

То есть LIMIT и OFFSET применяются уже к результату после фильтрации и сортировки.


Пример с таблицей #

Допустим, есть пользователи:

id | username
---+---------
1  | alex
2  | bob
3  | tom
4  | kate
5  | john
6  | mike
7  | anna
8  | sam
9  | nick
10 | max
11 | leo
12 | rick

Запрос:

SELECT id, username
FROM users
ORDER BY id
LIMIT 5 OFFSET 0;

Результат:

id | username
---+---------
1  | alex
2  | bob
3  | tom
4  | kate
5  | john

Следующая страница:

SELECT id, username
FROM users
ORDER BY id
LIMIT 5 OFFSET 5;

Результат:

id | username
---+---------
6  | mike
7  | anna
8  | sam
9  | nick
10 | max

Третья страница:

SELECT id, username
FROM users
ORDER BY id
LIMIT 5 OFFSET 10;

Результат:

id | username
---+---------
11 | leo
12 | rick

LIMIT без OFFSET #

Можно использовать только LIMIT.

Например, получить последние 10 заказов:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 10;

Это нормальный и частый сценарий.

Примеры:

-- последние 20 уведомлений пользователя
SELECT *
FROM notifications
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
-- топ-5 самых дорогих товаров
SELECT *
FROM products
ORDER BY price DESC
LIMIT 5;
-- 100 последних логов
SELECT *
FROM logs
ORDER BY created_at DESC
LIMIT 100;

OFFSET без LIMIT #

Технически можно использовать OFFSET без LIMIT:

SELECT *
FROM users
ORDER BY id
OFFSET 100;

Смысл:

пропусти первые 100 строк,
верни все остальные.

Но на практике так делают редко, потому что обычно нужно вернуть конкретный размер страницы.


Пагинация в backend API #

Например endpoint:

GET /users?page=3&page_size=20

Backend может превратить это в SQL:

SELECT *
FROM users
ORDER BY id
LIMIT 20 OFFSET 40;

Потому что:

OFFSET = (3 - 1) * 20 = 40

Или endpoint может принимать сразу limit и offset:

GET /users?limit=20&offset=40

Тогда SQL почти напрямую:

SELECT *
FROM users
ORDER BY id
LIMIT 20 OFFSET 40;

Такой подход часто используют в REST API.


Важная проблема OFFSET: большие смещения #

OFFSET удобен, но у него есть проблема производительности.

Запрос:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 1000000;

Не означает, что база «магически прыгнет» сразу к строке номер 1 000 001.

Часто БД должна пройти много строк, отсортировать или просканировать индекс, пропустить первый миллион строк и только потом вернуть 10.

То есть большой OFFSET может быть дорогим.

PostgreSQL документация прямо указывает: строки, пропущенные OFFSET, всё равно должны быть вычислены внутри сервера, поэтому большой OFFSET может быть неэффективным.


Проблема стабильности при изменении данных #

Допустим, пользователь смотрит список заказов.

Первая страница:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 0;

Потом между запросами кто-то создал новый заказ.

Вторая страница:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 10;

Из-за новой строки часть данных может сместиться.

Возможные проблемы:

одна запись может повториться на двух страницах;
одна запись может быть пропущена;
страницы становятся нестабильными при активных вставках/удалениях.

Это особенно заметно в лентах, уведомлениях, чатах, логах, заказах.


Почему ORDER BY должен быть детерминированным #

Даже если ORDER BY есть, он должен задавать стабильный порядок.

Например:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;

Если у многих заказов одинаковый created_at, порядок между ними может быть нестабильным.

Лучше добавить уникальный tie-breaker:

SELECT *
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 20;

Теперь порядок стабильнее:

сначала сортировка по created_at,
если created_at одинаковый — по id.

Для пагинации это важно.


Альтернатива: keyset pagination #

Для больших таблиц и бесконечных лент часто лучше не OFFSET, а keyset pagination.

Её ещё называют:

cursor pagination;
seek pagination;
пагинация по курсору;
пагинация по последнему значению.

Идея: вместо «пропусти 100000 строк» мы говорим:

дай следующие строки после последней виденной записи

Пример первой страницы:

SELECT *
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;

Допустим, последняя строка на странице имеет:

created_at = '2026-06-30 12:00:00'
id = 5000

Следующая страница:

SELECT *
FROM orders
WHERE (created_at, id) < ('2026-06-30 12:00:00', 5000)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Плюсы:

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

Минусы:

сложнее перейти сразу на страницу 100;
нужен cursor/последнее значение;
нужна стабильная сортировка.

Для обычной админки с небольшими таблицами LIMIT/OFFSET нормален. Для больших пользовательских лент чаще лучше keyset pagination.


Индексы для LIMIT/OFFSET #

Если запрос часто такой:

SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

полезен индекс:

CREATE INDEX orders_user_created_idx
ON orders (user_id, created_at DESC);

Ещё лучше с id как tie-breaker:

CREATE INDEX orders_user_created_id_idx
ON orders (user_id, created_at DESC, id DESC);

Тогда PostgreSQL может быстрее найти заказы конкретного пользователя в нужном порядке.

Без подходящего индекса база может делать:

фильтрацию;
сортировку;
сканирование большого количества строк;
отбрасывание OFFSET;
возврат LIMIT.

LIMIT и OFFSET после JOIN #

LIMIT ограничивает итоговый результат запроса, а не количество строк в каждой таблице.

Пример:

SELECT
    users.id,
    users.username,
    orders.id AS order_id
FROM users
JOIN orders ON orders.user_id = users.id
ORDER BY users.id
LIMIT 10;

Если у одного пользователя много заказов, он может занять несколько строк результата.

Например:

alex | order 1
alex | order 2
alex | order 3

Поэтому LIMIT 10 здесь означает:

вернуть 10 строк результата JOIN

А не:

вернуть 10 пользователей

Это частая ошибка.

Если нужно получить 10 пользователей, а потом их заказы, обычно используют подзапрос:

SELECT
    u.id,
    u.username,
    o.id AS order_id
FROM (
    SELECT *
    FROM users
    ORDER BY id
    LIMIT 10 OFFSET 0
) AS u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id;

Сначала выбираются 10 пользователей, потом к ним присоединяются заказы.


LIMIT и агрегаты #

Если есть агрегатный запрос:

SELECT user_id, COUNT(*) AS orders_count
FROM orders
GROUP BY user_id
ORDER BY orders_count DESC
LIMIT 10;

Смысл:

сначала сгруппировать заказы по пользователям;
посчитать количество заказов;
отсортировать по количеству;
вернуть топ-10 пользователей.

Здесь LIMIT применяется уже после GROUP BY и ORDER BY.


LIMIT и DELETE/UPDATE #

В PostgreSQL у SELECT есть LIMIT, но у DELETE и UPDATE напрямую LIMIT в таком же виде нет.

Например, в PostgreSQL нельзя стандартно написать:

DELETE FROM logs
LIMIT 1000;

Обычно используют подзапрос:

DELETE FROM logs
WHERE id IN (
    SELECT id
    FROM logs
    WHERE created_at < now() - interval '30 days'
    ORDER BY id
    LIMIT 1000
);

Так удаляют данные батчами, чтобы не держать слишком большую транзакцию.

В MySQL синтаксис может отличаться: там DELETE ... LIMIT поддерживается. Поэтому детали зависят от СУБД.


LIMIT ALL и OFFSET 0 #

В PostgreSQL можно встретить:

LIMIT ALL

Это означает:

не ограничивать количество строк

А:

OFFSET 0

означает:

ничего не пропускать

На практике это редко пишут вручную, но такие конструкции иногда генерируют ORM или query builders.


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

1. LIMIT/OFFSET без ORDER BY #

Плохо:

SELECT *
FROM users
LIMIT 10 OFFSET 20;

Проблема:

нет гарантированного порядка;
страницы могут быть нестабильными;
одни и те же строки могут появляться неожиданно.

Лучше:

SELECT *
FROM users
ORDER BY id
LIMIT 10 OFFSET 20;

2. Сортировка по неуникальному полю #

Плохо:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

Если много строк с одинаковым created_at, лучше:

SELECT *
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

3. Большой OFFSET на больших таблицах #

Плохо:

SELECT *
FROM events
ORDER BY id
LIMIT 50 OFFSET 5000000;

Проблема:

база всё равно должна пройти огромный объём строк;
запрос может быть медленным;
нагрузка растёт с номером страницы.

Лучше для ленты:

SELECT *
FROM events
WHERE id > 5000000
ORDER BY id
LIMIT 50;

Или для обратной сортировки:

SELECT *
FROM events
WHERE id < 5000000
ORDER BY id DESC
LIMIT 50;

4. Неправильно считать OFFSET #

Если страница начинается с 1:

OFFSET = (page - 1) * limit

Если страница начинается с 0:

OFFSET = page * limit

Частая ошибка — сдвиг на одну страницу.


5. Путать LIMIT с количеством сущностей после JOIN #

Если запрос возвращает строки JOIN, LIMIT ограничивает именно строки результата, а не количество основных сущностей.


Где это используется в реальных проектах #

LIMIT и OFFSET используют для:

пагинации списков в API;
админских таблиц;
вывода последних записей;
топ-N запросов;
батчевой обработки данных;
постраничной загрузки логов;
ограничения тяжёлых выборок;
тестовых запросов при анализе данных.

Примеры:

-- последние 20 уведомлений пользователя
SELECT *
FROM notifications
WHERE user_id = 123
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- страница товаров
SELECT *
FROM products
WHERE is_active = true
ORDER BY id
LIMIT 50 OFFSET 100;
-- топ-10 пользователей по числу заказов
SELECT user_id, COUNT(*) AS total_orders
FROM orders
GROUP BY user_id
ORDER BY total_orders DESC
LIMIT 10;
-- обработать старые записи батчем
SELECT *
FROM events
WHERE processed = false
ORDER BY id
LIMIT 1000;

Итог #

LIMIT и OFFSET нужны для получения части результата запроса.

SELECT *
FROM table_name
ORDER BY id
LIMIT 10 OFFSET 20;

Означает:

отсортировать строки по id;
пропустить первые 20;
вернуть следующие 10.

Главные правила:

LIMIT ограничивает количество возвращаемых строк.
OFFSET пропускает указанное количество строк.
Для стабильного результата нужен ORDER BY.
Для больших OFFSET запросы могут становиться медленными.
Для больших таблиц и лент часто лучше keyset/cursor pagination.
После JOIN LIMIT ограничивает строки итогового результата, а не количество сущностей.

В обычной backend-разработке LIMIT/OFFSET — простой и удобный способ пагинации, но для больших таблиц и активно меняющихся данных его нужно использовать осторожно.


41. Что делает оператор WITH в SQL? #

Что такое WITH в SQL #

WITH в SQL используется для создания временного именованного результата внутри одного запроса.

Такой временный результат называется:

CTE — Common Table Expression

По-русски часто говорят:

общее табличное выражение;
CTE;
WITH-запрос;
временная именованная выборка.

PostgreSQL описывает WITH как способ написать вспомогательные выражения для использования в более крупном запросе. Эти выражения называются Common Table Expressions.

Общий вид:

WITH cte_name AS (
    SELECT ...
)
SELECT ...
FROM cte_name;

То есть WITH позволяет сначала описать промежуточный набор данных, дать ему имя, а потом использовать это имя в основном запросе.


Простой пример #

Допустим, есть таблица заказов:

orders
id | user_id | amount | status
---+---------+--------+--------
1  | 10      | 500    | paid
2  | 10      | 300    | paid
3  | 11      | 200    | new
4  | 12      | 900    | paid

Нужно сначала выбрать только оплаченные заказы, а потом посчитать сумму по пользователям.

Без WITH:

SELECT
    user_id,
    SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY user_id;

С WITH:

WITH paid_orders AS (
    SELECT *
    FROM orders
    WHERE status = 'paid'
)
SELECT
    user_id,
    SUM(amount) AS total_amount
FROM paid_orders
GROUP BY user_id;

Здесь:

paid_orders

— это временный результат, который существует только внутри этого SQL-запроса.

Он не создаёт настоящую таблицу в базе. После выполнения запроса paid_orders исчезает.


Зачем нужен WITH #

Главная польза WITH — сделать сложный запрос понятнее.

Например, вместо одного большого вложенного запроса:

SELECT
    users.id,
    users.username,
    stats.total_amount
FROM users
JOIN (
    SELECT
        user_id,
        SUM(amount) AS total_amount
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
) AS stats ON stats.user_id = users.id
WHERE stats.total_amount > 1000;

Можно написать так:

WITH user_order_stats AS (
    SELECT
        user_id,
        SUM(amount) AS total_amount
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
)
SELECT
    users.id,
    users.username,
    user_order_stats.total_amount
FROM users
JOIN user_order_stats ON user_order_stats.user_id = users.id
WHERE user_order_stats.total_amount > 1000;

Логика становится читаемее:

1. Сначала считаем сумму оплаченных заказов по пользователям.
2. Потом соединяем этот результат с users.
3. Потом оставляем только пользователей с суммой больше 1000.

WITH как альтернатива подзапросу #

WITH часто используют вместо подзапроса в FROM.

Подзапрос:

SELECT *
FROM (
    SELECT user_id, COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
) AS order_stats
WHERE orders_count > 5;

То же самое через WITH:

WITH order_stats AS (
    SELECT user_id, COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
)
SELECT *
FROM order_stats
WHERE orders_count > 5;

По смыслу это близко. Разница в читаемости: WITH удобнее, когда промежуточных шагов несколько.


Несколько CTE в одном WITH #

В одном WITH можно объявить несколько временных выражений.

Пример:

WITH paid_orders AS (
    SELECT *
    FROM orders
    WHERE status = 'paid'
),
user_totals AS (
    SELECT
        user_id,
        SUM(amount) AS total_amount
    FROM paid_orders
    GROUP BY user_id
),
big_customers AS (
    SELECT *
    FROM user_totals
    WHERE total_amount > 1000
)
SELECT
    users.id,
    users.username,
    big_customers.total_amount
FROM users
JOIN big_customers ON big_customers.user_id = users.id;

Здесь запрос разбит на понятные этапы:

paid_orders   -> только оплаченные заказы;
user_totals   -> суммы по пользователям;
big_customers -> пользователи с суммой больше 1000;
основной SELECT -> соединение с таблицей users.

Такой стиль особенно полезен, когда запрос становится длинным: отчёты, аналитика, сложные фильтры, расчёты, агрегаты.


CTE можно использовать как обычную таблицу #

После объявления CTE можно обращаться к нему в FROM, JOIN, подзапросах и других частях основного запроса.

Пример:

WITH active_users AS (
    SELECT *
    FROM users
    WHERE is_active = true
)
SELECT
    active_users.id,
    active_users.username,
    orders.id AS order_id
FROM active_users
JOIN orders ON orders.user_id = active_users.id;

Здесь active_users ведёт себя как временная таблица результата.

Но важно: это не физическая таблица, не TEMP TABLE и не VIEW. Это часть одного SQL-запроса.


WITH не создаёт постоянную таблицу #

WITH не делает это:

CREATE TABLE paid_orders AS ...

И не делает это:

CREATE VIEW paid_orders AS ...

CTE существует только во время выполнения текущего запроса.

Например:

WITH paid_orders AS (
    SELECT *
    FROM orders
    WHERE status = 'paid'
)
SELECT *
FROM paid_orders;

После этого нельзя выполнить:

SELECT *
FROM paid_orders;

База скажет, что такой таблицы нет.


Где WITH особенно полезен #

WITH удобно использовать, когда:

запрос сложный и его нужно разбить на шаги;
один промежуточный результат используется несколько раз;
нужно сделать рекурсивный запрос;
нужно выполнить INSERT/UPDATE/DELETE и использовать RETURNING;
нужно улучшить читаемость отчёта;
нужно избежать глубоко вложенных подзапросов.

Пример: один результат используется несколько раз #

Допустим, нужно взять активных пользователей и отдельно посчитать по ним заказы и платежи.

WITH active_users AS (
    SELECT id, username
    FROM users
    WHERE is_active = true
)
SELECT
    active_users.id,
    active_users.username,
    COUNT(DISTINCT orders.id) AS orders_count,
    COUNT(DISTINCT payments.id) AS payments_count
FROM active_users
LEFT JOIN orders ON orders.user_id = active_users.id
LEFT JOIN payments ON payments.user_id = active_users.id
GROUP BY active_users.id, active_users.username;

Без WITH условие активных пользователей пришлось бы держать внутри основного запроса или повторять в нескольких местах.


WITH RECURSIVE #

Одна из важных возможностей — рекурсивные запросы.

Для этого используется:

WITH RECURSIVE

Рекурсивный CTE может ссылаться сам на себя.

Это нужно для иерархий:

категории и подкатегории;
дерево комментариев;
структура папок;
организационная структура сотрудников;
граф зависимостей;
родительские и дочерние записи.

Пример таблицы категорий:

CREATE TABLE categories (
    id BIGINT PRIMARY KEY,
    parent_id BIGINT REFERENCES categories(id),
    name TEXT NOT NULL
);

Данные:

id | parent_id | name
---+-----------+------------
1  | NULL      | Electronics
2  | 1         | Phones
3  | 2         | Smartphones
4  | 1         | Laptops

Нужно получить категорию Electronics и все её подкатегории.

WITH RECURSIVE category_tree AS (
    SELECT
        id,
        parent_id,
        name,
        1 AS level
    FROM categories
    WHERE id = 1

    UNION ALL

    SELECT
        c.id,
        c.parent_id,
        c.name,
        category_tree.level + 1
    FROM categories c
    JOIN category_tree ON c.parent_id = category_tree.id
)
SELECT *
FROM category_tree
ORDER BY level, id;

Результат:

id | parent_id | name        | level
---+-----------+-------------+------
1  | NULL      | Electronics | 1
2  | 1         | Phones      | 2
4  | 1         | Laptops     | 2
3  | 2         | Smartphones | 3

Логика рекурсивного CTE:

1. Нерекурсивная часть выбирает стартовую строку.
2. Рекурсивная часть ищет дочерние строки.
3. Результаты добавляются к общему набору.
4. Процесс повторяется, пока новые строки больше не находятся.

PostgreSQL поддерживает WITH RECURSIVE для рекурсивных запросов, где CTE может ссылаться сам на себя. ( PostgreSQL)


WITH с INSERT / UPDATE / DELETE #

В PostgreSQL WITH можно использовать не только с SELECT, но и с data-modifying statements: INSERT, UPDATE, DELETE, MERGE, если они находятся в CTE. В документации PostgreSQL это отдельно описано как data-modifying statements in WITH.

Например, можно вставить строку и сразу использовать результат RETURNING.

WITH new_user AS (
    INSERT INTO users (username, email)
    VALUES ('alex', 'alex@example.com')
    RETURNING id, username
)
INSERT INTO user_profiles (user_id, bio)
SELECT id, 'New user profile'
FROM new_user;

Что происходит:

1. CTE new_user вставляет пользователя.
2. RETURNING возвращает id нового пользователя.
3. Основной INSERT создаёт профиль для этого пользователя.

Это удобно, когда нужно связать несколько операций в одном SQL-запросе.


Пример WITH + DELETE RETURNING #

Допустим, нужно удалить старые сессии и записать информацию об удалённых сессиях в audit-таблицу.

WITH deleted_sessions AS (
    DELETE FROM user_sessions
    WHERE expires_at < now()
    RETURNING id, user_id, expires_at
)
INSERT INTO audit_deleted_sessions (session_id, user_id, expires_at, deleted_at)
SELECT
    id,
    user_id,
    expires_at,
    now()
FROM deleted_sessions;

Логика:

DELETE удаляет старые сессии;
RETURNING возвращает удалённые строки;
INSERT пишет их в audit.

Это сильная сторона PostgreSQL: можно использовать результат INSERT/UPDATE/DELETE ... RETURNING внутри CTE.


WITH и материализация в PostgreSQL #

Важный нюанс PostgreSQL: CTE может быть материализован или встроен оптимизатором в основной запрос.

Материализация означает, что PostgreSQL сначала вычисляет CTE как промежуточный результат, а потом основной запрос работает с этим результатом.

Раньше CTE в PostgreSQL часто воспринимали как «optimization fence», то есть границу оптимизации. В современных версиях PostgreSQL оптимизатор может встроить CTE в основной запрос, если это безопасно и выгодно. Документация PostgreSQL отдельно описывает Common Table Expression Materialization, включая MATERIALIZED и NOT MATERIALIZED.

Пример явной материализации:

WITH expensive_query AS MATERIALIZED (
    SELECT *
    FROM large_table
    WHERE status = 'active'
)
SELECT *
FROM expensive_query
WHERE created_at >= now() - interval '1 day';

Пример запроса, где можно попросить не материализовать:

WITH filtered_users AS NOT MATERIALIZED (
    SELECT *
    FROM users
    WHERE is_active = true
)
SELECT *
FROM filtered_users
WHERE created_at >= now() - interval '30 days';

На практике это нужно не всегда, но важно понимать: WITH — не всегда «создай временную таблицу». Оптимизатор может выбрать другой план выполнения.


WITH vs VIEW #

WITH и VIEW похожи тем, что оба позволяют дать имя запросу.

Но разница такая:

КритерийWITH / CTEVIEW
Срок жизниТолько один запросПостоянный объект схемы
Создаётся черезWITH ... AS (...)CREATE VIEW ... AS SELECT ...
Где доступенТолько внутри текущего запросаВ других запросах тоже
ИспользованиеРазбить сложный запрос на шагиПереиспользуемое представление
Хранит данныеНетОбычный VIEW тоже не хранит данные

PostgreSQL описывает обычный VIEW как представление запроса: оно не хранит данные физически, а запрос выполняется при обращении к view.

Пример CTE:

WITH active_users AS (
    SELECT *
    FROM users
    WHERE is_active = true
)
SELECT *
FROM active_users;

Пример VIEW:

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE is_active = true;

После создания view можно делать:

SELECT *
FROM active_users;

А CTE доступен только внутри одного запроса.


WITH vs TEMP TABLE #

WITH также не равен временной таблице.

КритерийWITH / CTETEMP TABLE
СуществуетТолько внутри одного запросаОбычно в рамках сессии/транзакции
Можно индексироватьНет напрямуюДа
Можно использовать в нескольких запросахНетДа
Подходит дляЛогического разбиения запросаБольших промежуточных данных
СозданиеЛёгкое, внутри SQLОтдельный объект

Пример temp table:

CREATE TEMP TABLE active_users_temp AS
SELECT *
FROM users
WHERE is_active = true;

Потом можно:

CREATE INDEX ON active_users_temp (id);

SELECT *
FROM active_users_temp;

SELECT COUNT(*)
FROM active_users_temp;

CTE так использовать нельзя: он живёт только в одном запросе.


WITH vs подзапрос #

Подзапрос хорош, когда логика короткая:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
    WHERE status = 'paid'
);

WITH лучше, когда логика длинная или многошаговая:

WITH paid_users AS (
    SELECT DISTINCT user_id
    FROM orders
    WHERE status = 'paid'
),
active_paid_users AS (
    SELECT users.*
    FROM users
    JOIN paid_users ON paid_users.user_id = users.id
    WHERE users.is_active = true
)
SELECT *
FROM active_paid_users;

Главный критерий — читаемость и сложность запроса.


Практический пример для backend #

Допустим, нужно получить пользователей:

только активных;
с количеством заказов;
с суммой оплаченных заказов;
с датой последнего заказа;
только тех, у кого сумма заказов больше 1000.

Запрос через WITH:

WITH active_users AS (
    SELECT id, username, email
    FROM users
    WHERE is_active = true
),
paid_order_stats AS (
    SELECT
        user_id,
        COUNT(*) AS orders_count,
        SUM(amount) AS total_amount,
        MAX(created_at) AS last_order_at
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
),
big_customers AS (
    SELECT *
    FROM paid_order_stats
    WHERE total_amount > 1000
)
SELECT
    active_users.id,
    active_users.username,
    active_users.email,
    big_customers.orders_count,
    big_customers.total_amount,
    big_customers.last_order_at
FROM active_users
JOIN big_customers ON big_customers.user_id = active_users.id
ORDER BY big_customers.total_amount DESC;

Без WITH это можно было бы написать через вложенные подзапросы, но читать было бы тяжелее.


Частая ошибка: думать, что WITH ускоряет запрос сам по себе #

WITH не является автоматической оптимизацией.

Он может:

улучшить читаемость;
помочь структурировать запрос;
избавить от повторения логики;
помочь с рекурсией;
помочь связать DML через RETURNING.

Но он не гарантирует ускорение.

Иногда CTE может быть быстрее, иногда медленнее, иногда одинаково. Нужно смотреть:

EXPLAIN ANALYZE

Особенно если запрос работает с большими таблицами.


Частая ошибка: использовать WITH там, где достаточно обычного JOIN #

Иногда пишут лишний CTE:

WITH user_orders AS (
    SELECT *
    FROM orders
)
SELECT *
FROM users
JOIN user_orders ON user_orders.user_id = users.id;

Это не даёт пользы. Проще:

SELECT *
FROM users
JOIN orders ON orders.user_id = users.id;

WITH нужен, когда он реально делает запрос понятнее или решает конкретную задачу.


Частая ошибка: забывать, что порядок CTE не всегда означает порядок выполнения #

Если объявлены несколько CTE, это не всегда значит, что PostgreSQL физически выполнит их строго сверху вниз.

Например:

WITH a AS (
    SELECT ...
),
b AS (
    SELECT ...
)
SELECT ...

Для обычных SELECT CTE оптимизатор может перестраивать план выполнения.

Но логическая зависимость сохраняется: если b использует a, то b зависит от результата a.

С data-modifying CTE нужно быть особенно аккуратным. В PostgreSQL data-modifying statements в WITH выполняются в рамках одного statement и одного snapshot; их взаимодействие лучше строить через RETURNING, а не через ожидание, что один CTE «увидит» изменения другого в таблице напрямую. Документация PostgreSQL отдельно описывает особенности data-modifying statements в WITH.


Когда WITH лучше использовать #

Хорошие случаи:

сложный отчёт из нескольких этапов;
агрегации перед JOIN;
фильтрация перед дальнейшими расчётами;
рекурсивная выборка дерева;
INSERT/UPDATE/DELETE с RETURNING;
один промежуточный результат нужен несколько раз;
нужно улучшить читаемость SQL.

Примеры:

WITH monthly_sales AS (
    SELECT
        date_trunc('month', created_at) AS month,
        SUM(amount) AS total
    FROM orders
    WHERE status = 'paid'
    GROUP BY date_trunc('month', created_at)
)
SELECT *
FROM monthly_sales
ORDER BY month;
WITH duplicate_emails AS (
    SELECT email
    FROM users
    GROUP BY email
    HAVING COUNT(*) > 1
)
SELECT users.*
FROM users
JOIN duplicate_emails ON duplicate_emails.email = users.email;
WITH recent_orders AS (
    SELECT *
    FROM orders
    WHERE created_at >= now() - interval '7 days'
)
SELECT
    user_id,
    COUNT(*) AS orders_count
FROM recent_orders
GROUP BY user_id;

Когда WITH лучше не использовать #

Не стоит использовать CTE просто ради самого факта.

Плохие случаи:

CTE ничего не упрощает;
запрос становится длиннее без причины;
CTE мешает оптимизатору;
один простой JOIN можно написать напрямую;
нужно переиспользовать результат в нескольких SQL-запросах — тогда лучше VIEW или TEMP TABLE.

Итог #

WITH в SQL создаёт временное именованное выражение внутри одного запроса.

Пример:

WITH active_users AS (
    SELECT *
    FROM users
    WHERE is_active = true
)
SELECT *
FROM active_users;

Главные применения:

разбить сложный SQL на понятные части;
заменить сложные вложенные подзапросы;
переиспользовать промежуточный результат;
делать рекурсивные запросы через WITH RECURSIVE;
использовать INSERT/UPDATE/DELETE ... RETURNING внутри одного запроса;
улучшить читаемость отчётов и аналитических выборок.

Главное помнить: WITH — это не постоянная таблица и не автоматический способ ускорить запрос. Это инструмент для структурирования SQL и решения задач, где нужен именованный промежуточный результат.


42. Для чего нужны подзапросы в SQL? #

Что такое подзапрос #

Подзапрос — это SELECT, вложенный внутрь другого SQL-запроса.

Пример:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

Здесь внутренний запрос:

SELECT user_id
FROM orders

возвращает список пользователей, у которых есть заказы. Внешний запрос использует этот результат, чтобы выбрать пользователей из таблицы users.

PostgreSQL описывает подзапросы как выражения, которые могут использоваться с EXISTS, IN, NOT IN, ANY/SOME, ALL, а также как скалярные подзапросы. Например, EXISTS проверяет, возвращает ли подзапрос хотя бы одну строку. ( PostgreSQL)


Для чего нужны подзапросы #

Подзапросы нужны, когда результат одного запроса используется внутри другого запроса.

Обычно они применяются для таких задач:

фильтрация строк по результату другого SELECT;
проверка существования связанных данных;
сравнение значения с агрегатом;
создание временного набора данных в FROM;
вычисление значения в SELECT;
обновление или удаление строк по результату выборки;
замена некоторых JOIN-сценариев;
разбиение сложной логики на части.

Главная идея:

сначала получить промежуточный результат,
потом использовать его в основном запросе.

Пример 1: найти пользователей, у которых есть заказы #

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

Логика:

1. Внутренний запрос получает user_id из orders.
2. Внешний запрос выбирает users, чей id есть среди этих user_id.

То есть подзапрос отвечает на вопрос:

какие пользователи встречаются в таблице заказов?

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


Пример 2: найти пользователей без заказов #

Для этого часто используют NOT EXISTS:

SELECT *
FROM users u
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Смысл:

выбрать пользователя,
для которого не существует ни одного заказа.

Внутренний запрос здесь связан с внешним:

WHERE o.user_id = u.id

Это значит: для каждого пользователя u проверяется, есть ли у него строки в orders.

EXISTS в PostgreSQL возвращает true, если подзапрос возвращает хотя бы одну строку; NOT EXISTS — наоборот, если строк нет. ( PostgreSQL)


Где может находиться подзапрос #

Подзапрос можно использовать в разных частях SQL:

WHERE;
FROM;
SELECT;
HAVING;
INSERT;
UPDATE;
DELETE;

Разберём основные случаи.


Подзапрос в WHERE #

Это самый частый вариант.

Пример:

SELECT *
FROM products
WHERE price > (
    SELECT AVG(price)
    FROM products
);

Смысл:

выбрать товары, цена которых выше средней цены по всем товарам.

Внутренний запрос:

SELECT AVG(price)
FROM products

возвращает одно значение — среднюю цену.

Внешний запрос сравнивает price каждого товара с этим значением.


Подзапрос в SELECT #

Подзапрос может вычислять отдельное значение для каждой строки результата.

Пример:

SELECT
    u.id,
    u.username,
    (
        SELECT COUNT(*)
        FROM orders o
        WHERE o.user_id = u.id
    ) AS orders_count
FROM users u;

Результат:

id | username | orders_count
---+----------+-------------
1  | alex     | 5
2  | bob      | 0
3  | tom      | 2

Логика:

для каждого пользователя посчитать количество его заказов.

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

u.id

Подзапрос в FROM #

Подзапрос в FROM создаёт временную таблицу результата, с которой дальше можно работать.

Пример:

SELECT
    stats.user_id,
    stats.orders_count
FROM (
    SELECT
        user_id,
        COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
) AS stats
WHERE stats.orders_count > 5;

Здесь внутренний запрос:

SELECT user_id, COUNT(*) AS orders_count
FROM orders
GROUP BY user_id

сначала создаёт набор:

user_id | orders_count
--------+-------------
1       | 10
2       | 3
3       | 7

А внешний запрос оставляет только тех, у кого заказов больше 5.

PostgreSQL описывает FROM как часть table expression: там могут использоваться не только обычные таблицы, но и более сложные выражения, включая соединения и подзапросы. ( PostgreSQL)


Подзапрос в HAVING #

HAVING фильтрует уже сгруппированные данные.

Пример:

SELECT
    user_id,
    SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
HAVING SUM(amount) > (
    SELECT AVG(amount)
    FROM orders
);

Смысл:

выбрать пользователей,
у которых сумма заказов больше средней суммы одного заказа.

На практике в HAVING подзапросы встречаются реже, чем в WHERE, но они бывают полезны в аналитических запросах.


Скалярный подзапрос #

Скалярный подзапрос — это подзапрос, который должен вернуть ровно одно значение: одну строку и одну колонку.

Пример:

SELECT *
FROM products
WHERE price > (
    SELECT AVG(price)
    FROM products
);

Внутренний запрос возвращает одно значение:

AVG(price)

Это значение можно использовать как обычное число в сравнении.

Другой пример:

SELECT
    id,
    username,
    (
        SELECT MAX(created_at)
        FROM orders
        WHERE orders.user_id = users.id
    ) AS last_order_at
FROM users;

Здесь подзапрос возвращает дату последнего заказа пользователя.

Важный момент: если скалярный подзапрос вернёт больше одной строки, база выдаст ошибку.

Плохой пример:

SELECT *
FROM users
WHERE id = (
    SELECT user_id
    FROM orders
);

Если в orders много разных user_id, подзапрос вернёт много строк, а оператор = ожидает одно значение.

Правильно использовать IN:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

Подзапрос с IN #

IN проверяет, входит ли значение в набор значений, возвращённых подзапросом.

Пример:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
    WHERE status = 'paid'
);

Смысл:

найти пользователей,
у которых есть оплаченные заказы.

Внутренний запрос может вернуть много user_id.

Внешний запрос проверяет:

users.id находится среди этих user_id или нет

Подзапрос с NOT IN #

NOT IN проверяет, что значение не входит в набор.

Пример:

SELECT *
FROM users
WHERE id NOT IN (
    SELECT user_id
    FROM orders
);

Смысл:

найти пользователей, которых нет среди владельцев заказов.

Но с NOT IN есть важный нюанс: если подзапрос возвращает NULL, результат может стать неожиданным.

Например, если orders.user_id может быть NULL, то:

WHERE id NOT IN (
    SELECT user_id
    FROM orders
)

может не вернуть ожидаемые строки из-за SQL-логики NULL.

На практике для поиска отсутствующих связей часто безопаснее использовать NOT EXISTS:

SELECT *
FROM users u
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Подзапрос с EXISTS #

EXISTS проверяет не конкретное значение, а сам факт наличия строк.

Пример:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Смысл:

выбрать пользователей,
для которых существует хотя бы один заказ.

Здесь базе не важно, что именно возвращает внутренний SELECT.

Поэтому часто пишут:

SELECT 1

Это просто общепринятый стиль. Главное — есть строка или нет.


IN vs EXISTS #

Оба варианта могут решать похожие задачи.

Через IN:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

Через EXISTS:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Логически оба запроса ищут пользователей, у которых есть заказы.

Разница:

IN сравнивает значение с набором значений;
EXISTS проверяет существование подходящей строки.

В современных СУБД оптимизатор часто может превратить такие запросы в похожие планы выполнения. Но по смыслу:

IN удобно, когда есть явный набор значений;
EXISTS удобно, когда нужно проверить наличие связанной строки.

Для NOT-логики чаще безопаснее NOT EXISTS, особенно если в подзапросе возможны NULL.


Коррелированный подзапрос #

Коррелированный подзапрос — это подзапрос, который зависит от строки внешнего запроса.

Пример:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Внутренний запрос использует:

u.id

из внешнего запроса.

То есть для каждого пользователя проверяется:

есть ли заказ, где orders.user_id = id этого пользователя

PostgreSQL указывает, что подзапрос может ссылаться на переменные из окружающего запроса; такие значения действуют как константы во время конкретной оценки подзапроса. ( PostgreSQL)

Коррелированные подзапросы удобны, но с ними нужно быть осторожным на больших таблицах. Если оптимизатор не сможет эффективно переписать запрос, подзапрос может выполняться много раз.


Некоррелированный подзапрос #

Некоррелированный подзапрос не зависит от внешнего запроса.

Пример:

SELECT *
FROM products
WHERE price > (
    SELECT AVG(price)
    FROM products
);

Внутренний запрос:

SELECT AVG(price)
FROM products

не использует строки внешнего запроса. Он может быть вычислен отдельно.

Смысл:

сначала посчитать среднюю цену;
потом выбрать товары дороже этой средней цены.

Подзапросы в UPDATE #

Подзапросы можно использовать не только в SELECT, но и в UPDATE.

Пример: обновить статус пользователей, у которых нет заказов.

UPDATE users u
SET status = 'inactive'
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Смысл:

если для пользователя нет ни одного заказа,
поставить ему status = inactive.

Другой пример: записать пользователю дату последнего заказа.

UPDATE users u
SET last_order_at = (
    SELECT MAX(o.created_at)
    FROM orders o
    WHERE o.user_id = u.id
);

В PostgreSQL UPDATE допускает использование подзапроса, который может ссылаться на старые значения текущей обновляемой строки. ( PostgreSQL)


Подзапросы в DELETE #

Пример: удалить уведомления пользователей, которые заблокированы.

DELETE FROM notifications
WHERE user_id IN (
    SELECT id
    FROM users
    WHERE status = 'blocked'
);

Смысл:

сначала найти заблокированных пользователей;
потом удалить их уведомления.

Или через EXISTS:

DELETE FROM notifications n
WHERE EXISTS (
    SELECT 1
    FROM users u
    WHERE u.id = n.user_id
      AND u.status = 'blocked'
);

Подзапросы в INSERT #

Подзапрос может быть источником данных для вставки.

Пример: создать записи в таблице статистики на основе заказов.

INSERT INTO user_order_stats (user_id, orders_count, total_amount)
SELECT
    user_id,
    COUNT(*),
    SUM(amount)
FROM orders
GROUP BY user_id;

Формально здесь используется INSERT ... SELECT, а не подзапрос в скобках, но идея похожая: результат одного запроса становится входными данными для другой операции.

Можно использовать и вложенный подзапрос:

INSERT INTO vip_users (user_id)
SELECT id
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
    GROUP BY user_id
    HAVING SUM(amount) > 10000
);

Подзапрос как замена JOIN #

Некоторые задачи можно написать и через подзапрос, и через JOIN.

Например, пользователи с заказами.

Через подзапрос:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

Через JOIN:

SELECT DISTINCT u.*
FROM users u
JOIN orders o ON o.user_id = u.id;

Оба варианта могут дать похожий результат.

Но есть нюанс: JOIN может размножить строки.

Если у пользователя 5 заказов, то обычный JOIN вернёт 5 строк с этим пользователем. Поэтому пришлось добавить:

DISTINCT

А вариант с EXISTS сразу проверяет факт наличия заказа:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Для задачи «есть ли связанная запись» EXISTS часто читается очень естественно.


Подзапрос vs JOIN #

Выбор зависит от задачи.

JOIN лучше, когда нужно получить данные из обеих таблиц:

SELECT
    u.username,
    o.id AS order_id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id;

Смысл:

получить пользователя и конкретные заказы.

Подзапрос лучше читается, когда нужно проверить условие:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Смысл:

получить пользователей, у которых есть хотя бы один заказ.

Примерное правило:

нужны колонки из связанной таблицы -> JOIN;
нужно проверить наличие/отсутствие связанных строк -> EXISTS / NOT EXISTS;
нужно сравнить с агрегатом -> скалярный подзапрос;
нужно создать промежуточный набор -> подзапрос в FROM или WITH.

Подзапрос vs WITH #

Подзапрос:

SELECT *
FROM (
    SELECT user_id, COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
) AS stats
WHERE stats.orders_count > 5;

То же самое через WITH:

WITH stats AS (
    SELECT user_id, COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
)
SELECT *
FROM stats
WHERE stats.orders_count > 5;

По смыслу они близки.

WITH обычно удобнее, когда:

логика большая;
несколько промежуточных шагов;
один результат нужно использовать несколько раз;
нужно повысить читаемость.

Подзапрос удобнее, когда логика короткая и локальная.


Подзапросы с ANY / SOME / ALL #

Есть ещё операторы ANY, SOME, ALL.

Пример с ANY:

SELECT *
FROM products
WHERE price > ANY (
    SELECT price
    FROM products
    WHERE category_id = 10
);

Смысл:

выбрать товары,
цена которых больше хотя бы одной цены из категории 10.

Пример с ALL:

SELECT *
FROM products
WHERE price > ALL (
    SELECT price
    FROM products
    WHERE category_id = 10
);

Смысл:

выбрать товары,
цена которых больше всех цен из категории 10.

PostgreSQL поддерживает подзапросные выражения ANY/SOME и ALL; они сравнивают левое значение с результатами подзапроса по заданному оператору. ( PostgreSQL)

На практике в backend-разработке ANY и ALL встречаются реже, чем IN, EXISTS, NOT EXISTS и скалярные подзапросы.


Типичные задачи с подзапросами #

1. Найти записи выше среднего #

SELECT *
FROM products
WHERE price > (
    SELECT AVG(price)
    FROM products
);

Используется агрегатный подзапрос.


2. Найти пользователей с заказами #

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Используется проверка существования.


3. Найти пользователей без заказов #

SELECT *
FROM users u
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Используется проверка отсутствия.


4. Найти товары из категорий, которые активны #

SELECT *
FROM products
WHERE category_id IN (
    SELECT id
    FROM categories
    WHERE is_active = true
);

Используется фильтрация по набору значений.


5. Посчитать количество заказов в SELECT #

SELECT
    u.id,
    u.username,
    (
        SELECT COUNT(*)
        FROM orders o
        WHERE o.user_id = u.id
    ) AS orders_count
FROM users u;

Используется коррелированный скалярный подзапрос.


6. Удалить строки по условию из другой таблицы #

DELETE FROM notifications
WHERE user_id IN (
    SELECT id
    FROM users
    WHERE status = 'deleted'
);

Используется подзапрос в DELETE.


Производительность подзапросов #

Подзапрос сам по себе не плохой и не хороший.

Производительность зависит от:

типа подзапроса;
наличия индексов;
размера таблиц;
плана выполнения;
возможности оптимизатора переписать запрос;
коррелированности подзапроса;
операторов IN / EXISTS / JOIN;
селективности условий.

Проверять нужно через:

EXPLAIN ANALYZE

Индексы для подзапросов #

Например, есть запрос:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Для него полезен индекс:

CREATE INDEX orders_user_id_idx
ON orders (user_id);

Почему: внутренний подзапрос ищет заказы по user_id.

Если индекса нет, база может делать тяжёлый поиск по orders.

Другой пример:

SELECT *
FROM orders
WHERE user_id IN (
    SELECT id
    FROM users
    WHERE status = 'active'
);

Полезны индексы:

CREATE INDEX users_status_idx ON users (status);
CREATE INDEX orders_user_id_idx ON orders (user_id);

Частая ошибка: NOT IN и NULL #

Очень важная ошибка.

Допустим:

SELECT *
FROM users
WHERE id NOT IN (
    SELECT user_id
    FROM orders
);

Если orders.user_id содержит NULL, результат может стать не тем, который ожидается.

Причина: в SQL сравнение с NULL даёт не true и не false, а unknown.

Безопаснее:

SELECT *
FROM users u
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Или явно убрать NULL:

SELECT *
FROM users
WHERE id NOT IN (
    SELECT user_id
    FROM orders
    WHERE user_id IS NOT NULL
);

Но для анти-связей обычно лучше NOT EXISTS.


Частая ошибка: скалярный подзапрос возвращает много строк #

Плохо:

SELECT *
FROM users
WHERE id = (
    SELECT user_id
    FROM orders
);

Если orders содержит много строк, будет ошибка.

Нужно либо IN:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

либо агрегат, если нужно одно значение:

SELECT *
FROM users
WHERE id = (
    SELECT MAX(user_id)
    FROM orders
);

Частая ошибка: лишний подзапрос без смысла #

Плохо:

SELECT *
FROM (
    SELECT *
    FROM users
) AS u
WHERE u.is_active = true;

Проще:

SELECT *
FROM users
WHERE is_active = true;

Подзапрос не нужен, если он не упрощает логику и не решает отдельную задачу.


Частая ошибка: коррелированный подзапрос вместо JOIN/агрегации на больших данных #

Например:

SELECT
    u.id,
    u.username,
    (
        SELECT COUNT(*)
        FROM orders o
        WHERE o.user_id = u.id
    ) AS orders_count
FROM users u;

На небольших данных это нормально.

Но на больших таблицах иногда выгоднее заранее агрегировать заказы и сделать LEFT JOIN:

SELECT
    u.id,
    u.username,
    COALESCE(o.orders_count, 0) AS orders_count
FROM users u
LEFT JOIN (
    SELECT
        user_id,
        COUNT(*) AS orders_count
    FROM orders
    GROUP BY user_id
) o ON o.user_id = u.id;

Это не универсальное правило. В PostgreSQL оптимизатор может строить разные планы, но при проблемах с производительностью такие варианты нужно сравнивать через EXPLAIN ANALYZE.


Практический пример из backend #

Допустим, есть таблицы:

users
orders
payments

Нужно получить активных пользователей, у которых:

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

Запрос:

SELECT *
FROM users u
WHERE u.is_active = true

  AND EXISTS (
      SELECT 1
      FROM orders o
      WHERE o.user_id = u.id
        AND o.status = 'paid'
  )

  AND NOT EXISTS (
      SELECT 1
      FROM payments p
      WHERE p.user_id = u.id
        AND p.status = 'overdue'
  )

  AND (
      SELECT COALESCE(SUM(o.amount), 0)
      FROM orders o
      WHERE o.user_id = u.id
        AND o.status = 'paid'
  ) > (
      SELECT AVG(amount)
      FROM orders
      WHERE status = 'paid'
  );

Здесь подзапросы используются для трёх разных задач:

EXISTS      -> проверить наличие оплаченных заказов;
NOT EXISTS  -> проверить отсутствие просроченных платежей;
SUM / AVG   -> сравнить сумму пользователя со средним значением.

Когда лучше использовать подзапрос #

Подзапрос хорошо подходит, когда вопрос формулируется так:

найди строки, для которых существует связанная запись;
найди строки, для которых связанной записи нет;
сравни значение со средним/максимальным/минимальным;
отфильтруй по набору значений из другой таблицы;
сначала посчитай агрегат, потом используй его;
получи вычисляемое значение для каждой строки;
используй результат одного SELECT внутри другого.

Когда лучше использовать JOIN #

JOIN обычно лучше, когда нужно получить данные из нескольких таблиц в одном результате.

Например:

SELECT
    u.username,
    o.id AS order_id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id;

Здесь нужен не просто факт наличия заказа, а сами данные заказа.


Итог #

Подзапросы в SQL нужны для того, чтобы использовать результат одного запроса внутри другого.

Они применяются для:

фильтрации через IN / NOT IN;
проверки существования через EXISTS / NOT EXISTS;
сравнения с агрегатами;
вычисления значений в SELECT;
создания временного набора данных в FROM;
обновления и удаления строк по результату другой выборки;
разбиения сложной логики на части.

Главные виды:

скалярный подзапрос     -> возвращает одно значение;
табличный подзапрос     -> возвращает набор строк/колонок;
коррелированный         -> зависит от внешнего запроса;
некоррелированный       -> не зависит от внешнего запроса.

Главное практическое правило:

если нужно проверить наличие связанных данных — часто удобен EXISTS;
если нужно проверить отсутствие — чаще безопаснее NOT EXISTS;
если нужны данные из обеих таблиц — обычно JOIN;
если нужно сравнить со средним/максимумом/минимумом — скалярный подзапрос;
если логика стала многошаговой — можно использовать подзапрос в FROM или WITH.

Подзапросы — это нормальный и важный инструмент SQL, но на больших таблицах их нужно проверять через EXPLAIN ANALYZE и поддерживать правильными индексами.


43. Можно ли использовать JOIN и подзапрос в одном SQL-запросе? #

Да, можно #

JOIN и подзапрос можно использовать в одном SQL-запросе. Это нормальная и частая практика.

Например:

SELECT
    u.id,
    u.username,
    o.id AS order_id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id IN (
    SELECT user_id
    FROM user_roles
    WHERE role_id = 1
);

Здесь одновременно используются:

JOIN       -> чтобы соединить users и orders;
подзапрос  -> чтобы отфильтровать только пользователей с нужной ролью.

PostgreSQL рассматривает JOIN как часть table expression в FROM, а подзапросы могут использоваться в выражениях вроде IN, EXISTS, ANY, ALL и других местах запроса. Поэтому они спокойно комбинируются в одном SQL.


Пример 1: JOIN + подзапрос в WHERE #

Допустим, есть таблицы:

users
orders
payments

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

SELECT
    u.id AS user_id,
    u.username,
    o.id AS order_id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id IN (
    SELECT p.user_id
    FROM payments p
    WHERE p.status = 'success'
);

Что здесь происходит:

1. JOIN соединяет пользователей с их заказами.
2. Подзапрос выбирает user_id пользователей с успешными платежами.
3. WHERE оставляет только тех пользователей, чей id есть в результате подзапроса.

То есть JOIN отвечает за получение связанных данных, а подзапрос — за дополнительную фильтрацию.


Пример 2: JOIN + EXISTS #

Часто вместо IN используют EXISTS.

SELECT
    u.id,
    u.username,
    o.id AS order_id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE EXISTS (
    SELECT 1
    FROM payments p
    WHERE p.user_id = u.id
      AND p.status = 'success'
);

Смысл:

вывести заказы только тех пользователей,
для которых существует хотя бы один успешный платёж.

EXISTS проверяет сам факт наличия строки в подзапросе. PostgreSQL указывает, что EXISTS возвращает true, если подзапрос возвращает хотя бы одну строку.


Пример 3: JOIN с подзапросом в FROM #

Подзапрос можно использовать как временную таблицу в FROM, а потом присоединить его через JOIN.

Например, нужно получить пользователей и сумму их оплаченных заказов:

SELECT
    u.id,
    u.username,
    stats.total_amount
FROM users u
JOIN (
    SELECT
        user_id,
        SUM(amount) AS total_amount
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
) AS stats ON stats.user_id = u.id
WHERE stats.total_amount > 1000;

Здесь подзапрос:

SELECT
    user_id,
    SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY user_id

сначала формирует результат:

user_id | total_amount
--------+-------------
1       | 1500
2       | 700
3       | 3200

А основной запрос делает JOIN этого результата с users.

То есть подзапрос в FROM можно воспринимать как временную таблицу результата, доступную только внутри текущего запроса.


Пример 4: JOIN + скалярный подзапрос в SELECT #

Подзапрос может стоять прямо в SELECT.

Например, нужно вывести пользователей, их заказы и дату последнего платежа пользователя:

SELECT
    u.id,
    u.username,
    o.id AS order_id,
    o.amount,
    (
        SELECT MAX(p.created_at)
        FROM payments p
        WHERE p.user_id = u.id
    ) AS last_payment_at
FROM users u
JOIN orders o ON o.user_id = u.id;

Здесь:

JOIN соединяет users и orders;
подзапрос в SELECT считает last_payment_at для каждого пользователя.

Такой подзапрос называется скалярным, потому что должен вернуть одно значение: одну строку и одну колонку.


Пример 5: JOIN + подзапрос с агрегатом #

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

SELECT
    u.username,
    o.id AS order_id,
    o.amount
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.amount > (
    SELECT AVG(amount)
    FROM orders
);

Здесь:

JOIN нужен, чтобы получить username пользователя;
подзапрос нужен, чтобы посчитать среднюю сумму заказа;
WHERE сравнивает каждый заказ со средним значением.

Подзапрос:

SELECT AVG(amount)
FROM orders

возвращает одно значение. Его можно использовать в сравнении:

o.amount > (...)

Пример 6: JOIN + NOT EXISTS #

Нужно получить пользователей и их профили, но только тех пользователей, у которых нет заказов.

SELECT
    u.id,
    u.username,
    p.bio
FROM users u
JOIN user_profiles p ON p.user_id = u.id
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Смысл:

JOIN соединяет пользователя с профилем;
NOT EXISTS оставляет только тех пользователей,
для которых не существует ни одного заказа.

NOT EXISTS часто безопаснее, чем NOT IN, если во внутреннем наборе возможны NULL.


Пример 7: JOIN + подзапрос в условии соединения #

Подзапрос можно использовать даже внутри ON, хотя так делают реже.

Например, нужно присоединить только последний заказ пользователя:

SELECT
    u.id,
    u.username,
    o.id AS last_order_id,
    o.created_at
FROM users u
LEFT JOIN orders o
    ON o.user_id = u.id
   AND o.created_at = (
       SELECT MAX(o2.created_at)
       FROM orders o2
       WHERE o2.user_id = u.id
   );

Что происходит:

1. Берём пользователя.
2. Через LEFT JOIN пытаемся присоединить его заказ.
3. В ON через подзапрос оставляем только заказ с максимальной датой.

Но у такого варианта есть нюанс: если у пользователя два заказа с одинаковым максимальным created_at, вернутся оба. Поэтому в реальных проектах часто добавляют id как tie-breaker или используют оконные функции.

Более устойчивый вариант через ROW_NUMBER():

SELECT
    u.id,
    u.username,
    o.id AS last_order_id,
    o.created_at
FROM users u
LEFT JOIN (
    SELECT
        id,
        user_id,
        created_at,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY created_at DESC, id DESC
        ) AS rn
    FROM orders
) o ON o.user_id = u.id
   AND o.rn = 1;

Здесь подзапрос заранее нумерует заказы каждого пользователя, а JOIN берёт только rn = 1.


JOIN и подзапрос решают разные задачи #

Обычно:

JOIN нужен, чтобы объединить данные из нескольких таблиц;
подзапрос нужен, чтобы получить промежуточный результат для фильтрации, сравнения или расчёта.

Пример:

SELECT
    u.username,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.amount > (
    SELECT AVG(amount)
    FROM orders
);

Здесь невозможно сказать, что JOIN или подзапрос «лучше». Они выполняют разные роли:

JOIN       -> получить username из users;
подзапрос  -> вычислить среднюю сумму заказа.

Можно ли заменить подзапрос на JOIN #

Иногда да.

Например:

SELECT *
FROM users
WHERE id IN (
    SELECT user_id
    FROM orders
);

можно переписать через JOIN:

SELECT DISTINCT u.*
FROM users u
JOIN orders o ON o.user_id = u.id;

Но это не всегда одно и то же по удобству.

Подзапрос с EXISTS:

SELECT *
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

часто лучше выражает смысл:

найди пользователей, у которых есть хотя бы один заказ

А JOIN лучше, когда нужны сами данные заказов:

SELECT
    u.username,
    o.id,
    o.amount
FROM users u
JOIN orders o ON o.user_id = u.id;

Можно ли заменить JOIN на подзапрос #

Иногда можно, но не всегда это удобно.

Например:

SELECT
    o.id,
    o.amount,
    (
        SELECT u.username
        FROM users u
        WHERE u.id = o.user_id
    ) AS username
FROM orders o;

Это альтернатива:

SELECT
    o.id,
    o.amount,
    u.username
FROM orders o
JOIN users u ON u.id = o.user_id;

Второй вариант через JOIN обычно читается лучше, потому что связь между orders и users явно выражена в FROM.

Практическое правило:

если нужно подтянуть поля из связанной таблицы — обычно JOIN;
если нужно проверить наличие/отсутствие или сравнить с агрегатом — часто подзапрос;
если нужно и то и другое — можно использовать оба вместе.

Пример полноценного запроса из backend #

Допустим, нужно получить список активных пользователей:

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

Запрос:

SELECT
    u.id,
    u.username,
    p.bio,
    order_stats.orders_count,
    order_stats.total_amount
FROM users u
JOIN user_profiles p ON p.user_id = u.id
JOIN (
    SELECT
        user_id,
        COUNT(*) AS orders_count,
        SUM(amount) AS total_amount
    FROM orders
    GROUP BY user_id
) AS order_stats ON order_stats.user_id = u.id
WHERE u.is_active = true

  AND EXISTS (
      SELECT 1
      FROM orders o
      WHERE o.user_id = u.id
        AND o.status = 'paid'
  )

  AND NOT EXISTS (
      SELECT 1
      FROM payments pay
      WHERE pay.user_id = u.id
        AND pay.status = 'overdue'
  )

  AND order_stats.total_amount > (
      SELECT AVG(amount)
      FROM orders
  );

Здесь одновременно есть:

JOIN users -> user_profiles;
JOIN users -> агрегированный подзапрос order_stats;
EXISTS для проверки оплаченных заказов;
NOT EXISTS для проверки отсутствия просроченных платежей;
скалярный подзапрос для средней суммы заказа.

Такой запрос вполне нормален, если он понятен, корректен и имеет подходящие индексы.


Производительность #

Сам факт, что в запросе есть и JOIN, и подзапрос, не делает его плохим.

Проблемы появляются не от комбинации, а от конкретного плана выполнения:

нет индексов;
подзапрос выполняется слишком много раз;
JOIN размножает строки;
агрегация делается на огромной таблице;
неправильный порядок фильтрации;
неудачная селективность условий;
большие промежуточные результаты.

Проверять нужно через:

EXPLAIN ANALYZE

PostgreSQL строит план выполнения для всего запроса целиком. Подзапросы и JOIN могут быть оптимизированы, переписаны или выполнены разными способами в зависимости от статистики, индексов и условий.


Какие индексы могут понадобиться #

Для запроса:

SELECT
    u.id,
    u.username,
    o.id AS order_id
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE EXISTS (
    SELECT 1
    FROM payments p
    WHERE p.user_id = u.id
      AND p.status = 'success'
);

обычно полезны индексы:

CREATE INDEX orders_user_id_idx
ON orders (user_id);

CREATE INDEX payments_user_status_idx
ON payments (user_id, status);

Почему:

orders.user_id нужен для JOIN;
payments.user_id и payments.status нужны для EXISTS.

Если индексов нет, база может сканировать большие таблицы.


Частая ошибка 1: JOIN размножает строки #

Пример:

SELECT
    u.id,
    u.username
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id IN (
    SELECT user_id
    FROM payments
    WHERE status = 'success'
);

Если у пользователя 10 заказов, он появится 10 раз.

Это не ошибка SQL. Это нормальное поведение JOIN.

Если нужны уникальные пользователи:

SELECT DISTINCT
    u.id,
    u.username
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id IN (
    SELECT user_id
    FROM payments
    WHERE status = 'success'
);

Но иногда лучше вообще не делать JOIN, если заказы не нужны:

SELECT
    u.id,
    u.username
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
)
AND EXISTS (
    SELECT 1
    FROM payments p
    WHERE p.user_id = u.id
      AND p.status = 'success'
);

Частая ошибка 2: подзапрос возвращает много строк, а используется = #

Плохо:

SELECT
    u.id,
    u.username,
    o.id AS order_id
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = (
    SELECT user_id
    FROM payments
    WHERE status = 'success'
);

Если подзапрос вернёт несколько user_id, будет ошибка.

Правильно:

WHERE u.id IN (
    SELECT user_id
    FROM payments
    WHERE status = 'success'
)

Или:

WHERE EXISTS (
    SELECT 1
    FROM payments p
    WHERE p.user_id = u.id
      AND p.status = 'success'
)

Частая ошибка 3: NOT IN и NULL #

Плохо:

SELECT
    u.id,
    u.username
FROM users u
JOIN user_profiles p ON p.user_id = u.id
WHERE u.id NOT IN (
    SELECT user_id
    FROM orders
);

Если orders.user_id содержит NULL, результат может быть неожиданным.

Лучше:

SELECT
    u.id,
    u.username
FROM users u
JOIN user_profiles p ON p.user_id = u.id
WHERE NOT EXISTS (
    SELECT 1
    FROM orders o
    WHERE o.user_id = u.id
);

Частая ошибка 4: фильтр по правой таблице ломает LEFT JOIN #

Плохо:

SELECT
    u.id,
    u.username,
    o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';

Хотели оставить всех пользователей, но условие в WHERE уберёт строки, где o.status IS NULL. В результате LEFT JOIN начнёт вести себя ближе к INNER JOIN.

Правильнее:

SELECT
    u.id,
    u.username,
    o.id AS order_id
FROM users u
LEFT JOIN orders o
    ON o.user_id = u.id
   AND o.status = 'paid';

А подзапросы можно использовать отдельно, например для фильтрации пользователей:

SELECT
    u.id,
    u.username,
    o.id AS paid_order_id
FROM users u
LEFT JOIN orders o
    ON o.user_id = u.id
   AND o.status = 'paid'
WHERE EXISTS (
    SELECT 1
    FROM user_profiles p
    WHERE p.user_id = u.id
);

JOIN + подзапрос или WITH #

Когда подзапрос становится большим, часто его переписывают через WITH.

Например так:

SELECT
    u.id,
    u.username,
    stats.total_amount
FROM users u
JOIN (
    SELECT
        user_id,
        SUM(amount) AS total_amount
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
) stats ON stats.user_id = u.id
WHERE stats.total_amount > 1000;

Можно переписать:

WITH order_stats AS (
    SELECT
        user_id,
        SUM(amount) AS total_amount
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
)
SELECT
    u.id,
    u.username,
    order_stats.total_amount
FROM users u
JOIN order_stats ON order_stats.user_id = u.id
WHERE order_stats.total_amount > 1000;

WITH не обязателен, но часто делает запрос более читаемым. PostgreSQL описывает WITH как способ написать вспомогательные выражения для использования в более крупном запросе.


Итог #

Да, JOIN и подзапрос можно использовать в одном SQL-запросе.

Это делают, когда нужно одновременно:

соединить таблицы через JOIN;
отфильтровать данные через IN / EXISTS / NOT EXISTS;
сравнить значения с агрегатом;
присоединить результат агрегированного подзапроса;
вычислить дополнительное значение в SELECT;
разделить сложную SQL-логику на части.

Простой пример:

SELECT
    u.id,
    u.username,
    o.id AS order_id
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE EXISTS (
    SELECT 1
    FROM payments p
    WHERE p.user_id = u.id
      AND p.status = 'success'
);

Практическое правило:

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

Главное — не сам факт смешивания JOIN и подзапроса, а корректность логики, отсутствие случайного размножения строк и нормальный план выполнения через EXPLAIN ANALYZE.


44. Что такое хранимая процедура в SQL? #

Хранимая процедура (по сути аналог функции в Java) - это единожды созданный и сохранённый в базе данных набор SQL-инструкций (PL/pgSQL, PL/Python), выполняющий определённую задачу. Служит для повторного использования кода и оптимизации

Ключевые особенности:

  • Входные параметры - передача данных в процедуру
  • Выходные параметры - возврат результата
  • Операторы управления потоком исполнения - циклы, условные конструкции (IFWHILE)

Пример создания и вызова хранимой процедуры:

CREATE PROCEDURE CalculateBonus (IN employee_id INT, OUT bonus_amount DECIMAL(10,2))  
BEGIN  
SELECT salary * 0.1 INTO bonus_amount  
FROM employees  
WHERE id = employee_id;  
END;  

Вызов:

CALL CalculateBonus(101, @bonus);  
SELECT @bonus;  

Преимущества:

  • снижение трафика между клиентом и сервером
  • лучшая организация кода


45. Чем отличаются хранимые процедуры и хранимые функции? #

Главное отличие #

Хранимая функция — это объект БД, который обычно возвращает значение и может использоваться внутри SQL-выражений.

Хранимая процедура — это объект БД, который обычно выполняет действие/операцию и вызывается отдельной командой, например CALL.

В PostgreSQL процедура создаётся через CREATE PROCEDURE, а функция — через CREATE FUNCTION. Документация PostgreSQL прямо разделяет эти объекты: CREATE FUNCTION определяет новую функцию, а CREATE PROCEDURE определяет новую процедуру.


Функция #

Функция предназначена для вычисления и возврата результата.

Пример простой функции:

CREATE FUNCTION add_numbers(a INTEGER, b INTEGER)
RETURNS INTEGER
LANGUAGE sql
AS $$
    SELECT a + b;
$$;

Вызов:

SELECT add_numbers(2, 3);

Результат:

5

Функцию можно использовать как часть обычного SQL-запроса:

SELECT
    id,
    username,
    add_numbers(score, bonus) AS total_score
FROM users;

То есть функция ведёт себя как выражение:

LOWER(email)
COUNT(*)
NOW()
custom_function(...)

PostgreSQL поддерживает встроенные функции и пользовательские функции; документация указывает, что пользователи могут определять собственные функции.


Процедура #

Процедура предназначена не столько для вычисления значения, сколько для выполнения набора действий.

Пример процедуры:

CREATE PROCEDURE deactivate_old_users()
LANGUAGE plpgsql
AS $$
BEGIN
    UPDATE users
    SET is_active = false
    WHERE last_login_at < now() - interval '1 year';
END;
$$;

Вызов:

CALL deactivate_old_users();

Здесь процедура не используется внутри SELECT. Она вызывается отдельной командой CALL.

В PostgreSQL пользовательские процедуры описаны как объект, похожий на функцию, но создаваемый через CREATE PROCEDURE, а не CREATE FUNCTION; вызываются процедуры через CALL.


Ключевая разница в вызове #

Функция вызывается как часть выражения:

SELECT calculate_discount(1000, 10);

Или внутри запроса:

SELECT
    id,
    price,
    calculate_discount(price, 10) AS discounted_price
FROM products;

Процедура вызывается отдельной командой:

CALL recalculate_user_statistics();

То есть:

функция -> SELECT function_name(...)
процедура -> CALL procedure_name(...)

Возвращаемое значение #

Функция обычно должна иметь RETURNS.

Пример:

CREATE FUNCTION get_user_orders_count(p_user_id BIGINT)
RETURNS INTEGER
LANGUAGE sql
AS $$
    SELECT COUNT(*)
    FROM orders
    WHERE user_id = p_user_id;
$$;

Вызов:

SELECT get_user_orders_count(10);

Функция возвращает значение:

orders_count

Процедура в PostgreSQL не вызывается как выражение и не обязана возвращать значение как функция. Она может иметь параметры IN, OUT, INOUT, но её основной смысл — выполнить действия через CALL, а не участвовать в выражении SELECT. PostgreSQL CREATE PROCEDURE поддерживает параметры, включая output-параметры, но вызов всё равно идёт через CALL.


Сравнение #

КритерийФункцияПроцедура
СозданиеCREATE FUNCTIONCREATE PROCEDURE
ВызовSELECT func(...)CALL proc(...)
Основной смыслВернуть значениеВыполнить действие
Использование в SELECTДаНет
Можно использовать в WHEREДаНет
Можно использовать в JOIN, FROMДа, если возвращает таблицуНет как обычную таблицу
Возврат результатаЧерез RETURNSЧерез OUT/INOUT, но не как выражение
Типичный примеррасчёт скидки, форматирование, агрегациямассовое обновление, обслуживание данных, batch-операция
Управление транзакциями в PostgreSQLНельзя делать обычный COMMIT/ROLLBACK внутри функцииВ процедурах возможно при определённых условиях

Функция может возвращать таблицу #

Функция в PostgreSQL может возвращать не только одно значение, но и набор строк.

Пример:

CREATE FUNCTION get_user_orders(p_user_id BIGINT)
RETURNS TABLE (
    order_id BIGINT,
    amount NUMERIC,
    created_at TIMESTAMP
)
LANGUAGE sql
AS $$
    SELECT
        id,
        amount,
        created_at
    FROM orders
    WHERE user_id = p_user_id;
$$;

Вызов:

SELECT *
FROM get_user_orders(10);

Результат может выглядеть так:

order_id | amount | created_at
---------+--------+---------------------
1        | 500    | 2026-06-01 12:00:00
2        | 300    | 2026-06-02 14:30:00

Такая функция может использоваться почти как таблица:

SELECT *
FROM get_user_orders(10)
WHERE amount > 400;

Процедура для такого сценария обычно не подходит. Если нужно получить табличный результат внутри SELECT, используют функцию, представление или обычный запрос.


Функция может использоваться в WHERE #

Например:

SELECT *
FROM products
WHERE calculate_discount(price, 10) > 100;

Или:

SELECT *
FROM users
WHERE normalize_email(email) = 'test@example.com';

Функция здесь участвует в фильтрации строк.

Процедуру так использовать нельзя:

SELECT *
FROM users
WHERE some_procedure(id);

Это некорректная идея: процедура не является выражением, возвращающим значение для WHERE.


Процедура лучше подходит для операций #

Процедура хорошо подходит, когда нужно выполнить последовательность действий.

Например:

CREATE PROCEDURE close_old_orders()
LANGUAGE plpgsql
AS $$
BEGIN
    UPDATE orders
    SET status = 'closed'
    WHERE status = 'paid'
      AND created_at < now() - interval '30 days';

    INSERT INTO audit_log (event_type, created_at)
    VALUES ('old_orders_closed', now());
END;
$$;

Вызов:

CALL close_old_orders();

Здесь смысл не в том, чтобы вернуть значение, а в том, чтобы:

обновить заказы;
записать событие в audit_log;
выполнить бизнес-операцию внутри БД.

Главное отличие в транзакциях PostgreSQL #

В PostgreSQL процедуры отличаются от функций тем, что процедуры могут использовать управление транзакциями — например COMMIT и ROLLBACK — но только при соблюдении условий вызова.

Документация PostgreSQL показывает пример процедуры, внутри которой выполняются COMMIT и ROLLBACK, а затем процедура вызывается через CALL.

Пример из практической логики:

CREATE PROCEDURE process_batches()
LANGUAGE plpgsql
AS $$
DECLARE
    batch_id BIGINT;
BEGIN
    FOR batch_id IN
        SELECT id
        FROM batches
        WHERE status = 'new'
    LOOP
        UPDATE batches
        SET status = 'processing'
        WHERE id = batch_id;

        COMMIT;
    END LOOP;
END;
$$;

Смысл:

обработать часть данных;
зафиксировать изменения;
перейти к следующей части.

Функция для такого сценария не подходит. Внутри функции нельзя просто управлять транзакциями как внутри процедуры.

Но важный нюанс: даже в процедуре транзакционный контроль зависит от контекста вызова. Например, если процедура вызывается внутри уже открытой транзакции приложения, поведение и допустимость COMMIT/ROLLBACK нужно проверять по конкретной СУБД и режиму выполнения.


Функции тоже могут менять данные #

Иногда думают, что функция «только читает», а процедура «только меняет данные». Это не совсем так.

В PostgreSQL функция может выполнять INSERT, UPDATE, DELETE, если написана на подходящем языке, например plpgsql.

Пример:

CREATE FUNCTION mark_notification_read(p_notification_id BIGINT)
RETURNS VOID
LANGUAGE plpgsql
AS $$
BEGIN
    UPDATE notifications
    SET is_read = true
    WHERE id = p_notification_id;
END;
$$;

Вызов:

SELECT mark_notification_read(100);

Функция изменила данные, хотя это функция.

Но с точки зрения дизайна это часто спорно. Если объект БД выполняет действие и меняет состояние, процедура может быть более понятной по смыслу:

CALL mark_notification_read(100);

То есть отличие не только техническое, но и семантическое:

функция лучше подходит для вычислений;
процедура лучше подходит для команд/операций.

Функции бывают IMMUTABLE, STABLE, VOLATILE #

В PostgreSQL для функций можно указывать характеристику изменчивости:

IMMUTABLE
STABLE
VOLATILE

Пример:

CREATE FUNCTION add_numbers(a INTEGER, b INTEGER)
RETURNS INTEGER
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT a + b;
$$;

Смысл:

при одинаковых входных данных функция всегда возвращает одинаковый результат

Это важно для оптимизатора запросов и индексов.

Пример функции для нормализации email:

CREATE FUNCTION normalize_email(email TEXT)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT lower(trim(email));
$$;

Такую функцию можно использовать в expression index:

CREATE INDEX users_normalized_email_idx
ON users (normalize_email(email));

Процедуры обычно не используются таким образом, потому что они не являются выражениями внутри SQL-запроса.


Функция может использоваться в индексах, процедура — нет #

Например:

CREATE INDEX users_lower_email_idx
ON users (lower(email));

lower(email) — функция.

Пользовательскую функцию тоже можно использовать в индексе, если она подходит по свойствам.

Например:

CREATE INDEX users_normalized_email_idx
ON users (normalize_email(email));

Это полезно, когда часто ищут по преобразованному значению:

SELECT *
FROM users
WHERE normalize_email(email) = normalize_email(' TEST@EXAMPLE.COM ');

Процедуру в индексе использовать нельзя, потому что индекс строится по выражению, а процедура не является выражением.


Функции могут использоваться в constraints #

Функцию можно использовать в CHECK, если она корректно подходит для такой логики.

Например:

CREATE FUNCTION is_valid_discount(value NUMERIC)
RETURNS BOOLEAN
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT value >= 0 AND value <= 100;
$$;

И затем:

CREATE TABLE coupons (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    discount NUMERIC NOT NULL CHECK (is_valid_discount(discount))
);

Процедура в CHECK не подходит.

Опять же причина та же:

constraint ожидает выражение;
функция может быть выражением;
процедура — отдельный вызов через CALL.

Функция может быть частью SELECT-пайплайна #

Пример:

SELECT
    id,
    email,
    normalize_email(email) AS normalized_email
FROM users
WHERE normalize_email(email) LIKE '%@gmail.com';

Здесь функция работает как обычный вычисляемый элемент запроса.

Процедура так не используется:

процедура не встраивается в SELECT;
процедура вызывается отдельно;
процедура выполняет команду.

Процедура может быть удобнее для batch-операций #

Например, нужно обработать старые события пачками:

CREATE PROCEDURE archive_old_events()
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO archived_events (id, payload, created_at)
    SELECT id, payload, created_at
    FROM events
    WHERE created_at < now() - interval '90 days';

    DELETE FROM events
    WHERE created_at < now() - interval '90 days';
END;
$$;

Вызов:

CALL archive_old_events();

Это больше похоже на команду обслуживания БД:

перенести старые события;
удалить их из основной таблицы;
записать лог;
возможно обработать транзакции пачками.

Для такой задачи процедура читается естественнее, чем функция.


Функция может быть проще для бизнес-расчётов #

Например, расчёт скидки:

CREATE FUNCTION calculate_discount_price(
    price NUMERIC,
    discount_percent NUMERIC
)
RETURNS NUMERIC
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT price - price * discount_percent / 100;
$$;

Использование:

SELECT
    id,
    price,
    calculate_discount_price(price, 15) AS final_price
FROM products;

Это именно функция: она получает входные значения и возвращает вычисленный результат.


Stored procedure в разных СУБД может означать разное #

В PostgreSQL долгое время до версии 11 не было отдельного CREATE PROCEDURE; многие называли «хранимыми процедурами» обычные функции на plpgsql. Начиная с PostgreSQL 11 появились полноценные процедуры, вызываемые через CALL, с отличиями от функций, включая возможность транзакционного контроля. PostgreSQL JDBC-документация также отмечает: PostgreSQL поддерживает функции, возвращающие результат, и начиная с версии 11 процедуры, которые могут выполнять transaction control.

В других СУБД различия могут отличаться:

PostgreSQL -> FUNCTION и PROCEDURE разделены, PROCEDURE вызывается через CALL.
SQL Server -> stored procedure вызывается EXEC, function используется в выражениях.
Oracle -> есть procedures и functions в PL/SQL.
MySQL -> есть stored procedures и stored functions.

Но общее правило почти везде одно:

function -> вернуть значение;
procedure -> выполнить действие.

Пример рядом: функция и процедура для одной темы #

Допустим, есть таблица:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    amount NUMERIC NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Функция: посчитать сумму заказов пользователя.

CREATE FUNCTION get_user_total_paid_amount(p_user_id BIGINT)
RETURNS NUMERIC
LANGUAGE sql
AS $$
    SELECT COALESCE(SUM(amount), 0)
    FROM orders
    WHERE user_id = p_user_id
      AND status = 'paid';
$$;

Вызов:

SELECT get_user_total_paid_amount(10);

Использование в запросе:

SELECT
    id,
    username,
    get_user_total_paid_amount(id) AS total_paid
FROM users;

Процедура: закрыть старые оплаченные заказы.

CREATE PROCEDURE close_old_paid_orders()
LANGUAGE plpgsql
AS $$
BEGIN
    UPDATE orders
    SET status = 'closed'
    WHERE status = 'paid'
      AND created_at < now() - interval '30 days';
END;
$$;

Вызов:

CALL close_old_paid_orders();

Разница по смыслу:

get_user_total_paid_amount -> вычисляет и возвращает сумму;
close_old_paid_orders      -> выполняет изменение состояния данных.

Что лучше использовать в backend-проекте #

Для обычного backend-разработчика правило такое.

Функция подходит, когда нужно:

посчитать значение;
нормализовать строку;
проверить простое условие;
вернуть табличный результат;
использовать логику внутри SELECT/WHERE/JOIN;
создать expression index;
использовать вычисление в CHECK;
скрыть повторяющееся SQL-выражение.

Процедура подходит, когда нужно:

выполнить бизнес-операцию в БД;
сделать batch-обработку;
обновить много таблиц;
выполнить обслуживание данных;
архивировать/переносить данные;
использовать транзакционный контроль внутри БД;
сделать команду, вызываемую через CALL.

Почему в приложениях процедуры используют осторожно #

Хранимые процедуры могут быть полезны, но их не всегда любят переносить в БД.

Плюсы:

меньше сетевых round-trip между приложением и БД;
логика выполняется ближе к данным;
можно атомарно выполнить сложную операцию;
можно переиспользовать SQL-логику несколькими приложениями;
можно скрыть детали таблиц за процедурным API.

Минусы:

бизнес-логика размазывается между backend и БД;
сложнее тестировать обычными unit-тестами;
сложнее версионировать без дисциплины миграций;
сложнее отлаживать;
можно получить сильную зависимость от конкретной СУБД;
часть команды может хуже читать PL/pgSQL, чем Python/Java/Go-код.

Поэтому в backend-проектах часто делают так:

простые вычисления и ограничения -> в БД через функции/constraints;
основная бизнес-логика -> в приложении;
тяжёлые data-processing операции -> иногда в процедурах;
отчётные/аналитические вычисления -> функции, views, materialized views, CTE.

Важный нюанс: функция не обязана быть чистой #

В математическом смысле функция обычно означает:

одни входные данные -> один результат;
без побочных эффектов.

Но в SQL/PLpgSQL функция технически может иметь побочные эффекты: писать в таблицы, вызывать другие функции, логировать действия.

Поэтому лучше различать:

технически можно;
архитектурно желательно.

Технически функция может менять данные.
Архитектурно функцию чаще ожидают как вычисление, а процедуру — как команду.


Короткое сравнение на уровне смысла #

Функция отвечает на вопрос:

Что вернуть?

Процедура отвечает на вопрос:

Что выполнить?

Функция:

SELECT calculate_bonus(user_id);

Процедура:

CALL recalculate_all_bonuses();

Функция больше похожа на выражение.
Процедура больше похожа на команду.


Итог #

Хранимая функция и хранимая процедура — оба объекта хранятся в БД и позволяют выполнять код на стороне базы, но используются для разных задач.

Функция:

создаётся через CREATE FUNCTION;
обычно возвращает значение через RETURNS;
вызывается через SELECT или внутри SQL-выражений;
может использоваться в SELECT, WHERE, JOIN, FROM;
подходит для вычислений, преобразований, табличных результатов;
может участвовать в индексах и constraints.

Процедура:

создаётся через CREATE PROCEDURE;
вызывается через CALL;
обычно выполняет действия, а не используется как выражение;
подходит для batch-операций, обслуживания данных, сложных команд;
в PostgreSQL может использовать управление транзакциями при определённых условиях.

Практический ориентир:

нужно получить значение или использовать логику внутри SELECT -> функция;
нужно выполнить операцию, изменить состояние, обработать пачку данных -> процедура.


46. Что такое составной индекс в SQL? #

Что такое составной индекс #

Составной индекс — это индекс, построенный сразу по нескольким колонкам таблицы.

Его ещё называют:

multi-column index;
composite index;
combined index;
многоколоночный индекс.

Пример:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

Здесь индекс построен не только по user_id, и не только по status, а по их комбинации:

(user_id, status)

PostgreSQL официально называет такие индексы multicolumn indexes и указывает, что индекс может быть определён более чем на одной колонке таблицы.


Простой пример #

Есть таблица заказов:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now(),
    amount NUMERIC NOT NULL
);

Частый запрос:

SELECT *
FROM orders
WHERE user_id = 10
  AND status = 'paid';

Для такого запроса можно создать составной индекс:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

Теперь база может быстрее найти заказы конкретного пользователя с конкретным статусом.


Как составной индекс устроен логически #

Для B-tree индекса порядок колонок имеет значение.

Индекс:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

можно представить как отсортированную структуру сначала по user_id, а внутри одного user_id — по status.

Упрощённо:

(user_id, status)

(1, 'cancelled')
(1, 'new')
(1, 'paid')
(2, 'new')
(2, 'paid')
(3, 'paid')

То есть индекс хранит не два независимых индекса, а один общий индекс по паре значений.

Это важно: составной индекс (user_id, status) — не то же самое, что два отдельных индекса:

CREATE INDEX orders_user_id_idx ON orders (user_id);
CREATE INDEX orders_status_idx ON orders (status);

Главное правило: важен порядок колонок #

Индекс:

ON orders (user_id, status)

и индекс:

ON orders (status, user_id)

— это разные индексы.

Они могут быть полезны для разных запросов.

Индекс (user_id, status) хорошо подходит для запросов:

WHERE user_id = 10
WHERE user_id = 10
  AND status = 'paid'

Но он обычно хуже подходит для запроса:

WHERE status = 'paid'

Потому что первая колонка индекса — user_id, а условие только по второй колонке.

PostgreSQL указывает, что многоколоночный B-tree индекс наиболее эффективен, когда ограничения есть по ведущим, то есть левым, колонкам индекса. Точные правила: равенства по ведущим колонкам и затем диапазонное условие по первой колонке без равенства ограничивают просматриваемую часть индекса.


Правило левого префикса #

Для B-tree составного индекса часто используют термин:

leftmost prefix rule
правило левого префикса

Если индекс создан так:

CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at);

то он хорошо подходит для запросов, где используются левые части индекса:

-- используется первая колонка
WHERE user_id = 10
-- используются первая и вторая колонки
WHERE user_id = 10
  AND status = 'paid'
-- используются первая, вторая и третья колонки
WHERE user_id = 10
  AND status = 'paid'
  AND created_at >= '2026-01-01'

Но хуже подходит для запроса только по status:

WHERE status = 'paid'

Потому что status — не первая колонка индекса.


Почему индекс по (user_id, status) не равен индексу по (status, user_id) #

Допустим, запросы в проекте чаще такие:

SELECT *
FROM orders
WHERE user_id = 123
  AND status = 'paid';

Тогда хороший индекс:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

Потому что сначала мы сильно сужаем данные по конкретному пользователю, потом по статусу.

Но если основной запрос такой:

SELECT *
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC;

то индекс (user_id, status) уже не так полезен. Для такого запроса логичнее думать об индексе:

CREATE INDEX orders_status_created_idx
ON orders (status, created_at DESC);

Порядок колонок выбирается под реальные запросы, а не просто по названиям полей.


Составной индекс и ORDER BY #

Составной индекс может помогать не только в WHERE, но и в ORDER BY.

Например, частый запрос:

SELECT *
FROM orders
WHERE user_id = 10
ORDER BY created_at DESC
LIMIT 20;

Хороший индекс:

CREATE INDEX orders_user_created_idx
ON orders (user_id, created_at DESC);

Почему:

user_id помогает быстро найти заказы конкретного пользователя;
created_at DESC уже хранит их в нужном порядке;
LIMIT 20 позволяет быстро взять первые 20 строк.

Это очень частый сценарий для backend API:

последние уведомления пользователя;
последние заказы пользователя;
последние сообщения в чате;
последние файлы в папке;
последние события по объекту.

Пример:

CREATE INDEX notifications_user_created_idx
ON notifications (user_id, created_at DESC);

Для запроса:

SELECT *
FROM notifications
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 50;

Составной индекс и диапазонные условия #

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

Пример:

CREATE INDEX orders_user_status_created_idx
ON orders (user_id, status, created_at);

Запрос:

SELECT *
FROM orders
WHERE user_id = 10
  AND status = 'paid'
  AND created_at >= '2026-01-01';

Здесь логика хорошая:

user_id = 10        -> равенство по первой колонке;
status = 'paid'     -> равенство по второй колонке;
created_at >= дата  -> диапазон по третьей колонке.

Обычно это хороший порядок колонок для B-tree:

сначала equality-условия;
потом range-условие;
потом иногда колонка сортировки.

Пример:

WHERE user_id = ?
  AND status = ?
  AND created_at BETWEEN ? AND ?

Индекс:

ON orders (user_id, status, created_at)

Что будет с запросом только по первой колонке #

Если есть индекс:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

то запрос:

SELECT *
FROM orders
WHERE user_id = 10;

может использовать этот индекс.

Почему: user_id — первая колонка составного индекса.

То есть составной индекс (user_id, status) частично может заменить обычный индекс (user_id).

Но это не всегда значит, что отдельный индекс (user_id) точно не нужен. Составной индекс больше по размеру, может быть менее удобен для некоторых планов, и всё зависит от конкретных запросов.


Что будет с запросом только по второй колонке #

Если есть индекс:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

запрос:

SELECT *
FROM orders
WHERE status = 'paid';

обычно не сможет эффективно использовать этот индекс как обычный поиск по status, потому что status не является левой колонкой.

Для такого запроса лучше отдельный индекс:

CREATE INDEX orders_status_idx
ON orders (status);

или составной индекс под конкретный запрос:

CREATE INDEX orders_status_created_idx
ON orders (status, created_at DESC);

Составной UNIQUE индекс #

Составной индекс может быть уникальным.

Например, пользователь не может иметь две папки с одинаковым именем:

CREATE UNIQUE INDEX folders_user_name_unique_idx
ON folders (user_id, name);

Или через constraint:

CREATE TABLE folders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    name TEXT NOT NULL,
    UNIQUE (user_id, name)
);

Смысл:

у одного пользователя имя папки должно быть уникальным;
у разных пользователей одинаковые имена папок разрешены.

Пример:

user_id | name
--------+------
1       | docs   -> можно
1       | docs   -> нельзя
2       | docs   -> можно

PostgreSQL создаёт уникальный индекс для UNIQUE constraint; уникальность может задаваться по одной колонке или по группе колонок.


Составной PRIMARY KEY #

Primary key тоже может быть составным.

Частый пример — связующая таблица many-to-many:

CREATE TABLE user_roles (
    user_id BIGINT NOT NULL REFERENCES users(id),
    role_id BIGINT NOT NULL REFERENCES roles(id),
    PRIMARY KEY (user_id, role_id)
);

Здесь первичный ключ состоит из двух колонок:

(user_id, role_id)

Это запрещает повторить одну и ту же связь:

user_id = 10, role_id = 2
user_id = 10, role_id = 2  -> нельзя

Но разрешает:

user_id = 10, role_id = 3
user_id = 11, role_id = 2

То есть один пользователь может иметь много ролей, и одна роль может быть у многих пользователей, но одна и та же пара не дублируется.


Составной индекс vs несколько одиночных индексов #

Допустим, есть запрос:

SELECT *
FROM orders
WHERE user_id = 10
  AND status = 'paid';

Вариант 1 — два отдельных индекса:

CREATE INDEX orders_user_id_idx ON orders (user_id);
CREATE INDEX orders_status_idx ON orders (status);

Вариант 2 — один составной индекс:

CREATE INDEX orders_user_status_idx ON orders (user_id, status);

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

Но PostgreSQL также умеет комбинировать несколько отдельных индексов через bitmap scan: система может просканировать нужные индексы, собрать bitmap позиций строк, объединить их через AND или OR, а затем прочитать строки таблицы.

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


Когда нужен составной индекс #

Составной индекс полезен, когда в запросах часто встречается комбинация условий.

Примеры:

WHERE user_id = ?
  AND status = ?
WHERE tenant_id = ?
  AND created_at >= ?
WHERE chat_id = ?
ORDER BY created_at DESC
LIMIT 50
WHERE user_id = ?
  AND folder_id = ?
  AND filename = ?
WHERE order_id = ?
  AND product_id = ?

Под такие запросы можно делать индексы:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);
CREATE INDEX events_tenant_created_idx
ON events (tenant_id, created_at);
CREATE INDEX messages_chat_created_idx
ON messages (chat_id, created_at DESC);
CREATE UNIQUE INDEX files_user_folder_name_idx
ON files (user_id, folder_id, filename);

Как выбирать порядок колонок #

Нет универсального правила, но есть практические ориентиры.

1. Сначала колонки, которые часто используются через = #

Например:

WHERE user_id = ?
  AND status = ?
  AND created_at >= ?

Хороший индекс:

ON orders (user_id, status, created_at)

Потому что user_id и status — равенство, created_at — диапазон.


2. Учитывать самые частые запросы #

Если чаще ищешь так:

WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20

индекс:

ON orders (user_id, created_at DESC)

Если чаще так:

WHERE status = ?
ORDER BY created_at DESC
LIMIT 20

индекс:

ON orders (status, created_at DESC)

3. Учитывать селективность #

Селективность — насколько сильно колонка сужает выборку.

Например:

status имеет 3 значения: new, paid, cancelled;
user_id имеет миллионы разных значений.

Условие:

WHERE user_id = 123

обычно сильно сужает выборку.

Условие:

WHERE status = 'paid'

может выбрать огромную часть таблицы.

Поэтому в индексе для запроса конкретного пользователя часто логично ставить user_id первым:

ON orders (user_id, status)

Но если главный запрос начинается со статуса и не содержит user_id, тогда нужен другой индекс.


4. Учитывать сортировку #

Для запроса:

SELECT *
FROM messages
WHERE chat_id = 100
ORDER BY created_at DESC
LIMIT 50;

индекс:

CREATE INDEX messages_chat_created_idx
ON messages (chat_id, created_at DESC);

лучше, чем просто:

CREATE INDEX messages_chat_idx
ON messages (chat_id);

Потому что первый индекс помогает и найти строки по chat_id, и получить их уже в нужном порядке.


INCLUDE — не совсем составной индекс #

В PostgreSQL есть INCLUDE-колонки:

CREATE INDEX orders_user_created_include_idx
ON orders (user_id, created_at DESC)
INCLUDE (amount, status);

Здесь ключевые колонки индекса:

user_id, created_at

А amount, status добавлены как дополнительные данные в индекс, но не участвуют в сортировке ключа так же, как основные колонки.

Это может помочь для index-only scan, когда запросу достаточно данных из индекса и не нужно идти в таблицу.

Пример запроса:

SELECT created_at, amount, status
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;

INCLUDE полезен, когда колонка нужна в SELECT, но не нужна для поиска или сортировки.


Составной индекс и покрывающий индекс #

Если индекс содержит все колонки, нужные запросу, база иногда может выполнить запрос без чтения самой таблицы.

Например:

CREATE INDEX orders_user_status_amount_idx
ON orders (user_id, status)
INCLUDE (amount);

Запрос:

SELECT amount
FROM orders
WHERE user_id = 10
  AND status = 'paid';

Теоретически может быть выполнен через индекс, потому что:

user_id и status нужны для поиска;
amount нужен для результата;
все эти данные есть в индексе.

В PostgreSQL это может привести к Index Only Scan, если соблюдены условия видимости строк. PostgreSQL описывает index-only scans как возможность возвращать результат только из индекса, если индекс содержит все нужные данные и visibility map позволяет не обращаться к heap.


Минусы составных индексов #

Составной индекс не бесплатный.

Минусы:

занимает место на диске;
замедляет INSERT;
замедляет UPDATE колонок, входящих в индекс;
замедляет DELETE;
увеличивает стоимость обслуживания таблицы;
может быть бесполезным, если порядок колонок выбран неправильно;
может дублировать другие индексы.

Например, если создать много индексов:

CREATE INDEX orders_user_idx ON orders (user_id);
CREATE INDEX orders_user_status_idx ON orders (user_id, status);
CREATE INDEX orders_user_status_created_idx ON orders (user_id, status, created_at);
CREATE INDEX orders_status_idx ON orders (status);
CREATE INDEX orders_created_idx ON orders (created_at);

это может ускорить некоторые SELECT, но сделать записи тяжелее.

Общее правило PostgreSQL: индексы ускоряют чтение, но добавляют накладные расходы на изменение данных и занимают место.


Частая ошибка: индекс не под тот порядок колонок #

Есть индекс:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

А запросы в реальности такие:

SELECT *
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC;

Этот индекс не очень подходит.

Лучше:

CREATE INDEX orders_status_created_idx
ON orders (status, created_at DESC);

Индекс должен проектироваться под реальные WHERE, JOIN, ORDER BY, а не просто «на всякий случай».


Частая ошибка: думать, что составной индекс заменяет все одиночные #

Индекс:

ON orders (user_id, status)

может помочь для:

WHERE user_id = ?

и:

WHERE user_id = ?
  AND status = ?

Но не обязательно хорошо поможет для:

WHERE status = ?

Поэтому он не полностью заменяет индекс:

ON orders (status)

Если такие запросы реально важны, отдельный индекс по status или другой составной индекс может быть нужен.


Частая ошибка: индексировать низкоселективную колонку первой #

Например:

CREATE INDEX orders_status_user_idx
ON orders (status, user_id);

Если status имеет всего несколько значений, а основной запрос всегда ищет конкретного пользователя:

WHERE user_id = ?
  AND status = ?

часто лучше:

CREATE INDEX orders_user_status_idx
ON orders (user_id, status);

Но это не абсолютный закон. Нужно смотреть на реальные запросы и планы.


Частая ошибка: не проверять через EXPLAIN ANALYZE #

Создать индекс — не значит, что база обязана его использовать.

PostgreSQL может выбрать Seq Scan, если считает, что так дешевле.

Например:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 10
  AND status = 'paid';

Нужно смотреть:

используется ли индекс;
сколько строк реально прочитано;
есть ли Sort;
есть ли Bitmap Index Scan;
сколько времени занимает запрос;
совпадает ли оценка строк с реальностью.

Если статистика устарела, может понадобиться:

ANALYZE orders;

Пример из backend: уведомления #

Есть таблица:

CREATE TABLE notifications (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    type TEXT NOT NULL,
    is_read BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Частый API-запрос:

SELECT *
FROM notifications
WHERE user_id = 123
  AND is_read = false
ORDER BY created_at DESC
LIMIT 20;

Хороший индекс:

CREATE INDEX notifications_user_read_created_idx
ON notifications (user_id, is_read, created_at DESC);

Почему:

user_id = 123           -> берём уведомления конкретного пользователя;
is_read = false         -> фильтруем непрочитанные;
created_at DESC LIMIT   -> быстро берём самые свежие.

Если бы был только индекс:

CREATE INDEX notifications_user_idx
ON notifications (user_id);

база нашла бы уведомления пользователя, но дальше ей пришлось бы дополнительно фильтровать is_read и сортировать по created_at.


Пример из backend: файлы пользователя #

Есть таблица:

CREATE TABLE files (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    folder_id BIGINT NOT NULL,
    filename TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Нужно запретить пользователю иметь два файла с одинаковым именем в одной папке:

CREATE UNIQUE INDEX files_user_folder_filename_idx
ON files (user_id, folder_id, filename);

Теперь можно:

user_id = 1, folder_id = 10, filename = 'report.pdf'
user_id = 1, folder_id = 11, filename = 'report.pdf'
user_id = 2, folder_id = 10, filename = 'report.pdf'

Но нельзя:

user_id = 1, folder_id = 10, filename = 'report.pdf'
user_id = 1, folder_id = 10, filename = 'report.pdf'

Такой индекс одновременно:

ускоряет поиск файла в папке;
обеспечивает бизнес-уникальность.

Пример из many-to-many #

Таблица ролей пользователя:

CREATE TABLE user_roles (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

Primary key создаёт уникальный составной индекс по:

(user_id, role_id)

Он хорошо подходит для запроса:

SELECT *
FROM user_roles
WHERE user_id = 10;

и:

SELECT *
FROM user_roles
WHERE user_id = 10
  AND role_id = 2;

Но если часто нужно искать всех пользователей конкретной роли:

SELECT *
FROM user_roles
WHERE role_id = 2;

может понадобиться дополнительный индекс:

CREATE INDEX user_roles_role_user_idx
ON user_roles (role_id, user_id);

Это хороший пример того, почему порядок колонок важен.


Составной индекс и JOIN #

Составной индекс полезен и для соединений, если JOIN использует несколько колонок.

Например, есть таблица order_items:

CREATE TABLE order_items (
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INTEGER NOT NULL,
    PRIMARY KEY (order_id, product_id)
);

Запрос:

SELECT *
FROM order_items
WHERE order_id = 100
  AND product_id = 50;

Составной primary key (order_id, product_id) хорошо подходит.

Если есть составная связь:

ON a.tenant_id = b.tenant_id
AND a.external_id = b.external_id

то индекс:

ON b (tenant_id, external_id)

может сильно ускорить такой JOIN.


Как понять, нужен ли составной индекс #

Смотри на реальные запросы.

Если часто есть:

WHERE a = ?
  AND b = ?

или:

WHERE a = ?
ORDER BY b DESC
LIMIT ?

или:

WHERE a = ?
  AND b BETWEEN ? AND ?

то составной индекс может быть полезен.

Если запросы всегда только по отдельным колонкам:

WHERE a = ?

и отдельно:

WHERE b = ?

то два одиночных индекса могут быть лучше.


Практическое правило #

Для запроса:

WHERE user_id = ?
  AND status = ?
ORDER BY created_at DESC
LIMIT 20

часто хороший индекс:

ON table_name (user_id, status, created_at DESC)

Для запроса:

WHERE tenant_id = ?
  AND created_at >= ?

часто хороший индекс:

ON table_name (tenant_id, created_at)

Для запроса:

WHERE chat_id = ?
ORDER BY created_at DESC
LIMIT 50

часто хороший индекс:

ON messages (chat_id, created_at DESC)

Итог #

Составной индекс — это индекс по нескольким колонкам:

CREATE INDEX idx_name
ON table_name (column1, column2, column3);

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

Главное:

составной индекс хранит комбинацию значений, а не независимые индексы;
порядок колонок в индексе важен;
B-tree индекс наиболее эффективен по ведущим левым колонкам;
индекс (a, b) хорошо подходит для WHERE a = ... и WHERE a = ... AND b = ...;
индекс (a, b) обычно не лучший вариант для WHERE b = ...;
составной UNIQUE индекс защищает уникальность комбинации;
составной PRIMARY KEY часто используется в many-to-many таблицах;
лишние составные индексы замедляют запись и занимают место.

Хороший составной индекс проектируют не абстрактно, а под конкретные частые запросы и проверяют через:

EXPLAIN ANALYZE


47. Что такое VIEW в SQL? #

Что такое VIEW #

VIEW в SQL — это именованное представление запроса.

Проще говоря, VIEW — это объект в базе данных, который выглядит как таблица, но на самом деле хранит не данные, а SQL-запрос.

Пример:

CREATE VIEW active_users AS
SELECT
    id,
    username,
    email,
    created_at
FROM users
WHERE is_active = true;

После этого можно обращаться к view почти как к таблице:

SELECT *
FROM active_users;

Но физически отдельной таблицы active_users с копией данных обычно нет. В PostgreSQL обычный view реализуется как объект без собственного хранения данных: view фактически является «пустой таблицей» с правилом ON SELECT DO INSTEAD, то есть при обращении к нему PostgreSQL подставляет определённый запрос.


Простая аналогия #

Обычная таблица:

хранит реальные строки данных

VIEW:

хранит SQL-запрос, который показывает данные из других таблиц

То есть VIEW можно воспринимать как сохранённый SELECT.

Например, вместо того чтобы каждый раз писать:

SELECT
    id,
    username,
    email
FROM users
WHERE is_active = true
  AND deleted_at IS NULL;

можно один раз создать view:

CREATE VIEW active_users AS
SELECT
    id,
    username,
    email
FROM users
WHERE is_active = true
  AND deleted_at IS NULL;

И потом писать проще:

SELECT *
FROM active_users;

Что происходит при SELECT из VIEW #

Когда ты выполняешь:

SELECT *
FROM active_users;

База фактически использует запрос, который лежит внутри view:

SELECT
    id,
    username,
    email
FROM users
WHERE is_active = true
  AND deleted_at IS NULL;

То есть обычный view не является кэшем результата.

Если в таблице users изменились данные, то при следующем запросе к active_users ты увидишь актуальные данные из users.

Например:

UPDATE users
SET is_active = false
WHERE id = 10;

После этого пользователь id = 10 уже не попадёт в:

SELECT *
FROM active_users;

Потому что view каждый раз показывает данные согласно своему SELECT.


Зачем нужны VIEW #

VIEW используют для нескольких задач.

Основные причины:

упростить сложные запросы;
скрыть сложность JOIN и агрегаций;
ограничить доступ к данным;
дать удобный интерфейс к таблицам;
переиспользовать один и тот же SELECT;
сделать схему более стабильной для приложения;
подготовить данные для отчётов.

1. Упрощение сложных запросов #

Допустим, есть таблицы:

users
orders
payments

И часто нужен отчёт по пользователям:

SELECT
    u.id,
    u.username,
    COUNT(o.id) AS orders_count,
    COALESCE(SUM(o.amount), 0) AS total_orders_amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;

Можно создать view:

CREATE VIEW user_order_stats AS
SELECT
    u.id AS user_id,
    u.username,
    COUNT(o.id) AS orders_count,
    COALESCE(SUM(o.amount), 0) AS total_orders_amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;

Теперь вместо большого запроса можно писать:

SELECT *
FROM user_order_stats
WHERE total_orders_amount > 1000;

Это делает SQL в приложении проще и понятнее.


2. Сокрытие лишних колонок #

Допустим, есть таблица пользователей:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL,
    email TEXT NOT NULL,
    password_hash TEXT NOT NULL,
    is_active BOOLEAN NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Нельзя давать некоторым частям системы доступ к password_hash.

Можно создать view:

CREATE VIEW public_users AS
SELECT
    id,
    username,
    created_at
FROM users
WHERE is_active = true;

Теперь можно выдавать доступ не к таблице users, а к view public_users.

Запрос:

SELECT *
FROM public_users;

не покажет:

email
password_hash
служебные поля

Это один из классических сценариев использования view: предоставить ограниченное представление данных.


3. Сокрытие условий фильтрации #

Например, в проекте используется soft delete:

deleted_at TIMESTAMP

И почти во всех запросах нужно писать:

WHERE deleted_at IS NULL

Можно создать view:

CREATE VIEW not_deleted_files AS
SELECT *
FROM files
WHERE deleted_at IS NULL;

Теперь запросы становятся проще:

SELECT *
FROM not_deleted_files
WHERE user_id = 123;

Так view помогает не забывать важные условия фильтрации.


4. Удобный интерфейс поверх сложной схемы #

Иногда структура таблиц нормализована и удобна для хранения, но неудобна для чтения.

Например:

orders
order_items
products
users

Чтобы получить понятный отчёт, нужно несколько JOIN.

Можно создать view:

CREATE VIEW order_details AS
SELECT
    o.id AS order_id,
    u.username,
    p.name AS product_name,
    oi.quantity,
    oi.quantity * oi.price AS item_total,
    o.created_at
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id;

После этого для чтения можно использовать:

SELECT *
FROM order_details
WHERE order_id = 100;

То есть view превращает сложную схему в более удобное представление.


VIEW не хранит данные как таблица #

Это важнейшее отличие от обычной таблицы.

Обычная таблица:

CREATE TABLE active_users_table AS
SELECT *
FROM users
WHERE is_active = true;

создаёт физическую копию данных на момент выполнения.

А view:

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE is_active = true;

создаёт сохранённый запрос.

Если в users добавится новый активный пользователь, обычная таблица-копия сама не обновится. А view сразу начнёт показывать нового пользователя, потому что он читает данные из исходной таблицы при выполнении запроса.


VIEW vs TABLE #

КритерийTABLEVIEW
Хранит данные физическиДаОбычно нет
Создаётся черезCREATE TABLECREATE VIEW
Используется в SELECTДаДа
Данные обновляются при изменении источниковНе автоматически, если это копияДа, потому что view читает источники
Можно индексировать напрямуюДаОбычный view — нет
Подходит для хранения данныхДаНет
Подходит для упрощения чтенияИногдаДа

VIEW vs подзапрос #

Подзапрос существует только внутри одного SQL-запроса.

Пример:

SELECT *
FROM (
    SELECT *
    FROM users
    WHERE is_active = true
) AS active_users;

А view можно создать один раз и потом использовать во многих запросах:

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE is_active = true;

Потом:

SELECT *
FROM active_users;

SELECT COUNT(*)
FROM active_users;

SELECT *
FROM active_users
WHERE created_at >= now() - interval '30 days';

То есть view — это переиспользуемый именованный запрос на уровне схемы БД.


VIEW vs WITH / CTE #

WITH создаёт временное именованное выражение только внутри одного запроса.

WITH active_users AS (
    SELECT *
    FROM users
    WHERE is_active = true
)
SELECT *
FROM active_users;

После выполнения запроса active_users исчезает.

VIEW сохраняется в базе:

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE is_active = true;

И потом доступен в других запросах.

PostgreSQL описывает WITH как способ написать вспомогательные выражения для использования в более крупном запросе; такие CTE можно воспринимать как временные таблицы, существующие только для одного запроса.

Сравнение:

КритерийWITH / CTEVIEW
СуществуетТолько один запросПостоянный объект БД
Создаётся черезWITH ... AS (...)CREATE VIEW ... AS SELECT ...
Можно переиспользовать в разных запросахНетДа
Хорош дляРазбить один сложный запросПереиспользуемое представление данных
Хранит данныеНетОбычный view тоже нет

VIEW vs MATERIALIZED VIEW #

Есть обычный view и materialized view.

Обычный view:

CREATE VIEW user_stats AS
SELECT ...

обычно не хранит результат. При обращении выполняется запрос к исходным таблицам.

Materialized view:

CREATE MATERIALIZED VIEW user_stats_mv AS
SELECT ...

физически хранит результат запроса. В PostgreSQL CREATE MATERIALIZED VIEW выполняет запрос при создании и использует его результат для заполнения materialized view; позже данные можно обновлять через REFRESH MATERIALIZED VIEW.

Сравнение:

КритерийVIEWMATERIALIZED VIEW
Хранит результат запросаНетДа
Данные всегда актуальны относительно таблицДа, при каждом чтенииНет, до следующего refresh
ЧтениеМожет быть медленным, если запрос сложныйЧасто быстрее
Обновление данныхНе требуетсяНужно REFRESH MATERIALIZED VIEW
Можно индексироватьОбычный view — нетДа, как физически сохранённый результат
Подходит дляУпрощение доступа, безопасностьТяжёлые отчёты, аналитика, кэширование результата

PostgreSQL также описывает materialized views как похожие на views по rule system, но с сохранением результата в table-like форме.


Пример обычного VIEW #

Допустим, есть таблицы:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL,
    is_active BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    amount NUMERIC NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Создадим view:

CREATE VIEW paid_orders_view AS
SELECT
    o.id AS order_id,
    o.amount,
    o.created_at,
    u.id AS user_id,
    u.username
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
  AND u.is_active = true;

Теперь можно писать:

SELECT *
FROM paid_orders_view
WHERE amount > 1000;

Вместо того чтобы каждый раз писать JOIN, WHERE o.status = 'paid' и u.is_active = true.


Можно ли делать JOIN с VIEW #

Да. View можно использовать в запросах почти как таблицу.

Например:

SELECT
    pov.order_id,
    pov.username,
    p.method
FROM paid_orders_view pov
JOIN payments p ON p.order_id = pov.order_id;

Здесь:

paid_orders_view используется как источник данных;
payments присоединяется через JOIN.

Это нормально. Но важно помнить: внутри paid_orders_view уже есть свой запрос, и итоговый запрос может стать сложным. При проблемах с производительностью нужно смотреть EXPLAIN ANALYZE.


Можно ли фильтровать VIEW #

Да.

SELECT *
FROM paid_orders_view
WHERE amount > 500
ORDER BY created_at DESC
LIMIT 20;

Логически это похоже на то, как если бы фильтр применялся к результату view.

В реальности оптимизатор СУБД может встроить view в общий запрос и построить единый план выполнения. Поэтому view не обязательно означает, что база сначала полностью вычислит весь view, а потом применит внешний фильтр.


Можно ли обновлять данные через VIEW #

Иногда можно, но не всегда.

Простой view может быть автоматически обновляемым.

Например:

CREATE VIEW active_users AS
SELECT
    id,
    username,
    email
FROM users
WHERE is_active = true;

В некоторых случаях можно выполнить:

UPDATE active_users
SET username = 'new_name'
WHERE id = 10;

И изменение попадёт в базовую таблицу users.

Но сложные views обычно не обновляемые автоматически. Например, если view содержит:

JOIN;
GROUP BY;
DISTINCT;
агрегаты;
UNION;
оконные функции;
вычисляемые колонки без прямого соответствия базовой таблице.

то базе может быть непонятно, как изменение view должно отразиться в исходных таблицах.

PostgreSQL документация по CREATE VIEW описывает updatable views и CHECK OPTION; CHECK OPTION позволяет запрещать INSERT/UPDATE через view, если новая строка не удовлетворяет условию view.


Пример CHECK OPTION #

Допустим, есть view только для активных пользователей:

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE is_active = true
WITH CHECK OPTION;

Теперь если попытаться через view вставить или обновить строку так, что она не будет соответствовать условию view, база отклонит операцию.

Например:

INSERT INTO active_users (username, email, is_active)
VALUES ('alex', 'alex@example.com', false);

Такой insert противоречит условию:

WHERE is_active = true

Поэтому WITH CHECK OPTION защищает view от вставки/обновления строк, которые потом не были бы видны через этот же view.


Для чего VIEW используют в безопасности #

View часто используют как слой ограничения доступа.

Например, есть таблица:

CREATE TABLE employees (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    full_name TEXT NOT NULL,
    department TEXT NOT NULL,
    salary NUMERIC NOT NULL,
    passport_number TEXT
);

Не всем пользователям БД можно видеть зарплаты и паспортные данные.

Создаём view:

CREATE VIEW employee_public_info AS
SELECT
    id,
    full_name,
    department
FROM employees;

Далее можно дать доступ к view:

GRANT SELECT ON employee_public_info TO reporting_user;

Но не давать доступ к исходной таблице employees.

Так пользователь сможет делать:

SELECT *
FROM employee_public_info;

но не увидит:

salary
passport_number

Это не единственный механизм безопасности, но очень распространённый.


VIEW и права доступа #

В PostgreSQL view — это отдельный объект схемы. У него могут быть свои права доступа. Это позволяет выдать SELECT на view, не выдавая прямой доступ к исходной таблице.

Например:

GRANT SELECT ON public_users TO app_readonly;

Но при проектировании безопасности надо учитывать владельца view, security_invoker/security_barrier, row-level security и другие нюансы PostgreSQL. Для базового понимания достаточно: view может быть удобным слоем ограничения видимых колонок и строк.


VIEW и производительность #

Обычный view сам по себе обычно не ускоряет запрос.

Это важный момент.

Если view содержит тяжёлый запрос:

CREATE VIEW heavy_report AS
SELECT
    ...
FROM huge_table_a
JOIN huge_table_b ...
GROUP BY ...;

то:

SELECT *
FROM heavy_report;

всё равно должен выполнить тяжёлую логику.

View улучшает:

читаемость;
переиспользование;
безопасность;
логическую организацию схемы.

Но не является автоматическим кэшем.

Для ускорения тяжёлых отчётов часто используют:

индексы;
оптимизацию запроса;
материализованные представления;
агрегатные таблицы;
партиционирование;
предрасчёт данных.

Если нужен именно сохранённый результат запроса, тогда смотрят в сторону MATERIALIZED VIEW.


Можно ли создать индекс на VIEW #

На обычный view напрямую индекс обычно не создают, потому что он не хранит данные.

Нельзя мыслить так:

создам обычный view и индекс на него, чтобы запрос ускорился

Для ускорения нужно индексировать исходные таблицы, которые используются внутри view.

Например, если view использует:

JOIN orders o ON o.user_id = users.id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC

то могут быть полезны индексы:

CREATE INDEX orders_user_id_idx
ON orders (user_id);

CREATE INDEX orders_status_created_idx
ON orders (status, created_at DESC);

А вот materialized view хранит данные физически, поэтому на него можно создавать индексы.


VIEW и изменение структуры таблиц #

View зависит от таблиц, на которых построен.

Например:

CREATE VIEW public_users AS
SELECT
    id,
    username,
    email
FROM users;

Если потом удалить колонку email из users, view сломается или удаление колонки будет запрещено из-за зависимости, в зависимости от конкретной операции.

Например:

ALTER TABLE users DROP COLUMN email;

может затронуть view, потому что view использует эту колонку.

Поэтому при миграциях нужно учитывать зависимости:

какие views используют таблицу;
какие колонки используются внутри views;
нужно ли обновить CREATE VIEW;
нужно ли сделать CREATE OR REPLACE VIEW.

CREATE OR REPLACE VIEW #

Чтобы обновить определение view, часто используют:

CREATE OR REPLACE VIEW active_users AS
SELECT
    id,
    username,
    email,
    created_at
FROM users
WHERE is_active = true;

Это заменяет определение существующего view.

Но есть ограничения: нельзя произвольно изменить всё, если от view зависят другие объекты. В PostgreSQL CREATE OR REPLACE VIEW заменяет view, но новое определение должно быть совместимо по именам, порядку и типам уже существующих колонок; можно добавить новые колонки в конец списка.


DROP VIEW #

Удалить view можно так:

DROP VIEW active_users;

Если от него зависят другие объекты, может понадобиться:

DROP VIEW active_users CASCADE;

Но CASCADE нужно использовать осторожно: он может удалить зависимые объекты.


Пример VIEW в backend-проекте #

Допустим, есть сервис файлового хранилища.

Таблица:

CREATE TABLE user_files (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    file_path TEXT NOT NULL,
    size_bytes BIGINT NOT NULL,
    is_deleted BOOLEAN NOT NULL DEFAULT false,
    uploaded_at TIMESTAMP NOT NULL DEFAULT now()
);

В приложении почти всегда нужны только неудалённые файлы.

Можно создать view:

CREATE VIEW active_user_files AS
SELECT
    id,
    user_id,
    file_path,
    size_bytes,
    uploaded_at
FROM user_files
WHERE is_deleted = false;

Теперь запросы приложения:

SELECT *
FROM active_user_files
WHERE user_id = 123
ORDER BY uploaded_at DESC;

Вместо:

SELECT
    id,
    user_id,
    file_path,
    size_bytes,
    uploaded_at
FROM user_files
WHERE user_id = 123
  AND is_deleted = false
ORDER BY uploaded_at DESC;

Польза:

меньше повторения условий;
меньше риск забыть is_deleted = false;
запросы в приложении читаются проще;
можно скрыть служебные поля.

Пример VIEW для отчёта #

CREATE VIEW monthly_sales_report AS
SELECT
    date_trunc('month', created_at) AS month,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY date_trunc('month', created_at);

Использование:

SELECT *
FROM monthly_sales_report
ORDER BY month DESC;

Такой view удобен для отчётности.

Но если таблица orders огромная, такой запрос может быть тяжёлым. Тогда обычный view не ускорит его сам по себе. Для ускорения можно рассмотреть materialized view:

CREATE MATERIALIZED VIEW monthly_sales_report_mv AS
SELECT
    date_trunc('month', created_at) AS month,
    COUNT(*) AS orders_count,
    SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY date_trunc('month', created_at);

И потом обновлять:

REFRESH MATERIALIZED VIEW monthly_sales_report_mv;

Когда использовать VIEW #

View хорошо использовать, когда нужно:

спрятать сложный SELECT;
переиспользовать одинаковую логику чтения;
ограничить набор колонок;
ограничить набор строк;
дать удобный слой для отчётов;
сделать стабильный интерфейс к данным;
скрыть детали нормализованной схемы.

Примеры:

active_users;
public_users;
paid_orders_view;
order_details;
monthly_sales_report;
available_products;
not_deleted_files;
user_order_stats.

Когда VIEW не лучший выбор #

View не стоит использовать, если:

нужно физически хранить результат запроса;
нужно ускорить тяжёлый запрос за счёт кэша;
данные должны обновляться по расписанию;
нужно индексировать результат;
логика используется только один раз и проще написать обычный SELECT;
view скрывает слишком сложный запрос и затрудняет отладку.

В таких случаях варианты:

обычный SELECT;
WITH / CTE;
подзапрос;
materialized view;
отдельная агрегатная таблица;
индексы на исходные таблицы.

Частая ошибка 1: думать, что VIEW — это копия таблицы #

Неверно:

VIEW хранит копию результата

Правильно:

обычный VIEW хранит запрос, а не данные

Копию результата хранит materialized view или таблица, созданная через CREATE TABLE AS.


Частая ошибка 2: ожидать ускорения от обычного VIEW #

Неверно:

запрос медленный, значит создам VIEW и он станет быстрым

Обычный view сам по себе не обязан ускорять запрос. Он скорее делает SQL удобнее.

Для производительности нужно смотреть:

EXPLAIN ANALYZE

и работать с:

индексами;
планом выполнения;
структурой запроса;
материализацией;
размером таблиц;
статистикой.

Частая ошибка 3: делать слишком много вложенных VIEW #

Например:

view_a использует view_b;
view_b использует view_c;
view_c использует view_d;

Снаружи запрос выглядит простым:

SELECT *
FROM view_a;

Но внутри может быть огромный SQL с множеством JOIN, фильтров и агрегаций.

Это усложняет:

отладку;
понимание плана;
оптимизацию;
миграции схемы.

View нужно использовать осознанно, а не превращать в непрозрачный слой, где никто не понимает итоговый запрос.


Частая ошибка 4: использовать VIEW вместо прав на уровне приложения и БД #

View может помочь с безопасностью, но его недостаточно само по себе.

Если нужно реально ограничивать доступ, надо правильно настроить:

GRANT / REVOKE;
владельца view;
доступ к исходным таблицам;
row-level security, если используется;
security definer/invoker нюансы;
права роли приложения.

Иначе можно случайно оставить прямой доступ к исходной таблице, и view не будет защищать данные.


Итог #

VIEW в SQL — это сохранённый именованный SELECT, к которому можно обращаться как к таблице:

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE is_active = true;

Использование:

SELECT *
FROM active_users;

Главное:

обычный VIEW обычно не хранит данные;
VIEW показывает актуальные данные из исходных таблиц;
VIEW упрощает сложные запросы;
VIEW помогает скрывать лишние строки и колонки;
VIEW можно использовать в SELECT, JOIN, WHERE как источник данных;
VIEW не является автоматическим способом ускорения запроса;
для физического хранения результата используют MATERIALIZED VIEW.

В backend-разработке view полезен как удобный слой чтения: для отчётов, публичных представлений, soft-delete-фильтров, агрегированных данных и сокрытия сложных JOIN.


48. Какие сущности есть в PostgreSQL? #

Общая идея #

В PostgreSQL под «сущностями» обычно имеют в виду объекты базы данных: базы, схемы, таблицы, индексы, представления, функции, роли и другие элементы, из которых состоит PostgreSQL-система.

Важно понимать иерархию:

PostgreSQL cluster
  ├── database
  │     ├── schema
  │     │     ├── table
  │     │     ├── view
  │     │     ├── index
  │     │     ├── sequence
  │     │     ├── function
  │     │     ├── procedure
  │     │     ├── type
  │     │     ├── trigger
  │     │     └── ...
  │     └── ...
  ├── roles
  └── tablespaces

В документации PostgreSQL указано, что database cluster содержит одну или несколько баз данных, роли и некоторые другие объекты являются общими для всего кластера, а база содержит схемы; схемы, в свою очередь, содержат таблицы и другие именованные объекты: типы данных, функции, операторы и т.д.


Database cluster #

Кластер PostgreSQL — это не кластер в смысле «много серверов», а набор баз данных, управляемых одним экземпляром PostgreSQL.

Один запущенный PostgreSQL-инстанс обычно управляет одним database cluster.

Внутри cluster находятся:

databases;
roles;
tablespaces;
глобальные настройки;
системные каталоги;
WAL;
служебные данные.

Примерно:

PostgreSQL instance
  └── database cluster
        ├── postgres
        ├── app_db
        ├── test_db
        ├── roles
        └── tablespaces

Клиентское подключение в PostgreSQL работает только с одной конкретной базой данных внутри кластера. То есть если приложение подключилось к app_db, оно не может напрямую делать обычный SELECT из таблицы другой базы test_db в том же запросе.


Database #

Database — это отдельная база данных внутри PostgreSQL-кластера.

Пример создания:

CREATE DATABASE shop_db;

Внутри базы данных находятся схемы и объекты этих схем:

schemas;
tables;
views;
indexes;
functions;
procedures;
types;
extensions;
publications;
subscriptions;

Обычно в backend-проекте одна база соответствует одному приложению или одному сервису:

notification_service_db;
cloud_storage_db;
billing_db;
analytics_db;

При подключении приложение указывает конкретную БД:

host=localhost
port=5432
dbname=notification_service_db
user=app_user
password=...

Schema #

Schema — это пространство имён внутри базы данных.

Схема помогает группировать объекты и избегать конфликтов имён.

Пример:

CREATE SCHEMA billing;
CREATE SCHEMA auth;
CREATE SCHEMA storage;

Тогда можно иметь таблицы:

billing.users
auth.users
storage.users

Это разные таблицы, потому что они лежат в разных схемах.

PostgreSQL описывает schema как namespace, содержащий именованные объекты: таблицы, типы данных, функции, операторы. Имена объектов могут повторяться в разных схемах.

По умолчанию часто используется схема:

public

Например:

public.users
public.orders
public.notifications

Если схема не указана явно, PostgreSQL ищет объект через search_path.

Пример:

SELECT * FROM users;

PostgreSQL должен понять, какую именно таблицу users искать. Он смотрит в схемы из search_path.


Table #

Table — основная сущность для хранения данных.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Таблица состоит из:

колонок;
строк;
типов данных;
constraints;
индексов;
триггеров;
прав доступа;
статистики.

PostgreSQL CREATE TABLE создаёт новую пустую таблицу в текущей базе данных; если указана схема, таблица создаётся в ней, иначе — в текущей схеме.

Примеры таблиц в backend:

users;
orders;
payments;
notifications;
files;
comments;
sessions;
refresh_tokens;

Column #

Column — колонка таблицы.

Пример:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    amount NUMERIC NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT now()
);

Здесь колонки:

id;
user_id;
amount;
status;
created_at.

У каждой колонки есть тип:

BIGINT;
TEXT;
NUMERIC;
BOOLEAN;
TIMESTAMP;
UUID;
JSONB;
INTEGER;
DATE.

Колонка может иметь ограничения:

email TEXT NOT NULL UNIQUE

Или значение по умолчанию:

created_at TIMESTAMP NOT NULL DEFAULT now()

Row #

Row — строка таблицы, конкретная запись.

Например:

id | username | email
---+----------+------------------
1  | alex     | alex@example.com
2  | bob      | bob@example.com

Каждая строка — это один объект предметной области:

один пользователь;
один заказ;
один файл;
одно уведомление;
один платёж.

С точки зрения PostgreSQL строка имеет не только пользовательские поля, но и служебную MVCC-информацию, например версии строк, транзакционные id и т.д. Но на прикладном уровне обычно говорят просто: таблица состоит из строк и колонок.


Constraint #

Constraint — ограничение целостности данных.

Примеры:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    age INTEGER CHECK (age >= 0)
);

Основные constraints:

NOT NULL;
UNIQUE;
PRIMARY KEY;
FOREIGN KEY;
CHECK;
EXCLUDE.

Примеры:

PRIMARY KEY (id)
UNIQUE (email)
CHECK (amount > 0)
FOREIGN KEY (user_id) REFERENCES users(id)

PostgreSQL описывает constraint как SQL-объект, который определяет допустимые значения в таблице; constraints проверяются при вставке или обновлении строк.


Primary Key #

Primary Key — главный уникальный идентификатор строки.

Пример:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

Обычно primary key используется:

для однозначного поиска строки;
для связей через foreign key;
для ORM;
для индексации;
для ссылочной целостности.

Primary key фактически означает:

значение уникально;
значение не NULL;
это главный идентификатор строки.

Foreign Key #

Foreign Key — внешний ключ, связь с другой таблицей.

Пример:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    amount NUMERIC NOT NULL
);

Здесь:

orders.user_id -> users.id

То есть заказ принадлежит пользователю.

Foreign key защищает от битых ссылок. Нельзя создать заказ на пользователя, которого нет:

INSERT INTO orders (user_id, amount)
VALUES (999, 100);

Если users.id = 999 не существует, PostgreSQL отклонит вставку.


Index #

Index — структура для ускорения поиска, сортировки, соединений и проверки уникальности.

Пример:

CREATE INDEX orders_user_id_idx
ON orders (user_id);

Индекс помогает запросам вида:

SELECT *
FROM orders
WHERE user_id = 123;

PostgreSQL указывает, что CREATE INDEX создаёт индекс по колонкам указанного relation — таблицы или materialized view; индексы в основном используются для повышения производительности. PostgreSQL поддерживает B-tree, hash, GiST, SP-GiST, GIN и BRIN index methods.

Частые индексы:

CREATE INDEX orders_user_created_idx
ON orders (user_id, created_at DESC);
CREATE UNIQUE INDEX users_email_unique_idx
ON users (email);
CREATE INDEX products_data_gin_idx
ON products USING GIN (data);

Важный момент: индекс — это отдельный объект БД, но он связан с таблицей.


Sequence #

Sequence — генератор чисел.

Часто используется для автоинкрементных id.

Пример:

CREATE SEQUENCE users_id_seq;

При использовании identity-колонки PostgreSQL создаёт sequence неявно:

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    username TEXT NOT NULL
);

PostgreSQL описывает identity column как специальную колонку, значение которой генерируется автоматически из неявной sequence; sequence создаёт генератор чисел.

Можно вручную получить следующее значение:

SELECT nextval('users_id_seq');

Sequence — отдельная сущность, потому что она живёт независимо от строк таблицы и не откатывает выданные значения при ROLLBACK.


View #

View — сохранённый именованный SELECT.

Пример:

CREATE VIEW active_users AS
SELECT id, username, email
FROM users
WHERE is_active = true;

Использование:

SELECT *
FROM active_users;

Обычный view не хранит данные физически. PostgreSQL указывает: CREATE VIEW определяет view запроса, view не материализуется физически, а запрос выполняется каждый раз при обращении к view.

View используют для:

упрощения сложных SELECT;
сокрытия колонок;
ограничения строк;
отчётов;
стабильного интерфейса чтения.

Materialized View #

Materialized View — материализованное представление.

Оно похоже на view, но хранит результат запроса физически.

Пример:

CREATE MATERIALIZED VIEW monthly_sales AS
SELECT
    date_trunc('month', created_at) AS month,
    SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY date_trunc('month', created_at);

Обычный VIEW каждый раз выполняет запрос заново.

MATERIALIZED VIEW хранит результат, но его нужно обновлять:

REFRESH MATERIALIZED VIEW monthly_sales;

Используют для тяжёлых отчётов и аналитики, когда не нужна абсолютная актуальность в каждую миллисекунду.


Function #

Function — хранимая функция в базе данных.

Пример:

CREATE FUNCTION normalize_email(email TEXT)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT lower(trim(email));
$$;

Вызов:

SELECT normalize_email(' TEST@EXAMPLE.COM ');

Функции можно использовать внутри SQL:

SELECT *
FROM users
WHERE normalize_email(email) = normalize_email('test@example.com');

Функция обычно возвращает значение:

число;
строку;
boolean;
дату;
строку таблицы;
табличный результат.

Procedure #

Procedure — хранимая процедура.

Пример:

CREATE PROCEDURE deactivate_old_users()
LANGUAGE plpgsql
AS $$
BEGIN
    UPDATE users
    SET is_active = false
    WHERE last_login_at < now() - interval '1 year';
END;
$$;

Вызов:

CALL deactivate_old_users();

Отличие от функции:

function -> используется как выражение и возвращает значение;
procedure -> вызывается через CALL и обычно выполняет действие.

Trigger #

Trigger — объект, который автоматически срабатывает при событии на таблице или view.

События:

INSERT;
UPDATE;
DELETE;
TRUNCATE.

Пример:

CREATE TRIGGER set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

Типичный кейс:

автоматически обновить updated_at;
записать audit log;
проверить сложное правило;
синхронизировать служебную таблицу;
запретить некоторые операции.

Триггер сам по себе обычно вызывает trigger function.


Rule #

Rule — правило переписывания запроса.

В PostgreSQL rules связаны с rewrite system. Например, обычные views в PostgreSQL исторически реализуются через rule system: при обращении к view запрос переписывается на запрос к базовым таблицам. В документации по views указано, что view фактически является relation с правилом ON SELECT DO INSTEAD.

На практике обычный backend-разработчик редко пишет rules вручную. Чаще используются:

views;
triggers;
functions;
constraints.

Type #

Type — тип данных.

PostgreSQL имеет встроенные типы:

integer;
bigint;
text;
boolean;
numeric;
timestamp;
date;
uuid;
jsonb;
array;
inet.

Но можно создавать пользовательские типы.

Например enum:

CREATE TYPE notification_type AS ENUM ('like', 'comment', 'repost');

Использование:

CREATE TABLE notifications (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    type notification_type NOT NULL,
    text TEXT NOT NULL
);

PostgreSQL хранит информацию о типах в pg_type; base types и enum types создаются через CREATE TYPE, domains — через CREATE DOMAIN, а для каждой таблицы автоматически создаётся composite type, представляющий структуру строки таблицы.


Domain #

Domain — пользовательский тип поверх существующего типа с ограничениями.

Пример:

CREATE DOMAIN positive_amount AS NUMERIC
CHECK (VALUE > 0);

Использование:

CREATE TABLE payments (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    amount positive_amount NOT NULL
);

Теперь amount должен быть положительным.

Domain полезен, когда одно и то же ограничение повторяется во многих таблицах.

Например:

email;
positive_amount;
non_empty_text;
percent_value;
phone_number.

Enum #

Enum — перечислимый тип.

Пример:

CREATE TYPE order_status AS ENUM ('new', 'paid', 'cancelled');

Использование:

CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status order_status NOT NULL
);

Enum удобен, когда набор значений мал и редко меняется.

Например:

notification_type;
order_status;
user_role_type;
payment_status.

Если значения часто меняются и имеют дополнительные атрибуты, лучше отдельная справочная таблица.


Operator #

Operator — оператор PostgreSQL.

Например:

=
>
<
+
-
@
@>
&&

PostgreSQL позволяет создавать пользовательские операторы. Это редко нужно в обычной backend-разработке, но важно для расширяемости PostgreSQL.

Например, многие расширения добавляют свои операторы:

PostGIS;
pg_trgm;
ltree;
hstore.

Extension #

Extension — расширение PostgreSQL.

Пример:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

Или:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

Или:

CREATE EXTENSION IF NOT EXISTS postgis;

Расширение может добавить:

функции;
типы;
операторы;
индексы;
таблицы;
views;
служебные объекты.

Примеры популярных extensions:

pgcrypto;
uuid-ossp;
postgis;
pg_trgm;
btree_gin;
btree_gist;
citext;
hstore;
pg_stat_statements.

Role #

Role — роль PostgreSQL.

В PostgreSQL роль может быть пользователем, группой или и тем и другим, в зависимости от атрибутов.

Пример:

CREATE ROLE app_user LOGIN PASSWORD 'secret';

Роль с правом логина:

CREATE ROLE readonly_user LOGIN PASSWORD 'secret';

Групповая роль:

CREATE ROLE developers;

Выдача прав:

GRANT SELECT ON users TO readonly_user;

В PostgreSQL права выдаются ролям. Документация GRANT показывает, что права могут выдаваться на объекты базы данных: таблицы, колонки, views, sequences, databases, schemas, tablespaces, functions, procedures, types и другие объекты.


Privilege #

Privilege — право доступа.

Примеры прав:

SELECT;
INSERT;
UPDATE;
DELETE;
TRUNCATE;
REFERENCES;
TRIGGER;
USAGE;
EXECUTE;
CREATE;
CONNECT.

Пример:

GRANT SELECT, INSERT ON orders TO app_user;
GRANT USAGE ON SCHEMA billing TO app_user;
GRANT EXECUTE ON FUNCTION normalize_email(TEXT) TO app_user;

При создании объекта у него появляется владелец. По умолчанию обычно только владелец или superuser может полноценно управлять объектом, а другим ролям права нужно выдавать через GRANT.


Tablespace #

Tablespace — место хранения объектов на файловой системе.

Пример:

CREATE TABLESPACE fast_storage
LOCATION '/mnt/fast_disk/postgres';

Таблицу можно создать в tablespace:

CREATE TABLE large_events (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload JSONB NOT NULL
) TABLESPACE fast_storage;

Tablespace используют, когда нужно физически разнести данные:

таблицы на один диск;
индексы на другой диск;
архивные данные на медленное хранилище;
горячие данные на быстрое хранилище.

Роли и tablespaces относятся к глобальным объектам кластера. Например, pg_dumpall выгружает глобальные объекты, общие для всех баз в кластере: роли, tablespaces и grants для configuration parameters.


Database Object Owner #

У большинства объектов есть владелец.

Например:

владелец таблицы;
владелец схемы;
владелец функции;
владелец view;
владелец sequence.

Пример смены владельца:

ALTER TABLE orders OWNER TO app_owner;

Владелец важен для:

изменения объекта;
удаления объекта;
выдачи прав;
безопасности;
миграций.

Foreign Table #

Foreign Table — внешняя таблица, данные которой находятся не в текущей PostgreSQL-базе, а во внешнем источнике.

Обычно используется через FDW — Foreign Data Wrapper.

Например, postgres_fdw позволяет подключаться к другой PostgreSQL-базе.

Примерная схема:

local PostgreSQL
  └── foreign table
        └── remote PostgreSQL table

Foreign table выглядит как таблица, но данные берутся из внешнего источника.


Foreign Data Wrapper, Foreign Server, User Mapping #

Для внешних таблиц есть несколько сущностей:

foreign data wrapper;
foreign server;
user mapping;
foreign table.

Примерно:

CREATE EXTENSION postgres_fdw;

CREATE SERVER remote_pg
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'remote-host', dbname 'remote_db', port '5432');

CREATE USER MAPPING FOR app_user
SERVER remote_pg
OPTIONS (user 'remote_user', password 'secret');

CREATE FOREIGN TABLE remote_users (
    id BIGINT,
    username TEXT
)
SERVER remote_pg
OPTIONS (schema_name 'public', table_name 'users');

Это уже более продвинутый уровень PostgreSQL.


Publication #

Publication — объект logical replication.

Publication определяет, какие изменения и из каких таблиц публикуются для подписчиков.

Пример:

CREATE PUBLICATION app_publication
FOR TABLE users, orders;

Используется в logical replication:

publisher -> publication -> subscriber

Subscription #

Subscription — подписка на publication другого PostgreSQL-сервера.

Пример:

CREATE SUBSCRIPTION app_subscription
CONNECTION 'host=primary dbname=app_db user=repl password=secret'
PUBLICATION app_publication;

Используется для logical replication.

Смысл:

один PostgreSQL публикует изменения;
другой PostgreSQL подписывается и применяет их.

Policy #

Policy — политика Row Level Security.

RLS позволяет ограничивать доступ к строкам таблицы.

Пример:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY user_documents_policy
ON documents
USING (user_id = current_setting('app.current_user_id')::bigint);

Смысл:

пользователь видит только свои документы.

RLS часто применяется в multi-tenant системах, где одна таблица хранит данные многих клиентов или пользователей.


Collation #

Collation — правила сравнения и сортировки строк.

Например, сортировка текста может отличаться в зависимости от языка и локали.

Пример:

ORDER BY name COLLATE "ru_RU";

Collation влияет на:

сортировку;
сравнение строк;
индексы по текстовым колонкам;
поведение ORDER BY.

Cast #

Cast — преобразование типов.

Пример:

SELECT '123'::integer;

Или:

SELECT CAST('2026-06-30' AS date);

PostgreSQL хранит пути преобразования типов в системном каталоге pg_cast; там находятся встроенные и пользовательские conversion paths.


Aggregate #

Aggregate function — агрегатная функция.

Встроенные:

COUNT(*)
SUM(amount)
AVG(price)
MIN(created_at)
MAX(created_at)

PostgreSQL позволяет создавать пользовательские агрегатные функции.

Например, можно создать свой агрегат для специфической бизнес-логики, но это редко нужно в обычной backend-разработке.


Operator Class и Operator Family #

Это сущности, связанные с индексами.

Например, чтобы PostgreSQL понимал, как индексировать и сравнивать значения определённого типа, используются operator classes.

Обычно backend-разработчик сталкивается с этим косвенно:

CREATE INDEX users_email_trgm_idx
ON users USING GIN (email gin_trgm_ops);

Здесь:

gin_trgm_ops

— operator class для trigram-поиска через pg_trgm.


Language #

Procedural language — язык для функций и процедур.

Примеры:

sql;
plpgsql;
plpython3u;
plperl;

Обычно в PostgreSQL чаще всего используют:

LANGUAGE sql

или:

LANGUAGE plpgsql

plpgsql — стандартный процедурный язык PostgreSQL для функций, процедур и триггеров.


Event Trigger #

Event Trigger — триггер на DDL-события.

Обычный trigger срабатывает на INSERT, UPDATE, DELETE в таблице.

Event trigger срабатывает на события уровня схемы, например:

CREATE TABLE;
ALTER TABLE;
DROP TABLE;
CREATE FUNCTION;

Используется редко, обычно для:

аудита DDL;
контроля изменений схемы;
запрета некоторых операций;
инфраструктурных задач.

System Catalogs #

System catalogs — системные таблицы PostgreSQL, где хранится metadata о базе.

Примеры:

pg_class;
pg_attribute;
pg_type;
pg_proc;
pg_constraint;
pg_namespace;
pg_roles;
pg_index.

PostgreSQL указывает, что system catalogs — это место, где СУБД хранит schema metadata, например информацию о таблицах и колонках, а также внутреннюю служебную информацию; системные каталоги PostgreSQL являются обычными таблицами, но менять их вручную обычно нельзя и опасно.

Например:

SELECT *
FROM pg_class;

pg_class описывает таблицы и другие relation-like объекты: индексы, sequences, views, materialized views, composite types, TOAST tables.


Information Schema #

information_schema — стандартный SQL-интерфейс для просмотра метаданных.

Например, посмотреть таблицы:

SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema = 'public';

Посмотреть колонки:

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'users';

В PostgreSQL часто используют и information_schema, и системные каталоги pg_catalog.

Разница:

information_schema -> более стандартный SQL-подход;
pg_catalog -> PostgreSQL-specific, обычно подробнее и мощнее.

TOAST table #

TOAST — служебный механизм PostgreSQL для хранения больших значений.

Например, если в таблице есть большие TEXT, BYTEA, JSONB, PostgreSQL может вынести большие значения во внутреннюю TOAST-таблицу.

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

Примеры данных, которые могут задействовать TOAST:

большой JSONB;
длинный TEXT;
бинарные данные BYTEA;
большие массивы.

Partitioned Table и Partition #

Partitioned table — партиционированная таблица.

Пример:

CREATE TABLE events (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    created_at DATE NOT NULL,
    payload JSONB NOT NULL
) PARTITION BY RANGE (created_at);

Партиции:

CREATE TABLE events_2026_06
PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

Сущности:

partitioned table -> логическая родительская таблица;
partition -> физическая дочерняя таблица с частью данных.

Партиционирование используют для больших таблиц:

логи;
события;
аудит;
транзакции;
исторические данные.

Temporary Table #

Temporary table — временная таблица, существующая в рамках сессии или транзакции.

Пример:

CREATE TEMP TABLE temp_user_ids (
    user_id BIGINT
);

Используется для:

промежуточных расчётов;
сложных batch-операций;
импорта данных;
временных наборов id.

Temporary tables находятся в специальных temporary schemas.


Unlogged Table #

Unlogged table — таблица, изменения которой не пишутся в WAL обычным образом.

Пример:

CREATE UNLOGGED TABLE staging_events (
    id BIGINT,
    payload JSONB
);

Плюсы:

быстрее запись;
меньше WAL.

Минусы:

не crash-safe как обычная таблица;
не подходит для критичных данных;
не реплицируется физической репликацией как обычные WAL-logged изменения.

Используется для временных/staging-данных.


Large Object #

Large Object — специальный механизм PostgreSQL для хранения больших бинарных объектов.

На практике в backend чаще хранят файлы не в PostgreSQL, а в S3/MinIO и кладут в БД только metadata:

file_path;
bucket;
object_key;
size;
content_type;
owner_id.

Но large objects как сущность в PostgreSQL существуют.


Configuration Parameter #

PostgreSQL имеет параметры конфигурации:

work_mem;
shared_buffers;
maintenance_work_mem;
search_path;
statement_timeout;
lock_timeout;
synchronous_commit;
wal_level.

Часть прав может выдаваться и на configuration parameters через GRANT, что отражено в документации GRANT.


Relation #

В PostgreSQL часто встречается термин relation.

Это не только обычная таблица.

В pg_class relation-like объектами считаются:

tables;
indexes;
sequences;
views;
materialized views;
composite types;
TOAST tables;
foreign tables.

PostgreSQL прямо говорит, что pg_class описывает таблицы и другие объекты, имеющие колонки или похожие на таблицы; для всех этих типов используется термин “relations”.

То есть когда в ошибке или документации PostgreSQL пишет relation, это может быть:

таблица;
view;
sequence;
index;
materialized view;
foreign table.

Например ошибка:

relation "users" does not exist

обычно означает, что PostgreSQL не нашёл таблицу/view/relation с таким именем в текущем search_path.


Namespace #

Namespace в PostgreSQL практически соответствует schema.

В системных каталогах schema хранится как namespace.

Например, pg_namespace хранит схемы.

Поэтому внутри PostgreSQL можно встретить термины:

schema;
namespace.

Для прикладной разработки обычно достаточно говорить schema.


OID #

OID — object identifier, внутренний идентификатор объектов PostgreSQL.

PostgreSQL использует OID как внутренние primary keys для разных системных таблиц. Тип oid представляет object identifier.

Например, в системных каталогах объекты имеют OID:

SELECT oid, relname
FROM pg_class
WHERE relname = 'users';

В обычных прикладных таблицах OID почти никогда не используют напрямую.


Как посмотреть сущности через psql #

В psql есть meta-команды.

Посмотреть базы:

\l

Посмотреть схемы:

\dn

Посмотреть таблицы:

\dt

Посмотреть views:

\dv

Посмотреть materialized views:

\dm

Посмотреть sequences:

\ds

Посмотреть функции:

\df

Посмотреть индексы:

\di

Описать таблицу:

\d users

Подробно:

\d+ users

Как посмотреть сущности через SQL #

Список таблиц:

SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema');

Список views:

SELECT schemaname, viewname
FROM pg_views
WHERE schemaname NOT IN ('pg_catalog', 'information_schema');

Список индексов:

SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public';

Список функций:

SELECT n.nspname AS schema_name,
       p.proname AS function_name
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public';

Список constraints:

SELECT conname, contype
FROM pg_constraint;

Типы constraints в pg_constraint.contype обычно:

p -> primary key;
f -> foreign key;
u -> unique;
c -> check;
x -> exclusion.

Самые важные сущности для backend-разработчика #

Для backend-разработки чаще всего реально нужны:

database;
schema;
table;
column;
row;
primary key;
foreign key;
constraint;
index;
sequence;
view;
materialized view;
function;
procedure;
trigger;
role;
grant/privilege;
extension;
type/enum;
partition;
publication/subscription, если есть logical replication.

Остальные сущности важны реже, но знать о них полезно:

operator class;
collation;
cast;
foreign data wrapper;
foreign table;
policy;
event trigger;
tablespace;
system catalogs;
TOAST;
OID.

Краткая таблица #

СущностьДля чего нужна
ClusterНабор баз данных под одним PostgreSQL-инстансом
DatabaseОтдельная база данных
SchemaПространство имён внутри базы
TableХранит строки данных
ColumnПоле таблицы
RowКонкретная запись
ConstraintОграничение целостности
Primary KeyГлавный идентификатор строки
Foreign KeyСвязь между таблицами
IndexУскорение поиска/сортировки/проверки уникальности
SequenceГенератор чисел
ViewСохранённый SELECT без физического хранения результата
Materialized ViewСохранённый физический результат SELECT
FunctionХранимая функция, возвращающая значение
ProcedureХранимая процедура, вызываемая через CALL
TriggerАвтоматическая реакция на INSERT/UPDATE/DELETE
TypeТип данных
DomainТип с ограничением
EnumПеречислимый тип
RoleПользователь/группа прав
TablespaceФизическое место хранения
ExtensionРасширение возможностей PostgreSQL
PublicationПубликация изменений для logical replication
SubscriptionПодписка на publication
PolicyRow Level Security
System CatalogsМетаданные PostgreSQL

Итог #

В PostgreSQL есть много сущностей, но основная иерархия такая:

cluster
  -> database
     -> schema
        -> tables / views / indexes / sequences / functions / procedures / types / triggers

Для обычной прикладной разработки главные сущности:

таблицы;
колонки;
строки;
ключи;
constraints;
индексы;
связи;
sequence;
views;
functions/procedures;
roles и privileges.

PostgreSQL является объектно-реляционной СУБД, поэтому кроме обычных таблиц и индексов в нём много расширяемых объектов: пользовательские типы, операторы, агрегаты, operator classes, extensions, policies и foreign tables. Это делает PostgreSQL не просто хранилищем таблиц, а полноценной системой с большим набором объектов, управляющих структурой, доступом, логикой и производительностью данных.


49. Какие инструменты используют для работы с БД #

Общая идея #

Для работы с БД используют не один инструмент, а набор инструментов под разные задачи:

1. Консольные клиенты
2. GUI-клиенты
3. ORM и query builders
4. Драйверы подключения
5. Миграционные инструменты
6. Инструменты администрирования
7. Инструменты backup/restore
8. Инструменты мониторинга
9. Инструменты профилирования запросов
10. Инструменты контейнеризации и локальной разработки

Если говорить про backend-разработчика, то чаще всего нужны:

psql / pgAdmin / DBeaver;
SQLAlchemy / Django ORM;
asyncpg / psycopg;
Alembic / Django migrations;
EXPLAIN ANALYZE;
pg_dump / pg_restore;
Docker / docker-compose;
Prometheus / Grafana / pg_stat_statements.

1. Консольные клиенты #

Консольный клиент — это инструмент, через который можно подключиться к БД из терминала и выполнять SQL-запросы.

Для PostgreSQL основной инструмент:

psql

psql — терминальный клиент PostgreSQL. Через него можно интерактивно выполнять SQL-запросы, смотреть результаты, запускать SQL-файлы, проверять схему, выполнять служебные команды. PostgreSQL официально описывает psql как terminal-based front-end к PostgreSQL. ( PostgreSQL)

Пример подключения:

psql -h localhost -p 5432 -U postgres -d app_db

Пример запроса:

SELECT *
FROM users
LIMIT 10;

Полезные команды psql:

\l      -- список баз данных
\c db   -- подключиться к базе
\dt     -- список таблиц
\d users -- описание таблицы users
\di     -- список индексов
\df     -- список функций
\x      -- расширенный вывод
\q      -- выход

Консольные клиенты полезны, потому что они:

быстро работают;
удобны на сервере;
подходят для диагностики;
используются в скриптах;
не требуют графического интерфейса;
часто доступны в Docker-контейнере с БД.

2. GUI-клиенты #

GUI-клиенты — это графические программы для работы с БД.

Они удобны, когда нужно:

посмотреть таблицы;
открыть данные;
написать SQL-запрос;
посмотреть связи;
экспортировать результат;
изменить данные вручную;
посмотреть индексы, constraints, views;
работать с несколькими БД через интерфейс.

Частые GUI-инструменты:

pgAdmin;
DBeaver;
DataGrip;
TablePlus;
HeidiSQL;
Navicat.

pgAdmin #

pgAdmin — официальный популярный графический инструмент для PostgreSQL. На сайте pgAdmin он описывается как open-source management tool для PostgreSQL.

Через pgAdmin можно:

создавать базы и таблицы;
писать SQL в Query Tool;
смотреть схему;
смотреть индексы и constraints;
управлять ролями;
смотреть активность сервера;
делать backup/restore через GUI;
работать с функциями, процедурами, views.

pgAdmin хорош именно для PostgreSQL, потому что заточен под него.


DBeaver #

DBeaver — универсальный клиент для разных БД. Официальная документация описывает его как universal database management tool для профессиональной работы с данными.

Он удобен, если в проекте не только PostgreSQL, но и:

MySQL;
MariaDB;
SQLite;
Oracle;
SQL Server;
ClickHouse;
Redis;
MongoDB;
другие источники данных.

Плюсы DBeaver:

много СУБД в одном интерфейсе;
удобный SQL editor;
просмотр таблиц и данных;
ER-диаграммы;
экспорт/импорт;
поддержка SSH tunnel;
удобен для повседневной работы разработчика.

DataGrip #

DataGrip — IDE для баз данных от JetBrains.

Часто используют, если разработчик уже работает в:

PyCharm;
IntelliJ IDEA;
WebStorm;
GoLand.

Плюсы:

сильный SQL autocomplete;
подсветка ошибок;
навигация по схеме;
рефакторинг SQL;
интеграция с JetBrains IDE;
удобная работа с несколькими подключениями.

3. Драйверы подключения #

Драйвер — это библиотека, через которую приложение подключается к базе данных.

Например, Python-приложение не общается с PostgreSQL напрямую «магией». Ему нужен драйвер.

Для PostgreSQL в Python часто используют:

psycopg / psycopg2;
asyncpg;
pg8000.

Для Java:

PostgreSQL JDBC Driver.

Для Node.js:

pg;
postgres.js.

Для Go:

pgx;
lib/pq.

Пример на Python с psycopg:

import psycopg

conn = psycopg.connect(
    "host=localhost port=5432 dbname=app_db user=postgres password=secret"
)

with conn.cursor() as cur:
    cur.execute("SELECT id, username FROM users LIMIT 10")
    rows = cur.fetchall()

print(rows)

Драйвер нужен для:

подключения к БД;
отправки SQL-запросов;
получения результата;
работы с транзакциями;
параметризации запросов;
обработки ошибок;
работы с connection pool.

4. ORM #

ORM — Object-Relational Mapping.

ORM позволяет работать с таблицами как с объектами языка программирования.

Например, вместо ручного SQL:

SELECT *
FROM users
WHERE id = 1;

можно написать в Python через ORM:

user = session.get(User, 1)

Частые ORM:

SQLAlchemy;
Django ORM;
Tortoise ORM;
Peewee;
Prisma;
Hibernate;
Entity Framework.

SQLAlchemy официально описывает себя как Python SQL toolkit and Object Relational Mapper, который даёт разработчикам гибкость и мощность SQL.


SQLAlchemy #

SQLAlchemy часто используют с:

FastAPI;
Flask;
чистыми Python-сервисами;
микросервисами;
Alembic для миграций.

Пример модели:

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, BigInteger


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)

Пример запроса:

from sqlalchemy import select

stmt = select(User).where(User.username == "alex")
user = session.execute(stmt).scalar_one_or_none()

Django ORM #

Django ORM используют в Django-проектах.

Пример модели:

from django.db import models


class UserFile(models.Model):
    user = models.ForeignKey(
        "auth.User",
        on_delete=models.CASCADE,
        related_name="files"
    )
    file = models.FileField(upload_to="files/")
    uploaded_at = models.DateTimeField(auto_now_add=True)

Пример запроса:

files = UserFile.objects.filter(user=request.user)

ORM полезен, потому что:

ускоряет разработку;
помогает описывать модели;
даёт миграции;
упрощает CRUD;
помогает строить запросы;
снижает количество ручного SQL.

Но ORM не отменяет SQL. Для сложных запросов, оптимизации и диагностики всё равно нужно понимать:

JOIN;
индексы;
EXPLAIN ANALYZE;
транзакции;
изоляцию;
constraints;
N+1 queries.

5. Query Builder #

Query builder — промежуточный вариант между чистым SQL и ORM.

Он позволяет собирать SQL программно.

Например, SQLAlchemy Core:

from sqlalchemy import select

stmt = (
    select(users.c.id, users.c.username)
    .where(users.c.is_active == True)
    .limit(10)
)

Плюсы query builder:

меньше ручной склейки SQL-строк;
безопаснее параметризация;
удобно строить динамические запросы;
больше контроля, чем у ORM;
SQL-логика ближе к реальному SQL.

Используется, когда ORM слишком тяжёлый или когда нужно точнее контролировать SQL.


6. Инструменты миграций #

Миграции нужны, чтобы управлять изменениями схемы БД.

Например, сегодня есть таблица:

users(id, username)

Завтра нужно добавить:

email
created_at

Нельзя просто руками менять production-базу без истории. Для этого используют миграции.

Частые инструменты:

Alembic;
Django migrations;
Flyway;
Liquibase;
Prisma Migrate;
Rails migrations.

Alembic #

Alembic часто используют вместе с SQLAlchemy. Официальная документация Alembic описывает его как инструмент создания, управления и выполнения change management scripts для реляционной БД с использованием SQLAlchemy.

Пример команд:

alembic revision -m "create users table"
alembic upgrade head
alembic downgrade -1

С autogenerate:

alembic revision --autogenerate -m "add email to users"

Alembic может сравнить текущую схему БД с metadata приложения и сгенерировать «очевидные» изменения в миграции.


Django migrations #

В Django миграции встроены.

Типичный workflow:

python manage.py makemigrations
python manage.py migrate

makemigrations создаёт файл миграции по изменениям моделей.
migrate применяет миграции к БД.

Миграции нужны для:

создания таблиц;
добавления колонок;
изменения типов;
создания индексов;
создания constraints;
отката изменений;
синхронизации схемы в команде;
развёртывания изменений на production.

7. Инструменты backup и restore #

Для работы с БД нужны инструменты резервного копирования и восстановления.

Для PostgreSQL основные:

pg_dump;
pg_restore;
pg_dumpall;
pg_basebackup.

Пример backup одной базы:

pg_dump -h localhost -U postgres -d app_db -F c -f app_db.dump

Восстановление:

pg_restore -h localhost -U postgres -d app_db app_db.dump

SQL-дамп:

pg_dump -h localhost -U postgres -d app_db > backup.sql

Восстановление SQL-дампа:

psql -h localhost -U postgres -d app_db < backup.sql

Backup-инструменты нужны для:

резервных копий;
переноса БД;
локального восстановления;
создания тестовой копии production;
миграции между окружениями;
восстановления после ошибки.

Важно: репликация не заменяет backup. Если в production случайно выполнить:

DROP TABLE users;

это изменение уйдёт и на реплики. Backup нужен отдельно.


8. Инструменты анализа запросов #

Для оптимизации запросов в PostgreSQL используют:

EXPLAIN;
EXPLAIN ANALYZE;
pg_stat_statements;
auto_explain;
логи медленных запросов.

Самое базовое:

EXPLAIN
SELECT *
FROM orders
WHERE user_id = 123;

Более полезное:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 123;

EXPLAIN показывает план выполнения запроса, а EXPLAIN ANALYZE реально выполняет запрос и показывает фактическое время, количество строк, выбранные методы сканирования и соединения.

Через это смотрят:

используется ли индекс;
есть ли Seq Scan;
есть ли Sort;
какой JOIN выбран;
сколько строк ожидалось;
сколько строк реально получено;
где самая дорогая часть запроса.

9. pg_stat_statements #

pg_stat_statements — расширение PostgreSQL для сбора статистики по выполняемым SQL-запросам.

Оно помогает понять:

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

Обычно это один из главных инструментов для поиска проблемных запросов в production.

Пример запроса:

SELECT
    query,
    calls,
    total_exec_time,
    mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

10. Инструменты мониторинга #

Для production-БД нужны инструменты мониторинга.

Частые варианты:

Prometheus;
Grafana;
postgres_exporter;
Zabbix;
Datadog;
New Relic;
Percona Monitoring and Management;
pgwatch.

Что мониторят:

CPU;
RAM;
disk usage;
IOPS;
connections;
locks;
deadlocks;
replication lag;
slow queries;
cache hit ratio;
transactions per second;
WAL generation;
autovacuum activity;
table/index bloat.

Для PostgreSQL особенно важно следить за:

долгими транзакциями;
блокировками;
ростом WAL;
состоянием autovacuum;
replication lag;
количеством подключений;
использованием диска.

11. Инструменты администрирования #

Администрирование БД включает:

создание ролей;
выдачу прав;
настройку пользователей;
создание баз;
управление extensions;
настройку репликации;
анализ блокировок;
управление вакуумом;
проверку конфигурации.

Инструменты:

psql;
pgAdmin;
DBeaver;
patronictl, если используется Patroni;
repmgr, если используется repmgr;
cloud console, если managed PostgreSQL.

Примеры SQL-команд:

CREATE ROLE app_user LOGIN PASSWORD 'secret';
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public
TO app_user;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

12. Инструменты для локальной разработки #

Для локальной разработки часто используют:

Docker;
Docker Compose;
локальный PostgreSQL;
testcontainers;
SQLite для простых тестов;
отдельную test database.

Пример docker-compose.yml для PostgreSQL:

services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: app_db
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

После запуска:

docker compose up -d

Подключение:

psql -h localhost -p 5432 -U app_user -d app_db

Docker полезен, потому что:

одинаковая версия БД у всех разработчиков;
быстрый запуск окружения;
легко пересоздать БД;
удобно для тестов;
можно поднять PostgreSQL, Redis, MinIO и backend вместе.

13. Инструменты тестирования БД #

Для тестов используют:

pytest;
pytest-django;
pytest-asyncio;
testcontainers;
factory_boy;
Faker;
тестовую БД PostgreSQL;
транзакционные фикстуры.

Главные задачи:

создать тестовую схему;
применить миграции;
подготовить данные;
запустить тест;
очистить данные;
проверить результат.

Для Django часто используют встроенную тестовую БД.

Для FastAPI + SQLAlchemy часто делают:

отдельный PostgreSQL для тестов;
Alembic migrations;
session fixture;
rollback после теста;
очистку таблиц между тестами.

14. Инструменты для миграции и синхронизации данных #

Иногда нужно не просто изменить схему, а перенести данные:

из старой БД в новую;
из MySQL в PostgreSQL;
из production в staging;
из одной схемы в другую;
из монолита в микросервис.

Инструменты:

pg_dump / pg_restore;
COPY;
psql \copy;
ETL-инструменты;
Airbyte;
Debezium;
Kafka Connect;
custom scripts;
logical replication.

Для массовой загрузки CSV в PostgreSQL часто используют:

COPY users(username, email)
FROM '/path/users.csv'
DELIMITER ','
CSV HEADER;

Или клиентский вариант:

\copy users(username, email) FROM 'users.csv' DELIMITER ',' CSV HEADER

15. Инструменты CDC и репликации #

CDC — Change Data Capture. Это инструменты, которые читают изменения из БД и передают их дальше.

Используют для:

синхронизации сервисов;
загрузки данных в аналитику;
event-driven архитектуры;
репликации в Kafka;
обновления search index;
интеграций.

Частые инструменты:

Debezium;
Kafka Connect;
PostgreSQL logical replication;
pglogical;
native publication/subscription.

В PostgreSQL есть встроенная logical replication через:

PUBLICATION;
SUBSCRIPTION.

Пример:

CREATE PUBLICATION app_pub FOR TABLE users, orders;

16. Инструменты для работы с connection pool #

БД не любит бесконечное количество подключений. Поэтому часто используют connection pooling.

Инструменты:

PgBouncer;
Pgpool-II;
pool внутри SQLAlchemy;
pool внутри asyncpg;
pool в Django через сторонние решения или настройки окружения.

PgBouncer часто ставят между приложением и PostgreSQL:

Application -> PgBouncer -> PostgreSQL

Он помогает:

уменьшить количество реальных подключений к БД;
переиспользовать соединения;
снизить накладные расходы;
защитить БД от всплеска соединений.

17. Инструменты для документации схемы #

Для описания схемы и ER-диаграмм используют:

DBeaver ER diagrams;
DataGrip diagrams;
dbdiagram.io;
SchemaSpy;
Draw.io;
Mermaid;
PlantUML;
pgModeler.

Пример Mermaid ER:

erDiagram
    USERS ||--o{ ORDERS : has
    ORDERS ||--o{ ORDER_ITEMS : contains
    PRODUCTS ||--o{ ORDER_ITEMS : included_in

Такие инструменты помогают понять:

таблицы;
связи;
foreign keys;
many-to-many;
зависимости;
структуру проекта.

18. Инструменты безопасности #

Для безопасности БД используют:

roles;
GRANT / REVOKE;
Row Level Security;
SSL/TLS;
секреты в env/secret manager;
audit extensions;
логирование подключений;
pg_hba.conf.

Пример выдачи прав:

GRANT SELECT ON users TO readonly_user;

Пример отзыва прав:

REVOKE DELETE ON users FROM app_user;

Для хранения паролей и connection strings используют:

.env для локальной разработки;
Docker secrets;
Kubernetes secrets;
Vault;
cloud secret managers.

Что обычно использует backend-разработчик #

Для Python backend + PostgreSQL типичный набор такой:

PostgreSQL              -> сама БД
psql                    -> быстрые проверки из терминала
DBeaver / pgAdmin       -> GUI для просмотра данных
SQLAlchemy / Django ORM -> работа с БД из кода
psycopg / asyncpg       -> драйвер подключения
Alembic / Django migrations -> миграции схемы
EXPLAIN ANALYZE         -> анализ запросов
pg_dump / pg_restore    -> backup/restore
Docker Compose          -> локальное окружение
pytest + test DB        -> тестирование
Prometheus/Grafana      -> мониторинг
pg_stat_statements      -> поиск тяжёлых запросов
PgBouncer               -> pooling подключений

Пример по стеку Django #

Для Django-проекта обычно:

Django ORM;
Django migrations;
psycopg;
PostgreSQL;
pgAdmin или DBeaver;
manage.py dbshell;
pytest-django;
pg_dump;
Docker Compose.

Пример команд:

python manage.py makemigrations
python manage.py migrate
python manage.py dbshell

Пример по стеку FastAPI + SQLAlchemy #

Для FastAPI чаще:

SQLAlchemy ORM/Core;
Alembic;
psycopg или asyncpg;
PostgreSQL;
DBeaver / pgAdmin;
Docker Compose;
pytest;
EXPLAIN ANALYZE;
pg_dump / pg_restore.

Пример workflow:

1. Модель описывается в SQLAlchemy.
2. Alembic создаёт миграцию.
3. Миграция применяется к PostgreSQL.
4. FastAPI работает с БД через Session/AsyncSession.
5. Запросы проверяются через psql/DBeaver.
6. Производительность проверяется через EXPLAIN ANALYZE.

Что важно понимать на практике #

Инструменты можно разделить по ролям.

Для написания SQL:

psql;
DBeaver;
pgAdmin;
DataGrip.

Для доступа из приложения:

драйвер;
ORM;
query builder;
connection pool.

Для изменения схемы:

Alembic;
Django migrations;
Flyway;
Liquibase.

Для администрирования:

psql;
pgAdmin;
pg_dump;
pg_restore;
pg_stat_activity;
pg_locks;
pg_stat_statements.

Для production:

мониторинг;
логирование;
backup;
replication;
connection pooling;
alerting.

Итог #

Для работы с БД используют разные категории инструментов:

psql                         -> консольная работа с PostgreSQL;
pgAdmin / DBeaver / DataGrip -> графическая работа с БД;
ORM                          -> работа с таблицами через код;
драйверы                     -> подключение приложения к БД;
Alembic / Django migrations  -> управление изменениями схемы;
pg_dump / pg_restore         -> backup и restore;
EXPLAIN ANALYZE              -> анализ производительности запросов;
pg_stat_statements           -> статистика запросов;
Prometheus / Grafana         -> мониторинг;
Docker Compose               -> локальная разработка;
PgBouncer                    -> пул подключений;
Debezium / logical replication -> потоковая передача изменений.

Для backend-разработчика минимально уверенный набор:

SQL;
psql;
DBeaver или pgAdmin;
ORM;
драйвер подключения;
миграции;
EXPLAIN ANALYZE;
backup/restore;
основы мониторинга.

То есть работа с БД — это не только «писать SELECT». Это полный цикл: спроектировать схему, написать запросы, подключить приложение, применить миграции, проверить производительность, настроить права, сделать backup и понимать, что происходит в production.


50. Что такое и зачем нужно партицирование #

Что такое партицирование #

Партицирование — это разделение одной большой логической таблицы на несколько меньших физических частей — партиций.

Для приложения это обычно выглядит как одна таблица:

SELECT *
FROM orders
WHERE created_at >= '2026-07-01'
  AND created_at < '2026-08-01';

Но внутри БД данные могут лежать не в одной огромной таблице orders, а, например, в отдельных партициях:

orders
 ├── orders_2026_01
 ├── orders_2026_02
 ├── orders_2026_03
 └── orders_2026_07

В PostgreSQL партиционированная таблица сама по себе является “виртуальной” таблицей без собственного хранения данных; реальные строки хранятся в партициях. При вставке PostgreSQL направляет строку в нужную партицию по ключу партицирования.

Зачем нужно партицирование #

Главная цель — упростить и ускорить работу с очень большими таблицами.

Например, есть таблица logs на 500 миллионов строк. Большинство запросов смотрит данные только за последние 7 дней:

SELECT *
FROM logs
WHERE created_at >= now() - interval '7 days';

Без партицирования БД работает с одной огромной таблицей и её большими индексами.

С партицированием по дате можно сделать так:

logs_2026_06
logs_2026_07
logs_2026_08

Тогда запрос за июль может читать только июльскую партицию, а не всю таблицу. Это называется partition pruning — отсечение ненужных партиций. PostgreSQL прямо указывает, что правильный выбор ключа партицирования важен, потому что условия WHERE, совпадающие с границами партиций, позволяют не читать лишние партиции.

Основные преимущества #

Партицирование полезно, когда таблица очень большая и запросы часто фильтруют данные по одному признаку: дата, регион, tenant_id, категория и так далее.

Основные плюсы:

  1. Быстрее выборки по ключу партицирования

Например, если таблица заказов разбита по месяцам, а запрос ищет заказы только за июль, БД может не трогать остальные месяцы.

SELECT *
FROM orders
WHERE created_at >= '2026-07-01'
  AND created_at < '2026-08-01';
  1. Проще удалять старые данные

Без партицирования:

DELETE FROM logs
WHERE created_at < '2025-01-01';

Это может удалить миллионы строк, создать нагрузку, заблокировать ресурсы и потом потребовать VACUUM.

С партицированием можно просто удалить старую партицию:

DROP TABLE logs_2024_12;

PostgreSQL отмечает, что удаление или отсоединение отдельной партиции намного быстрее массового DELETE и позволяет избежать лишней нагрузки от VACUUM.

  1. Индексы становятся меньше

Вместо одного огромного индекса на всю таблицу каждая партиция может иметь свой отдельный индекс. Маленькие индексы проще держать в памяти, быстрее читать и обслуживать.

  1. Удобнее обслуживать данные

Можно отдельно архивировать старые партиции, переносить их на более дешёвое хранилище, перестраивать индексы только на конкретной партиции, загружать данные пачками в новую партицию.

PostgreSQL также указывает, что редко используемые данные можно переносить на более дешёвые и медленные носители.

Виды партицирования #

В PostgreSQL есть три основных встроенных вида:

1. RANGE partitioning #

Разделение по диапазонам.

Чаще всего используется для дат:

CREATE TABLE orders (
    id bigint,
    created_at date,
    amount numeric
) PARTITION BY RANGE (created_at);

Пример партиции:

CREATE TABLE orders_2026_07
PARTITION OF orders
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

Подходит для:

логи
заказы
история платежей
события
аудит
метрики

2. LIST partitioning #

Разделение по конкретным значениям.

Например, по стране:

CREATE TABLE users (
    id bigint,
    country text,
    name text
) PARTITION BY LIST (country);

Партиции:

CREATE TABLE users_az
PARTITION OF users
FOR VALUES IN ('AZ');

CREATE TABLE users_tr
PARTITION OF users
FOR VALUES IN ('TR');

Подходит, когда есть фиксированные группы: страна, регион, тип клиента, категория.

3. HASH partitioning #

Разделение по хэшу значения.

CREATE TABLE users (
    id bigint,
    username text
) PARTITION BY HASH (id);

Пример:

CREATE TABLE users_p0
PARTITION OF users
FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE users_p1
PARTITION OF users
FOR VALUES WITH (MODULUS 4, REMAINDER 1);

Подходит, когда нет хорошего диапазона или списка, но нужно равномерно распределить данные между партициями.

PostgreSQL официально поддерживает RANGE, LIST и HASH partitioning.

Простой пример #

Допустим, есть таблица логов:

CREATE TABLE logs (
    id bigserial,
    created_at date not null,
    message text
) PARTITION BY RANGE (created_at);

Создаём партиции по месяцам:

CREATE TABLE logs_2026_07
PARTITION OF logs
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

CREATE TABLE logs_2026_08
PARTITION OF logs
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

Теперь вставка идёт в основную таблицу:

INSERT INTO logs (created_at, message)
VALUES ('2026-07-07', 'User logged in');

Но физически строка попадёт в logs_2026_07.

Запрос:

SELECT *
FROM logs
WHERE created_at >= '2026-07-01'
  AND created_at < '2026-08-01';

может читать только logs_2026_07.

Когда партицирование нужно #

Партицирование имеет смысл, когда:

таблица очень большая;
данные естественно делятся на группы;
запросы часто фильтруют по этому признаку;
нужно регулярно удалять или архивировать старые данные;
индексы стали слишком большими;
массовые DELETE/UPDATE стали дорогими;
таблица содержит временные данные: логи, события, платежи, метрики.

Типичный хороший случай:

orders по created_at
logs по created_at
notifications по created_at
events по created_at
payments по created_at
user_activity по user_id или created_at

Когда партицирование не нужно #

Партицирование не является универсальной оптимизацией.

Оно может быть бесполезным или даже вредным, если:

таблица небольшая;
запросы не фильтруют по ключу партицирования;
партиций слишком много;
ключ партицирования выбран неправильно;
большинство запросов всё равно читает все партиции;
обычного индекса достаточно.

Например, если таблица разбита по created_at, но запросы почти всегда ищут по email, партицирование по дате особой пользы не даст:

SELECT *
FROM users
WHERE email = 'test@example.com';

В таком случае нужен индекс по email, а не партицирование по дате.

PostgreSQL отдельно предупреждает, что плохой дизайн партицирования может ухудшить планирование и выполнение запросов, а слишком большое количество партиций увеличивает время планирования и потребление памяти. ( PostgreSQL)

Отличие партицирования от шардирования #

Партицирование — это разделение таблицы внутри одной базы данных.

Шардирование — это разделение данных между разными серверами или инстансами базы данных.

Условно:

Партицирование:
один PostgreSQL → одна таблица → много партиций внутри одной БД

Шардирование:
несколько PostgreSQL-серверов → данные распределены между ними

Партицирование чаще решает проблему слишком большой таблицы внутри одной БД.

Шардирование чаще решает проблему, когда один сервер уже не справляется по CPU, RAM, диску или количеству запросов.

Главное #

Партицирование нужно не просто “для ускорения”, а для управляемости больших таблиц.

Оно помогает:

читать только нужную часть данных;
быстрее удалять старые данные;
уменьшать размер индексов;
обслуживать большие таблицы частями;
архивировать данные без тяжёлых DELETE.

Но оно работает хорошо только тогда, когда ключ партицирования совпадает с реальными запросами. Самый частый и понятный пример — большая таблица логов или заказов, разбитая по дате.


51. Что такое CTE в PostgreSQL? #

Что такое CTE #

CTE — Common Table Expression, «общее табличное выражение». В PostgreSQL это временный именованный результат внутри одного SQL-запроса, который объявляется через WITH и потом используется в основном запросе как будто это временная таблица. CTE существует только во время выполнения этого одного запроса и не сохраняется в базе. В официальной документации PostgreSQL CTE описываются как вспомогательные выражения, которые можно воспринимать как временные таблицы для одного запроса.

Простейший пример:

WITH active_users AS (
    SELECT id, username
    FROM users
    WHERE is_active = true
)
SELECT *
FROM active_users;

Здесь active_users — это CTE. Сначала PostgreSQL логически формирует результат:

SELECT id, username
FROM users
WHERE is_active = true

А потом основной запрос читает данные из active_users.

Зачем нужны CTE #

Главная польза CTE — сделать сложный SQL-запрос более читаемым. Вместо глубоко вложенных подзапросов можно разбить логику на понятные шаги.

Например, без CTE запрос может быть тяжело читать:

SELECT user_id, total
FROM (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
) AS user_totals
WHERE total > 1000;

С CTE читается проще:

WITH user_totals AS (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
)
SELECT user_id, total
FROM user_totals
WHERE total > 1000;

То есть CTE удобно использовать, когда нужно:

разбить сложный запрос на этапы;

переиспользовать промежуточный результат;

сделать запрос понятнее;

работать с рекурсивными структурами;

использовать INSERT, UPDATE, DELETE, MERGE внутри WITH.

PostgreSQL поддерживает CTE не только для SELECT, но и для INSERT, UPDATE, DELETE, MERGE. При этом сам WITH тоже может быть привязан к основному SELECT, INSERT, UPDATE, DELETE или MERGE.

Общий синтаксис #

WITH cte_name AS (
    SELECT ...
)
SELECT ...
FROM cte_name;

Можно объявить несколько CTE:

WITH
user_totals AS (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
),
rich_users AS (
    SELECT user_id
    FROM user_totals
    WHERE total > 1000
)
SELECT *
FROM rich_users;

Второй CTE может использовать результат первого.

CTE vs подзапрос #

CTE и подзапрос часто могут решать одну и ту же задачу.

Подзапрос:

SELECT *
FROM (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
) t
WHERE total > 1000;

CTE:

WITH user_totals AS (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
)
SELECT *
FROM user_totals
WHERE total > 1000;

По смыслу похоже, но CTE обычно лучше читается, особенно когда промежуточных шагов несколько.

Важный нюанс производительности #

В старых версиях PostgreSQL CTE часто воспринимался как «барьер оптимизации»: PostgreSQL мог сначала полностью вычислить CTE, а потом уже применять внешний фильтр.

В современных версиях PostgreSQL поведение умнее. Если CTE нерекурсивный, не имеет побочных эффектов и используется один раз, PostgreSQL может встроить его в основной запрос и оптимизировать совместно. Если CTE используется несколько раз, он обычно материализуется, то есть вычисляется отдельно. Это поведение можно явно контролировать через MATERIALIZED и NOT MATERIALIZED.

Пример:

WITH w AS NOT MATERIALIZED (
    SELECT *
    FROM big_table
)
SELECT *
FROM w
WHERE key = 123;

NOT MATERIALIZED говорит планировщику: не обязательно вычислять CTE отдельно, можно встроить его в основной запрос.

А так можно принудительно материализовать:

WITH w AS MATERIALIZED (
    SELECT *
    FROM big_table
)
SELECT *
FROM w
WHERE key = 123;

Это может быть полезно, если внутри CTE дорогие вычисления, которые не хочется выполнять несколько раз.

Рекурсивные CTE #

CTE могут быть рекурсивными через WITH RECURSIVE. Это нужно для иерархий, деревьев, графов, категорий, вложенных комментариев, организационных структур.

Пример: получить числа от 1 до 5:

WITH RECURSIVE nums(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1
    FROM nums
    WHERE n < 5
)
SELECT *
FROM nums;

Результат:

1
2
3
4
5

В PostgreSQL рекурсивный CTE состоит из нерекурсивной части, затем UNION или UNION ALL, затем рекурсивной части, которая ссылается на сам CTE. Документация отдельно указывает, что такие запросы полезны для иерархических или древовидных данных.

CTE с изменением данных #

CTE может использоваться не только для выборки, но и для изменения данных.

Например, удалить строки и сразу вставить удалённые данные в лог:

WITH moved_rows AS (
    DELETE FROM products
    WHERE created_at < now() - interval '1 year'
    RETURNING *
)
INSERT INTO products_log
SELECT *
FROM moved_rows;

Здесь DELETE удаляет строки и через RETURNING возвращает их. Затем основной INSERT вставляет эти строки в таблицу логов. PostgreSQL допускает INSERT, UPDATE, DELETE, MERGE внутри WITH, но для дальнейшего использования результата обычно нужен RETURNING. ( PostgreSQL)

Кратко #

CTE в PostgreSQL — это именованный временный результат внутри одного SQL-запроса.

Используется так:

WITH name AS (
    SELECT ...
)
SELECT ...
FROM name;

Главное назначение — упростить сложные запросы, разбить их на логические этапы и иногда переиспользовать промежуточный результат.

Важно не считать CTE всегда «бесплатной заменой» подзапроса. В простых случаях он улучшает читаемость, но в тяжёлых запросах нужно проверять план через EXPLAIN / EXPLAIN ANALYZE, особенно если CTE большой, используется несколько раз или явно материализуется.


52. Как объявить индекс в SQL? #

Базовый синтаксис #

В SQL индекс обычно объявляют через CREATE INDEX.

Для PostgreSQL базовый вариант такой:

CREATE INDEX index_name
ON table_name (column_name);

Пример:

CREATE INDEX idx_users_email
ON users (email);

Этот запрос создаёт индекс idx_users_email на колонке email таблицы users.

В PostgreSQL CREATE INDEX создаёт индекс по указанной колонке или колонкам таблицы/материализованного представления. Индексы нужны, чтобы база могла быстрее находить строки, но они добавляют накладные расходы при изменении данных. Источник: документация PostgreSQL.

Индекс по нескольким колонкам #

CREATE INDEX idx_orders_user_id_status
ON orders (user_id, status);

Такой индекс называется составным, или multicolumn index.

Он может быть полезен для запросов вида:

SELECT *
FROM orders
WHERE user_id = 10
  AND status = 'paid';

В PostgreSQL составные индексы поддерживаются не всеми типами индексов, но поддерживаются, например, B-tree, GiST, GIN и BRIN.

Уникальный индекс #

CREATE UNIQUE INDEX idx_users_email_unique
ON users (email);

Такой индекс не только ускоряет поиск, но и запрещает дублирующиеся значения в колонке email.

Например, второй пользователь с таким же email уже не сможет быть добавлен.

Индекс с указанием типа #

В PostgreSQL по умолчанию создаётся B-tree индекс. Это самый распространённый вариант, который подходит для =, <, >, BETWEEN, ORDER BY и многих обычных фильтров.

Явно можно написать так:

CREATE INDEX idx_users_email
ON users USING btree (email);

Для других задач можно использовать другие типы:

CREATE INDEX idx_products_tags
ON products USING gin (tags);

Например, GIN часто используют для массивов, JSONB, полнотекстового поиска.

Частичный индекс #

Частичный индекс создаётся не по всей таблице, а только по строкам, которые подходят под условие.

CREATE INDEX idx_orders_active
ON orders (user_id)
WHERE status = 'active';

Такой индекс будет содержать только строки, где:

status = 'active'

Это полезно, если часто ищешь только активные записи, а остальные строки в индекс добавлять не нужно. В PostgreSQL частичный индекс определяется через условие-предикат WHERE.

Индекс по выражению #

Индекс можно создать не только по колонке, но и по выражению.

Например, для поиска email без учёта регистра:

CREATE INDEX idx_users_lower_email
ON users (lower(email));

Тогда такой запрос сможет использовать индекс:

SELECT *
FROM users
WHERE lower(email) = lower('Test@Mail.com');

В PostgreSQL для индексов по выражениям используется синтаксис с выражением внутри скобок.

Индекс без блокировки записи в таблицу #

В PostgreSQL есть вариант:

CREATE INDEX CONCURRENTLY idx_users_email
ON users (email);

CONCURRENTLY используют на больших таблицах в production, чтобы создание индекса меньше мешало обычной работе приложения. Без CONCURRENTLY создание индекса может сильнее блокировать операции записи в таблицу.

С IF NOT EXISTS #

Чтобы не получить ошибку, если индекс уже существует:

CREATE INDEX IF NOT EXISTS idx_users_email
ON users (email);

Как обычно называют индексы #

Часто используют такой стиль:

idx_<table>_<column>

Примеры:

idx_users_email
idx_orders_user_id
idx_orders_user_id_status
idx_products_created_at

Удаление индекса #

DROP INDEX idx_users_email;

В PostgreSQL индекс удаляется отдельной командой DROP INDEX.

Главное #

Обычный индекс:

CREATE INDEX idx_table_column
ON table_name (column_name);

Уникальный индекс:

CREATE UNIQUE INDEX idx_table_column_unique
ON table_name (column_name);

Составной индекс:

CREATE INDEX idx_table_col1_col2
ON table_name (col1, col2);

Частичный индекс:

CREATE INDEX idx_table_column_condition
ON table_name (column_name)
WHERE condition;

Индекс по выражению:

CREATE INDEX idx_table_expression
ON table_name ((expression));

На практике после создания индекса нужно проверять, используется ли он запросом:

EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'test@example.com';

Сам факт наличия индекса не гарантирует, что PostgreSQL его выберет. Планировщик использует индекс только тогда, когда считает это выгоднее, чем последовательное чтение таблицы. PostgreSQL сам обновляет индекс при изменении таблицы, но для качественного выбора плана нужны актуальные статистики, обычно через ANALYZE/autovacuum.


53. Чем отличаются реляционные и нереляционные базы данных? | SQL vs NoSQL #

Реляционная база данных — это БД, где данные хранятся в таблицах: строки, колонки, связи между таблицами, JOIN, ограничения, транзакции.

Нереляционная база данных, или NoSQL, — это не один конкретный тип БД, а семейство баз данных, которые могут хранить данные не только в таблицах: документами, ключ-значение, графами, широкими колонками и т.д. AWS прямо описывает SQL/relational БД как табличные, а NoSQL как БД с разными моделями данных.

Реляционные БД / SQL #

В реляционных БД данные обычно организованы так:

users
-----
id | username | email

orders
------
id | user_id | amount | status

Связь между таблицами делается через ключи:

SELECT users.username, orders.amount
FROM users
JOIN orders ON orders.user_id = users.id;

То есть заказ лежит в таблице orders, пользователь — в таблице users, а связь между ними задаётся через user_id.

Типичные реляционные БД:

PostgreSQL
MySQL
MariaDB
Oracle Database
Microsoft SQL Server
SQLite

Главные признаки реляционных БД:

ПризнакОписание
СтруктураТаблицы, строки, колонки
СхемаОбычно строгая: заранее задаются поля и типы
СвязиPRIMARY KEY, FOREIGN KEY, JOIN
ЯзыкSQL
ТранзакцииОбычно сильная поддержка ACID
Хорошо подходят дляФинансов, заказов, пользователей, складов, платежей, CRM, ERP

ACID — это свойства транзакций: Atomicity, Consistency, Isolation, Durability. PostgreSQL в своей документации определяет ACID как набор свойств, который помогает сохранять корректность данных при конкурентной работе, ошибках и сбоях питания.

Нереляционные БД / NoSQL #

NoSQL-базы не обязаны хранить данные в виде классических таблиц. Они часто выбирают модель хранения под конкретную задачу.

Основные виды NoSQL:

Тип NoSQLКак хранит данныеПримеры
Document DBДокументы, обычно JSON/BSONMongoDB, CouchDB
Key-valueКлюч → значениеRedis, DynamoDB
Wide-columnТаблицы с гибкими колонкамиCassandra, HBase
Graph DBУзлы и связиNeo4j, Amazon Neptune
Search engineДокументы + индекс поискаElasticsearch, OpenSearch

Пример документа в MongoDB:

{
  "_id": 1,
  "username": "alex",
  "email": "alex@example.com",
  "orders": [
    {
      "id": 101,
      "amount": 2500,
      "status": "paid"
    }
  ]
}

Здесь пользователь и его заказы могут лежать внутри одного документа. В реляционной БД это чаще были бы две таблицы: users и orders.

MongoDB в документации описывает проектирование схемы как процесс организации данных под нужды приложения и оптимизацию производительности. То есть NoSQL не означает «схемы вообще нет»; скорее схема часто более гибкая и проектируется иначе.

Главное отличие в мышлении #

В SQL-подходе ты обычно сначала проектируешь нормализованную структуру:

users
orders
products
order_items
payments

Данные разделены по таблицам, дублирование уменьшается, связи выражаются через ключи.

В NoSQL-подходе ты чаще проектируешь данные под конкретные запросы приложения:

Как я буду читать данные?
Какие документы мне нужны целиком?
Что выгоднее хранить вместе?
Какие операции должны быть максимально быстрыми?

Например, если приложение часто открывает профиль пользователя вместе с последними заказами, в document DB часть заказов можно встроить прямо в документ пользователя.

SQL vs NoSQL по пунктам #

КритерийSQL / RelationalNoSQL / Non-relational
ХранениеТаблицыДокументы, ключ-значение, графы, широкие колонки
СхемаОбычно строгаяОбычно гибкая
СвязиЧерез JOIN, FKЧасто через вложенность, дублирование или отдельную логику
ЗапросыSQLЗависит от конкретной БД
ТранзакцииОбычно сильная сторонаЗависит от конкретной БД
МасштабированиеЧасто вертикальное + репликация/шардированиеЧасто проще горизонтальное масштабирование
КонсистентностьОбычно приоритет на строгую целостностьЧасто компромисс между скоростью, доступностью и консистентностью
Типовые задачиБанкинг, заказы, платежи, учетЛоги, кэш, события, документы, большие распределенные нагрузки

IBM тоже выделяет среди ключевых различий SQL и NoSQL: реляционность/нереляционность, заранее заданную или динамическую схему, способ масштабирования, тип данных и пригодность для многострочных транзакций или неструктурированных данных.

Пример на одной задаче #

Допустим, есть интернет-магазин.

В PostgreSQL структура может быть такой:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username TEXT NOT NULL
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id),
    amount NUMERIC NOT NULL
);

Запрос:

SELECT u.username, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 1;

В MongoDB похожие данные можно хранить так:

{
  "_id": 1,
  "username": "alex",
  "orders": [
    { "id": 101, "amount": 2500 },
    { "id": 102, "amount": 1800 }
  ]
}

В SQL данные более нормализованы. В NoSQL данные могут быть собраны так, чтобы быстрее читать их целиком.

Когда лучше SQL #

SQL/реляционная БД обычно лучше, когда:

нужны строгие связи между сущностями;

важна целостность данных;

много сложных запросов с JOIN, агрегациями, фильтрами;

нужны транзакции;

данные хорошо укладываются в таблицы;

есть бизнес-логика типа заказов, платежей, балансов, пользователей, ролей.

Для backend-разработки PostgreSQL часто является базовым и самым универсальным выбором.

Примеры задач:

пользователи и роли
заказы и оплаты
банковские операции
складской учет
бронирования
CRM
админ-панели
финансовые системы

Когда лучше NoSQL #

NoSQL часто выбирают, когда:

данные плохо укладываются в строгие таблицы;

структура данных часто меняется;

нужно хранить большие объемы событий, логов, метрик;

нужен быстрый доступ по ключу;

нужна высокая горизонтальная масштабируемость;

данные удобно хранить документами.

Примеры задач:

кэширование — Redis
логи и события — Elasticsearch/OpenSearch
гибкие JSON-документы — MongoDB
большие распределенные key-value нагрузки — DynamoDB
граф связей — Neo4j

Важный момент: NoSQL не значит «лучше SQL» #

NoSQL — не замена SQL во всех случаях. Это другой набор инструментов.

Ошибка — выбирать MongoDB или Redis только потому, что «они быстрее». Быстрее в чём? В чтении по ключу — возможно. В сложных связях, транзакциях, аналитических выборках с JOIN — реляционная БД часто удобнее и надёжнее.

Также ошибка — думать, что NoSQL всегда не поддерживает транзакции. Некоторые NoSQL-БД поддерживают транзакции, но модель, ограничения и стоимость таких операций зависят от конкретной системы. Например, MongoDB заявляет поддержку multi-document ACID transactions, но это не делает её полностью такой же по подходу, как PostgreSQL.

Практическое правило выбора #

Для обычного backend-приложения по умолчанию чаще выбирают:

PostgreSQL как основная БД
Redis как кэш / хранилище сессий / rate limit / очереди
Elasticsearch или OpenSearch для сложного поиска
MongoDB — если данные реально документные и гибкая структура важнее строгих связей

То есть на практике часто используют не «SQL или NoSQL», а комбинацию:

PostgreSQL — основное надежное хранилище
Redis — быстрый кэш
Elasticsearch — поиск
S3/MinIO — файлы

Итог #

Реляционные БД — это таблицы, строгая структура, связи, SQL, транзакции и высокая целостность данных.

NoSQL — это нереляционные модели хранения: документы, ключ-значение, графы, широкие колонки. Они часто дают гибкость схемы, удобное горизонтальное масштабирование и хорошую производительность под конкретные сценарии.

Для большинства backend-систем PostgreSQL остаётся хорошим основным выбором. NoSQL обычно добавляют не вместо PostgreSQL, а рядом с ним, когда появляется конкретная задача: кэш, поиск, логи, документы, графы или сверхбыстрый доступ по ключу.


54. Как выполняется SQL-запрос в PostgreSQL под капотом? | Что происходит после отправки SQL-запроса в PostgreSQL? #

Общая схема выполнения запроса #

После отправки SQL-запроса PostgreSQL проходит примерно такой путь:

Клиент
PostgreSQL backend process
Parser
Rewriter
Planner / Optimizer
Executor
Storage / Buffer Cache / WAL / MVCC
Результат клиенту

Официальная документация PostgreSQL описывает этот путь так: сначала приложение устанавливает соединение и отправляет запрос серверу, затем запрос проходит парсер, систему переписывания, планировщик/оптимизатор и исполнитель.


1. Клиент отправляет запрос серверу #

Например, приложение отправляет:

SELECT id, username
FROM users
WHERE email = 'test@example.com';

Клиентом может быть:

psql
Django
FastAPI + SQLAlchemy
pgAdmin
DBeaver
любой backend-сервис через драйвер psycopg / asyncpg / JDBC / etc.

В PostgreSQL есть frontend/backend protocol. В простом режиме клиент отправляет серверу Query message, внутри которого находится SQL-строка. Сервер обрабатывает её и возвращает сообщения с результатом, ошибкой или статусом выполнения.

Для prepared statements и параметризованных запросов часто используется extended query protocol:

Parse → Bind → Execute

Parse создаёт prepared statement из SQL-строки, Bind подставляет параметры и создаёт portal, а Execute запускает выполнение.


2. Parser: PostgreSQL разбирает SQL #

На этапе парсинга PostgreSQL проверяет синтаксис и строит query tree — внутреннее дерево запроса.

Например, из этого:

SELECT id, username
FROM users
WHERE email = 'test@example.com';

PostgreSQL понимает:

операция: SELECT
источник данных: users
нужные колонки: id, username
условие фильтрации: email = 'test@example.com'

На этом этапе проверяется, что запрос синтаксически корректный. Если написать:

SELEC id FROM users;

PostgreSQL остановится уже на парсере и вернёт ошибку синтаксиса.

Но парсер — это не выполнение. Он ещё не читает строки из таблицы. Он только переводит SQL-текст во внутреннее представление.


3. Semantic analysis: проверка объектов, колонок, типов #

После разбора PostgreSQL должен понять, существуют ли таблицы, колонки, функции, операторы и совместимы ли типы данных.

Например:

SELECT unknown_column
FROM users;

Такой запрос не пройдёт, если unknown_column нет в таблице users.

Или:

SELECT *
FROM users
WHERE id = 'abc';

Если id — integer, PostgreSQL должен понять, можно ли привести строку 'abc' к нужному типу. Если нельзя — будет ошибка.


4. Rewriter: переписывание запроса #

После парсинга запрос попадает в rewrite system. Это слой между parser и planner/optimizer. PostgreSQL может переписать query tree перед планированием. ( PostgreSQL)

Классический пример — VIEW.

Допустим, есть view:

CREATE VIEW active_users AS
SELECT id, username
FROM users
WHERE is_active = true;

Потом ты пишешь:

SELECT *
FROM active_users
WHERE username = 'alex';

PostgreSQL не хранит active_users как обычную таблицу. View фактически раскрывается в запрос к базовой таблице:

SELECT id, username
FROM users
WHERE is_active = true
  AND username = 'alex';

Документация PostgreSQL прямо указывает, что одним из применений rewrite system является реализация views: запрос к view переписывается в запрос к базовым таблицам из определения view.


5. Planner / Optimizer: выбор плана выполнения #

Дальше PostgreSQL должен решить, как именно выполнить запрос.

Один и тот же SQL можно выполнить разными способами. Например:

SELECT *
FROM users
WHERE email = 'test@example.com';

Варианты:

1. Прочитать всю таблицу users последовательно — Seq Scan.
2. Использовать индекс по email — Index Scan.
3. Использовать Bitmap Index Scan + Bitmap Heap Scan.

Задача planner/optimizer — выбрать план, который ожидаемо будет самым быстрым. PostgreSQL генерирует возможные пути выполнения, оценивает их стоимость и выбирает самый дешёвый путь, из которого строит полноценное plan tree для executor.

Пример:

EXPLAIN
SELECT *
FROM users
WHERE email = 'test@example.com';

Возможный план:

Index Scan using idx_users_email on users
  Index Cond: (email = 'test@example.com')

А если таблица маленькая или условие возвращает большую часть строк, PostgreSQL может выбрать:

Seq Scan on users
  Filter: (email = 'test@example.com')

Это нормально. Наличие индекса не гарантирует, что PostgreSQL его использует. Планировщик выбирает вариант по оценочной стоимости.

Документация PostgreSQL подчёркивает, что выбор правильного плана критичен для производительности, а EXPLAIN показывает план, который создаёт planner.


6. Откуда planner знает, какой план выгоднее #

Planner использует статистику по таблицам и индексам:

сколько строк в таблице;
насколько колонка уникальна;
какие значения встречаются часто;
сколько строк примерно пройдёт фильтр;
есть ли подходящие индексы;
сколько будет стоить чтение страниц;
какой join-алгоритм выгоднее.

Поэтому после больших изменений данных важен ANALYZE, а в обычной работе — autovacuum/autanalyze.

Например:

ANALYZE users;

Если статистика устарела, PostgreSQL может ошибиться: например, выбрать Seq Scan, когда выгоднее был бы индекс, или выбрать плохой порядок JOIN.


7. Выбор алгоритмов JOIN #

Для запроса:

SELECT u.username, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 10;

PostgreSQL может выбрать разные алгоритмы соединения:

Nested Loop Join
Hash Join
Merge Join

Условно:

Nested Loop — часто хорош, когда с одной стороны мало строк и есть индекс.
Hash Join — часто хорош для больших неотсортированных наборов.
Merge Join — часто хорош, когда данные уже отсортированы или их выгодно отсортировать.

Planner также выбирает порядок соединения таблиц. Для большого количества JOIN перебор всех вариантов может стать слишком дорогим, поэтому PostgreSQL может использовать Genetic Query Optimizer, когда число joins превышает заданный порог.


8. Executor: реальное выполнение плана #

После выбора плана он передаётся executor.

Executor не «выполняет SQL-текст». Он выполняет дерево плана.

Например план может быть таким:

Hash Join
  -> Seq Scan on orders
  -> Hash
       -> Seq Scan on users

Или таким:

Nested Loop
  -> Index Scan on users
  -> Index Scan on orders

Executor рекурсивно обходит plan tree и извлекает строки. PostgreSQL описывает это как demand-pull pipeline: каждый узел плана при вызове должен вернуть следующую строку или сообщить, что строк больше нет.

То есть верхний узел просит строку у дочернего узла, тот — у своего дочернего, и так далее до узлов чтения таблицы или индекса.

Например:

Limit
  ↓ просит строку
Sort
  ↓ просит строки
Seq Scan
  ↓ читает таблицу

9. Scan nodes: чтение таблицы или индекса #

Внизу плана обычно находятся scan nodes. Это узлы, которые реально получают строки из источников данных.

Основные варианты:

Seq Scan — последовательное чтение таблицы.
Index Scan — поиск через индекс + чтение строк из heap.
Index Only Scan — чтение только из индекса, если хватает данных и видимость подтверждена.
Bitmap Index Scan — сначала строится bitmap подходящих строк, потом читается heap.

Документация PostgreSQL указывает, что нижние узлы плана — это scan nodes, которые возвращают сырые строки из таблицы; среди них бывают sequential scans, index scans и bitmap index scans.


10. Buffer cache: PostgreSQL читает страницы данных #

PostgreSQL не читает таблицу как «список объектов». Данные физически лежат в страницах, обычно по 8 KB.

Когда executor просит строку, PostgreSQL проверяет:

страница уже есть в shared_buffers?
    да → читаем из памяти
    нет → читаем страницу с диска/из OS cache в shared_buffers

shared_buffers — это часть shared memory PostgreSQL, которая кэширует части файлов данных, организованных в страницы. Когда страница изменена, она считается dirty page, пока не будет записана обратно в файловую систему.

То есть даже при Seq Scan PostgreSQL фактически читает не «строки», а страницы таблицы, а потом уже извлекает из них версии строк.


11. MVCC: проверка видимости строк #

PostgreSQL использует MVCC — multiversion concurrency control. Поэтому строка в таблице — это не просто «текущее значение». Внутри могут существовать разные версии строки.

Например, одна транзакция обновила пользователя:

UPDATE users
SET username = 'new_name'
WHERE id = 1;

PostgreSQL не обязательно физически перезаписывает старую строку на месте. Для UPDATE создаётся новая версия строки, а старая помечается как удалённая для будущих транзакций. В описании executor PostgreSQL указывает, что для UPDATE создаётся новая обновлённая строка, а старая помечается удалённой.

Когда другой запрос читает таблицу, PostgreSQL проверяет: видна ли конкретная версия строки текущей транзакции.

В уровне изоляции Read Committed, который используется по умолчанию, обычный SELECT видит только данные, зафиксированные до начала этого запроса, и не видит незакоммиченные данные или изменения, которые были закоммичены параллельными транзакциями уже во время выполнения этого же запроса.


12. Индекс не всегда избавляет от чтения таблицы #

Допустим есть индекс:

CREATE INDEX idx_users_email ON users(email);

Запрос:

SELECT *
FROM users
WHERE email = 'test@example.com';

При Index Scan PostgreSQL сначала ищет подходящую запись в индексе, но потом обычно идёт в heap-таблицу за самой строкой и проверкой видимости.

Почему? Потому что информация о видимости строки хранится не в обычной индексной записи, а в heap. Документация PostgreSQL отдельно указывает, что visibility information не хранится в index entries, а index-only scan может избежать обращения к heap только если visibility map показывает, что строки на странице видимы всем текущим и будущим транзакциям.

То есть:

Index Scan:
индекс → heap → проверка видимости → результат

Index Only Scan:
индекс → visibility map → результат

Но Index Only Scan возможен не всегда.


13. WHERE, сортировка, агрегация, LIMIT #

Во время выполнения executor применяет:

WHERE-фильтры;
вычисления выражений;
JOIN;
GROUP BY;
HAVING;
ORDER BY;
LIMIT/OFFSET;
оконные функции;
агрегаты.

Важно: логический порядок SQL и физический порядок выполнения не всегда совпадают.

Логически можно объяснять так:

FROM
JOIN
WHERE
GROUP BY
HAVING
SELECT
ORDER BY
LIMIT

Но физически PostgreSQL выполняет тот план, который выбрал planner. Например, он может сначала использовать индекс по условию WHERE, потом сделать join, потом агрегировать, потом отсортировать.


14. Для SELECT результат отправляется клиенту #

Для SELECT сервер обычно отправляет клиенту:

RowDescription — описание колонок результата;
DataRow — строки результата;
CommandComplete — команда завершена;
ReadyForQuery — сервер готов принять следующий запрос.

Это описано в PostgreSQL frontend/backend protocol: для SELECT, FETCH, SHOW, EXPLAIN обычно возвращаются RowDescription, затем ноль или больше DataRow, затем CommandComplete; после завершения всей строки запросов сервер отправляет ReadyForQuery.


15. Для INSERT / UPDATE / DELETE дополнительно работают WAL и блокировки #

Для изменяющих запросов путь похожий:

UPDATE users
SET username = 'alex'
WHERE id = 10;

PostgreSQL также парсит, переписывает, планирует и выполняет запрос. Но во время выполнения появляются дополнительные действия:

поиск строк;
проверка MVCC-видимости;
блокировка изменяемых строк;
создание новой версии строки при UPDATE;
запись WAL;
обновление индексов при необходимости;
фиксация или откат транзакции.

WAL — Write-Ahead Logging. Его центральный принцип: изменения в файлах данных, где лежат таблицы и индексы, должны записываться только после того, как соответствующие WAL-записи были записаны на постоянное хранилище. Это нужно для восстановления после сбоя.

Упрощённо:

Сначала WAL
Потом изменения страниц данных

Именно поэтому после падения сервера PostgreSQL может восстановить согласованное состояние.


16. Что происходит при COMMIT #

Когда транзакция фиксируется:

COMMIT;

PostgreSQL должен сделать изменения транзакции долговечными согласно настройкам durability и synchronous_commit.

Упрощённо:

изменения уже могли быть в shared_buffers;
WAL-записи должны быть записаны/сброшены на диск;
после commit изменения становятся видимыми другим транзакциям по правилам изоляции.

WAL позволяет не записывать сразу все изменённые страницы таблиц на диск при каждом commit. Достаточно надёжно записать WAL, а сами dirty pages могут быть сброшены позже.


17. Что показывает EXPLAIN / EXPLAIN ANALYZE #

EXPLAIN показывает план, который PostgreSQL собирается использовать:

EXPLAIN
SELECT *
FROM users
WHERE email = 'test@example.com';

EXPLAIN ANALYZE реально выполняет запрос и показывает фактические метрики:

EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'test@example.com';

Документация PostgreSQL говорит, что EXPLAIN показывает execution plan, который planner создаёт для запроса. В плане есть дерево узлов, scan nodes, join/aggregate/sort nodes и оценки стоимости.

Пример:

Index Scan using idx_users_email on users
  Index Cond: (email = 'test@example.com')

Это значит, что PostgreSQL решил искать строки через индекс.

А вот:

Seq Scan on users
  Filter: (email = 'test@example.com')

Это значит, что PostgreSQL решил читать таблицу последовательно и фильтровать строки.


Полная цепочка на примере #

Запрос:

SELECT u.id, u.username, COUNT(o.id) AS orders_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.is_active = true
GROUP BY u.id, u.username
ORDER BY orders_count DESC
LIMIT 10;

Под капотом:

1. Клиент отправляет SQL в PostgreSQL.
2. PostgreSQL проверяет синтаксис.
3. Строится query tree.
4. Проверяется, что users, orders, id, username, is_active существуют.
5. Rewriter раскрывает views/rules, если они есть.
6. Planner оценивает варианты:
   - как читать users;
   - как читать orders;
   - каким JOIN соединять;
   - как группировать;
   - как сортировать;
   - когда применить LIMIT.
7. Выбирается самый дешёвый план.
8. Executor начинает выполнять дерево плана.
9. Scan nodes читают страницы таблиц/индексов через shared_buffers.
10. Для строк проверяется MVCC-видимость.
11. Выполняется JOIN.
12. Выполняется GROUP BY и COUNT.
13. Выполняется ORDER BY.
14. Применяется LIMIT 10.
15. Результат отправляется клиенту.

Главное #

SQL-запрос в PostgreSQL не выполняется «сразу как написан». Он проходит несколько внутренних стадий:

SQL text
→ parse tree
→ rewritten query tree
→ execution plan
→ executor
→ чтение таблиц/индексов
→ MVCC-проверка
→ результат клиенту

Самые важные компоненты:

Parser — проверяет SQL и строит дерево запроса.
Rewriter — переписывает запрос, например раскрывает VIEW.
Planner/Optimizer — выбирает самый выгодный план.
Executor — реально выполняет план.
Storage/Buffer Manager — читает и пишет страницы данных.
MVCC — решает, какие версии строк видны текущей транзакции.
WAL — обеспечивает восстановление и надёжность изменений.

Для backend-разработчика главное практическое следствие такое: производительность запроса зависит не только от SQL-текста, но и от выбранного плана, статистики, индексов, объёма данных, актуальности ANALYZE, MVCC-состояния таблиц и того, сколько данных реально пришлось прочитать из памяти или диска.


55. Как работает, для чего используется и что происходит при SELECT FOR UPDATE в PostgreSQL? #

SELECT FOR UPDATE используется для блокировки строк в таблице, участвующих в запросе до окончания транзакции, чтобы предотвратить их изменение другими транзакциями

BEGIN;  
  
-- Блокировка строки с id = 1  
SELECT * FROM accounts  
WHERE id = 1  
FOR UPDATE;  
  
-- Делаем обновление  
UPDATE accounts  
SET balance = balance - 100  
WHERE id = 1;  
  
COMMIT;  
  • В данном случае, строка с id = 1 заблокирована для других транзакций, пока текущая не завершится
  • Если другая транзакция попытается заблокировать ту же строку, она будет ожидать завершения текущей транзакции


56. Что такое и для чего нужен buffer cache (Буферный Кэш) в базе данных? #

Что такое buffer cache в БД #

Buffer cache — это область оперативной памяти, где база данных держит недавно использованные страницы таблиц и индексов.

База данных обычно работает не с отдельными строками напрямую, а со страницами/блоками данных. В PostgreSQL типичный размер блока — 8 KB, если не была изменена сборочная настройка BLCKSZ; параметр shared_buffers, если указан без единиц, трактуется как количество таких блоков.

Упрощённо:

Запросу нужны данные
PostgreSQL проверяет shared buffer cache
если страница уже в памяти → читает из RAM
если страницы нет → читает с диска/из OS cache и кладёт в buffer cache

То есть buffer cache нужен, чтобы не ходить каждый раз на диск за одними и теми же данными.


Что именно там хранится #

В buffer cache хранятся не “результаты SQL-запросов”, а страницы данных:

страницы таблиц
страницы индексов
изменённые страницы, которые ещё не записаны на диск
служебная информация о буферах

Например, запрос:

SELECT *
FROM users
WHERE id = 100;

может использовать индекс users_pkey.

Тогда PostgreSQL может загрузить в buffer cache:

страницу индекса, где находится ключ id = 100
страницу таблицы, где лежит нужная строка

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


Зачем он нужен #

Главная причина — разница в скорости между RAM и диском. Читать из памяти значительно быстрее, чем каждый раз обращаться к диску.

Buffer cache нужен для:

1. Ускорения повторных чтений.
2. Уменьшения количества физических операций чтения с диска.
3. Кэширования страниц таблиц и индексов.
4. Снижения нагрузки на storage.
5. Более быстрой работы JOIN, WHERE, ORDER BY, индексных сканов.
6. Буферизации изменённых страниц перед записью на диск.

Если нужная страница уже есть в кэше, это называется cache hit. PostgreSQL в EXPLAIN для BUFFERS описывает hit как ситуацию, когда чтения удалось избежать, потому что блок уже был найден в кэше.


Пример без buffer cache #

Допустим, таблица users большая, и приложение часто ищет пользователей по id.

SELECT *
FROM users
WHERE id = 100;

Без кэша каждый такой запрос постоянно обращался бы к диску:

запрос 1 → чтение страницы с диска
запрос 2 → снова чтение страницы с диска
запрос 3 → снова чтение страницы с диска

С buffer cache:

запрос 1 → страницы нет в кэше → прочитать с диска → положить в cache
запрос 2 → страница уже в cache → прочитать из памяти
запрос 3 → страница уже в cache → прочитать из памяти

Как это выглядит в EXPLAIN #

Можно посмотреть использование буферов так:

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM users
WHERE id = 100;

Пример вывода:

Buffers: shared hit=3 read=1

Смысл:

shared hit=3 — 3 блока уже были в shared buffer cache
shared read=1 — 1 блок пришлось прочитать с диска или из кэша ОС

PostgreSQL указывает, что BUFFERS показывает количество shared blocks hit/read/dirtied/written; shared blocks относятся к обычным таблицам и индексам.


Shared buffers в PostgreSQL #

В PostgreSQL основной buffer cache называется shared buffers. Его размер задаётся параметром:

shared_buffers = 4GB

shared_buffers определяет, сколько памяти PostgreSQL использует под shared memory buffers. Этот параметр применяется при старте сервера, то есть для изменения обычно нужен рестарт PostgreSQL.

Название “shared” означает, что эта память общая для backend-процессов PostgreSQL:

один процесс загрузил страницу в shared_buffers
другой процесс может использовать эту же страницу

Это не отдельный кэш на каждое подключение.


Buffer cache и кэш операционной системы #

Важно: у PostgreSQL есть свой shared_buffers, но у операционной системы тоже есть файловый кэш.

Когда PostgreSQL читает страницу, она может оказаться:

1. уже в shared_buffers PostgreSQL
2. не в shared_buffers, но в page cache операционной системы
3. вообще не в памяти, тогда нужно физическое чтение с диска

Поэтому shared read в EXPLAIN не всегда означает реальное физическое чтение с диска. Это означает, что блока не было в shared buffers PostgreSQL, и PostgreSQL запросил его у ОС. ОС могла отдать его из собственного кэша.


Что происходит при UPDATE/INSERT/DELETE #

Buffer cache нужен не только для чтения.

Например:

UPDATE users
SET email = 'new@mail.com'
WHERE id = 100;

PostgreSQL загружает нужную страницу в shared buffers, изменяет её в памяти и помечает как dirty page — грязную страницу.

страница была прочитана
страница изменена в памяти
страница помечена как dirty
позже она будет записана на диск

В EXPLAIN (ANALYZE, BUFFERS) это может отражаться как:

Buffers: shared hit=5 dirtied=1

dirtied означает, что запрос изменил ранее не изменённый блок, а written означает, что backend вытеснил из кэша уже грязный блок и записал его.


Что происходит, когда кэш заполнен #

Buffer cache ограничен размером shared_buffers.

Если кэш заполнен, PostgreSQL должен освободить место под новые страницы. Для этого он вытесняет менее полезные страницы из кэша.

Упрощённо:

нужна новая страница
свободного буфера нет
PostgreSQL выбирает старую/менее используемую страницу
если она dirty — сначала записывает на диск
потом загружает новую страницу

Это нормальное поведение. Проблема появляется, когда рабочий набор данных сильно больше доступной памяти, и база постоянно вытесняет одни страницы ради других. Тогда растёт I/O, а запросы замедляются.


Как посмотреть содержимое buffer cache #

В PostgreSQL есть расширение pg_buffercache.

CREATE EXTENSION pg_buffercache;

Оно позволяет смотреть, что происходит в shared buffer cache в реальном времени. Официальная документация описывает pg_buffercache как модуль для исследования состояния shared buffer cache.

Например, можно посмотреть, какие таблицы занимают место в shared buffers:

SELECT
    c.relname,
    count(*) AS buffers
FROM pg_buffercache b
JOIN pg_class c
    ON b.relfilenode = pg_relation_filenode(c.oid)
GROUP BY c.relname
ORDER BY buffers DESC;

На практике это используют для диагностики: какие таблицы и индексы реально сидят в памяти.


Чем buffer cache не является #

Buffer cache — это не кэш результатов запросов.

То есть PostgreSQL обычно не хранит результат:

SELECT count(*) FROM orders;

как готовое значение “count = 1000000”.

Он кэширует страницы, которые понадобились для выполнения запроса:

страницы таблицы orders
страницы индекса orders_created_at_idx

Если повторить запрос, он может выполниться быстрее не потому, что PostgreSQL запомнил результат, а потому что нужные страницы уже лежат в памяти.


Почему после первого запуска запрос быстрее #

Частая ситуация:

первый запуск запроса — 2 секунды
второй запуск — 100 мс

Причина может быть в прогретом кэше:

первый запуск загрузил страницы с диска/из ОС
второй запуск использовал страницы из памяти

Поэтому при тестировании производительности важно учитывать состояние кэша. Иначе можно сравнить не два плана запроса, а “холодный кэш” против “тёплого кэша”.


Коротко #

Buffer cache в базе данных — это память для кэширования страниц таблиц и индексов.

В PostgreSQL основной такой кэш — shared_buffers.

Главное:

Buffer cache хранит страницы данных и индексов.
Он уменьшает количество обращений к диску.
Cache hit означает, что блок уже был в памяти.
Cache read означает, что PostgreSQL пришлось запросить блок у ОС.
Dirty page — изменённая страница, которую позже нужно записать на диск.
shared_buffers задаёт размер кэша PostgreSQL.
Buffer cache не хранит готовые результаты SQL-запросов.

Практический смысл: чем чаще нужные таблицы и индексы находятся в buffer cache, тем меньше I/O и тем быстрее работают запросы.


57. Что такое и зачем нужен ForeignKey (Внешний ключ) #

Внешний ключ - это столбец-ссылка на первичный ключ (или уникальную колонку) другой таблицы. Может быть null.


58. Какие задачи решают с PostgreSQL в backend-разработке? #

Какие задачи PostgreSQL обычно решает в backend-разработке #

PostgreSQL в backend-проекте чаще всего используется не просто как “место, куда складывают данные”. Он решает несколько классов задач: хранение бизнес-данных, обеспечение целостности, конкурентный доступ, быстрый поиск, аналитические выборки, фоновые процессы и иногда часть бизнес-логики.

1. Хранение основных данных приложения #

Самая базовая задача — хранить сущности приложения:

пользователи
заказы
платежи
уведомления
файлы
товары
комментарии
права доступа
история действий

Например:

CREATE TABLE users (
    id bigserial PRIMARY KEY,
    email text NOT NULL UNIQUE,
    created_at timestamptz NOT NULL DEFAULT now()
);

PostgreSQL — объектно-реляционная СУБД, поэтому хорошо подходит для классической модели “таблицы + связи + SQL-запросы”. Официальный сайт PostgreSQL описывает его как open source object-relational database system с упором на надёжность, функциональность и производительность.

2. Обеспечение целостности данных #

Backend не должен держать всю целостность только в коде. Часть правил лучше фиксировать на уровне БД:

ALTER TABLE users
ADD CONSTRAINT users_email_unique UNIQUE (email);
ALTER TABLE orders
ADD CONSTRAINT orders_user_id_fk
FOREIGN KEY (user_id) REFERENCES users(id);

Это защищает данные даже если ошибка появилась в коде, в миграции, в админке или во внутреннем скрипте.

PostgreSQL поддерживает PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK, NOT NULL. Foreign key нужен для ссылочной целостности: значение в дочерней таблице должно соответствовать строке в родительской таблице.

Практический пример:

нельзя создать заказ на несуществующего пользователя
нельзя создать двух пользователей с одним email
нельзя записать отрицательную цену
нельзя оставить обязательное поле пустым

3. Транзакции #

PostgreSQL решает задачу атомарного изменения данных.

Например, создание заказа:

BEGIN;

INSERT INTO orders (user_id, status)
VALUES (10, 'created');

INSERT INTO order_items (order_id, product_id, quantity)
VALUES (100, 5, 2);

COMMIT;

Смысл: либо все изменения применяются, либо ни одно.

В backend это нужно для операций вроде:

создание заказа
списание баланса
резерв товара
перевод денег между счетами
создание пользователя и профиля
обновление нескольких связанных таблиц

Без транзакций легко получить частично записанное состояние: заказ создан, но товары к нему не добавились.

4. Конкурентный доступ #

PostgreSQL помогает безопасно работать нескольким запросам одновременно.

Например, два пользователя одновременно пытаются списать деньги с одного баланса. Для этого можно использовать транзакцию и строковую блокировку:

BEGIN;

SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

Это не даёт двум операциям одновременно некорректно изменить одну и ту же строку.

В backend это важно для:

балансов
лимитов
остатков товара
бронирований
очередей задач
промокодов
счётчиков

5. Быстрый поиск через индексы #

PostgreSQL решает задачу быстрого поиска данных.

Например:

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);

После этого запросы вида:

SELECT *
FROM orders
WHERE user_id = 10
ORDER BY created_at DESC;

могут выполняться быстрее, потому что БД не обязана каждый раз читать всю таблицу.

Индексы используют для:

поиска по id/email/username
фильтрации заказов по пользователю
сортировки по дате
JOIN между таблицами
поиска по статусу
диапазонов дат

Отдельно для массивов, JSONB и полнотекстового поиска PostgreSQL может использовать GIN-индексы. В документации PostgreSQL GIN описывается как индекс, который хранит пары “ключ — список строк”, где этот ключ встречается.

6. Связи между сущностями #

Backend почти всегда работает со связанными данными:

пользователь → заказы
заказ → позиции заказа
товар → категория
пост → комментарии
роль → права

В PostgreSQL это обычно выражается через foreign key:

CREATE TABLE orders (
    id bigserial PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users(id),
    status text NOT NULL
);

И потом запрашивается через JOIN:

SELECT
    users.email,
    orders.id,
    orders.status
FROM orders
JOIN users ON users.id = orders.user_id;

То есть PostgreSQL помогает не просто хранить отдельные документы, а строить связанную модель данных.

7. Фильтрация, сортировка, пагинация #

Типичная backend-задача — отдать список сущностей в API:

GET /orders?status=paid&limit=20&offset=40

SQL-запрос:

SELECT *
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

PostgreSQL здесь отвечает за:

фильтрацию
сортировку
лимиты
пагинацию
агрегации
поиск

Для больших таблиц часто переходят с OFFSET на keyset pagination:

SELECT *
FROM orders
WHERE created_at < :last_seen_created_at
ORDER BY created_at DESC
LIMIT 20;

8. Агрегации и отчёты #

PostgreSQL часто используют для расчётов:

SELECT
    status,
    count(*) AS total
FROM orders
GROUP BY status;

Или:

SELECT
    date_trunc('day', created_at) AS day,
    sum(amount) AS revenue
FROM payments
GROUP BY day
ORDER BY day;

В backend это нужно для:

статистики в админке
отчётов
дашбордов
счётчиков
финансовых сумм
аналитики по пользователям

Для тяжёлых отчётов можно использовать materialized views. PostgreSQL materialized view хранит результат запроса в table-like form, то есть результат физически сохраняется и может переиспользоваться.

9. Хранение JSON/JSONB #

PostgreSQL может хранить не только строго реляционные данные, но и полуструктурированные данные через json/jsonb.

Например:

CREATE TABLE events (
    id bigserial PRIMARY KEY,
    event_type text NOT NULL,
    payload jsonb NOT NULL
);

Это удобно для:

логов событий
webhook payload
гибких настроек
метаданных файла
внешних API-ответов
редко используемых атрибутов

PostgreSQL поддерживает JSON-типы, SQL/JSON-функции, JSONPath-запросы и генерацию JSON из реляционных данных.

Но JSONB не должен заменять нормальную схему везде. PostgreSQL указывает, что обновление JSON-данных в строке подчиняется обычным правилам конкурентного доступа: обновление берёт row-level lock на всю строку.

10. Полнотекстовый поиск #

PostgreSQL может решать задачи поиска по тексту:

поиск по статьям
поиск по товарам
поиск по комментариям
поиск по документам

Пример:

SELECT *
FROM articles
WHERE to_tsvector('russian', title || ' ' || body)
      @@ plainto_tsquery('russian', 'postgres индекс');

Для небольших и средних проектов встроенного full-text search часто достаточно без отдельного Elasticsearch/OpenSearch. В документации PostgreSQL есть отдельная глава про Full Text Search, включая таблицы и индексы для такого поиска.

11. Разделение доступа и безопасность данных #

PostgreSQL может помогать с безопасностью:

отдельные пользователи БД
GRANT/REVOKE
readonly-пользователь
ограничение прав приложения
row-level security

Например, приложение не должно подключаться под суперпользователем. Лучше отдельная роль с минимальными правами.

PostgreSQL также поддерживает Row-Level Security: политики могут ограничивать, какие строки пользователь может читать, вставлять, обновлять или удалять.

Пример задачи:

tenant A не должен видеть данные tenant B
обычный пользователь не должен читать чужие строки
админ видит больше данных, чем клиент

12. Очереди и фоновые задачи #

PostgreSQL иногда используют как простую очередь задач.

Например:

SELECT *
FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Так несколько воркеров могут параллельно брать задачи, не забирая одни и те же строки.

Это подходит для умеренной нагрузки. Но если очередь становится центральной частью системы с большим потоком событий, обычно лучше использовать RabbitMQ, Kafka, Redis Streams или другой специализированный брокер.

13. Outbox для событий #

PostgreSQL часто участвует в реализации transactional outbox.

В одной транзакции приложение записывает бизнес-данные и событие:

BEGIN;

INSERT INTO orders (id, status)
VALUES (100, 'created');

INSERT INTO outbox_events (event_type, payload)
VALUES ('OrderCreated', '{"order_id": 100}');

COMMIT;

Это решает проблему: данные в БД записались, но событие в брокер не отправилось. Потом отдельный publisher читает outbox_events и публикует события.

На backend-проектах это важно для микросервисов, интеграций, уведомлений, аудита и событийной архитектуры.

14. Аудит и история изменений #

В PostgreSQL часто хранят историю:

кто изменил запись
когда изменил
старое значение
новое значение
причина изменения
история статусов заказа

Пример:

CREATE TABLE order_status_history (
    id bigserial PRIMARY KEY,
    order_id bigint NOT NULL REFERENCES orders(id),
    old_status text,
    new_status text NOT NULL,
    changed_at timestamptz NOT NULL DEFAULT now()
);

Это нужно для расследования ошибок, финансовых операций, админки и юридически значимых действий.

15. Миграции схемы #

Backend-разработчик постоянно меняет структуру БД:

добавить таблицу
добавить колонку
создать индекс
изменить constraint
добавить enum/status
перенести данные

Это делается через миграции:

Django migrations
Alembic
ручные SQL-миграции
Liquibase/Flyway

PostgreSQL здесь является не только хранилищем, но и частью процесса разработки: схема данных эволюционирует вместе с кодом.

16. Масштабирование больших таблиц #

Когда таблицы становятся большими, PostgreSQL используют для задач вроде:

партиционирование по дате
архивация старых данных
read replicas
материализованные представления
частичные индексы
оптимизация запросов через EXPLAIN

Партиционирование — это разбиение логически одной большой таблицы на меньшие физические части. Документация PostgreSQL описывает partitioning именно как splitting one large table into smaller physical pieces.

Пример:

orders_2026_01
orders_2026_02
orders_2026_03

Снаружи приложение может работать с одной таблицей orders, а PostgreSQL физически хранит данные по частям.

Коротко #

В backend-разработке PostgreSQL обычно решает такие задачи:

хранение бизнес-данных
связи между сущностями
целостность данных через constraints
транзакции
конкурентное изменение данных
индексы и быстрый поиск
фильтрация, сортировка, пагинация
агрегации и отчёты
JSONB для гибких данных
полнотекстовый поиск
права доступа и безопасность
очереди умеренной нагрузки
outbox для событий
аудит и история изменений
миграции схемы
оптимизация больших таблиц

Практический смысл: PostgreSQL в backend — это не просто “таблица с данными”, а основной механизм сохранения состояния приложения, контроля целостности и выполнения сложных запросов.


59. Что такое и как работает CHECK constraint в SQL? #

Что такое CHECK constraint #

CHECK constraint — это ограничение на уровне таблицы, которое запрещает записывать в колонку или строку данные, не проходящие заданное условие.

Простой пример:

CREATE TABLE products (
    id bigserial PRIMARY KEY,
    name text NOT NULL,
    price numeric NOT NULL CHECK (price > 0)
);

Здесь правило такое:

price должен быть больше 0

Такой запрос пройдёт:

INSERT INTO products (name, price)
VALUES ('Keyboard', 100);

А такой не пройдёт:

INSERT INTO products (name, price)
VALUES ('Mouse', -50);

PostgreSQL описывает CHECK как ограничение, которое состоит из ключевого слова CHECK и выражения в скобках; выражение должно проверять допустимость значения.

Для чего нужен CHECK #

CHECK нужен, чтобы бизнес-правило было закреплено не только в backend-коде, но и в самой базе.

Например:

цена не может быть отрицательной
возраст должен быть больше 0
процент скидки должен быть от 0 до 100
дата окончания должна быть позже даты начала
статус должен входить в допустимый набор
количество товара не может быть меньше 0

Пример со скидкой:

CREATE TABLE discounts (
    id bigserial PRIMARY KEY,
    percent integer NOT NULL CHECK (percent BETWEEN 0 AND 100)
);

Теперь база сама не позволит записать:

percent = -10
percent = 150

Даже если в коде backend-разработчик случайно забыл валидацию.

Как CHECK работает при INSERT и UPDATE #

При вставке или обновлении строки БД вычисляет выражение внутри CHECK.

Например:

CHECK (price > 0)

При вставке:

INSERT INTO products (name, price)
VALUES ('Monitor', 300);

БД проверяет:

300 > 0 → true → строка разрешена

При вставке:

INSERT INTO products (name, price)
VALUES ('Monitor', -300);

БД проверяет:

-300 > 0 → false → ошибка constraint violation

MySQL также описывает, что CHECK проверяется при INSERT, UPDATE, REPLACE, LOAD DATA и других операциях; если выражение даёт FALSE, возникает ошибка.

CHECK на одну колонку #

Ограничение можно записать прямо возле колонки:

CREATE TABLE users (
    id bigserial PRIMARY KEY,
    age integer CHECK (age >= 18)
);

Это удобно, когда правило относится только к одной колонке.

Например:

CREATE TABLE accounts (
    id bigserial PRIMARY KEY,
    balance numeric CHECK (balance >= 0)
);

CHECK на несколько колонок #

CHECK может проверять не одну колонку, а связь между несколькими колонками.

Например:

CREATE TABLE bookings (
    id bigserial PRIMARY KEY,
    start_at timestamptz NOT NULL,
    end_at timestamptz NOT NULL,
    CHECK (end_at > start_at)
);

Здесь правило:

дата окончания должна быть позже даты начала

Это уже не проверка отдельного значения, а проверка всей строки.

Ещё пример:

CREATE TABLE transfers (
    id bigserial PRIMARY KEY,
    from_account_id bigint NOT NULL,
    to_account_id bigint NOT NULL,
    amount numeric NOT NULL CHECK (amount > 0),
    CHECK (from_account_id <> to_account_id)
);

Тут запрещается перевод самому себе.

Именованный CHECK constraint #

Лучше давать ограничениям имена:

CREATE TABLE products (
    id bigserial PRIMARY KEY,
    price numeric NOT NULL,
    CONSTRAINT products_price_positive CHECK (price > 0)
);

Плюс именованного ограничения — понятная ошибка и удобнее менять схему.

Например, ошибка будет ссылаться на:

products_price_positive

А не на автоматически сгенерированное имя.

Добавление CHECK в существующую таблицу #

Можно добавить ограничение после создания таблицы:

ALTER TABLE products
ADD CONSTRAINT products_price_positive
CHECK (price > 0);

Но если в таблице уже есть строки, которые нарушают условие, команда не пройдёт.

Например, если уже есть:

price = -100

то ALTER TABLE ... ADD CONSTRAINT ... CHECK (price > 0) завершится ошибкой.

В PostgreSQL для больших таблиц иногда используют NOT VALID, чтобы не проверять старые строки сразу:

ALTER TABLE products
ADD CONSTRAINT products_price_positive
CHECK (price > 0) NOT VALID;

А потом отдельно валидируют:

ALTER TABLE products
VALIDATE CONSTRAINT products_price_positive;

Это полезно на больших production-таблицах, чтобы аккуратнее добавлять ограничения.

Важный момент про NULL #

CHECK не заменяет NOT NULL.

Например:

CREATE TABLE products (
    id bigserial PRIMARY KEY,
    price numeric CHECK (price > 0)
);

Такое значение не пройдёт:

price = -10

Но NULL может пройти, потому что выражение:

NULL > 0

даёт не false, а unknown.

В SQL CHECK обычно нарушается именно когда условие возвращает false. Если результат true или unknown, строка может быть допустимой. В MySQL это тоже указано: constraint нарушается, если результат условия FALSE; не если TRUE или UNKNOWN.

Поэтому, если поле обязательно, нужно писать так:

CREATE TABLE products (
    id bigserial PRIMARY KEY,
    price numeric NOT NULL CHECK (price > 0)
);

То есть:

NOT NULL запрещает NULL
CHECK запрещает неправильное значение

CHECK и enum/status #

Иногда CHECK используют для ограничения списка значений:

CREATE TABLE orders (
    id bigserial PRIMARY KEY,
    status text NOT NULL CHECK (status IN ('new', 'paid', 'cancelled'))
);

Так база не позволит записать:

status = 'unknown'
status = 'abc'
status = ''

Но если статусы часто меняются или используются во многих таблицах, иногда лучше отдельная таблица-справочник или enum-тип.

CHECK не должен проверять другие строки #

CHECK хорошо подходит для проверки одной строки:

price > 0
end_at > start_at
discount BETWEEN 0 AND 100

Но не подходит для правил вида:

у пользователя не больше 5 активных заказов
сумма всех платежей не больше лимита
нет пересекающихся бронирований

Потому что это уже зависит от других строк. Для таких задач обычно используют:

UNIQUE constraint
EXCLUDE constraint
foreign key
триггеры
транзакции и блокировки
прикладную бизнес-логику

Где CHECK полезен в backend #

Типичные примеры:

CHECK (amount > 0)
CHECK (quantity >= 0)
CHECK (rating BETWEEN 1 AND 5)
CHECK (discount_percent BETWEEN 0 AND 100)
CHECK (end_at > start_at)
CHECK (status IN ('draft', 'published', 'archived'))

Практический смысл: backend может ошибиться, тесты могут не покрыть сценарий, админка может отправить некорректные данные, миграционный скрипт может быть написан неправильно. CHECK не даёт плохим данным попасть в таблицу.

Коротко #

CHECK constraint — это правило на уровне БД, которое проверяет строку перед вставкой или обновлением.

Главное:

CHECK проверяет условие.
Если условие false — INSERT/UPDATE запрещается.
CHECK хорошо подходит для бизнес-ограничений внутри одной строки.
CHECK не заменяет NOT NULL.
CHECK лучше именовать через CONSTRAINT name.
CHECK защищает данные даже при ошибке в backend-коде.

Простой пример:

CREATE TABLE products (
    id bigserial PRIMARY KEY,
    price numeric NOT NULL,
    CONSTRAINT products_price_positive CHECK (price > 0)
);

Это означает: в таблице products не может быть товара с price <= 0.


60. Что такое и как работает FOREIGN KEY REFERENCES? #

Foreign Key (внешний ключ) - это колонка (или набор колонок) таблице, ссылающаяся на первичный ключ (или любую UNIQUE колонку) другой таблицы. Обеспечивает ссылочную целостность.

CREATE TABLE users (  
id INT PRIMARY KEY  
);  
  
CREATE TABLE orders (  
order_id INT PRIMARY KEY,  
user_id INT,  
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE  
);  

Foreign Key может быть составным?
Да, Foreign Key может быть составным (Composite Foreign Key)

ХарактеристикаPrimary Key (PK)Foreign Key (FK)
НазначениеГарантирует уникальность строкОбеспечивает ссылочную целостность
УникальностьВсегда уникаленМожет повторяться
NULL?НельзяМожно
КоличествоОдин на таблицу (может быть составным)Может быть несколько
УдалениеНельзя менять без CASCADEON DELETE CASCADE - удалит зависимые записи