Тема 06

Базы данных

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

Что реально проверяют этой секцией

Умеешь ли ты рассуждать о БД как о системе с состоянием, конкурентностью и физикой. Кандидат, который говорит «поставим индекс, станет быстрее», и кандидат, который говорит «сначала посмотрю план, там 50 тысяч Rows Removed by Filter при 980 итоговых строках, значит нужен частичный индекс по этому предикату, но он замедлит вставку на 15 %» — это два разных грейда. Ровно так же с транзакциями: расшифровать ACID умеют все, а объяснить, почему на Repeatable Read вылетает 40001 и что с этим делать в коде — единицы.

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

На этой главе стоит вся секция. Синтаксис здесь вторичен, проверяют понимание модели: что такое отношение, что NULL делает с данными, в каком порядке сервер на самом деле выполняет запрос и какими физическими алгоритмами соединяет таблицы.

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

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

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

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

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

PostgreSQL против MySQL

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

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

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

DDL, DML, DCL, TCL

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

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

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

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

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

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

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

Ключи: PRIMARY KEY и FOREIGN KEY

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вопросы

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

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

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

Как выбирать

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

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

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

Чем добить

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

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

Key-value

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

Документные

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

Колоночные

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

Графовые

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

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

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

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

Философия

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

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

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

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

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

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

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

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

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

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

Механика TRUNCATE

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Агрегаты

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

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

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

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

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

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

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

Инструменты

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

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

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

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

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

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

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

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

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

Nested Loop

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

Hash Join

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

Merge Join

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

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

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

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

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

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

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

Что следует

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Зачем

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

VIEW

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

MATERIALIZED VIEW

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

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

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

Как выбирать

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

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

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

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

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

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

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

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

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

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

Красный флаг

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

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

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

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

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

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

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

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

6.2Транзакции, ACID, уровни изоляции

Самая частая тема на собесах и самая «разделяющая»: расшифровать ACID умеют все, а объяснить, что сломается без каждой буквы, нарисовать временную диаграмму write skew и рассказать, почему приложение обязано ретраить транзакции, могут единицы. Копаем до дна.

Сначала — в чём вообще проблема

Проблема одна: что будет, если две операции полезут в одни и те же данные одновременно? Пока пользователь в системе один, запросы идут по очереди, каждый видит результат предыдущего, и ни транзакции, ни уровни изоляции не нужны. Всё, что ниже, существует только потому, что параллельно работают сотни соединений. Договоримся о словах, иначе временные диаграммы дальше будут читаться как шифр.

1. Транзакция — пачка запросов, которую база обязана выполнить как один запрос

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

2. Две транзакции сразу — отсюда растут все беды этой главы

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

Аномалией считается результат, который при выполнении по одной получиться не мог. Проверка простая: возьми две транзакции и мысленно прогони их строго по очереди — сначала T1, потом T2, и наоборот. Если фактический результат не совпал ни с одним из двух вариантов, это аномалия; списать её на «повезло с порядком» не выйдет.

3. Снапшот — фотография базы на один момент времени

Снапшот (snapshot, «снимок») фиксирует состояние данных на конкретный момент. Транзакция, работающая по снапшоту, видит базу такой, какой та была в момент снимка, а более поздних чужих коммитов для неё просто нет. Отсюда главное свойство PostgreSQL: читающие не блокируют пишущих, а пишущие не блокируют читающих — читателю нечего ждать, он смотрит на свою фотографию. Как это устроено физически, разберём в следующей главе, про MVCC.

4. Сериализация здесь — не про превращение объекта в байты

Слово перегружено, и на собесах это регулярно путают. В транзакциях сериализуемость (serializability) означает одно: результат параллельного выполнения совпадает с каким-нибудь выполнением тех же транзакций по очереди, одна за другой, как будто их выстроили в последовательность (в «серию»). К сериализации объектов в JSON или protobuf это отношения не имеет, просто термины совпали.

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

5. Фантом и write skew — два слова, которые понадобятся через абзац

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

При аномалии записи (write skew, дословно «перекос записи») обе транзакции проверили одно и то же общее условие, обе решили, что действовать можно, и обе записали, но в разные строки. Никто ничью запись не перетёр, конфликта записи нет, обе успешно закоммитились — а вместе они нарушили правило, которое каждая проверила по отдельности. Ниже разберём её на диаграмме.

Транзакция — это не «BEGIN/COMMIT»

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

ACID на сквозном примере: перевод 100 рублей

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;   -- списали у Ани
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;   -- зачислили Боре
  INSERT INTO transfers(from_id, to_id, amount) VALUES (1, 2, 100);
COMMIT;
Перевод 100 рублей: что даёт каждая буква ACID A — Atomicity оба UPDATE и INSERT применяются либо все, либо ни один без неё: списали и упали C — Consistency инварианты соблюдены: CHECK balance >= 0, FK, сумма денег в системе без неё: баланс ушёл в минус I — Isolation параллельный перевод не увидит промежуточное состояние «списали, не зачли» без неё: отчёт на 100 руб. меньше D — Durability после ответа «OK» перевод переживёт выключение питания (fsync WAL) без неё: клиенту сказали да, денег нет Что физически обеспечивает каждую букву в PostgreSQL A — WAL + правило «изменения видны только после записи о COMMIT»; откат = никто не видит новые версии строк. C — это НЕ работа СУБД в чистом виде: половину даёт движок (констрейнты, FK, типы), половину — прикладной код. I — MVCC: снапшот + xmin/xmax дают чтению «своё» состояние базы; для записи — блокировки строк и SSI. D — fsync журнала до ответа клиенту; параметры synchronous_commit, wal_sync_method, синхронная реплика.
ACID не четыре равноправные буквы. За A, I и D стоят реальные механизмы движка (журнал, снапшоты, fsync). C движок только помогает поддерживать: если в коде списали, но не зачислили, СУБД честно и атомарно сохранит несогласованное состояние.
Ответ, который отличает мидла от джуна

«Спорнее всего в ACID буква C. Atomicity, Isolation и Durability обеспечивают конкретные механизмы СУБД. А Consistency описывает прикладной инвариант: “сумма денег в системе не меняется при переводе”. СУБД проверит только то, что ей объявили: типы, NOT NULL, CHECK, FK. Если разработчик написал только первый UPDATE, транзакция будет атомарной, изолированной и долговечной — и при этом оставит базу в бизнес-несогласованном состоянии.» Дальше можно добавить, что в теореме CAP буква C означает совсем другое (линеаризуемость), и путать их не надо.

Аномалии конкурентного доступа

Напомню критерий: аномалию нельзя получить ни при каком последовательном выполнении тех же транзакций. Ниже пять классических аномалий, каждая на временной диаграмме двух транзакций. Читай диаграммы слева направо: по горизонтали время, две строки отданы параллельным транзакциям T1 и T2, прямоугольник обозначает операцию, которую та или другая выполняет в этот момент.

1. Грязное чтение (dirty read): прочитали то, чего в итоге не было T1 UPDATE accounts bal = bal - 100 ROLLBACK изменения отменены T2 SELECT bal видит списание решение принято по данным, которых нет Запрещено на Read Committed и выше. В PostgreSQL невозможно ни на одном уровне: незакоммиченные версии не видны никому. 2. Неповторяющееся чтение (non-repeatable read): один и тот же SELECT дал разные значения T1 SELECT bal → 500 начало отчёта SELECT bal → 400 то же условие, другой ответ T2 UPDATE bal = 400 COMMIT Запрещено на Repeatable Read и выше. Ломает любой отчёт из нескольких запросов: итог не сходится с деталями. время →
Чтение чужих изменений. Аномалии 1 и 2 различаются тем, закоммичена ли чужая транзакция. При грязном чтении прочитали незакоммиченное, при неповторяющемся прочитали дважды, и между чтениями кто-то успел закоммитить.
3. Фантомное чтение (phantom read): повторный запрос вернул НОВЫЕ строки T1 SELECT count(*) WHERE dept = 5 → 10 тот же SELECT → 11 появилась строка-фантом T2 INSERT ... dept = 5 COMMIT Отличие от неповторяющегося чтения: там изменилась прочитанная строка, здесь изменился НАБОР строк под предикатом. В PostgreSQL на Repeatable Read фантомов нет: снапшот транзакции не увидит строку, закоммиченную позже его старта. 4. Потерянное обновление (lost update): read-modify-write двух транзакций T1 SELECT qty → 100 UPDATE qty = 150 COMMIT T2 SELECT qty → 100 UPDATE qty = 130 +50 от T1 потеряны Лечение: UPDATE qty = qty + 50 (атомарно в БД), либо SELECT FOR UPDATE, либо version-колонка. Уровень изоляции сам по себе не спасает.
Фантомы и потерянные обновления. Потерянное обновление опасно тем, что не проявляется как ошибка: обе транзакции завершились успешно, просто одно из изменений бесследно исчезло. Его чаще всего и встречаешь в реальном коде вида «прочитал в Go, посчитал, записал».
5. Аномалия записи (write skew): каждый читает, каждый пишет — но в разные строки Инвариант: на дежурстве должен остаться минимум один врач. Сейчас дежурят двое: Аня и Боря. T1 SELECT count(*) → 2 «можно уйти» UPDATE Аня: off COMMIT T2 SELECT count(*) → 2 «можно уйти» UPDATE Боря: off COMMIT Ни одна транзакция не изменила строку, которую прочитала другая — конфликта записи НЕТ, обе коммитятся. Результат: дежурных ноль. Инвариант нарушен. Repeatable Read это НЕ ловит — нужен SERIALIZABLE (SSI) или явная блокировка.
Write skew. Самая коварная аномалия. Чужую запись здесь никто не перетёр, просто «мы оба приняли решение по снимку, который перестал быть верным». Её не закрывает Repeatable Read, и как раз ради неё существует Serializable Snapshot Isolation.

Уровни изоляции по стандарту SQL

УровеньГрязное чтениеНеповторяющееся чтениеФантомыПотерянное обновлениеWrite skew
READ UNCOMMITTEDвозможновозможновозможновозможновозможно
READ COMMITTEDнетвозможновозможновозможновозможно
REPEATABLE READнетнетпо стандарту возможно, в PG нетв PG нет (ошибка 40001)возможно
SERIALIZABLEнетнетнетнетнет
Уровни в PostgreSQL: три вместо четырёх

PostgreSQL реализует изоляцию через MVCC-снапшоты, а не через блокировки чтения, поэтому таблица стандарта на него ложится иначе:

  • READ UNCOMMITTED физически = READ COMMITTED. Уровень принимается синтаксически, но грязное чтение невозможно в принципе: незакоммиченная версия строки не видна никому, потому что её xmin принадлежит активной транзакции.
  • READ COMMITTED берёт новый снапшот на каждый оператор. Отсюда неповторяющееся чтение и фантомы между операторами одной транзакции.
  • На REPEATABLE READ снапшот один на всю транзакцию, его берут при первом операторе. Отсюда бесплатно пропадают неповторяющееся чтение и даже фантомы, которые стандарт разрешает: уровень выходит строже стандарта. Взамен появляется could not serialize access due to concurrent update, если ты пытаешься обновить строку, изменённую после твоего снапшота.
  • SERIALIZABLE реализован через SSI (Serializable Snapshot Isolation): движок отслеживает зависимости чтение-запись между транзакциями и, обнаружив «опасную структуру» из двух подряд идущих rw-конфликтов, откатывает одну из транзакций с ошибкой сериализации. Блокировок чтения по-прежнему нет — платишь не ожиданием, а вероятностью отката.
Подвох Read Committed в PostgreSQL

На Read Committed UPDATE ... WHERE ведёт себя хитро. Если строку поменяла другая транзакция и успела закоммититься, пока мы ждали блокировку, PostgreSQL не откатывает нас, а перечитывает строку заново (EvalPlanQual) и повторно проверяет на ней условие WHERE. То есть один оператор может работать с «более свежими» данными, чем его собственный снапшот. Следствие: UPDATE t SET x = x + 1 на Read Committed безопасен и не теряет обновления, а вот «прочитал SELECTом, посчитал в Go, записал UPDATEом» — теряет. Здесь и проходит граница, за которой нужны FOR UPDATE или version-колонка.

СУБДУровень по умолчаниюЧем реализовано
PostgreSQLREAD COMMITTEDснапшот на каждый оператор, MVCC в heap
MySQL / InnoDBREPEATABLE READconsistent read из undo-лога + gap locks и next-key locks против фантомов
OracleREAD COMMITTEDundo-сегменты; SERIALIZABLE есть, но это snapshot isolation, а не настоящая сериализуемость
SQL ServerREAD COMMITTED (блокировочный)по умолчанию блокировки, опционально READ_COMMITTED_SNAPSHOT

Ошибка сериализации и обязательный ретрай

Как только ты вышел с Read Committed на Repeatable Read или Serializable, приложение получает новый класс ошибок: SQLSTATE 40001 serialization_failure и 40P01 deadlock_detected. Это не баги и не сбои. Так PostgreSQL штатно сообщает: «я не смог выполнить твою транзакцию так, будто она была одна; повтори её». Транзакция откачена целиком, база согласована, повтор корректен.

// Ретраим транзакцию с экспоненциальной паузой и джиттером.
// Обязателен для Repeatable Read и Serializable.
func WithRetry(ctx context.Context, db *pgxpool.Pool, fn func(pgx.Tx) error) error {
    const maxAttempts = 5
    var lastErr error

    for attempt := 0; attempt < maxAttempts; attempt++ {
        tx, err := db.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
        if err != nil {
            return err
        }

        err = fn(tx)
        if err == nil {
            if err = tx.Commit(ctx); err == nil {
                return nil // получилось
            }
        }
        _ = tx.Rollback(ctx) // после Commit это no-op, вызывать безопасно

        if !isRetryable(err) {
            return err // бизнес-ошибка или нарушение констрейнта: повтор не поможет
        }
        lastErr = err

        // Пауза со случайным разбросом: без джиттера конкуренты
        // синхронно повторят конфликт и снова столкнутся.
        backoff := time.Duration(1<<attempt) * 10 * time.Millisecond
        jitter := time.Duration(rand.Int63n(int64(backoff)))
        select {
        case <-time.After(backoff + jitter):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return fmt.Errorf("транзакция не удалась за %d попыток: %w", maxAttempts, lastErr)
}

func isRetryable(err error) bool {
    var pgErr *pgconn.PgError
    if !errors.As(err, &pgErr) {
        return false
    }
    switch pgErr.Code {
    case "40001": // serialization_failure
        return true
    case "40P01": // deadlock_detected
        return true
    }
    return false
}
Три правила ретрая, о которых спрашивают

1) Повторять надо всю транзакцию с самого начала, а не последний запрос: снапшот уже мёртв. 2) Функция fn обязана быть идемпотентной по побочным эффектам. Внутри неё нельзя отправлять письма, списывать деньги во внешнем API и писать в Kafka: при повторе это произойдёт дважды. Побочные эффекты запускай только после успешного коммита (или через outbox). 3) Джиттер обязателен: без него N конкурентов синхронно повторят конфликт, снова его получат, и выйдет живой лок на ровном месте.

Явные блокировки: FOR UPDATE, FOR SHARE, SKIP LOCKED, NOWAIT

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

SELECT ... FOR UPDATE берёт на прочитанные строки эксклюзивную блокировку строки до конца транзакции: другие транзакции не смогут ни обновить, ни удалить, ни взять на них FOR UPDATE. Так паттерн «прочитал — посчитал — записал» становится безопасным: между чтением и записью строку никто не тронет. Обычные SELECT при этом не блокируются — читатели в PostgreSQL никогда не ждут писателей.

ФормаЧто блокируетТипичный сценарий
FOR UPDATEполная эксклюзивная блокировка строкиread-modify-write: списание баланса, бронирование
FOR NO KEY UPDATEслабее: разрешает FOR KEY SHAREто, что берётся неявно при обычном UPDATE неключевых колонок
FOR SHAREразделяемая: читать можно всем, менять нельзя«эта строка не должна исчезнуть, пока я работаю»
FOR KEY SHAREслабейшая: запрещает только удаление и смену ключаберётся неявно при проверке FK
... NOWAITне ждать, сразу ошибка 55P03интерактивная операция: лучше сказать «занято», чем висеть
... SKIP LOCKEDмолча пропустить занятые строкиочередь на таблице: каждый воркер берёт свою пачку
Очередь на таблице: FOR UPDATE SKIP LOCKED Три воркера одновременно выполняют один и тот же запрос — и не мешают друг другу. tasks (status = new) id status 1 new 2 new 3 new 4 new 5 new Воркер A заблокировал строку 1 Воркер B строка 1 занята → пропустил, Воркер C пропустил 1 и 2, взял 3 взял 2 Без SKIP LOCKED B и C встают в очередь на строку 1 и ждут — параллелизма нет вообще Почему это лучше брокера не всегда + транзакционно с бизнес-данными + не нужен ещё один сервис - нагрузка на БД, bloat от UPDATE статуса - на десятках тысяч задач/с уже не тянет
SKIP LOCKED. Эта конструкция превращает обычную таблицу в рабочую очередь с конкурентными воркерами — без брокера и без гонок. До PostgreSQL 9.5 приходилось изобретать advisory-локи и «взять случайную строку», а сейчас хватает двух строчек SQL.
-- Очередь: воркер атомарно забирает пачку задач и помечает их своими
UPDATE tasks
SET    status = 'processing', locked_by = $1, locked_at = now()
WHERE  id IN (
    SELECT id FROM tasks
    WHERE  status = 'new' AND run_after <= now()
    ORDER  BY priority DESC, id
    LIMIT  10
    FOR UPDATE SKIP LOCKED          -- занятые строки пропускаем
)
RETURNING id, payload;

-- Read-modify-write под блокировкой: списание с проверкой остатка
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;   -- ждём, если строка занята
  -- проверили в приложении, что хватает
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- Не висеть на занятой строке в интерактивном сценарии
SELECT * FROM documents WHERE id = 42 FOR UPDATE NOWAIT;  -- ошибка 55P03, если занято

Оптимистичные и пессимистичные блокировки

Пессимистичная — «конфликт будет, займу заранее». SELECT FOR UPDATE.

BEGIN;
SELECT qty FROM stock
 WHERE id = 7 FOR UPDATE;   -- конкурент ждёт
UPDATE stock SET qty = qty - 1
 WHERE id = 7;
COMMIT;

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

Оптимистичная — «конфликт маловероятен, проверю при записи». Version-колонка.

-- прочитали: qty = 100, version = 7
UPDATE stock
   SET qty = 99, version = version + 1
 WHERE id = 7 AND version = 7;
-- RowsAffected = 0 → кто-то опередил,
-- перечитать и повторить бизнес-логику

Плюсы: никто никого не ждёт, работает через долгий диалог с пользователем и через stateless-сервисы. Минусы: при высокой конкуренции много повторов, и повтор надо уметь делать корректно.

Правило выбора

Если конкуренция за одну и ту же строку высокая и короткая (списание с популярного счёта, остаток товара на распродаже), бери пессимистичную: оптимистичная выродится в бесконечные ретраи. Если конкуренция редкая или транзакция длинная (пользователь открыл форму редактирования и думает 5 минут), бери оптимистичную: держать блокировку строки на время раздумий человека нельзя никогда. Часто лучше обоих третий вариант: переписать в атомарный SQL, например UPDATE stock SET qty = qty - 1 WHERE id = 7 AND qty >= 1. Тогда нет ни блокировок, ни версий, а проверка RowsAffected == 0 заменяет всю логику конфликта.

Блокировки в PostgreSQL: уровни и конфликты

Блокировки в PG бывают трёх сортов. Табличные (8 режимов) берутся автоматически под каждую команду. Строчные (4 режима) хранятся не в отдельной таблице замков в памяти, а прямо в служебном заголовке самой строки на диске и в multixact, поэтому память под них не кончается, сколько строк ни заблокируй. Бесплатными они от этого не становятся: каждая блокировка пишется в заголовок строки, а значит, грязнит страницу и попадает в WAL. Замки advisory пользователь вешает сам по произвольному числовому ключу, и ни к строке, ни к таблице они не привязаны. Пригодятся, когда синхронизировать нужно не данные, а действие («крон-задачу выполняет только один инстанс»).

Режим таблицыКто берётС чем конфликтует
ACCESS SHARESELECTтолько с ACCESS EXCLUSIVE
ROW SHARESELECT FOR UPDATE/SHAREс EXCLUSIVE и выше
ROW EXCLUSIVEINSERT, UPDATE, DELETEс SHARE и выше
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLYсам с собой и выше — DML не мешает
SHARECREATE INDEX (без CONCURRENTLY)с любой записью
SHARE ROW EXCLUSIVECREATE TRIGGER, часть ALTERс записью и сам с собой
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYсо всем, кроме ACCESS SHARE
ACCESS EXCLUSIVEDROP, TRUNCATE, большинство ALTER TABLE, VACUUM FULLсо всем, включая SELECT
Как одна миграция кладёт прод

ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT ... хочет ACCESS EXCLUSIVE. Он встаёт в очередь за уже идущим долгим SELECT. Но очередь блокировок в PostgreSQL честная (FIFO): все новые SELECT, пришедшие после ALTER, встают за ним. Итог: один аналитический запрос на 10 минут + одна безобидная миграция = таблица недоступна на 10 минут, приложение падает по таймаутам. Лечится это через SET lock_timeout = '3s' перед DDL и повтор в цикле: миграция сдастся, а не заблокирует всех.

-- Кто кого ждёт прямо сейчас (PG 10+): самый полезный запрос при инциденте
SELECT a.pid, a.state, now() - a.xact_start AS tx_age,
       a.wait_event_type, a.wait_event,
       pg_blocking_pids(a.pid) AS "заблокирован кем",
       left(a.query, 80) AS query
FROM   pg_stat_activity a
WHERE  a.backend_type = 'client backend'
  AND  (a.state <> 'idle' OR a.state IS NULL)
ORDER  BY tx_age DESC NULLS LAST;

-- Детали блокировок: что именно и в каком режиме удерживается
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM   pg_locks l
LEFT   JOIN pg_class c ON c.oid = l.relation
WHERE  NOT l.granted OR l.relation IS NOT NULL
ORDER  BY l.granted, l.pid;

-- Аварийные меры
SELECT pg_cancel_backend(pid);     -- отменить запрос: соединение живо, транзакция ждёт ROLLBACK
SELECT pg_terminate_backend(pid);  -- убить соединение целиком

Deadlock

Классический дедлок: перекрёстный порядок обновления T1 UPDATE row A блокировка получена UPDATE row B ждёт T2 T2 UPDATE row B блокировка получена UPDATE row A ждёт T1 Граф ожидания T1 T2 цикл → ждать бесполезно Что делает PostgreSQL раз в deadlock_timeout (по умолчанию 1 с) строит граф ожидания; найдя цикл — откатывает одну транзакцию с ошибкой 40P01, вторая продолжает работу
Дедлок и его разрешение. PostgreSQL не предотвращает дедлоки, а обнаруживает их постфактум. Проверка запускается, только когда транзакция прождала дольше deadlock_timeout: так процессор не тратится на граф при каждом ожидании.
Как проектировать, чтобы дедлоков не было
  1. Единый порядок захвата. Всегда обновляй строки в одном и том же порядке, например по возрастанию id. В переводе денег это буквально ORDER BY id перед блокировкой обоих счетов; без него переводы A→B и B→A под нагрузкой гарантированно поймают дедлок.
  2. Короткие транзакции. Чем меньше окно удержания блокировки, тем меньше шанс пересечения.
  3. Никаких сетевых вызовов внутри транзакции. HTTP-запрос на 2 секунды внутри BEGIN держит все блокировки те же 2 секунды.
  4. Блокировать всё нужное сразу, одним SELECT ... FOR UPDATE с IN (...) ORDER BY id, а не по одной строке за раз.
  5. И всё равно ретраить 40P01 — полностью исключить дедлоки в сложной системе нельзя.
-- Перевод без дедлока: фиксированный порядок захвата строк
BEGIN;
  SELECT id, balance FROM accounts
   WHERE id IN (1, 2)
   ORDER BY id                 -- всегда по возрастанию, куда бы ни шёл перевод
     FOR UPDATE;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

Чем опасны долгие транзакции

Любимый «взрослый» вопрос: он связывает всю тему воедино. Открытая транзакция, даже если она ничего не делает и висит в состоянии idle in transaction, держит свой xmin, а значит:

  • Блокирует VACUUM. Autovacuum не может очистить мёртвые версии строк новее самого старого активного снапшота, причём во всей базе, а не только в таблицах этой транзакции. Таблицы пухнут, индексы пухнут, планы деградируют.
  • Держит блокировки, взятые в начале, до самого конца, в том числе табличные, из-за которых миграции встают в очередь.
  • Тормозит реплики. При hot_standby_feedback = on долгий запрос на реплике удерживает xmin на мастере и блокирует VACUUM уже там. При выключенном фидбеке наоборот: запрос на реплике будет отменён с canceling statement due to conflict with recovery.
  • Приближает transaction ID wraparound. Самая старая транзакция задаёт горизонт заморозки; если она живёт сутками, база может дойти до аварийного режима «остановить приём новых транзакций».
  • Растёт вероятность дедлоков и конфликтов сериализации, ведь окно пересечения шире.
-- Долгие транзакции и «idle in transaction»: их смотрят первым делом
SELECT pid, state, now() - xact_start AS tx_age, now() - state_change AS idle_age,
       left(query, 100)
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL
ORDER  BY xact_start
LIMIT  20;
Настройки, которые надо назвать

idle_in_transaction_session_timeout убивает соединения, забывшие закоммитить (ставь всегда, хоть 60 секунд: спасает от «разработчик открыл BEGIN в psql и ушёл на обед»). statement_timeout ограничивает один запрос, lock_timeout — ожидание блокировки (для миграций обязателен), а transaction_timeout (PG 17) — всю транзакцию целиком. В Go-приложении к этому добавляется context.WithTimeout на каждый запрос, но помни: отмена контекста не всегда мгновенно останавливает работу на сервере.

Вопросы

11
Суть: A — «всё или ничего», C — инварианты не нарушены, I — параллельные транзакции не видят промежуточных состояний друг друга, D — после «OK» изменения переживут падение сервера. Из четырёх только C не является чистым механизмом СУБД.

Перевод 100 рублей со счёта 1 на счёт 2 состоит из трёх операций: списание, зачисление, запись в журнал переводов.

A — Atomicity (атомарность)

Применяются либо все три операции, либо ни одной. Без атомарности сценарий «сервер упал между UPDATEами» оставляет деньги списанными, но не зачисленными — они исчезают из системы. Механизм: WAL плюс правило, что изменения становятся видимыми только после записи о коммите. Откат в PostgreSQL почти бесплатен: новые версии строк просто никто никогда не увидит, потому что их xmin принадлежит откаченной транзакции.

C — Consistency (согласованность)

Транзакция переводит базу из одного корректного состояния в другое: CHECK (balance >= 0) не нарушен, FK на счета целы, сумма денег в системе не изменилась. Без неё баланс уходит в минус. Но есть оговорка: СУБД проверяет только объявленные ей инварианты. Инвариант «сумма денег постоянна» ей неизвестен, и если разработчик забыл второй UPDATE, транзакция будет атомарной, изолированной, долговечной и при этом бизнес-несогласованной. Поэтому C часто называют «половина ответственности приложения».

I — Isolation (изоляция)

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

D — Durability (долговечность)

После того как клиент получил «перевод выполнен», выключение питания не должно его отменить. Для этого перед подтверждением коммита запись WAL сбрасывается на диск через fsync. Отсюда практические ручки: synchronous_commit = off даёт кратный прирост скорости записи ценой потери последних миллисекунд транзакций при аварии (база при этом остаётся согласованной — теряются целые транзакции, а не половинки), а synchronous_standby_names расширяет durability до «переживёт потерю всего сервера».

Чем добить

«C в ACID и C в CAP надо различать: в ACID это “инварианты соблюдены”, в CAP — “все узлы видят одинаковые данные” (линеаризуемость). Совпадение буквы историческое, смысл разный.» И вторая добивка: «Durability не бинарна, это шкала от “записали в page cache ОС” до “подтвердили на трёх узлах в разных ЦОД”, и каждая ступень стоит задержки на коммите.»

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

1. Грязное чтение (dirty read)

T1 списала 100 рублей и ещё не закоммитила. T2 читает баланс и видит списание. T1 делает ROLLBACK. T2 приняла решение по данным, которых никогда не существовало. Запрещено начиная с Read Committed; в PostgreSQL невозможно вообще ни на каком уровне.

2. Неповторяющееся чтение (non-repeatable read)

T1 читает баланс — 500. T2 обновляет его на 400 и коммитит. T1 читает тот же баланс ещё раз — 400. Один и тот же запрос в одной транзакции дал разные ответы. Ломает любой отчёт из нескольких запросов: итоговая строка не сходится с детализацией. Запрещено с Repeatable Read.

3. Фантомное чтение (phantom read)

T1 считает count(*) WHERE dept = 5 — 10. T2 вставляет новую строку с dept = 5 и коммитит. T1 повторяет запрос — 11. Отличие от предыдущей аномалии: изменилась не строка, а множество строк, попадающих под предикат. По стандарту разрешено на Repeatable Read, но в PostgreSQL на Repeatable Read фантомов нет — снапшот берётся один на транзакцию. В MySQL/InnoDB для этого используются gap locks.

4. Потерянное обновление (lost update)

T1 читает qty = 100, T2 читает qty = 100, T1 пишет 150 и коммитит, T2 пишет 130 и коммитит. Прибавка T1 бесследно исчезла, при этом обе транзакции завершились успешно и ошибок не было. Это самая частая аномалия в реальном коде, потому что паттерн «прочитал в приложении → посчитал → записал» пишут все. Лечится не уровнем изоляции, а одним из трёх: атомарным UPDATE x = x + n, SELECT FOR UPDATE или version-колонкой.

5. Аномалия записи (write skew)

Инвариант: на дежурстве должен остаться хотя бы один врач; сейчас дежурят двое. T1 читает «дежурных 2», решает, что можно уйти, и снимает с дежурства Аню. T2 параллельно читает «дежурных 2» и снимает Борю. Обе транзакции изменили разные строки, поэтому конфликта записи нет и обе коммитятся. Дежурных ноль — инвариант нарушен. Snapshot isolation (Repeatable Read) это не ловит принципиально; нужен SERIALIZABLE с SSI, либо явная блокировка того, что читали, либо материализация инварианта в одну строку-счётчик.

Read skew: шестая аномалия, о которой забывают

Частный случай неповторяющегося чтения на нескольких строках: T1 читает баланс счёта A (500), T2 переводит 100 с A на B и коммитит, T1 читает баланс B и видит его уже с переводом. Сумма по двум чтениям не сходится, хотя каждое чтение по отдельности корректно. Ради этого в PostgreSQL отчёты и гоняют на REPEATABLE READ: один снапшот на всю транзакцию гарантирует «согласованный срез базы».

Суть: READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE; каждый следующий запрещает на одну аномалию больше. Стандарт определяет уровни через набор запрещённых аномалий, а не через реализацию.
УровеньDirty readNon-repeatable readPhantom readLost updateWrite skew
READ UNCOMMITTEDдадададада
READ COMMITTEDнетдададада
REPEATABLE READнетнетпо стандарту да, в PG нетв PG нет (ошибка 40001)да
SERIALIZABLEнетнетнетнетнет

В стандарте классическая таблица содержит только первые три колонки: потерянное обновление и write skew в ANSI SQL-92 вообще не упомянуты, их добавили позже в статье «A Critique of ANSI SQL Isolation Levels» (Berenson et al., 1995). Там же появились термины snapshot isolation и write skew, поэтому «уровень стандарта» и «уровень PostgreSQL» не одно и то же.

Как это выглядит в коде

-- на транзакцию
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- или уже внутри, до первого запроса
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- посмотреть текущий
SHOW transaction_isolation;
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
    ReadOnly:  true,   // read-only на Serializable дешевле: SSI почти не отслеживает такие транзакции
})
Как выбирать уровень на практике

Дефолтного Read Committed хватает для 95 % OLTP-операций, если добавить явные FOR UPDATE и атомарные UPDATE там, где есть read-modify-write. Repeatable Read берут для отчётов и выгрузок, которым нужен согласованный срез базы из нескольких запросов. Serializable нужен операциям со сложным инвариантом через несколько таблиц (бронирование, лимиты, расписание), и только вместе с готовым механизмом ретрая. Уровень задают на транзакцию, а не глобально: глобальный Serializable почти всегда оказывается преждевременной пессимизацией.

Суть: PostgreSQL реализует изоляцию через MVCC-снапшоты, а не через блокировки чтения. Отсюда: незакоммиченное не видно физически (RU = RC), а один снапшот на транзакцию убирает фантомы бесплатно (RR строже стандарта).

Почему READ UNCOMMITTED = READ COMMITTED

Грязное чтение возможно только там, где читатель может увидеть данные, изменённые незавершённой транзакцией. В PostgreSQL каждая версия строки помечена xmin, номером создавшей её транзакции, и правило видимости требует, чтобы эта транзакция была закоммичена. Незакоммиченная версия невидима технически, показать её просто нечем. Поэтому SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED принимается без ошибки (для совместимости со стандартом), но ведёт себя как Read Committed. Уровней у PostgreSQL фактически три.

Почему на REPEATABLE READ нет фантомов

RC и RR в PostgreSQL различаются моментом, когда берётся снапшот. На Read Committed снапшот берётся заново на каждый оператор: следующий SELECT увидит всё, что закоммитили соседи. На Repeatable Read снапшот берётся один раз, при первом операторе транзакции, и живёт до конца. У строки, вставленной и закоммиченной после этого момента, xmin больше границы снапшота, и она невидима, сколько раз запрос ни повторяй. Фантом не может появиться просто потому, что база для этой транзакции «застыла во времени». Стандарт фантомы на RR разрешает, PostgreSQL их запрещает: реализация здесь строже спецификации.

Что взамен появляется на RR

Запись. Если транзакция на Repeatable Read пытается обновить строку, изменённую и закоммиченную другой транзакцией после её снапшота, PostgreSQL не может корректно применить изменение (её снапшот описывает устаревший мир) и откатывает транзакцию с ERROR: could not serialize access due to concurrent update, SQLSTATE 40001. На Read Committed в этой же ситуации сработал бы EvalPlanQual: перечитал бы строку и заново проверил WHERE. Поэтому переход на RR всегда идёт в комплекте с ретраями.

SERIALIZABLE = SSI

С версии 9.1 PostgreSQL реализует настоящую сериализуемость через Serializable Snapshot Isolation: поверх обычных снапшотов движок отслеживает предикатные блокировки (SIREAD), то есть «что эта транзакция прочитала», и ищет в графе зависимостей опасную структуру из двух подряд идущих rw-конфликтов. Найдя её, откатывает одну из участниц с could not serialize access due to read/write dependencies among transactions. Читатели по-прежнему никого не блокируют: платишь не ожиданием, а вероятностью отката и памятью под отслеживание предикатов (max_pred_locks_per_transaction).

Ловушка: Read Committed и «неатомарный» оператор

Многие уверены, что один оператор на Read Committed видит один согласованный срез. Это так для SELECT, но для UPDATE/DELETE есть EvalPlanQual: если строка изменилась, пока мы ждали блокировку, PG перечитает её новую версию и заново применит к ней WHERE. Отсюда неинтуитивное поведение: UPDATE t SET x = x + 1 корректен и не теряет обновлений, а UPDATE t SET x = 5 WHERE x = 3 может внезапно не сработать, если конкурент успел поменять x на 4.

Суть: PostgreSQL — READ COMMITTED, MySQL/InnoDB — REPEATABLE READ. Разница историческая: в MySQL уровень RR был нужен для корректности statement-based репликации.

В PostgreSQL по умолчанию стоит READ COMMITTED: снапшот на каждый оператор. Компромисс «достаточно строго для большинства задач, минимум откатов». Глобально его почти никогда не меняют, уровень поднимают точечно на конкретную транзакцию.

В MySQL/InnoDB по умолчанию REPEATABLE READ, и дело не в заботе о строгости. Исторически MySQL использовал statement-based репликацию: на реплику передавался текст запроса, который там выполнялся заново. При Read Committed порядок применения операторов мог дать на реплике другой результат, поэтому потребовался более строгий уровень плюс gap locks — блокировки «промежутков» между значениями индекса, которые не дают вставить строку в диапазон, прочитанный чужой транзакцией. Gap locks и закрывают фантомы в InnoDB, и они же порождают знаменитые дедлоки MySQL на, казалось бы, невинных вставках.

PostgreSQLMySQL / InnoDB
ДефолтREAD COMMITTEDREPEATABLE READ
Как убираются фантомы на RRснапшот на всю транзакциюgap locks / next-key locks
Ценаошибка 40001, нужен ретрайожидание и дедлоки на вставках
Read Uncommittedне отличим от RCреально работает, грязное чтение возможно
SerializableSSI, настоящая сериализуемостьвсе чтения превращаются в FOR SHARE
Тонкость, которая ловит при миграции с MySQL на PostgreSQL

Код, написанный под MySQL, часто неявно полагается на RR: «внутри транзакции данные не меняются». После переезда на PostgreSQL с его Read Committed этот код внезапно ловит неповторяющиеся чтения. И наоборот: код, написанный под PG с расчётом на явные FOR UPDATE, в MySQL начинает ловить дедлоки из-за gap locks там, где их не ждали. Хороший пример к тезису «уровень изоляции — часть контракта приложения, а не деталь инфраструктуры».

Суть: SQLSTATE 40001 — не сбой, а штатный ответ «я не смог сделать вид, что твоя транзакция была одна, повтори её». На Repeatable Read и Serializable отсутствие ретрая — это баг, который проявится под нагрузкой.

Откуда берётся

  • Repeatable Read: транзакция пытается обновить строку, изменённую и закоммиченную после её снапшота, и получает could not serialize access due to concurrent update.
  • Serializable: SSI находит в графе зависимостей опасную структуру и отвечает could not serialize access due to read/write dependencies among transactions.
  • Рядом стоит 40P01 deadlock_detected — причина другая, обработка та же.

Откат при этом полный: база осталась согласованной, ничего наполовину не применилось, повтор той же транзакции корректен по определению. Поэтому SSI и работает: движок обещает «либо результат будет как при последовательном выполнении, либо я откачу». За вторую половину обещания отвечает приложение.

Как это выглядит правильно

// Оборачиваем всю бизнес-операцию, а не отдельный запрос
err := WithRetry(ctx, pool, func(tx pgx.Tx) error {
    var seats int
    if err := tx.QueryRow(ctx,
        `SELECT count(*) FROM bookings WHERE flight_id = $1`, flightID).Scan(&seats); err != nil {
        return err
    }
    if seats >= capacity {
        return ErrNoSeats           // бизнес-ошибка, не ретраим
    }
    _, err := tx.Exec(ctx,
        `INSERT INTO bookings(flight_id, user_id) VALUES ($1, $2)`, flightID, userID)
    return err
})
// Письмо отправляем только здесь, после успешного коммита:
// внутри fn оно ушло бы столько раз, сколько было попыток.

Что должно быть в реализации ретрая

  1. Повторять всю транзакцию с нуля — старый снапшот невалиден.
  2. Экспоненциальная пауза с джиттером. Без разброса конкуренты синхронно повторят конфликт и снова столкнутся.
  3. Ограничение числа попыток и осмысленная финальная ошибка.
  4. Никаких внешних побочных эффектов внутри: ни писем, ни платежей, ни сообщений в Kafka. Только чистая работа с БД; всё остальное после коммита или через outbox.
  5. Различать ретраибельные и нет. 23505 unique_violation или 23514 check_violation повторять бессмысленно, они просто повторятся.
  6. Метрика на число ретраев. Растёт доля конфликтов, значит, горячая точка разрастается и пора менять схему (шардировать счётчик, перейти на атомарный UPDATE).
Про идемпотентность в цифрах

Самый частый инцидент с ретраями звучит так: «клиенту трижды списали деньги». Причина всегда одна — внутри ретраимой функции был вызов внешнего API. Правило абсолютное: внутри транзакции — только SQL. Если внешний вызов обязателен, используй transactional outbox: записываешь намерение в таблицу в той же транзакции, а отдельный воркер отправляет его наружу с ключом идемпотентности.

Суть: FOR UPDATE резервирует строки под будущую запись и делает безопасным паттерн read-modify-write. SKIP LOCKED превращает таблицу в рабочую очередь, NOWAIT — способ не висеть в интерактивной операции.

FOR UPDATE и его слабые формы

SELECT ... FOR UPDATE берёт эксклюзивную блокировку на каждую возвращённую строку до конца транзакции. Другие транзакции не смогут её обновить, удалить или тоже заблокировать; обычные SELECT при этом работают свободно — в PostgreSQL читатели никогда не ждут писателей. Есть более слабые режимы: FOR NO KEY UPDATE (берётся неявно обычным UPDATE неключевых колонок), FOR SHARE («пусть читают, но менять нельзя») и FOR KEY SHARE (запрещает только удаление и смену ключа; его и берёт проверка внешнего ключа).

SKIP LOCKED — очередь на таблице

UPDATE tasks SET status = 'processing', locked_at = now()
WHERE id IN (
    SELECT id FROM tasks
    WHERE status = 'new' AND run_after <= now()
    ORDER BY priority DESC, id
    LIMIT 10
    FOR UPDATE SKIP LOCKED
)
RETURNING id, payload;

Без SKIP LOCKED десять воркеров выстроились бы в очередь за одной и той же первой строкой — параллелизм нулевой. С ним каждый пропускает занятое и забирает свою пачку. Это стандартный способ сделать очередь без брокера: плюс — задачи ставятся транзакционно вместе с бизнес-данными (никаких «в базе есть, в очереди нет»), минус — нагрузка на БД и bloat от постоянных UPDATE статуса, поэтому на десятках тысяч задач в секунду нужен настоящий брокер.

NOWAIT

FOR UPDATE NOWAIT вместо ожидания сразу отдаёт ошибку 55P03 lock_not_available. Нужен там, где ждать хуже, чем отказать: интерактивное редактирование документа («сейчас редактирует другой пользователь»), административная операция, которую можно повторить. Родственная настройка — SET LOCAL lock_timeout = '2s': ждать, но не бесконечно.

Три подвоха FOR UPDATE

1) Блокируются только строки, реально возвращённые запросом. Отсутствующую строку заблокировать нельзя, и от гонки «два потока вставляют одну и ту же запись» FOR UPDATE не спасает: там нужен UNIQUE-индекс или INSERT ... ON CONFLICT. 2) Нельзя использовать с агрегатами, GROUP BY, DISTINCT, UNION — будет ошибка. 3) С LIMIT без ORDER BY на конкурентной нагрузке легко поймать дедлок: два воркера возьмут пересекающиеся наборы в разном порядке. Порядок захвата всегда должен быть детерминированным.

Суть: пессимистичная — «займу заранее, конкурент подождёт» (FOR UPDATE). Оптимистичная — «проверю при записи, что никто не опередил» (WHERE version = N). Выбор определяется вероятностью конфликта и длиной операции.

Пессимистичная

Блокировка берётся до начала работы и держится до конца транзакции. Плюс: операция гарантированно проходит с первого раза, логика линейная, ретраи не нужны. Минусы: конкуренты стоят в очереди (пропускная способность по горячей строке падает до «одна транзакция за раз»), растёт риск дедлоков, и она принципиально не работает через «длинный» бизнес-процесс — блокировку нельзя держать, пока пользователь заполняет форму.

Оптимистичная через version-колонку

CREATE TABLE documents (
    id      bigint PRIMARY KEY,
    body    text   NOT NULL,
    version int    NOT NULL DEFAULT 1
);

-- 1) читаем без блокировки, отдаём пользователю body и version = 7
-- 2) через 5 минут он присылает изменения; пишем условно:
UPDATE documents
   SET body = $1, version = version + 1
 WHERE id = $2 AND version = $3;   -- $3 = 7
res, err := db.ExecContext(ctx,
    `UPDATE documents SET body=$1, version=version+1 WHERE id=$2 AND version=$3`,
    body, id, version)
if err != nil {
    return err
}
n, _ := res.RowsAffected()
if n == 0 {
    // Ноль строк = либо документа нет, либо version изменилась.
    // Это не ошибка БД, конфликт разбирает бизнес-логика:
    // перечитать, показать пользователю расхождение или смержить.
    return ErrConcurrentModification
}

Вместо целочисленной версии можно использовать updated_at (хуже: разрешение времени и переводы часов) или хеш содержимого (годится для HTTP ETag / If-Match: по сути та же оптимистичная блокировка, только вынесенная в протокол).

ПессимистичнаяОптимистичная
Когда проверяется конфликтдо работыпри записи
Конкурентждётработает и, возможно, проиграет
Стоимость конфликтаожиданиеповтор всей работы
Длинные операции с участием человеканепригоднаединственный вариант
Высокая конкуренция за одну строкуподходитвырождается в ретраи
Риск дедлокаестьнет
Третий вариант, который часто лучше обоих

Убрать read-modify-write вообще: UPDATE stock SET qty = qty - 1 WHERE id = $1 AND qty >= 1. Один атомарный оператор, никаких блокировок в коде, никаких версий, а RowsAffected() == 0 означает «не хватило остатка». Так же работает INSERT ... ON CONFLICT DO UPDATE вместо «проверил, есть ли строка, потом вставил». Хороший ответ на собесе всегда включает этот вариант: лучшая блокировка та, которая не понадобилась.

Суть: три сорта — табличные (8 режимов), строчные (4 режима) и advisory. Смотреть через pg_locks + pg_stat_activity, а быстрее всего — через pg_blocking_pids().

Табличные

Берутся автоматически под каждую команду. Запомни минимум: SELECT берёт ACCESS SHARE и конфликтует только с ACCESS EXCLUSIVE; INSERT/UPDATE/DELETE берут ROW EXCLUSIVE и не мешают друг другу на уровне таблицы (конфликты разрешаются построчно); VACUUM, ANALYZE и CREATE INDEX CONCURRENTLY берут SHARE UPDATE EXCLUSIVE и не мешают DML; ACCESS EXCLUSIVE (DROP, TRUNCATE, большинство ALTER TABLE, VACUUM FULL) блокирует вообще всё, включая SELECT.

Строчные

Их четыре: FOR KEY SHARE, FOR SHARE, FOR NO KEY UPDATE, FOR UPDATE. Хранятся они не в общей памяти, а прямо в заголовке версии кортежа (плюс структура multixact, когда строку держат несколько транзакций). Выходит, число строчных блокировок не ограничено: можно заблокировать миллион строк и не бояться исчерпать память блокировок. А вот табличных блокировок конечное число (max_locks_per_transaction).

Advisory

pg_advisory_lock(key) и pg_try_advisory_xact_lock(key) вешают замок по произвольному числу, ни с какой строкой не связанный. Удобен как распределённый мьютекс: «только один инстанс сервиса выполняет эту фоновую задачу». Транзакционная форма (_xact_) снимается автоматически при завершении транзакции, её и используй; обычная требует явного unlock и легко теряется.

-- Кто кого блокирует прямо сейчас: первый запрос при инциденте
SELECT pid, now() - xact_start AS tx_age, state, wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blocked_by, left(query, 90)
FROM   pg_stat_activity
WHERE  backend_type = 'client backend' AND state <> 'idle'
ORDER  BY tx_age DESC;

-- Что именно удерживается и в каком режиме
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM   pg_locks l LEFT JOIN pg_class c ON c.oid = l.relation
ORDER  BY l.granted, l.pid;

SELECT pg_cancel_backend(12345);     -- мягко: отменить запрос
SELECT pg_terminate_backend(12345);  -- жёстко: закрыть соединение
Честная очередь блокировок опаснее, чем кажется

Если ALTER TABLE ждёт ACCESS EXCLUSIVE за долгим SELECT, то все новые SELECT встают в очередь за ALTER, а не проходят мимо. Один десятиминутный аналитический запрос плюс одна миграция = таблица недоступна десять минут. Поэтому перед любым DDL на проде ставь SET lock_timeout = '3s' и повторяй в цикле — миграция сдастся, а не положит сервис.

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

Как возникает

T1 обновляет строку A и получает на неё блокировку, затем хочет строку B. T2 в это же время обновила B и хочет A. Каждая ждёт ресурс, который держит другая, и получается цикл. Классический источник: перевод денег «со счёта X на счёт Y» и одновременно «с Y на X»; или два процесса, обновляющие набор строк в разном порядке (например, потому что один шёл ORDER BY id, а второй — ORDER BY created_at).

Как разрешает PostgreSQL

Транзакция, прождавшая блокировку дольше deadlock_timeout (по умолчанию 1 с), запускает детектор: тот строит граф «кто кого ждёт» и ищет в нём цикл. Если цикл нашёлся, одна из транзакций (обычно та, что запустила проверку) откатывается с ERROR: deadlock detected, SQLSTATE 40P01, а в лог пишется, какие процессы и запросы участвовали. Остальные продолжают работу. Проверка запускается по таймауту, а не при каждом ожидании, чтобы не жечь процессор — обычное ожидание блокировки ещё не дедлок.

Как проектировать

  1. Единый порядок захвата. Главное правило: если операция трогает несколько строк, блокируй их в детерминированном порядке (по возрастанию первичного ключа), независимо от бизнес-направления операции.
  2. Захватывать всё сразу одним SELECT ... WHERE id IN (...) ORDER BY id FOR UPDATE, а не последовательно по одной строке.
  3. Короткие транзакции и никаких сетевых вызовов внутри — окно пересечения меньше.
  4. Осторожно с ON CONFLICT и FK: параллельные вставки в таблицу с уникальным индексом и разным порядком значений тоже дают дедлоки.
  5. Всё равно ретраить. Полностью исключить дедлоки в системе со сложными транзакциями нельзя, поэтому обработка 40P01 обязательна.
-- Плохо: порядок зависит от направления перевода
UPDATE accounts SET balance = balance - 100 WHERE id = $from;
UPDATE accounts SET balance = balance + 100 WHERE id = $to;

-- Хорошо: сначала захватываем оба счёта в фиксированном порядке
SELECT id FROM accounts WHERE id IN ($from, $to) ORDER BY id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = $from;
UPDATE accounts SET balance = balance + 100 WHERE id = $to;
Отличие от «просто долгого ожидания»

Дедлок разрешается автоматически за секунду и виден в логе как 40P01. А «всё висит, но дедлока нет» означает обычную блокировку: кто-то держит транзакцию открытой. Диагностика разная: для первого смотрят лог и порядок обновлений в коде, для второго — pg_stat_activity и pg_blocking_pids(). На собесе эти два случая часто путают.

Суть: открытая транзакция держит свой xmin, а значит блокирует VACUUM во всей базе, удерживает захваченные блокировки, тормозит реплики и приближает transaction ID wraparound. Причём вредит даже транзакция, которая ничего не делает.

Пять последствий

  1. Блокировка очистки. Autovacuum не имеет права удалить мёртвую версию строки, если она может быть нужна самому старому активному снапшоту. Одна транзакция, висящая часами, останавливает уборку по всей базе. Отсюда table bloat: таблица на 10 ГБ данных занимает 60 ГБ, индексы распухают, всё сканируется медленнее, кэш забит мусором.
  2. Удержание блокировок. Все блокировки живут до конца транзакции. Если в начале был ALTER или FOR UPDATE, соседи будут ждать до самого COMMIT — а из-за честной FIFO-очереди за ними встанут и невинные SELECT.
  3. Проблемы с репликами. При hot_standby_feedback = on долгий запрос на реплике удерживает xmin на мастере и мешает VACUUM уже там. При выключенном фидбеке запрос на реплике убьют с canceling statement due to conflict with recovery. Выбор между двумя неприятностями, и об этом полезно сказать вслух.
  4. Transaction ID wraparound. Счётчик транзакций 32-битный; чтобы старые данные не «стали будущими», VACUUM замораживает старые кортежи. Самая старая транзакция задаёт горизонт заморозки, и если она живёт сутками, база приближается к аварийному режиму, когда PostgreSQL перестаёт принимать новые транзакции.
  5. Больше конфликтов. Чем шире окно, тем выше вероятность дедлока и ошибки сериализации.

Особый случай: idle in transaction

Чаще всего транзакцию затягивает не тяжёлый запрос, а BEGIN, после которого приложение ушло делать HTTP-запрос, писать в Kafka или просто забыло закоммитить из-за пропущенного defer tx.Rollback(). Транзакция ничего не выполняет, но вредит точно так же. Это и есть главный аргумент против «сетевых вызовов внутри транзакции».

-- Диагностика
SELECT pid, state, now() - xact_start AS tx_age, now() - state_change AS in_state,
       left(query, 100)
FROM   pg_stat_activity
WHERE  xact_start IS NOT NULL
ORDER  BY xact_start
LIMIT  20;

-- Профилактика. SET — на сессию; на роль — ALTER ROLE … SET, на сервер — ALTER SYSTEM
SET idle_in_transaction_session_timeout = '60s';
SET statement_timeout = '30s';
SET lock_timeout = '3s';
Как это звучит на собесе

«Транзакция должна открываться как можно позже и закрываться как можно раньше, и внутри неё не должно быть ничего, кроме работы с БД. На уровне инфраструктуры обязательно выставлены statement_timeout и idle_in_transaction_session_timeout, а в мониторинге есть алерт на возраст самой старой транзакции — на него первым делом смотрят при жалобе на распухшую базу или на массовые таймауты.»

6.3MVCC и внутренности PostgreSQL

Здесь проверяют, понимаешь ли ты, что физически происходит с данными. Из механики MVCC растут все «странности» эксплуатации PostgreSQL: почему UPDATE не освобождает место, почему нужен autovacuum, почему долгая транзакция вредит всей базе и почему индексы распухают.

Сначала — словарь: как таблица физически лежит на диске

На практике всё упирается в вопрос: почему база после миллиона UPDATE занимает на диске в разы больше, чем в ней данных, и что с этим делать. Ответ собирается из четырёх понятий — с них и начнём, иначе дальше xmin, bloat и TOAST будут просто набором букв.

1. Куча, страница, кортеж

Таблица в PostgreSQL лежит на диске обычным файлом, и называют его кучей (heap). «Куча» здесь в бытовом смысле «свалка, где строки лежат без всякого порядка», а не та куча, что структура данных из алгоритмов, и не куча из управления памятью. Файл нарезан на страницы (page, они же блоки) фиксированного размера 8 КБ. Меньше страницы движок с диска не читает и на диск не пишет: даже ради одного байта читается вся страница целиком. Запомни эту цифру: из неё вырастет половина главы про индексы.

Внутри страницы лежат кортежи (tuple). Каждый кортеж хранит одну физическую версию одной строки: её данные плюс служебный заголовок примерно в 23 байта. Слово пришло из реляционной теории, где «кортеж» и «строка таблицы» просто синонимы; в разговоре про PostgreSQL всегда держи уточнение «строка в конкретной версии», потому что версий у одной логической строки бывает много.

2. Зачем вообще несколько версий одной строки

Возьмём бухгалтерскую книгу. Проще всего обновить остаток, стерев старое число и вписав новое. Но тогда коллега, который прямо сейчас читает эту страницу, увидит полустёртое значение — значит, на время правки книгу придётся у него отобрать. Так и работают блокировочные СУБД: писатель заставляет читателей ждать.

PostgreSQL делает иначе: старую запись не стирает, а перечёркивает и дописывает новую рядом, пометив у обеих, с какого момента какая действует. Читатель, пришедший раньше, спокойно читает перечёркнутую — она никуда не делась и для его момента времени всё ещё верна. Это и есть MVCC (Multi-Version Concurrency Control, «многоверсионное управление конкурентным доступом»): никто никого не ждёт, но в книге копятся перечёркнутые записи.

Где аналогия перестаёт работать: в бухгалтерской книге перечёркнутое остаётся навсегда, это и есть смысл книги. В базе перечёркнутое превращается в мусор, который надо периодически вычищать, иначе файл будет только расти. Уборке этого мусора отдана половина главы.

3. xid и пара xmin/xmax — «версия действовала с … по …»

«Момент времени» в PostgreSQL отсчитывают не часами, а номером транзакции (xid, transaction id): каждая пишущая транзакция получает следующий по порядку номер. Дальше всё просто: в заголовке каждого кортежа лежат два таких номера — xmin (какая транзакция эту версию создала) и xmax (какая её отменила; ноль значит, что версия ещё действует). Пара xmin/xmax и есть то самое «перечёркнуто вот этой транзакцией» из аналогии выше.

4. Bloat — те самые перечёркнутые строки

Bloat (дословно «раздутие», по-русски чаще говорят просто «блоат») означает место в файле, занятое версиями, которые уже никому не нужны, но ещё не переиспользованы. Из-за него таблица с 1 ГБ полезных данных запросто занимает 10 ГБ на диске: данные на месте, просто между ними дыры от мёртвых версий. Вычищает bloat команда VACUUM — и почти вся эксплуатационная головная боль PostgreSQL сводится к одному вопросу: успевает ли VACUUM за темпом появления мусора.

MVCC: чтение без блокировок

MVCC устроен так: вместо того чтобы менять строку на месте и блокировать читателей, создаём новую версию строки, а читатели продолжают видеть старую. Отсюда главное свойство PostgreSQL: читатели не блокируют писателей, писатели не блокируют читателей. Ждать приходится только писателю, который хочет изменить строку, уже изменяемую другим писателем.

Теперь точнее. У каждого кортежа — то есть у каждой версии строки — в служебном заголовке лежат поля:

  • xmin хранит идентификатор транзакции, которая создала эту версию;
  • xmax хранит идентификатор транзакции, которая её удалила или заменила (0, если версия ещё актуальна);
  • ctid указывает физический адрес версии: (номер страницы, номер строки в странице);
  • cmin/cmax нумеруют команды внутри транзакции, чтобы транзакция корректно видела собственные изменения.
Одна логическая строка = цепочка версий в куче (heap) Страница таблицы, 8 КБ xmin = 100 xmax = 150 balance = 500 мёртвая заменена tx 150 ctid указывает на новую версию xmin = 150 xmax = 0 balance = 400 живая актуальная версия UPDATE не изменил старую версию: он поставил ей xmax и дописал новую. Место старой освободит только VACUUM. Снапшот и правила видимости snapshot = { xmin: 140, xmax: 210, xip: [160] } xmin — всё, что старше, уже завершено xmax — всё, что новее, ещё не начиналось xip — список активных на момент снимка Версия видна, если одновременно: 1) её xmin закоммичен и не «в будущем» и не в xip 2) её xmax равен 0, либо не закоммичен, либо «в будущем» относительно снапшота Отсюда: незакоммиченное не видно НИКОМУ — грязного чтения нет. Read Committed берёт новый снапшот на каждый оператор; Repeatable Read и Serializable — один на всю транзакцию. Вся разница уровней изоляции в PG — это момент взятия снапшота.
Механика MVCC. Никаких блокировок для чтения не нужно: транзакция сравнивает xmin/xmax каждой версии со своим снапшотом и сама решает, какую видеть. Платить приходится мёртвыми версиями, которые кто-то потом должен убрать.

Что физически происходит при INSERT / UPDATE / DELETE

ОперацияЧто делает движокЧто остаётся мусором
INSERTновая версия с xmin = мой xid, xmax = 0; записи во все индексыничего (если транзакция закоммичена)
UPDATEновая версия целиком + старой проставляется xmax; записи во все индексы таблицы, а не только в те, чьи колонки менялись (новая версия лежит по новому ctid) — если это не HOT-обновлениестарая версия строки + старые индексные записи
DELETEтолько проставляется xmax; строка физически на местевся версия строки + все её индексные записи
ROLLBACKничего не откатывается физически: транзакция помечается abortedвсё, что она успела записать
UPDATE одной фразой

«В PostgreSQL UPDATE — это по сути DELETE + INSERT: старая версия строки не меняется, а помечается устаревшей, и создаётся новая целиком, даже если изменилось одно поле из тридцати.» Из этой фразы выводится всё: почему обновление широкой строки дорогое, почему таблица растёт при «просто обновлениях», почему нужен VACUUM, почему счётчик, обновляемый 1000 раз в секунду, создаёт 1000 мёртвых версий в секунду, и почему такой счётчик лучше держать в Redis.

Обычный UPDATE против HOT-обновления Обычный UPDATE: изменилась индексируемая колонка индекс по email 2 записи индекс по name 2 записи версия v1 (мёртвая) xmax = 150 версия v2 (живая) xmin = 150 Каждый индекс получил новую запись: запись в WAL растёт, индексы пухнут, VACUUM больше работы HOT-обновление: индексируемые колонки не тронуты индекс по email 1 запись версия v1 (мёртвая) HOT-цепочка версия v2 (живая) та же страница Индекс не трогаем вообще: он указывает на v1, а движок доходит по цепочке до v2 внутри страницы
HOT (Heap-Only Tuple). Срабатывает, только если новая версия помещается в ту же страницу и ни одна индексируемая колонка не изменилась. Отсюда практический приём: fillfactor ниже 100 (например 85) оставляет в странице место под будущие версии и резко повышает долю HOT-обновлений на часто обновляемых таблицах.

VACUUM, autovacuum и bloat

Мёртвой версией становится кортеж, которому уже проставили xmax закоммиченной транзакции и который больше не нужен ни одному живому снапшоту. Сам он не исчезает: UPDATE и DELETE в PostgreSQL физически ничего не стирают. Убирает мёртвые версии VACUUM, и только тогда, когда версия перестала быть нужна самому старому активному снапшоту во всей базе. Отсюда прямая связь «долгая транзакция → VACUUM не может ничего почистить → таблицы пухнут»: одна забытая открытая транзакция копит мусор по всей базе, а не только в тех таблицах, которых она касалась.

Попутно VACUUM обновляет карту видимости (visibility map): по два бита на каждую страницу таблицы: «в этой странице все версии видны всем и мёртвых нет» и «все версии заморожены». Карта понадобится в главе про индексы: с ней ответ можно отдать прямо из индекса, вообще не заглядывая в таблицу.

ФормаЧто делаетБлокировкаВозвращает место ОС
VACUUMпомечает место мёртвых версий свободным для переиспользования, чистит индексы, обновляет visibility mapSHARE UPDATE EXCLUSIVE — не мешает DMLнет (только если мусор в самом конце файла)
VACUUM FULLпереписывает таблицу в новый файл без дырACCESS EXCLUSIVE — таблица недоступнада
ANALYZEпересобирает статистику для планировщикаSHARE UPDATE EXCLUSIVE
pg_repackто же, что VACUUM FULL, но почти без блокировки (через триггеры и подмену файла)короткий эксклюзив в концеда

Autovacuum, фоновый демон, запускает VACUUM и ANALYZE по порогам. Основные параметры: autovacuum_vacuum_scale_factor (по умолчанию 0.2, то есть «чистим, когда мёртвых стало 20 % от таблицы») и autovacuum_vacuum_threshold. На больших таблицах дефолт плох: 20 % от 500 млн строк дают 100 млн мёртвых версий до первой уборки. Поэтому на горячих таблицах scale factor снижают до 0.01–0.05 или задают autovacuum_vacuum_max_threshold (PG 18).

Table bloat: как выглядит инцидент

Таблица на 10 ГБ полезных данных занимает 60 ГБ, все запросы по ней замедлились втрое, диск кончается. Причины почти всегда из этого списка: (1) висела долгая транзакция или idle in transaction; (2) незакрытый replication slot, который тоже держит xmin, причём вечно, даже если реплики давно нет; (3) часто обновляемая таблица с дефолтным scale factor 0.2; (4) autovacuum не успевает из-за autovacuum_vacuum_cost_delay и малого числа воркеров. Диагностируют по pg_stat_user_tables.n_dead_tup и last_autovacuum, лечат через pg_repack плюс устранение причины.

-- Мёртвые кортежи и когда последний раз убирались
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_autovacuum, last_autoanalyze
FROM   pg_stat_user_tables
ORDER  BY n_dead_tup DESC
LIMIT  20;

-- Кто держит горизонт очистки: транзакции, слоты, prepared transactions
SELECT slot_name, active, restart_lsn, xmin FROM pg_replication_slots;
SELECT max(age(backend_xmin)) FROM pg_stat_activity;

-- Настройка для горячей таблицы: чистим чаще и оставляем место под HOT
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02, fillfactor = 85);
Transaction ID wraparound

Идентификатор транзакции 32-битный, то есть счётчик обходит круг примерно каждые 4 млрд транзакций. Сравнение xid кольцевое: «половина круга назад — прошлое, половина вперёд — будущее». Чтобы очень старые строки не оказались «в будущем» и не исчезли, VACUUM их замораживает (ставит признак «видима всем всегда»). Если заморозка отстаёт, PostgreSQL сначала предупреждает в логе, затем переходит в аварийный режим и отказывается принимать новые транзакции, пока не выполнишь VACUUM вручную. Мониторить надо age(datfrozenxid) по базам — классический вопрос уровня «а ты эксплуатировал PostgreSQL на самом деле?». По таблицам то же показывает age(relfrozenxid), а 64-битные xid до сих пор остаются мечтой.

WAL: журнал упреждающей записи

WAL (Write-Ahead Log, «журнал упреждающей записи») ведётся в отдельном файле, куда база дописывает короткие заметки вида «в такой-то странице такие-то байты стали такими». Слово «упреждающей» означает порядок: заметка в журнал пишется раньше, чем изменение доходит до файлов данных. Никогда не наоборот.

Зачем такой крюк. Изменённые страницы разбросаны по огромному файлу — сбрасывать их на диск сразу значит писать в случайные места, а это медленно даже на SSD. Журнал же пишется подряд, в конец файла, а дешевле записи не бывает. Поэтому база делает так: страницы правит в памяти (shared_buffers) и оставляет «грязными», то есть изменёнными в памяти, но ещё не записанными на диск, а на диск гарантированно кладёт только короткую запись в журнал. Клиенту «COMMIT прошёл» отвечают после fsync журнала (системного вызова, который заставляет ОС физически дописать данные на диск, а не оставить в своём кэше) и до записи самих страниц.

Отсюда и живучесть (durability, буква D в ACID): если сервер выключат по питанию, грязные страницы из памяти пропадут, но журнал на диске цел. При старте база прочитает его и повторно применит все заметки после последней контрольной точки — это называется восстановлением по журналу (recovery). Тот же журнал даёт репликацию (реплика просто непрерывно применяет чужой WAL) и восстановление на момент времени (PITR).

Что происходит при UPDATE и COMMIT Backend UPDATE ... COMMIT shared_buffers страница стала грязной позже, асинхронно checkpointer / bgwriter Файлы данных base/*/16384 СНАЧАЛА WAL buffer запись об изменении pg_wal/, fsync только после этого «OK» walsender → реплика физическая репликация Зачем такой порядок 1. Крах после COMMIT: при старте PostgreSQL проигрывает WAL с последней контрольной точки — данные восстановлены. 2. Последовательная запись в журнал на порядок дешевле случайной записи страниц по всему файлу. 3. Тот же поток WAL — основа физической репликации и PITR. 4. CHECKPOINT сбрасывает грязные страницы и двигает точку, с которой начнётся восстановление. Реже = быстрее работа, но дольше recovery.
Write-Ahead Logging. Один и тот же поток WAL решает три задачи: восстановление после аварии, репликацию и point-in-time recovery. Поэтому «выключить WAL ради скорости» нельзя — можно только ослабить synchronous_commit, разменяв последние миллисекунды транзакций на пропускную способность.
Три настройки WAL, которые надо знать

synchronous_commit: on — fsync перед ответом клиенту (по умолчанию); off — ответ сразу, потеря последних транзакций при аварии (до трёх wal_writer_delay, по умолчанию до ~600 мс), но база остаётся согласованной (теряются целые транзакции, а не половинки); remote_apply — ждать применения на синхронной реплике. wal_level: replica для физической репликации, logical для логической. full_page_writes: после каждой контрольной точки первая модификация страницы пишет в WAL её целиком. Это защита от torn pages и главная причина всплесков объёма WAL сразу после checkpoint.

TOAST

Строка в PostgreSQL не может занимать больше одной страницы (8 КБ). Что делать со строкой, где лежит JSON на 2 МБ? Этим занимается встроенный механизм TOAST (The Oversized-Attribute Storage Technique): он прячет слишком крупные значения в отдельное хранилище. Ничего настраивать для этого не надо, он работает всегда и молча; знать о нём нужно потому, что он объясняет пару неочевидных эффектов в производительности. Когда версия строки не влезает в ~2 КБ (порог TOAST_TUPLE_THRESHOLD), движок сначала сжимает самые крупные значения переменной длины, а если строка всё ещё не влезает, выносит их в отдельную служебную таблицу pg_toast.pg_toast_NNNN, нарезая на чанки примерно по 2 КБ, а в самой строке оставляет указатель.

Стратегия колонкиСжиматьВыноситьКогда ставить
EXTENDED (дефолт для text/jsonb)дадаобычные большие тексты и JSON
EXTERNALнетдаесли нужны быстрые срезы substring() без распаковки
MAINдатолько в крайнем случаезначения, которые читают почти всегда
PLAINнетнеттипы фиксированной длины
Практические следствия TOAST

1) SELECT id, status FROM t по таблице с гигантским jsonb работает быстро: TOAST-значения не читаются, пока их не запросили. А SELECT * вытащит и распакует всё — вот одна из настоящих причин, почему SELECT * дорог. 2) Обновление любой колонки строки с TOAST-значением создаёт новую версию строки, но неизменённые TOAST-чанки переиспользуются, их не копируют. 3) По умолчанию сжимает pglz; с PG 14 доступен lz4 (default_toast_compression = lz4), он заметно быстрее при чуть худшем сжатии. 4) Размер TOAST-таблицы виден отдельно: pg_total_relation_size включает её, pg_relation_size — нет. Расхождение этих двух цифр часто и отвечает на «куда делось место».

Поиск проблемных запросов

-- pg_stat_statements: топ по суммарному времени, с него начинают оптимизацию.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;   -- + shared_preload_libraries

SELECT round(total_exec_time::numeric, 1) AS total_ms,
       calls,
       round(mean_exec_time::numeric, 2)  AS mean_ms,
       rows,
       round(100.0 * shared_blks_hit / NULLIF(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
       left(query, 100)
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  20;

-- Что происходит прямо сейчас
SELECT pid, state, now() - query_start AS dur, wait_event_type, wait_event, left(query, 90)
FROM   pg_stat_activity
WHERE  state = 'active' AND backend_type = 'client backend'
ORDER  BY dur DESC;

SELECT pg_stat_statements_reset();   -- обнулить перед экспериментом
Сортировать по total_exec_time, а не по mean_exec_time

Запрос на 3 секунды раз в час серверу не страшен. Запрос на 8 миллисекунд, вызываемый 40 тысяч раз в минуту, съедает сервер. Суммарное время показывает, куда реально уходит ресурс, и по нему же находят N+1: одинаковый нормализованный запрос с гигантским calls. Полезные соседние колонки: rows / calls (сколько строк возвращает в среднем), shared_blks_read (сколько читает мимо кэша) и stddev_exec_time (нестабильность плана).

Вопросы

6
Суть: вместо изменения строки на месте создаётся новая версия; каждая транзакция по своему снапшоту решает, какую версию видеть. Поэтому читатели не блокируют писателей, а писатели — читателей.

Служебные поля версии

У каждого кортежа в заголовке есть xmin (xid транзакции, создавшей версию), xmax (xid транзакции, удалившей или заменившей её; 0, если версия актуальна), ctid (физический адрес: страница, смещение), плюс cmin/ cmax для видимости внутри собственной транзакции. Их можно прямо посмотреть: SELECT xmin, xmax, ctid, * FROM accounts; — отличная демонстрация на собесе.

Снапшот

Снапшот состоит из трёх частей: xmin (все транзакции старше уже завершены), xmax (всё новее ещё не начиналось) и xip, список активных транзакций на момент снимка. Версия видна, если её создатель закоммичен и не входит в «активные или будущие», и при этом её xmax либо нулевой, либо принадлежит транзакции, которая для нас ещё не завершилась.

Почему из этого следует отсутствие грязного чтения

У незакоммиченной версии xmin принадлежит активной транзакции — правило видимости отбрасывает её у всех, кроме самой этой транзакции. Показать грязные данные в PostgreSQL физически нечем, поэтому READ UNCOMMITTED совпадает с READ COMMITTED.

Чем платим

  • Мёртвые версии копятся в таблице → нужен VACUUM, возможен bloat.
  • Раздувание индексов: каждая новая версия строки (кроме HOT) добавляет записи во все индексы.
  • count(*) не может быть мгновенным: видимость строки зависит от снапшота, поэтому приходится сканировать (спасает частично index-only scan через visibility map).
  • Долгая транзакция вредит всей базе, удерживая горизонт очистки.
Чем добить

«MVCC бывает разный: PostgreSQL хранит версии в самой таблице (heap), InnoDB и Oracle — в undo-логе. Отсюда разный профиль эксплуатации: у PG есть autovacuum и bloat, зато мгновенный ROLLBACK; у InnoDB нет VACUUM, зато откат большой транзакции физически дорог, а долгие чтения замедляются из-за длинных цепочек undo.»

Суть: UPDATE = вставка новой версии строки целиком плюс пометка старой (xmax). Старая версия остаётся в файле до VACUUM. HOT — оптимизация, когда новая версия влезла в ту же страницу и индексируемые колонки не менялись.

Пошагово

  1. Движок находит текущую версию строки и берёт на неё блокировку.
  2. Формирует новую версию целиком — со всеми колонками, даже неизменёнными.
  3. Пишет её в ту же страницу, если есть место, иначе в другую.
  4. Старой версии проставляет xmax = мой xid и связывает её ctid с новой.
  5. Добавляет записи во все индексы, если это не HOT-обновление.
  6. Пишет всё это в WAL (а после контрольной точки — ещё и полную страницу целиком).

Отсюда неочевидные следствия: обновление одного boolean в строке с jsonb на 500 КБ создаёт новую версию строки (правда, неизменённые TOAST-чанки переиспользуются); таблица, где 1000 раз в секунду инкрементируется счётчик, растёт со скоростью 1000 мёртвых версий в секунду; ROLLBACK ничего не откатывает физически — записанные версии просто остаются мусором.

HOT (Heap-Only Tuple)

Если новая версия помещается в ту же страницу и ни одна проиндексированная колонка не изменилась, PostgreSQL не трогает индексы вообще: индексная запись продолжает указывать на старую версию, а внутри страницы выстраивается HOT-цепочка, по которой движок доходит до актуальной. Так получается меньше записи в WAL, индексы не пухнут, а очистка цепочки может произойти прямо при обращении к странице, без полноценного VACUUM.

Как повысить долю HOT-обновлений

1) ALTER TABLE t SET (fillfactor = 85) оставит место в странице под будущие версии (по умолчанию 100, то есть места нет вообще). 2) Не индексировать часто обновляемые колонки. Индекс на updated_at или на status, который меняется каждую минуту, убивает HOT для всей таблицы. В разговоре про индексы это сильный аргумент. 3) Проверять эффект по pg_stat_user_tables.n_tup_hot_upd относительно n_tup_upd. В PG 16+ появились и другие оптимизации записи, но главным рычагом остаётся HOT.

Суть: VACUUM убирает мёртвые версии строк, чистит индексы, обновляет visibility map и замораживает старые xid. Bloat — это когда он не успевает или не может работать, и файл таблицы становится в разы больше полезных данных.

Что делает VACUUM

  • Помечает место мёртвых версий свободным для повторного использования (файл при этом не уменьшается).
  • Удаляет соответствующие записи из всех индексов.
  • Обновляет visibility map, битовую карту «в этой странице все версии видны всем», без которой index-only scan теряет смысл: ему приходится ходить в таблицу.
  • Замораживает старые кортежи, отодвигая угрозу transaction ID wraparound.
  • Попутно (через autovacuum) запускается ANALYZE, обновляющий статистику.

Обычный VACUUM берёт SHARE UPDATE EXCLUSIVE и не мешает SELECT/INSERT/UPDATE. VACUUM FULL устроен совсем иначе: переписывает таблицу в новый файл, возвращает место ОС, но берёт ACCESS EXCLUSIVE, то есть таблица недоступна полностью. На проде вместо него используют pg_repack.

Почему возникает bloat

  1. Долгая транзакция или idle in transaction удерживает горизонт, и VACUUM не имеет права удалять новые мёртвые версии.
  2. Забытый replication slot держит xmin бессрочно. Причина самая коварная: реплики уже может не быть, а слот остался.
  3. Дефолтный autovacuum_vacuum_scale_factor = 0.2 на большой таблице: уборка начнётся, когда мёртвых накопится 20 %.
  4. Autovacuum душат настройками (cost_delay, мало воркеров) — он не успевает за нагрузкой.
  5. Массовые UPDATE/DELETE одной транзакцией.
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum,
       pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM   pg_stat_user_tables
ORDER  BY n_dead_tup DESC LIMIT 20;

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02);  -- чистить чаще
ALTER TABLE orders SET (fillfactor = 85);                        -- больше HOT
Частая ошибка в ответе

«VACUUM освобождает место на диске» — неверно для обычного VACUUM. Он делает место переиспользуемым внутри файла; файл сожмётся, только если мусор оказался в самом конце. Вернуть место операционной системе может VACUUM FULL, pg_repack или пересоздание таблицы. Поэтому «удалил половину строк, а диск не освободился» не баг, а нормальное поведение.

Суть: WAL — журнал, куда изменение попадает до файлов данных. Он даёт восстановление после аварии, делает коммит дешёвым (последовательная запись вместо случайной) и служит потоком для репликации и PITR.

Правило упреждающей записи

Любое изменение сначала описывается записью в WAL, и только потом может быть сброшено в файл данных. Страницы меняются в shared_buffers и остаются «грязными»; COMMIT ждёт fsync журнала, а не страниц. Если сервер упадёт, при старте PostgreSQL проиграет WAL с последней контрольной точки и восстановит все закоммиченные изменения.

Почему это ещё и быстро

В WAL пишут последовательно, в один файл. Изменённые страницы сбрасываются случайной записью по всему файлу таблицы. Разница на порядок даже на SSD и на два порядка на HDD. Плюс несколько транзакций объединяют свои fsync в один (group commit). Так что WAL отвечает и за надёжность, и за производительность записи.

Checkpoint

Контрольная точка сбрасывает все грязные страницы на диск и записывает в журнал отметку «до этого места всё гарантированно в файлах данных». Она определяет, откуда начнётся восстановление. Настраивается через checkpoint_timeout и max_wal_size: чем реже контрольные точки, тем меньше нагрузка на диск в обычном режиме, но тем дольше recovery после аварии и больше объём WAL. checkpoint_completion_target размазывает запись во времени, чтобы не было всплеска I/O. Отдельно запомни full_page_writes: первая модификация страницы после контрольной точки пишет в WAL всю страницу. Так база защищается от частично записанных страниц, и отсюда же пики объёма WAL сразу после checkpoint.

Связь с репликацией и бэкапами

Физическая репликация буквально передаёт поток WAL на реплику, где он проигрывается. Логическая декодирует тот же WAL в логические изменения строк (wal_level = logical). PITR строится на базовой копии и архиве WAL и позволяет восстановиться на любой момент времени. Один механизм закрывает три задачи, и это хороший способ показать системное понимание.

synchronous_commit = off: что именно теряем

Теряются целые последние транзакции (до wal_writer_delay × 3, при значении по умолчанию — до ~600 мс), но база остаётся согласованной — не бывает «половина транзакции применилась». Поэтому настройка годится для потоков вроде метрик и логов, где потерять секунду данных не страшно, и категорически не годится для платежей. Настройку можно менять на уровне отдельной транзакции: SET LOCAL synchronous_commit = off.

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

Строка в PostgreSQL не может пересекать границу страницы, а страница — 8 КБ. Как только версия строки перестаёт влезать примерно в 2 КБ, включается TOAST: движок берёт самые крупные колонки переменной длины и сначала пытается их сжать (по умолчанию pglz, с PG 14 доступен lz4). Если этого мало — выносит значение в таблицу pg_toast.pg_toast_NNNN, нарезая на чанки примерно по 2 КБ, а в самой строке оставляет указатель. Одно TOAST-значение ограничено 1 ГБ.

Почему это важно знать на практике

  • Ленивое чтение. SELECT id, status по таблице с гигантским jsonb не читает TOAST вообще, а SELECT * вытащит и распакует всё. Это одна из самых конкретных причин не писать «звёздочку».
  • Место. pg_relation_size не включает TOAST, а pg_total_relation_size включает. В расхождении этих цифр обычно и кроется ответ на «куда делось место».
  • Обновления. Новая версия строки создаётся всегда, но неизменённые TOAST-чанки переиспользуются, а не копируются.
  • Стратегии хранения задаются на колонку: EXTENDED (сжимать и выносить, дефолт), EXTERNAL (только выносить — быстрее substring()), MAIN (сжимать, выносить в крайнем случае), PLAIN (ничего).
Связь с проектированием

Если в горячей OLTP-таблице лежит колонка с большими JSON-документами, её почти всегда стоит вынести в отдельную таблицу. Формально TOAST и так «выносит» её, но накладные расходы остаются: строка шире, страниц больше, любой SELECT * тянет всё, а обновления создают лишнюю работу. Если вынести её в таблицу 1:1, горячая часть станет узкой и плотной — больше строк на страницу, лучше попадание в кэш.

Суть: pg_stat_activity — что происходит прямо сейчас (кто висит, кто кого блокирует). pg_stat_statements — накопленная статистика по нормализованным запросам, сортировать по total_exec_time.

pg_stat_activity — здесь и сейчас

SELECT pid, usename, state,
       now() - xact_start  AS tx_age,
       now() - query_start AS query_age,
       wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blocked_by,
       left(query, 90)
FROM   pg_stat_activity
WHERE  backend_type = 'client backend' AND state <> 'idle'
ORDER  BY tx_age DESC NULLS LAST;

Что смотреть: state = 'active' с большим query_age выдаёт тормозящие запросы, state = 'idle in transaction' показывает забытые транзакции, которые блокируют VACUUM, а непустой blocked_by означает цепочки блокировок; wait_event_type подскажет природу ожидания (Lock, IO, LWLock, Client). На аварийный случай есть pg_cancel_backend (отменить запрос) и pg_terminate_backend (убить соединение).

pg_stat_statements — накопленная картина

SELECT calls,
       round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 2)  AS mean_ms,
       rows / GREATEST(calls, 1)          AS rows_per_call,
       shared_blks_read, shared_blks_hit,
       left(query, 100)
FROM   pg_stat_statements
ORDER  BY total_exec_time DESC
LIMIT  20;

Расширение требует shared_preload_libraries = 'pg_stat_statements' и рестарта. Оно нормализует запросы: константы заменяются на $1, поэтому миллион однотипных запросов схлопывается в одну строку с большим calls.

Как читать

  • Сортировать по total_exec_time, а не по среднему: запрос на 8 мс, вызываемый 40 тыс. раз в минуту, вреднее запроса на 3 секунды раз в час.
  • Огромный calls при крошечном mean — почти наверняка N+1 из ORM.
  • Большой rows_per_call значит, что запрос тащит в приложение лишнее.
  • Если shared_blks_read сравним с hit, данные не помещаются в кэш и чтение идёт с диска; повод посмотреть индексы или shared_buffers.
  • Большой stddev_exec_time — план нестабилен: разные параметры дают разные планы.
  • pg_stat_statements_reset() перед экспериментом, чтобы видеть эффект изменения чисто.
Чем добить

Рядом полезно назвать: auto_explain (автоматически пишет план запросов дольше порога прямо в лог — незаменим, когда медленный запрос воспроизводится только на проде), log_min_duration_statement, pg_stat_user_tables (seq_scan против idx_scan, мёртвые кортежи), pg_stat_user_indexes (неиспользуемые индексы) и pgBadger для разбора логов.

6.4Индексы

Индексы спрашивают всегда и всех, и грейд тут слышно с первой фразы: джун говорит «индекс ускоряет поиск», мидл объясняет устройство B-tree через размер страницы и число обращений к диску, а потом сам рассказывает, чем индексы вредны и как найти лишние.

Сначала — аналогия и два слова

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

1. Индекс — это оглавление, а не вторая копия таблицы

Чтобы найти в книге слово «MVCC», можно листать её подряд с первой страницы. А можно открыть предметный указатель в конце, найти там «MVCC — стр. 214» и сразу перейти. Указатель занимает место, его печатают вместе с книгой и переделывают при каждой правке текста — зато поиск сжимается с «прочитать 500 страниц» до «прочитать одну». Индекс в базе устроен так же: это отдельная структура, где значения колонки лежат по порядку, а рядом с каждым значением записан адрес строки в таблице (в PostgreSQL это ctid, пара «номер страницы, номер строки в странице»).

Аналогия держится почти до конца и ломается в одном месте: указатель в книге печатают один раз, а индекс в базе живёт вместе с данными. Каждый INSERT и каждый UPDATE индексируемой колонки обязан дописать в него запись, и её потом придётся чистить вакуумом. Поэтому «повесить индекс на всякий случай» совсем не бесплатно.

2. Страница — минимальная порция чтения (напоминание из 6.3)

Диск не умеет отдавать один байт: и HDD, и SSD, и файловый кэш ОС работают блоками. PostgreSQL читает и пишет страницами по 8 КБ. Всё, что дальше сказано про скорость, меряется не в сравнениях и не в строках, а в числе прочитанных страниц — в диск упирается именно оно. Запомни эту цифру: из неё выводится вся арифметика B-tree.

3. Селективность — какая доля строк переживёт условие

Селективность (selectivity) условия показывает, какая доля строк таблицы ему удовлетворяет. У WHERE email = 'x@y.z' на таблице из 10 млн пользователей селективность порядка одной десятимиллионной, то есть высокая: условие отсекает почти всё. У WHERE is_active = true, если активны 90 % пользователей, селективность 0.9, и она низкая: условие почти ничего не отсекает. (Слово путает: «высокая селективность» значит «мало строк на выходе».)

Отсюда правило, которое объясняет большинство ситуаций «индекс есть, а база его не использует»: индекс выгоден только при высокой селективности. Если под условие попадает 30 % таблицы, то сходить в индекс, а потом за каждой найденной строкой отдельно читать случайную страницу выйдет дороже, чем прочитать всю таблицу подряд. Планировщик это считает и сам выбирает второе.

С селективностью постоянно путают соседнее слово. Кардинальностью (cardinality) называют число различных значений в колонке. У email она равна числу строк, у колонки «пол» — три. Связь простая: чем выше кардинальность колонки, тем обычно выше селективность условий по ней, поэтому два слова и произносят через запятую.

Зачем нужна именно древовидная структура

Без индекса база ищет по условию через Seq Scan (sequential scan, последовательное сканирование): читает таблицу целиком, страницу за страницей, и на каждой строке проверяет условие. На 100 млн строк по 200 байт это 20 ГБ чтения с диска на каждый запрос — даже если в ответе одна строка.

Почему не бинарное дерево поиска, ведь у него та же сложность O(log n)? Потому что O(log n) считает сравнения, а платим мы за обращения к диску, и каждое читает целую страницу в 8 КБ. У бинарного дерева на 100 млн элементов высота ~27, а каждый узел лежит на отдельной странице: 27 случайных чтений. В страницу B-tree помещается несколько сотен ключей, поэтому дерево выходит широким и низким, всего 3–4 уровня.

B-tree в PostgreSQL: каждый узел — страница 8 КБ корень 1 страница, ~360 ключей внутренний ключ < 1000 внутренний 1000 .. 2000 внутренний ключ > 2000 лист лист лист лист лист лист листья связаны в двусвязный список: диапазонный поиск и ORDER BY идут по нему без возврата в дерево Арифметика, которую стоит проговорить вслух Страница 8 КБ, ключ bigint + указатель ≈ 22 байта → ~360 ключей на страницу. Высота 4 → 360^4 ≈ 1,7 · 10^10 строк. То есть любой поиск в таблице на миллиарды строк — это 3-4 чтения страниц, причём верхние уровни почти всегда уже в кэше.
Почему B-tree, а не бинарное дерево. Ветвление подбирается под размер блока диска: платим сравнениями внутри страницы (они бесплатны — страница уже в памяти) и экономим обращения к диску, которые на несколько порядков дороже.
Что ещё стоит знать про B-tree в PostgreSQL

Это B+-дерево: значения хранятся только в листьях, внутренние узлы содержат разделители. Листья связаны в двусвязный список, поэтому BETWEEN, >, < и ORDER BY обслуживаются одним проходом. Дерево сбалансировано по определению: все листья на одной глубине. Внутри работает алгоритм Lehman-Yao: операции не блокируют всё дерево, вставки идут конкурентно. С PostgreSQL 13 есть дедупликация: повторяющиеся значения хранятся один раз со списком ctid, и индексы по колонкам с низкой уникальностью резко худеют. Индексная запись не хранит информацию о видимости, поэтому после спуска по дереву обычно всё равно приходится заглянуть в heap, и обойтись без этого может только index-only scan через visibility map.

Типы индексов в PostgreSQL

ТипСтруктураОператорыПод чтоКогда не подходит
B-treeсбалансированное дерево=, <, >, BETWEEN, IN, LIKE 'abc%', IS NULL, сортировкадефолт для скалярных типов: числа, даты, строкимассивы, JSON, полнотекст, геометрия
Hashхеш-таблицатолько =очень длинные ключи при поиске строго по равенствулюбые диапазоны и сортировка; почти всегда проигрывает B-tree
GINинвертированный индекс@>, ?, &&, @@jsonb, массивы, полнотекстовый поиск, pg_trgm для LIKE '%x%'частые точечные обновления: запись дороже (смягчается fastupdate)
GiSTобобщённое сбалансированное дерево&&, <->, @>геометрия и PostGIS, диапазоны (tsrange), поиск ближайших (KNN), EXCLUDE-констрейнтыпростое равенство по скаляру
SP-GiSTнесбалансированные разбиения: quad-tree, k-d tree, radixкак GiSTданные с естественной иерархией: точки, IP-префиксы (inet), телефоны, текстовые префиксыравномерно «плоские» данные
BRINсводки по диапазонам блоков (min/max)<, >, BETWEENогромные таблицы с физически упорядоченными данными: логи и события по created_atесли порядок вставки не совпадает с порядком значений — бесполезен
По размеру BRIN вне конкуренции

BRIN хранит не значения, а для каждой группы страниц (по умолчанию 128) минимум и максимум. Поэтому индекс на таблицу в 500 ГБ занимает единицы мегабайт — против десятков гигабайт у B-tree. При запросе он отбрасывает целые диапазоны блоков, где искомого значения быть не может. Но условие жёсткое: физический порядок строк должен коррелировать со значением колонки. Для таблицы событий, куда пишут строго по времени, это выполняется идеально. Для колонки status, разбросанной по всей таблице, BRIN бесполезен. Корреляцию показывает pg_stats.correlation: если она близка к 1 или -1, BRIN сработает.

-- jsonb: два класса операторов GIN
CREATE INDEX ON docs USING gin (data);                  -- полный набор: @>, ?, ?|, ?&
CREATE INDEX ON docs USING gin (data jsonb_path_ops);   -- @>, @? и @@; заметно компактнее и быстрее
SELECT * FROM docs WHERE data @> '{"type": "invoice"}';

-- Полнотекстовый поиск
CREATE INDEX ON articles USING gin (to_tsvector('russian', body));
SELECT * FROM articles WHERE to_tsvector('russian', body) @@ plainto_tsquery('russian', 'база данных');

-- Поиск подстроки в середине: обычный индекс не поможет, нужен триграммный
CREATE EXTENSION pg_trgm;
CREATE INDEX ON users USING gin (email gin_trgm_ops);
SELECT * FROM users WHERE email LIKE '%gmail%';

-- Диапазоны без пересечений: GiST + EXCLUDE
CREATE TABLE bookings (
    room_id int,
    period  tsrange,
    EXCLUDE USING gist (room_id WITH =, period WITH &&)
);

-- BRIN на таблицу логов: индекс на гигабайты данных весит мегабайты
CREATE INDEX events_created_brin ON events USING brin (created_at) WITH (pages_per_range = 64);

Составной индекс и правило левого префикса

Составной (он же многоколоночный) индекс (a, b, c) хранится как B-tree, отсортированное сначала по a, затем внутри одинаковых a по b и, наконец, внутри одинаковых пар по c. Так устроен телефонный справочник: сначала город, потом улица, потом дом. Всех жителей Арбата по нему не найти, если не знаешь города, — придётся листать справочник целиком.

Левым префиксом называют первые N колонок индекса, взятые подряд слева, без пропусков. У (a, b, c) их три: (a), (a, b) и (a, b, c). А вот (b), (c) и (a, c) префиксами не считаются. Правило левого префикса говорит: индекс годится для поиска, только если условия запроса покрывают какой-нибудь его левый префикс. Причина та же, что со справочником: записи отсортированы в первую очередь по a, и без a область поиска в отсортированном списке не сузить.

Индекс (city, street, house) — физический порядок записей Москва | Арбат | 1 Москва | Арбат | 5 Москва | Арбат | 12 Москва | Тверская | 2 Москва | Тверская | 8 Питер | Невский | 3 Питер | Невский | 7 Питер | Садовая | 1 город меняется только здесь: внутри города всё сгруппировано Какие запросы попадут в индекс WHERE city = 'Москва' да, префикс (city) WHERE city = ... AND street = ... да, префикс (city, street) WHERE city = ... AND street = ... AND house = ... да, весь индекс WHERE city = ... AND house = 5 частично: city + фильтр WHERE street = 'Арбат' нет: пропущен левый столбец WHERE house = 5 нет ORDER BY city, street да, сортировка бесплатна Порядок колонок: сначала те, что участвуют в равенстве, потом те, что в диапазонах и сортировке. Колонка после диапазона теряет избирательность.
Левый префикс. Индекс (a, b, c) обслуживает запросы по (a), (a, b) и (a, b, c), но искать по (b) или (c) отдельно не помогает. Поэтому три отдельных индекса и один составной ведут себя совершенно по-разному.
Как выбирать порядок колонок
  1. Сначала колонки из условий равенства (=, IN), потом колонки из диапазонов (>, BETWEEN), потом колонки из ORDER BY. Причина: после первого диапазонного условия дальнейшие колонки индекса уже не сужают поиск, а только фильтруют.
  2. Среди равенств первыми идут более селективные, если запросы разные; если запрос всегда один и тот же, порядок среди равенств почти не важен.
  3. Учитывай направление сортировки: для ORDER BY a, b DESC нужен индекс (a, b DESC), иначе получишь дополнительную Sort.
  4. Один составной индекс обычно лучше трёх отдельных: PostgreSQL умеет объединять несколько индексов через BitmapAnd, но это дороже одного прохода.
-- Запрос
SELECT * FROM orders
WHERE  customer_id = $1 AND status = 'paid' AND created_at >= $2
ORDER  BY created_at DESC
LIMIT  20;

-- Правильный индекс: равенства первыми, диапазон + сортировка последними
CREATE INDEX orders_cust_status_created_idx
    ON orders (customer_id, status, created_at DESC);
-- Такой индекс отдаёт сразу 20 строк в нужном порядке: ни Sort, ни лишних чтений.

Покрывающие индексы и index-only scan

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

Покрывающий индекс (covering index) содержит все колонки, нужные конкретному запросу: и те, что в WHERE, и те, что в SELECT. Второй шаг тогда не нужен, ответ целиком лежит в индексе. Так и работает index-only scan — «сканирование только по индексу».

Но в PostgreSQL index-only scan срабатывает не всегда: в индексной записи нет xmin/xmax, и по ней не понять, видна ли строка этой транзакции. Спасает та самая карта видимости из главы про MVCC: если в её бите сказано «в этой странице таблицы все версии видны всем», проверять нечего и в таблицу идти не нужно. Если бит сброшен (страницу давно не вакуумировали) — придётся идти. Выходит, что index-only scan работает ровно настолько, насколько успевает autovacuum.

Index Scan против Index Only Scan Index Scan индекс нашли 500 ctid таблица (heap) 500 случайных чтений Зачем идти в таблицу, если значение уже в индексе? В индексной записи НЕТ информации о видимости: нужно проверить xmin/xmax самой версии строки. Плюс лишние колонки в SELECT тоже лежат только в heap. Index Only Scan индекс все нужные колонки visibility map 1 бит на страницу Если бит страницы взведён — все версии в ней видны всем, в heap идти не нужно. Данные берутся прямо из индекса. Условия: (1) все колонки запроса есть в индексе, (2) VACUUM свежий — иначе Heap Fetches в плане и выигрыш пропал.
Index-only scan. Единственный режим, в котором PostgreSQL вообще не читает таблицу. В EXPLAIN ANALYZE его выдаёт строка Heap Fetches: 0. Большое число там означает, что visibility map устарела и нужен VACUUM.
-- INCLUDE: колонка лежит только в листьях, в дереве не участвует.
-- Не раздувает внутренние узлы и не влияет на уникальность.
CREATE INDEX orders_cust_idx ON orders (customer_id) INCLUDE (status, total);
SELECT status, total FROM orders WHERE customer_id = 42;   -- Index Only Scan

-- Частичный индекс: индексируем только то, что реально ищут
CREATE INDEX orders_new_idx ON orders (created_at) WHERE status = 'new';
-- Таблица 200 млн строк, статус new у 5 тысяч → индекс на 5 тысяч записей вместо 200 млн

-- Функциональный индекс: под запрос с функцией над колонкой
CREATE INDEX users_lower_email_idx ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'a@b.c';          -- попадёт
SELECT * FROM users WHERE email = 'a@b.c';                 -- не попадёт, нужен обычный индекс

-- Уникальность только среди живых записей: UNIQUE-констрейнтом не выразить, частичным индексом — да
CREATE UNIQUE INDEX users_email_active_uniq ON users (email) WHERE deleted_at IS NULL;

Когда индекс не используется

СитуацияПочемуЧто делать
WHERE LOWER(email) = ..., WHERE date(ts) = ...индекс хранит значения колонки, а не результат функциифункциональный индекс либо переписать условие в диапазон по колонке
WHERE id::text = '5', сравнение int с bigint-параметромнеявное приведение типа = функция над колонкойпривести параметр к типу колонки, а не наоборот
LIKE '%abc%'B-tree ищет по префиксу, а тут его нетpg_trgm + GIN, либо полнотекстовый поиск
LIKE 'abc%' в базе не с C-локальюпорядок сортировки не совпадает с побайтовыминдекс с text_pattern_ops
Низкая селективность (status = 'active' у 80 % строк)Seq Scan дешевле: случайные чтения дороже последовательныхэто правильное поведение; при необходимости — частичный индекс на редкое значение
Маленькая таблицався таблица помещается в пару страницничего, всё в порядке
Устаревшая статистикапланировщик оценивает по pg_statisticANALYZE, поднять default_statistics_target
OR по разным колонкамодин индекс не покрывает оба условияпереписать через UNION ALL или полагаться на BitmapOr
ORDER BY a, b DESC при индексе (a, b)направление сортировки не совпалоиндекс (a, b DESC)
Индекс только что созданнет статистикиANALYZE после CREATE INDEX
Красный флаг на собеседовании: «индексы ускоряют всё»

Каждый индекс держит вторую копию данных, которую надо поддерживать в согласованном состоянии на каждой записи. Цена такая: (1) INSERT и DELETE обновляют все индексы таблицы; (2) UPDATE, задевший индексируемую колонку, ломает HOT-оптимизацию и добавляет записи во все индексы; (3) растёт объём WAL и, соответственно, трафик репликации; (4) индексы занимают диск и место в кэше, вытесняя оттуда полезные данные; (5) VACUUM обходит каждый индекс. На практике таблица с 12 индексами пишется в разы медленнее той же таблицы с тремя. Правильнее сказать так: индексы разменивают скорость записи на скорость чтения, и под каждый индекс должен найтись конкретный запрос.

-- Неиспользуемые индексы: idx_scan = 0 за всё время наблюдения
SELECT s.relname AS table, s.indexrelname AS index,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
       s.idx_scan
FROM   pg_stat_user_indexes s
JOIN   pg_index i ON i.indexrelid = s.indexrelid
WHERE  s.idx_scan = 0
  AND  NOT i.indisunique          -- уникальные не трогаем: они держат констрейнт
  AND  NOT i.indisprimary
ORDER  BY pg_relation_size(s.indexrelid) DESC;

-- Дублирующие индексы: одинаковый набор колонок (префиксы этот запрос не ловит)
SELECT indrelid::regclass, array_agg(indexrelid::regclass)
FROM   pg_index
GROUP  BY indrelid, indkey
HAVING count(*) > 1;

-- Создаём индекс без блокировки записи (на проде только так)
CREATE INDEX CONCURRENTLY orders_customer_id_idx ON orders (customer_id);
-- Проверить, что не остался невалидным после сбоя:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
Практика: правила, которые стоит проговорить вслух
  • Индекс создаётся под запрос, а не «на всякий случай для колонки».
  • Перед созданием смотришь EXPLAIN, после создания снова EXPLAIN плюс ANALYZE таблицы.
  • На проде только CREATE INDEX CONCURRENTLY и только вне транзакционного блока.
  • Составной индекс (a, b) делает индекс (a) лишним, его можно удалить.
  • Раз в квартал проверяй pg_stat_user_indexes на idx_scan = 0.
  • На горячей пишущей таблице лишний индекс стоит дороже, чем кажется: он бьёт и по WAL, и по репликации.

Вопросы

11
Суть: индекс — отдельная упорядоченная структура «значение → адрес строки». B-tree выбран потому, что стоимость меряется не сравнениями, а обращениями к диску: узел B-tree — это целая страница 8 КБ с сотнями ключей, поэтому дерево на 100 млн строк имеет высоту 3–4, а бинарное — 27.

Что физически лежит в индексе

В листьях B-tree лежат пары «ключ → ctid», где ctid хранит пару (номер страницы, номер слота внутри страницы), то есть физический адрес версии строки. Внутренние узлы содержат только разделители-ключи и ссылки на дочерние страницы. Вдобавок листья связаны в двусвязный список, поэтому индекс умеет отдавать данные уже отсортированными и обслуживать диапазонные условия BETWEEN, >, < одним проходом вправо по листьям.

Арифметика, которую хотят услышать

Страница 8 КБ, ключ bigint + ctid ≈ 16 байт с накладными расходами, и на страницу влезает порядка 400 записей. Три уровня дают 400³ ≈ 64 млн ключей, четыре — 25 млрд. Значит, чтобы найти конкретное значение в таблице на 100 млн строк, хватит 4 чтений страниц, причём верхние уровни почти всегда уже в shared_buffers, то есть реальных обращений к диску обычно 1–2. Против 20 ГБ последовательного чтения при Seq Scan.

Почему бинарное дерево проигрывает

  • Ветвление 2 против ~400, отсюда высота 27 против 4.
  • Узлы бинарного дерева разбросаны по произвольным местам, отсюда случайные чтения, самые дорогие для диска.
  • B-tree сбалансировано по построению: все листья на одном уровне, худший случай равен среднему. У обычного BST при вставке возрастающих ключей (а это типичный id) дерево вырождается в список.

Что происходит при вставке

Значение попадает в нужный лист; если лист переполнен, случается split: страница делится пополам, разделитель поднимается в родителя, при переполнении родителя split идёт вверх, и в пределе растёт высота дерева. Отсюда и цена записи: вставка в индекс не сводится к «дописать в конец», это поиск + возможная перестройка страниц + запись в WAL.

Отсюда же понятно, чем плох случайный UUID в первичном ключе

Монотонный ключ (bigserial, время) всегда попадает в самый правый лист: дерево растёт «в край», split случается редко и трогает одну страницу, а горячих страниц в памяти всего несколько. Случайный UUIDv4 попадает в случайный лист, поэтому рвёт страницы по всему индексу сразу: split-ов на порядок больше, в буферах приходится держать весь индекс целиком, и каждый разрыв страницы вдобавок тянет за собой full-page write в WAL. Плюс 16 байт против 8 у bigint — и этот размер повторяется в каждом вторичном индексе.

Лечится это UUIDv7: первые 48 бит в нём занимает время в миллисекундах, поэтому такие идентификаторы сортируются по времени и ложатся в правый край не хуже bigserial. Главное свойство UUID остаётся: их генерирует клиент, без похода в базу. В PostgreSQL 18 есть встроенная функция uuidv7(); в Go с версии 1.27 генератор лежит прямо в стандартной библиотеке — пакет uuid по RFC 9562: uuid.NewV7(), uuid.NewV4(), uuid.New(), uuid.Parse, а тип uuid.UUID объявлен как [16]byte. До 1.27 для этого брали внешний github.com/google/uuid.

Чем добить

«В PostgreSQL это B-tree по алгоритму Lehman–Yao: у страниц есть right-link, и читатели идут по дереву без блокировки всего пути, пока писатель делает split. С Postgres 13 у B-tree есть дедупликация повторяющихся ключей: она заметно уменьшает индексы по неуникальным колонкам и работает даже в уникальных индексах, где сдерживает раздувание от мёртвых версий одной и той же строки.»

Суть: B-tree — упорядочиваемые скалярные типы и 95 % задач; GIN — «много значений в одном поле» (jsonb, массивы, полнотекст, триграммы); GiST — пересечения геометрии и диапазонов; BRIN — огромные таблицы, физически упорядоченные по колонке; Hash — только равенство; SP-GiST — несбалансированные разбиения пространства.

Разбор по типам

  • B-tree берёт всё, что имеет порядок: =, <, >, BETWEEN, IN, IS NULL, префиксный LIKE 'abc%', а также ORDER BY и MIN/MAX без сортировки. Уникальность и первичные ключи умеет только он.
  • Hash понимает только =: ни сортировки, ни диапазонов, ни уникальности. С PG 10 он пишется в WAL (до этого не переживал крэш и не реплицировался), но выигрыш перед B-tree мал, и берут его редко — разве что для очень длинных строк, где хеш заметно компактнее.
  • GIN (inverted index) нужен, когда в одном значении много элементов: массивы (@>, &&), jsonb (@>, ?), полнотекстовый поиск (tsvector @@ tsquery), подстроки через pg_trgm. Внутри B-tree по элементам, а в листьях сжатые списки ctid. Читается быстро, пишется медленно; это смягчают настройки fastupdate и gin_pending_list_limit.
  • GiST — обобщённое дерево поиска для данных, где «ключом» служит область: геометрия PostGIS, диапазоны tsrange/int4range с оператором пересечения &&, ближайшие соседи (ORDER BY point <-> point), а также EXCLUDE-констрейнты (например, «брони не пересекаются»).
  • SP-GiST делит пространство (quad-tree, k-d tree, radix): точки, IP-сети inet, префиксный поиск по строкам. Хорош там, где данные неравномерны.
  • BRIN (Block Range INdex) хранит min/max по диапазону страниц (по умолчанию 128), и индекс на терабайтную таблицу занимает мегабайты. Работает, только если физический порядок строк коррелирует со значением колонки. Классический случай: created_at в append-only логах и метриках.

Как выбирать на собеседовании

Вопрос закрывает такой ответ: «сначала спрашиваю, какой оператор в WHERE. Оператор определяет класс индекса: под равенство и диапазон B-tree, под «содержит» GIN, под «пересекается» / «ближайший» GiST, под «строки идут по возрастанию и таблица огромная» BRIN». Проверить, что оператор реально поддерживается, можно через pg_amop или просто по EXPLAIN.

Подвох

jsonb с jsonb_path_ops заметно меньше и быстрее дефолтного jsonb_ops, но поддерживает только @>. А BRIN на таблице, куда пишут в случайном порядке (или после массового UPDATE, разбросавшего строки), бесполезен: диапазоны страниц перекрываются, и приходится читать почти всё. Корреляцию видно в pg_stats.correlation.

Суть: GiST (в PostGIS — по умолчанию), иногда SP-GiST для точек; B-tree для геометрии бесполезен, потому что у двумерных объектов нет естественного линейного порядка.

Почему не B-tree

Запрос «найди всё в радиусе 2 км» или «какие полигоны пересекают этот прямоугольник» не сводится к сравнению по одной оси. Отсортировать точки по x не помогает: близкие по обеим координатам точки окажутся в разных концах индекса. Нужна структура, которая группирует объекты по пространственной близости.

Как работает GiST на геометрии

В узлах хранятся ограничивающие прямоугольники (MBR, bounding box) дочерних объектов. Поиск спускается только в те ветки, чей прямоугольник пересекается с искомой областью. Получается R-tree, реализованное поверх обобщённого GiST. Результат приблизительный по bbox, поэтому PostGIS делает двухфазный поиск: индекс отдаёт кандидатов по bbox, затем точный предикат (ST_Intersects, ST_DWithin) отсеивает лишнее.

CREATE EXTENSION postgis;
ALTER TABLE places ADD COLUMN geom geography(Point, 4326);
CREATE INDEX places_geom_idx ON places USING GIST (geom);

-- Всё в радиусе 2 км: ST_DWithin умеет пользоваться индексом
SELECT id, name FROM places
WHERE  ST_DWithin(geom, ST_MakePoint($1, $2)::geography, 2000);

-- Ближайшие 10 (KNN, оператор расстояния идёт прямо в индекс)
SELECT id FROM places ORDER BY geom <-> ST_MakePoint($1, $2)::geography LIMIT 10;

Варианты

  • SP-GiST (quad-tree) на чистых точках бывает быстрее и компактнее GiST.
  • BRIN подойдёт гигантским таблицам, если данные физически сгруппированы по географии (например, загружены по регионам).
  • Геохеш / H3 в отдельной колонке + обычный B-tree обходятся без PostGIS: координату кодируют в строку/число так, чтобы у близких точек был общий префикс, и ищут по префиксу. Работает, но на границах ячеек теряет близкие точки, их добирают из соседних ячеек.
Разница geometry и geography

geometry считает на плоскости (быстро, но расстояния в градусах или в проекции), geography на сфероиде (медленнее, зато ST_DWithin сразу в метрах). Для «радиус в километрах по всей стране» берут geography; для локальных задач хватит geometry в подходящей метрической проекции.

Суть: составной индекс отсортирован по кортежу (a, b, c), поэтому эффективно ищет только по левому префиксу: a, a+b, a+b+c. Порядок колонок: сначала равенства, потом диапазон/сортировка.

Почему это следует из устройства

Ключи в индексе отсортированы сначала по a, внутри одинаковых a — по b, внутри одинаковых b — по c. Как телефонный справочник по «фамилия, имя, отчество»: всех Ивановых найти легко, а всех Петровичей — только полным перебором.

Запрос при индексе (a, b, c)Что будет
WHERE a = 1полноценный Index Scan
WHERE a = 1 AND b = 2полноценный, ещё уже диапазон
WHERE a = 1 AND b = 2 AND c = 3идеальный случай
WHERE a = 1 AND c = 3по a — по индексу, c проверяется фильтром на найденных строках
WHERE b = 2обычно Seq Scan; изредка Index Only Scan с полным проходом индекса, если он сильно меньше таблицы
WHERE a > 10 AND b = 2по индексу отработает только a: после диапазона порядок по b теряется
ORDER BY a, bсортировка бесплатна
ORDER BY b, aпридётся сортировать

Правило порядка колонок

  1. Сначала колонки с равенством (=, IN): они сужают поиск до непрерывного участка индекса.
  2. Затем колонка диапазона (>, BETWEEN). Полезной она бывает только одна и только последней среди «поисковых».
  3. Затем колонки сортировки, в том же направлении, что в ORDER BY.
  4. Селективность внутри группы равенств вторична: она влияет на размер индекса и на то, сколько других запросов он покроет, но не на то, применим ли он вообще.
Что запомнить

«Равенства → диапазон → сортировка». Если помнить только это правило, 80 % задач на составные индексы решаются с листа. И второе: индекс (a, b) делает индекс (a) избыточным — отдельный индекс по первой колонке можно удалить, сэкономив на записи.

На чём ловят

Совет «ставь самую селективную колонку первой» популярен, но неточен. Если самая селективная колонка участвует в запросе диапазоном, а менее селективная — равенством, правильный порядок обратный. Проверять надо планом, а не эвристикой.

Суть: если все колонки запроса есть в индексе, PostgreSQL может ответить вообще без чтения таблицы — это Index Only Scan. INCLUDE добавляет колонки только в листья индекса: они доступны для чтения, но не участвуют в поиске, сортировке и уникальности.

Почему индексу всё-таки обычно нужна таблица

В индексной записи нет информации о видимости: там нет xmin/xmax. Индекс не знает, видит ли текущая транзакция конкретную версию строки, поэтому обычный Index Scan обязан сходить в heap и проверить заголовок кортежа. Обойти это помогает visibility map — битовая карта, где взведённый бит означает «все версии на этой странице видны всем транзакциям». Если бит взведён, идти в heap не нужно.

Условия index-only scan

  • Все колонки из SELECT, WHERE, ORDER BY есть в индексе.
  • Свежий VACUUM: visibility map актуальна.
  • В плане это видно как Heap Fetches: 0. Большое число здесь означает, что формально index-only scan выбран, а фактически мы всё равно ходим в таблицу.

INCLUDE против добавления колонки в ключ

-- Вариант 1: колонка в ключе участвует в поиске и сортировке, но раздувает внутренние узлы
CREATE INDEX i1 ON orders (customer_id, total);
-- Вариант 2: с INCLUDE total лежит только в листьях
CREATE INDEX i2 ON orders (customer_id) INCLUDE (total);

-- Для уникальных индексов разница такая:
CREATE UNIQUE INDEX u ON users (email) INCLUDE (name);
-- уникальность по email, а name просто «едет» в листе.
-- Если бы написали (email, name), уникальной была бы пара, а это другая семантика.

INCLUDE (с PG 11) выгоден, когда колонка нужна только для чтения: она не увеличивает внутренние узлы дерева, значит высота и ветвление не страдают, а типы могут быть даже такими, для которых нет операторов сравнения B-tree.

Когда это реально спасает

Типичный кейс: агрегат по большой таблице вроде SELECT sum(total) FROM orders WHERE customer_id = 42. С обычным индексом это 5000 случайных чтений heap, с покрывающим — пара страниц индекса. На проде это выливается в миллисекунды против секунд.

Суть: частичный индексирует только строки, попавшие под WHERE (меньше размер, быстрее запись); функциональный индексирует результат выражения, что позволяет попасть в индекс запросам вида WHERE lower(email) = ....

Частичный индекс

-- Мягкое удаление: 99 % запросов ходят только за живыми записями
CREATE INDEX users_active_idx ON users (created_at) WHERE deleted_at IS NULL;

-- Очередь: строк в статусе pending всегда мало, а таблица огромная
CREATE INDEX jobs_pending_idx ON jobs (run_at) WHERE status = 'pending';

-- Уникальность только среди живых: UNIQUE-констрейнтом не выразить, частичным индексом — да
CREATE UNIQUE INDEX users_email_uniq ON users (email) WHERE deleted_at IS NULL;

Планировщик использует частичный индекс, только если может доказать, что предикат запроса влечёт предикат индекса. То есть WHERE status = 'pending' попадёт, а WHERE status = $1 с параметром, как правило, нет: на момент планирования значение неизвестно (спасает generic vs custom plan, но полагаться на это нельзя).

Функциональный индекс

CREATE INDEX users_lower_email_idx ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower($1);   -- попадёт

CREATE INDEX events_day_idx ON events ((created_at::date));
CREATE INDEX orders_meta_client_idx ON orders ((meta->>'client_id'));

Есть ограничение: выражение должно быть IMMUTABLE. Поэтому CREATE INDEX ... (created_at AT TIME ZONE 'UTC') пройдёт, а (now() - created_at) — нет: индекс, который меняется со временем, смысла не имеет.

Их можно совместить

Частичный + функциональный: CREATE INDEX ON users (lower(email)) WHERE deleted_at IS NULL;. И ещё приём: вместо created_at::date = '2024-01-01' лучше писать created_at >= '2024-01-01' AND created_at < '2024-01-02' — тогда хватит обычного индекса по колонке, без функционального.

Суть: в PostgreSQL констрейнт реализован уникальным индексом — физически это одно и то же. Разница логическая: констрейнт виден в метаданных как ограничение, на него можно сослаться внешним ключом и указать в ON CONFLICT ON CONSTRAINT; индекс гибче — умеет частичность, выражения и CONCURRENTLY.

Что происходит при создании констрейнта

ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
-- PostgreSQL молча создаёт индекс users_email_key и привязывает его к констрейнту.
-- DROP INDEX users_email_key;  -- ошибка: индекс принадлежит констрейнту
UNIQUE constraintUNIQUE index
Частичный (WHERE)нельзяможно
По выражениюнельзяможно
Цель для FOREIGN KEYдада (достаточно уникального индекса)
ON CONFLICT ON CONSTRAINT nameданет, только ON CONFLICT (columns)
Создание без блокировкинет (но можно в два шага)CREATE UNIQUE INDEX CONCURRENTLY
Отложенная проверка DEFERRABLEданет

Приём для прода

-- Добавить уникальность на живую таблицу без длинной блокировки:
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq ON users (email);
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE USING INDEX users_email_uniq;
-- Второй шаг берёт короткую блокировку и просто «усыновляет» готовый индекс.
NULL и уникальность

По стандарту NULL не равен NULL, поэтому уникальный индекс разрешает сколько угодно строк с NULL в колонке. Классический баг мягкого удаления: UNIQUE (email, deleted_at) не мешает вставить двух живых пользователей с одним email, если у обоих deleted_at IS NULL. Лечится частичным уникальным индексом или, с PG 15, синтаксисом UNIQUE NULLS NOT DISTINCT.

Суть: три причины — (1) условие не «ложится» на индекс (функция или каст над колонкой, LIKE '%x%', не левый префикс), (2) планировщик считает Seq Scan дешевле (низкая селективность, маленькая таблица), (3) он ошибается из-за устаревшей статистики.

Условие не подходит структуре

  • Функция над колонкой: WHERE lower(email) = 'a@b.c', WHERE date(created_at) = '2024-01-01'. Индекс хранит значения колонки, а не результат функции. Лечится функциональным индексом или переписыванием условия в диапазон.
  • Неявный каст: колонка varchar, а параметр приходит числом; или WHERE id::text = $1. Для индекса каст ничем не отличается от функции. Правильно приводить параметр к типу колонки, а не наоборот.
  • LIKE '%abc%': B-tree ищет по префиксу, а его тут нет. Нужен pg_trgm + GIN. Даже LIKE 'abc%' в базе не с C-локалью не попадёт в индекс без text_pattern_ops.
  • Не левый префикс составного индекса (см. вопрос про составные индексы).
  • OR по разным колонкам: одним индексом его не покрыть; иногда выручает BitmapOr по двум индексам, иногда переписывание в UNION ALL.
  • ORDER BY a ASC, b DESC при индексе (a, b): направления не совпали.

Индекс подходит, но он не выгоден

Если под условие попадает существенная доля таблицы (эмпирически больше 5–20 %), Index Scan проигрывает: он делает случайные чтения heap-страниц, а Seq Scan читает подряд, что на порядок дешевле, да ещё и пачками. Планировщик тут поступает правильно, и на собеседовании ждут ответа «здесь Seq Scan оптимален», а не «надо заставить индекс». Есть и промежуточный вариант, Bitmap Heap Scan: PostgreSQL собирает ctid в битовую карту, сортирует по номерам страниц и читает heap условно последовательно.

Планировщик ошибается

Оценки берутся из pg_statistic, который наполняет ANALYZE. После массовой загрузки, после создания индекса, после смены распределения данных статистика врёт, и в плане rows= и actual rows= расходятся на порядки. Лечение: ANALYZE table, поднять default_statistics_target (по умолчанию 100) для конкретной колонки, добавить CREATE STATISTICS для коррелированных колонок (город и страна, товар и категория).

Диагностика

Проверить гипотезу «индекс есть, но не берётся из-за цены» можно так: SET enable_seqscan = off; и снова EXPLAIN ANALYZE. Если с индексом реально быстрее — дело в оценках стоимости: обычно виноват завышенный random_page_cost (на SSD его снижают с 4.0 до 1.1) или заниженный effective_cache_size. В прод так, разумеется, не катят, это только диагностика.

Суть: индекс — это вторая копия данных, которую нужно синхронно поддерживать на каждой записи. Индексы разменивают скорость записи, место и объём WAL на скорость чтения.

Конкретная цена

  • INSERT / DELETE трогают все индексы таблицы: поиск места + возможный split страницы + запись в WAL по каждому индексу.
  • UPDATE, задевший индексируемую колонку, ломает HOT: вместо «новая версия в той же странице и индексы не трогаем» получаем записи во все индексы. Один лишний индекс по «горячей» колонке может удвоить стоимость обновления.
  • WAL растёт → растёт трафик репликации, дольше идёт восстановление, больше нагрузки на диск.
  • Место: индексы часто занимают больше самой таблицы. И они конкурируют с данными за shared_buffers и страничный кэш ОС — то есть вытесняют оттуда полезное.
  • VACUUM обходит каждый индекс, чтобы вычистить ссылки на мёртвые версии; чем больше индексов, тем дольше и тяжелее autovacuum.
  • Планировщик перебирает больше вариантов, и время планирования растёт (заметно на OLTP с тысячами простых запросов в секунду).

Как это звучит на собесе

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

Второй красный флаг

«Поставим индексы на все колонки, участвующие в WHERE». На деле планировщик обычно берёт один индекс на таблицу в запросе (BitmapAnd бывает исключением, и он не бесплатен), поэтому пять одноколоночных индексов проигрывают одному правильному составному, а стоят впятеро дороже на записи.

Суть: pg_stat_user_indexes.idx_scan = 0 за длительный период наблюдения — кандидат на удаление; плюс проверка на дубли по indkey и на индексы, являющиеся префиксом других.
-- 1. Ни разу не использованные, отсортированные по занимаемому месту
SELECT s.relname            AS tbl,
       s.indexrelname       AS idx,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS sz,
       s.idx_scan
FROM   pg_stat_user_indexes s
JOIN   pg_index i ON i.indexrelid = s.indexrelid
WHERE  s.idx_scan = 0
  AND  NOT i.indisunique       -- уникальные держат бизнес-инвариант
  AND  NOT i.indisprimary
ORDER  BY pg_relation_size(s.indexrelid) DESC;

-- 2. Полные дубликаты: одинаковый набор колонок в одном порядке
SELECT indrelid::regclass AS tbl, array_agg(indexrelid::regclass) AS dupes
FROM   pg_index GROUP BY indrelid, indkey, indpred, indexprs HAVING count(*) > 1;

-- 3. Когда сбросили статистику: без этого idx_scan = 0 ничего не значит
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();

Что обязательно оговорить

  • Статистика копится с момента последнего сброса и только на этом узле. На реплике запросы другие: индекс, «неиспользуемый» на мастере, может активно работать на реплике для отчётов. Проверять надо на всех узлах.
  • Есть редкие, но критичные запросы: месячная выгрузка, ночной пересчёт, ручной поиск в поддержке. Наблюдать нужно хотя бы полный бизнес-цикл — месяц.
  • Уникальные индексы и индексы под внешними ключами не удалять: они держат ограничения целостности, даже если idx_scan = 0. Кстати, колонка FK без индекса сама по себе беда: DELETE в родительской таблице будет делать Seq Scan по дочерней.
  • Безопасного приёма вместо DROP в PostgreSQL штатно нет: сделать индекс невидимым для планировщика нельзя (в MySQL для этого есть ALTER TABLE ... ALTER INDEX ... INVISIBLE). Поэтому на практике индекс переименовывают и держат неделю, чтобы быстро вернуть, либо снимают DDL заранее.
Обратная задача

Недостающие индексы ищут по pg_stat_user_tables: подозрительны таблицы, где seq_scan велик, а seq_tup_read / seq_scan даёт тысячи строк за скан; плюс pg_stat_statements по суммарному времени. Дальше смотрят EXPLAIN конкретного запроса, а не гадают.

Суть: обычный CREATE INDEX берёт блокировку SHARE, которая на всё время построения останавливает запись в таблицу. CONCURRENTLY строит индекс в два прохода, не мешая записи, ценой большей длительности и риска остаться в состоянии INVALID.

Что происходит без CONCURRENTLY

Блокировка SHARE конфликтует с ROW EXCLUSIVE, который берут INSERT/UPDATE/DELETE. На таблице в 500 ГБ индекс строится десятки минут — и всё это время запись в таблицу стоит. Хуже того, ожидающие запросы выстраиваются в очередь за нашей блокировкой и блокируют даже чтения, которые сами по себе конфликта не имели бы.

Как работает CONCURRENTLY

  1. Первый проход: строит индекс по снапшоту, параллельно записи продолжаются.
  2. Ждёт завершения всех транзакций, начавшихся до первого прохода.
  3. Второй проход: добирает строки, изменившиеся за время первого прохода.
  4. Ждёт транзакции, которые могли не видеть индекс, и помечает его валидным.

Ограничения, о которых спрашивают

  • Нельзя внутри транзакционного блока, так что миграция должна уметь выполнять такой шаг вне транзакции (в goose это -- +goose NO TRANSACTION, в golang-migrate — отдельный файл и x-no-transaction либо ручной запуск).
  • Идёт в 2–3 раза дольше и сильнее нагружает диск.
  • При сбое остаётся невалидный индекс: планировщик его не берёт, но на каждой записи он обновляется, то есть только вредит. Искать так: SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;, лечить DROP INDEX CONCURRENTLY и повторной попыткой.
  • Долгая транзакция блокирует завершение: если кто-то держит открытую транзакцию, CONCURRENTLY будет ждать её вечно. Перед запуском стоит проверить pg_stat_activity.
  • Уникальный индекс при конфликте данных упадёт на втором проходе, когда всё время уже потрачено.
Правило

На проде: CREATE INDEX CONCURRENTLY + DROP INDEX CONCURRENTLY, вне транзакции, с последующей проверкой indisvalid и ANALYZE таблицы. Так же работает REINDEX CONCURRENTLY (с PG 12), которым лечат раздутые индексы.

6.5Оптимизация запросов

Здесь проверяют не эрудицию, а то, что ты делаешь в 3 часа ночи, когда база встала. Нужно читать план выполнения и принимать решение по нему, а не гадать. И узнавать типовые антипаттерны (N+1, OFFSET, SELECT *) в чужом коде с первого взгляда.

EXPLAIN и EXPLAIN ANALYZE

EXPLAIN показывает план, который выбрал планировщик, вместе с его оценками: сколько строк он ожидает и во что оценивает стоимость. Запрос при этом не выполняется. EXPLAIN ANALYZE же реально выполняет запрос и добавляет к плану фактические числа: сколько строк вернул каждый узел, сколько времени занял, сколько раз был вызван.

Разница принципиальная: EXPLAIN отвечает на вопрос «что база собирается делать», EXPLAIN ANALYZE — «что она сделала и где ошиблась в оценках». Диагностика почти всегда живёт во втором: плохие планы чаще всего растут из расхождения между rows и actual rows.

Главный подвох: ANALYZE на пишущем запросе

EXPLAIN ANALYZE UPDATE ... реально выполнит UPDATE. Не «покажет план», а обновит строки. То же с DELETE, INSERT, TRUNCATE. На проде это способ уронить данные одной командой. Безопаснее обернуть запрос в транзакцию и откатить:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'cancelled' WHERE id = 42;
ROLLBACK;   -- обязательно; лучше набрать заранее, а не после

Подготовленный кандидат тут сделает оговорку: даже с ROLLBACK побочные эффекты остаются. Транзакция успела взять блокировки на изменяемые строки и держит их до конца блока; сработали триггеры (включая внешние вызовы, если они есть); сгенерировался WAL; выросли счётчики последовательностей — nextval не откатывается. На горячей таблице такой «безопасный» эксперимент легко выстроит очередь блокировок.

ОпцияЧто даётКогда включать
ANALYZEреальное выполнение + фактические строки и времяпочти всегда при разборе
BUFFERSсколько страниц прочитано из кэша (hit) и с диска (read)всегда вместе с ANALYZE
VERBOSEсписок выводимых колонок, схемы, имена функцийкогда непонятно, что за узел
SETTINGSизменённые от дефолта параметры планировщикакогда план «странный»
WALобъём сгенерированного WALразбор тяжёлой записи
FORMAT JSONмашиночитаемый пландля explain.dalibo.com / explain.depesz.com
GENERIC_PLAN (PG 16)план для запроса с параметрами без их значенийразбор запроса из pg_stat_statements
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM   orders o JOIN customers c ON c.id = o.customer_id
WHERE  o.created_at >= now() - interval '7 days' AND o.status = 'paid'
ORDER  BY o.created_at DESC
LIMIT  50;

Как читать план: снизу вверх и изнутри наружу

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

Дерево плана: данные текут снизу вверх Limit rows=50 actual=50 Sort (created_at DESC) Sort Method: top-N heapsort Hash Join cond: c.id = o.customer_id Index Scan on orders rows=1200 actual=1180 Buffers: hit=340 Hash Buckets: 4096 Memory: 512kB Seq Scan on customers rows=40000 actual=40000 Buffers: read=980 внешняя (probe) сторона внутренняя (build) сторона Читать начинаем отсюда: самые глубокие узлы — это сканы таблиц. Здесь и ищем проблему: Seq Scan 40 000 строк с диска (read, не hit).
Дерево плана. Отступы в текстовом выводе показывают уровни дерева. Каждый узел получает строки от детей и отдаёт родителю. Время в родителе включает время детей, поэтому «собственную» стоимость узла надо вычитать.

Узлы, которые надо узнавать

УзелЧто делаетКогда это нормально / плохо
Seq Scanчитает всю таблицу подряднорма для маленькой таблицы и для выборки большой доли строк; плохо, если фильтр отсекает почти всё
Index Scanспуск по индексу + поход в heap за каждой строкойнорма при выборке единиц-сотен строк; при десятках тысяч случайных чтений проигрывает Seq Scan
Index Only Scanтолько индекс, heap не читаетсялучший вариант; следи за Heap Fetches — если оно велико, выигрыш потерян
Bitmap Index Scan + Bitmap Heap Scanсобирает ctid в битовую карту, сортирует по страницам, читает heap почти последовательнокомпромисс между Index и Seq Scan; появление lossy в Heap Blocks означает нехватку work_mem
Nested Loopдля каждой строки внешней таблицы ищет совпадения во внутреннейотлично, если внешних строк единицы и есть индекс внутри; катастрофа, если внешних строк миллион (обычно — ошибка оценки)
Hash Joinстроит хеш меньшей таблицы в памяти, прогоняет большуюрабочая лошадка для больших джойнов по равенству; Batches > 1 = хеш не влез в work_mem, ушёл на диск
Merge Joinсливает два отсортированных потокахорош, когда обе стороны уже отсортированы (индексами); иначе платим за два Sort
Sortсортируетсмотри Sort Method: quicksort/top-N heapsort в памяти — ок, external merge Disk: 250MB — поднимай work_mem или дай индекс
HashAggregate / GroupAggregateгруппировка хешем или по отсортированному входуHashAggregate быстрее, но требует памяти; при её нехватке — Disk Usage
Materialize, Memoizeкэширует результат подузла между итерациямиMemoize (PG 14) спасает Nested Loop с повторяющимися ключами
Gather / Gather Mergeсобирает результат параллельных воркеровсмотри Workers Launched: может быть меньше запрошенных

Числа в плане и что они значат

Seq Scan on orders  (cost=0.00..24583.00 rows=980 width=64)
                    (actual time=0.021..312.447 rows=51230 loops=1)
  Filter: (status = 'paid'::text)
  Rows Removed by Filter: 948770
  Buffers: shared hit=1204 read=13379
  • cost=A..B дан в условных единицах планировщика, не в миллисекундах. A оценивает путь до первой строки (важен при LIMIT), B — до последней. За единицу принята стоимость последовательного чтения одной страницы (seq_page_cost = 1.0); случайное чтение по умолчанию стоит 4.0. Сравнивать cost можно только между планами одного запроса.
  • rows vs actual rows сравнивают оценку с фактом. Расхождение в 10+ раз и есть главная зацепка: почти всегда это устаревшая статистика, коррелированные колонки, которые планировщик считает независимыми, или неудачная оценка селективности выражения. Лечится ANALYZE, default_statistics_target, CREATE STATISTICS.
  • loops показывает, сколько раз узел выполнялся. Время и строки в плане указаны на одну итерацию: реальное время узла = actual time × loops. Классическая ловушка: «0.03 ms, всё быстро» при loops=200000, а это 6 секунд.
  • Rows Removed by Filter считает строки, которые прочитали и выбросили. Миллион выброшенных при тысяче нужных прямо говорит, что нужен индекс по этому предикату (возможно, частичный).
  • Buffers: shared hit=... read=... делит страницы на hit, взятые из shared_buffers, и read, прочитанные из ОС/диска. Точнее меры «сколько данных мы реально перелопатили» нет, и от прогретости кэша она зависит меньше, чем время. dirtied/written показывают побочную запись.
  • Planning Time / Execution Time: если планирование сравнимо с выполнением, а запрос простой, смотри в сторону избытка индексов или подготовленных выражений.
Алгоритм чтения плана за 30 секунд
  1. Найти узел с наибольшим собственным временем (время узла минус время детей, умноженное на loops).
  2. Посмотреть на его rows против actual rows. Если оценка врёт, проблема в статистике, а не в запросе.
  3. Посмотреть Rows Removed by Filter — если много, нужен индекс/предикат.
  4. Проверить Buffers read: при сотнях тысяч мы просто читаем слишком много данных.
  5. Проверить признаки нехватки памяти: Batches > 1, external merge, lossy, Disk Usage.

Кейс: запрос на таблице в несколько терабайт тормозит

Это любимый сценарий на собеседовании, потому что он проверяет порядок действий, а не знание команд. Правильный ответ начинается со слов «сначала измерю», а не «добавлю индекс».

  1. Понять, что именно тормозит. Не «сайт медленный», а конкретный запрос: pg_stat_statements с сортировкой по total_exec_time (не по mean: главным вредителем часто оказывается быстрый запрос, который выполняется 50 000 раз в минуту) и pg_stat_activity для того, что висит прямо сейчас.
  2. Снять план. EXPLAIN (ANALYZE, BUFFERS) на репрезентативных параметрах. Если запроса не дождаться, хватит EXPLAIN без ANALYZE плюс auto_explain с log_min_duration в проде.
  3. Проверить, не блокировки ли это. Медленный не значит «плохой план»: посмотреть wait_event_type = 'Lock' в pg_stat_activity. Если ждём блокировку, план тут ни при чём.
  4. Проверить статистику. rows против actual rows. Если оценка врёт на порядки, запустить ANALYZE и снять план заново. Половина «загадочных» проблем закрывается здесь.
  5. Уменьшить объём читаемых данных. В порядке предпочтения:
    • индекс под предикат (составной, частичный, покрывающий);
    • переписать запрос: убрать функцию с колонки, заменить OR на UNION ALL, коррелированный подзапрос — на джойн или оконную функцию, DISTINCT — на EXISTS, вынести тяжёлую агрегацию в CTE с MATERIALIZED или наоборот;
    • убрать SELECT *, чтобы стал возможен index-only scan.
  6. Партиционирование. Если таблица многотерабайтная и запросы всегда ограничены по времени или тенанту, подойдёт RANGE по дате или HASH по tenant_id. Заодно решается проблема обслуживания: удаление старых данных превращается из многочасового DELETE в мгновенный DETACH PARTITION.
  7. Изменить модель. Предагрегировать (материализованное представление, счётчики, роллапы), вынести холодные данные в архивную таблицу, денормализовать нужную колонку, чтобы избавиться от джойна. Это уже не «оптимизация запроса», а изменение схемы — но на терабайтах именно оно обычно и работает.
  8. Только потом — железо и параметры. work_mem для конкретного запроса, random_page_cost под SSD, max_parallel_workers_per_gather, вынос отчётов на реплику.
Как это звучит в ответе

«Сначала я нахожу конкретный запрос через pg_stat_statements, снимаю EXPLAIN ANALYZE BUFFERS и смотрю, где расходятся оценки и где читается больше всего страниц. Дальше иду от дешёвого к дорогому: статистика → индекс → переписывание запроса → партиционирование → изменение модели данных. Железо и настройки оставляю на конец: они дают проценты, а первые шаги — порядки».

Проблема N+1

N+1 случается, когда вместо одного запроса за связанными данными приложение делает один запрос за списком и ещё по одному на каждый его элемент. Сто заказов — сто один запрос. Каждый из них быстрый (0,4 мс), поэтому в логах БД проблема не выглядит проблемой; убивает сетевой round-trip: 101 × (RTT 0,5 мс + разбор + планирование) вместо одного.

N+1: сто быстрых запросов дороже одного «медленного» Было: 1 + N запросов 0 мс ≈ 90 мс SELECT * FROM orders LIMIT 100 1 round-trip SELECT * FROM customers WHERE id = ? — ещё 100 раз Стало: 1 запрос 0 мс ≈ 3 мс SELECT ... FROM orders JOIN customers ON ... либо WHERE customer_id = ANY($1) одним IN 1 round-trip, 1 план, 1 проход по данным
N+1 на оси времени. Каждый из ста запросов «быстрый», но суммарная задержка складывается из сотни сетевых обменов и сотни планирований. В логах медленных запросов такого не видно вообще, поэтому искать надо по количеству, а не по длительности.

Как обнаружить

  • Логи БД: временно log_min_duration_statement = 0 на одном инстансе и посмотреть, что один HTTP-запрос породил 200 строк лога с одинаковым текстом и разными параметрами. В pg_stat_statements то же видно по огромному calls при крошечном mean_exec_time.
  • Трейсинг: в OpenTelemetry на спане обработчика видно частокол одинаковых дочерних спанов db.query. Это самый наглядный способ показать проблему продакту.
  • Счётчик запросов в тесте лучше всего: он ловит регрессию до прода. Оборачиваем драйвер и проверяем ожидаемое число обращений.
// Тест, который ловит N+1 до релиза: считаем запросы через обёртку над драйвером.
type countingDriver struct {
    inner driver.Driver
    n     *atomic.Int64
}

func TestListOrders_NoNPlusOne(t *testing.T) {
    db, counter := newCountingDB(t)          // регистрирует обёрнутый драйвер
    svc := service.New(repo.New(db))

    _, err := svc.ListOrdersWithCustomers(ctx, 100)
    require.NoError(t, err)

    // Ровно два запроса: заказы + пачка клиентов одним IN.
    // Если кто-то вернёт цикл с запросом внутри, тест упадёт с n = 101.
    require.LessOrEqual(t, counter.Load(), int64(2),
        "похоже на N+1: запросов %d", counter.Load())
}

Как чинить

// Плохо: N+1
orders, _ := repo.ListOrders(ctx, 100)
for i, o := range orders {
    // отдельный поход в БД на каждой итерации
    c, err := repo.GetCustomer(ctx, o.CustomerID)
    if err != nil {
        return err
    }
    orders[i].Customer = c
}
// Хорошо: два запроса, батч по ключам
orders, _ := repo.ListOrders(ctx, 100)

ids := make([]int64, 0, len(orders))
seen := make(map[int64]struct{}, len(orders))
for _, o := range orders {
    if _, ok := seen[o.CustomerID]; !ok {
        seen[o.CustomerID] = struct{}{}
        ids = append(ids, o.CustomerID)
    }
}
// WHERE id = ANY($1): один запрос, один план
custs, _ := repo.GetCustomersByIDs(ctx, ids)
byID := make(map[int64]Customer, len(custs))
for _, c := range custs {
    byID[c.ID] = c
}
for i, o := range orders {
    orders[i].Customer = byID[o.CustomerID]
}
-- Вариант 1: JOIN, когда нужна плоская выборка
SELECT o.id, o.total, c.id, c.name
FROM   orders o JOIN customers c ON c.id = o.customer_id
WHERE  o.created_at >= $1;

-- Вариант 2: батч по ключам. В Go с pgx массив передаётся напрямую:
SELECT id, name FROM customers WHERE id = ANY($1);
-- ANY($1) лучше, чем IN (...) со склейкой: один текст запроса → один план в кэше,
-- а не тысяча разных текстов с разным числом плейсхолдеров.

-- Вариант 3: собираем вложенный список на стороне БД (вместо N запросов за позициями)
SELECT o.id, o.total,
       coalesce(jsonb_agg(jsonb_build_object('sku', i.sku, 'qty', i.qty))
                FILTER (WHERE i.id IS NOT NULL), '[]') AS items
FROM   orders o LEFT JOIN order_items i ON i.order_id = o.id
WHERE  o.id = ANY($1)
GROUP  BY o.id, o.total;

Dataloader / батчинг обобщает этот приём: слой собирает запросы по ключам, пришедшие за короткое окно (или за время одного HTTP-запроса), склеивает их в один WHERE id = ANY(...) и раздаёт результаты обратно. Обязателен в GraphQL, где структура запроса заранее неизвестна, и очень полезен в gRPC-агрегаторах. В Go это обычно канал заявок + таймер на 1–5 мс + дедупликация ключей + singleflight, чтобы одинаковые ключи не ходили в БД дважды.

Обратная сторона: JOIN-взрыв

Чинить N+1 одним большим JOIN через несколько связей «один ко многим» опасно: заказ с 10 позициями и 5 платежами превращается в 50 строк, а поля заказа дублируются 50 раз. Это «декартово раздувание». Правильнее либо сделать два-три отдельных батч-запроса и склеить результат в коде, либо собрать вложенные коллекции в JSON на стороне БД.

Пагинация: OFFSET/LIMIT против keyset

OFFSET n не умеет «перепрыгнуть» n строк. База обязана их найти, материализовать и выбросить. Значит, стоимость страницы растёт линейно с её номером: первая стоит 20 строк, пятитысячная — 100 020 строк работы ради тех же 20. Отсюда чаще всего и берётся «у нас в админке последние страницы отваливаются по таймауту».

OFFSET читает всё «до», keyset прыгает сразу в нужное место OFFSET 100000 LIMIT 20 прочитать и выбросить 100 000 строк отдать 20 ≈ 1,8 с WHERE (created_at, id) < ($1, $2) ORDER BY ... LIMIT 20 спуск по индексу — эти строки не читаются вообще отдать 20 ≈ 0,4 мс Время страницы от её номера мс № страницы OFFSET: линейно keyset: константа Второй, менее очевидный дефект OFFSET: пока пользователь листает, в начало списка вставляются новые строки — и элементы «съезжают»: часть видна дважды, часть не видна ни на одной странице.
OFFSET против keyset. Keyset (он же cursor, seek method) вместо номера строки передаёт значение ключа последней показанной строки, поэтому индекс сразу спускается в нужную точку. Стоимость страницы не зависит от её номера — и данные не «плывут» при вставках.
-- Плохо: деградирует линейно
SELECT id, created_at, title FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;

-- Хорошо: keyset по составному ключу (created_at может повторяться,
-- поэтому обязательно добавляем уникальный тай-брейкер id)
SELECT id, created_at, title FROM posts
WHERE  (created_at, id) < ($1, $2)      -- значения последней строки прошлой страницы
ORDER  BY created_at DESC, id DESC
LIMIT  20;

-- Нужен индекс ровно в порядке сортировки:
CREATE INDEX posts_created_id_idx ON posts (created_at DESC, id DESC);

Кортежи (a, b) < ($1, $2) сравниваются лексикографически, ровно как ключи составного индекса, поэтому запрос превращается в один спуск по дереву. Разворачивать его руками в a < $1 OR (a = $1 AND b < $2) не надо: планировщик такой OR обычно уже не превращает в один Index Scan.

// Курсор наружу отдаём непрозрачной строкой, чтобы клиент не завязывался на формат
type Cursor struct {
    CreatedAt time.Time `json:"c"`
    ID        int64     `json:"i"`
}

func encodeCursor(c Cursor) string {
    b, _ := json.Marshal(c)
    return base64.RawURLEncoding.EncodeToString(b)
}

func (r *Repo) ListPosts(ctx context.Context, after *Cursor, limit int) ([]Post, *Cursor, error) {
    const q = `
        SELECT id, created_at, title FROM posts
        WHERE  ($1::timestamptz IS NULL) OR (created_at, id) < ($1, $2)
        ORDER  BY created_at DESC, id DESC
        LIMIT  $3`
    // limit+1, чтобы без отдельного COUNT понять, есть ли следующая страница
    rows, err := r.db.Query(ctx, q, after.time(), after.id(), limit+1)
    ...
}
OFFSET/LIMITKeyset (cursor)
Стоимость страницы NO(N × размер страницы)O(размер страницы)
Переход на произвольную страницуданет, только вперёд/назад
Стабильность при вставкахнет, элементы «съезжают»да
Общее число страницможно посчитать COUNT(*) (тоже дорого)обычно не показывают, либо оценка через reltuples
Сложность реализациитривиальнонужен уникальный тай-брейкер и индекс в порядке сортировки
Где уместноадминки на десятки страниц, где нужна навигация «страница 7»бесконечные ленты, API, экспорт, любые большие объёмы
Промежуточные варианты

Если продукт требует именно номера страниц, но данных много: (1) ограничить глубину («не более 100 страниц, дальше уточните фильтр»), как делают все крупные поисковики; (2) гибрид: keyset внутри, а номера страниц служат ярлыками для уже пройденных курсоров; (3) для «прыжка» по отсортированной колонке — WHERE created_at < $anchor, где якорь берётся из заранее посчитанной таблицы границ.

SELECT * — чем он плох

  • Ломает index-only scan. Даже идеально покрывающий индекс бесполезен, если запрос требует все колонки: придётся идти в heap за каждой строкой.
  • Тянет TOAST. Одна колонка jsonb или text на мегабайт, которая никому не нужна, превращает лёгкий запрос в чтение внешних чанков.
  • Трафик и память. Строки едут по сети целиком и материализуются в приложении; на пагинации по 1000 строк 4 нужные колонки против 40 дают разницу в десятки мегабайт.
  • Ломкость при ALTER. Кто-то добавил колонку — и rows.Scan с фиксированным набором приёмников падает в рантайме с «expected 12 destination arguments, not 11». Или, что хуже, порядок колонок меняется и данные тихо разъезжаются по полям.
  • Непрозрачность. По коду нельзя понять, какие поля реально нужны, и рефакторинг схемы превращается в гадание.

Отдельно про SELECT count(*): он честно проверяет видимость каждой строки, поэтому на больших таблицах всегда дорог. Для «примерно сколько» есть SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders', для точного — либо инкрементальный счётчик в отдельной таблице, либо смирение. Уместен SELECT * разве что при интерактивной отладке в psql.

Батчевые вставки

Вставка миллиона строк по одной оборачивается миллионом round-trip, миллионом парсингов, миллионом фиксаций транзакции с fsync. Батчи дают 10–100×.

-- 1. Multi-row INSERT: один запрос, один разбор, одна транзакция
INSERT INTO events (user_id, kind, payload) VALUES
  ($1, $2, $3), ($4, $5, $6), ($7, $8, $9);
-- Практический предел: 65535 параметров на запрос в протоколе PostgreSQL.
-- При 3 колонках это ~21 800 строк; на практике батчи по 500–5000 строк оптимальны.

-- 2. unnest: текст запроса не меняется с размером батча, поэтому план в кэше один
INSERT INTO events (user_id, kind, payload)
SELECT * FROM unnest($1::bigint[], $2::text[], $3::jsonb[]);

-- 3. ON CONFLICT: идемпотентная вставка (upsert)
INSERT INTO counters (key, n) VALUES ($1, 1)
ON CONFLICT (key) DO UPDATE SET n = counters.n + EXCLUDED.n;

INSERT INTO events (id, ...) VALUES (...)
ON CONFLICT (id) DO NOTHING;    -- повторная доставка из брокера не сломает запись

-- 4. COPY: самый быстрый путь массовой загрузки
COPY events (user_id, kind, payload) FROM STDIN WITH (FORMAT csv);
// pgx: CopyFrom идёт по бинарному протоколу COPY, быстрее в Go не загрузить
rows := make([][]any, 0, len(events))
for _, e := range events {
    rows = append(rows, []any{e.UserID, e.Kind, e.Payload})
}
n, err := pool.CopyFrom(ctx,
    pgx.Identifier{"events"},
    []string{"user_id", "kind", "payload"},
    pgx.CopyFromRows(rows),
)

// pgx: Batch шлёт несколько разных запросов одним сетевым обменом (pipelining)
b := &pgx.Batch{}
for _, e := range events {
    b.Queue(`INSERT INTO events (user_id, kind) VALUES ($1, $2)`, e.UserID, e.Kind)
}
br := pool.SendBatch(ctx, b)
defer br.Close()
for range events {
    if _, err := br.Exec(); err != nil {   // ошибки надо вычитать по одной
        return err
    }
}
Правила массовой загрузки
  • COPY быстрее INSERT в разы: нет разбора SQL на каждую строку, меньше накладных расходов протокола, меньше WAL.
  • Один батч = одна транзакция. Батч на миллион строк превращается в долгую транзакцию со всеми её минусами (блокировки, удержание горизонта VACUUM, откат всего при ошибке в конце). Оптимум обычно 1000–10000 строк.
  • Для загрузки «с нуля»: сначала данные, потом индексы и внешние ключи. Построить индекс на готовой таблице быстрее, чем поддерживать его при вставке.
  • ON CONFLICT DO NOTHING делает consumer идемпотентным дешевле всего.
  • Осторожно с ON CONFLICT DO UPDATE в конкурентной вставке: он берёт блокировку на конфликтующую строку и при перекрёстном порядке ключей в батчах даёт дедлоки. Лечится сортировкой строк батча по ключу перед вставкой.

Партиционирование таблиц

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

ТипКлюч разбиенияТипичный случай
RANGEдиапазоны значений (обычно дата)события, логи, заказы по месяцам — 95 % реальных применений
LISTперечисление значенийпо региону, по тенанту, по стране
HASHhash(ключ) mod Nравномерное размазывание горячей таблицы, когда естественного диапазона нет
-- RANGE по времени: самый частый вариант
CREATE TABLE events (
    id          bigserial,
    created_at  timestamptz NOT NULL,
    user_id     bigint      NOT NULL,
    payload     jsonb,
    PRIMARY KEY (id, created_at)   -- ключ партиционирования обязан входить в PK
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_07 PARTITION OF events
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE events_2026_08 PARTITION OF events
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- DEFAULT-партиция ловит всё, что не попало ни в один диапазон.
-- Полезна как страховка, но если она распухла, значит, забыли нарезать новые партиции.
CREATE TABLE events_default PARTITION OF events DEFAULT;

-- HASH: N партиций примерно равного размера
CREATE TABLE sessions (user_id bigint, ...) PARTITION BY HASH (user_id);
CREATE TABLE sessions_0 PARTITION OF sessions FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- ... ещё 7 штук

-- Старые данные удаляем мгновенно: не DELETE на 300 млн строк с распуханием и VACUUM,
-- а операцией над одними метаданными, за миллисекунды
DROP TABLE events_2025_08;
-- Или мягко: отцепить, выгрузить в архив, потом дропнуть. CONCURRENTLY не блокирует
-- запись, но при DEFAULT-партиции (как выше) не работает — там без него.
ALTER TABLE events DETACH PARTITION events_2025_08 CONCURRENTLY;
Partition pruning: что читается, а что отсекается Запрос с условием по ключу партиционирования SELECT * FROM events WHERE created_at >= '2026-08-01' AND created_at < '2026-09-01' events_2026_06 отсечена events_2026_07 отсечена events_2026_08 Seq Scan, 2 ГБ events_2026_09 отсечена итого 2 ГБ вместо 800 ГБ Запрос без условия по ключу (или с функцией над ним) SELECT * FROM events WHERE user_id = 42 SELECT * FROM events WHERE date_trunc('month', created_at) = '2026-08-01' events_2026_06 events_2026_07 events_2026_08 events_2026_09 читаются ВСЕ, стало хуже, чем было
Партиционирование помогает только тем запросам, у которых в WHERE есть ключ партиционирования. Для остальных оно превращает один Seq Scan в N сканов плюс Append, то есть замедляет их. Поэтому ключ выбирают не по структуре данных, а по самому частому фильтру продакшн-нагрузки.
Когда партиционировать, а когда не надо
  • Надо, если таблица больше ~50–100 ГБ и есть естественный диапазон (время), по которому идут почти все запросы.
  • Надо, если у данных есть срок жизни: только DROP TABLE партиции позволяет удалять старое, не воюя с bloat и autovacuum на миллиардах строк.
  • Надо, если VACUUM/ANALYZE/REINDEX на монолитной таблице не укладываются в окно: партиции обслуживаются независимо и параллельно.
  • Не надо на таблице в 5 ГБ: индекс справится, а сложность (нарезка партиций, миграции, ограничения на PK и FK) ты получишь сразу.
  • Не надо, если в частых запросах нет ключа партиционирования, иначе станет хуже.

Ограничения, о которых спрашивают: ключ партиционирования обязан входить в любой UNIQUE/PRIMARY KEY (глобального уникального индекса поверх партиций не существует); внешние ключи на партиционированную таблицу поддерживаются только с PG 12; партиций лучше держать не больше нескольких сотен: планировщик обрабатывает их список на каждом запросе, и на тысячах партиций растёт время планирования; нарезку будущих партиций надо автоматизировать (pg_partman, cron-джоба или миграция), иначе однажды все строки уедут в DEFAULT или вставка упадёт с «no partition of relation found for row».

Вопросы

10
Суть: EXPLAIN только планирует и показывает оценки, запрос не выполняется. EXPLAIN ANALYZE реально выполняет запрос и показывает фактические строки и время. Значит на UPDATE/DELETE/ INSERT он изменит данные — оборачивать в транзакцию с ROLLBACK.

Что даёт каждый

  • EXPLAIN выдаёт план и оценки планировщика: cost, ожидаемые rows, width. Ничего не выполняется, безопасно всегда.
  • EXPLAIN ANALYZE добавляет actual time, actual rows, loops, Rows Removed by Filter. Только сравнив ожидание с фактом, можно понять, врёт ли статистика.
  • BUFFERS показывает, сколько страниц реально прочитано и откуда: shared hit (из кэша), read (с диска), dirtied, written. Почти всегда это полезнее времени: время плавает от нагрузки, а число страниц — нет.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT) SELECT ...;

-- Пишущий запрос: выполнить и откатить
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'done' WHERE id = 42;
ROLLBACK;

Риски и тонкости

  • Данные меняются по-настоящему. Классический инцидент на собеседовании и в жизни: «я просто посмотрел план» — а DELETE уже выполнился.
  • Побочные эффекты не откатываются полностью: последовательности (nextval) не возвращаются назад, триггеры могут отправить письмо или записать во внешнюю систему.
  • Долгий запрос будет выполнен целиком. Если он и так идёт 40 минут и держит блокировки, ANALYZE сделает это ещё раз. Ставь SET LOCAL statement_timeout.
  • Оверхед измерения. Таймеры сами по себе замедляют выполнение, иногда заметно на узлах с миллионами итераций. Режим EXPLAIN (ANALYZE, TIMING OFF) считает строки, но не время.
  • Параметры. План для PREPARE-запроса с generic plan может отличаться от плана, который ты получил, подставив константы руками. Смотреть надо на то, что реально исполняется, и тут поможет auto_explain.
Чем добить

«На проде обычно нельзя выполнить проблемный запрос руками — он тяжёлый. Поэтому включаем auto_explain с auto_explain.log_min_duration = 500ms и log_analyze = on: планы медленных запросов сами приезжают в лог, вместе с фактическими строками, ровно с теми параметрами, что были в бою. Плюс pg_stat_statements, чтобы понимать, какой запрос вообще главный по суммарному времени.»

Суть: план — дерево, читается снизу вверх и изнутри наружу: самые вложенные узлы выполняются первыми. Главный диагностический приём — сравнить rows (оценка) с actual rows (факт). Расхождение в 100 раз и больше означает, что планировщик выбирал алгоритм по неверным данным.

Числа в узле

  • cost=0.43..8.45 меряется в абстрактных «попугаях» планировщика, не в миллисекундах. Первое число оценивает получение первой строки, второе — всех. Разница важна: под LIMIT побеждает план с дешёвым стартом, даже если полная стоимость выше.
  • rows=1000 даёт оценку числа строк по статистике pg_statistic.
  • actual rows=98000 показывает факт. Из разъезда с оценкой растут почти все плохие планы.
  • loops=500 говорит, сколько раз узел выполнялся. Ловушка: actual time и actual rows в плане показаны на одну итерацию. Реальное время узла = actual time × loops. Узел с «0.02 ms» и 500 000 loops работает 10 секунд.
  • Rows Removed by Filter=49000: прочитали 50 000, отдали 1000. Прямое указание: нужен индекс по этому предикату (или частичный индекс).
  • Heap Fetches считает, сколько раз Index Only Scan всё-таки полез в heap. Если не ноль, visibility map устарела и нужен VACUUM.
  • Buffers: shared hit=12 read=8400 значит, что с диска поднято 8400 страниц (~66 МБ).
  • Sort Method: external merge Disk: 240MB — сортировка не влезла в work_mem и ушла на диск. Часто самая дешёвая победа: поднять work_mem для конкретной сессии.

Порядок разбора

  1. Найти узел с наибольшим собственным временем (общее минус время детей); на верхний узел не смотреть, он суммарный.
  2. Сравнить rows и actual rows в этом узле и его детях.
  3. Посмотреть, откуда пришло расхождение: устаревшая статистика → ANALYZE; коррелированные колонки → CREATE STATISTICS; выражение вместо колонки → функциональный индекс.
  4. Проверить Rows Removed by Filter и Heap Fetches.
  5. Посмотреть на тип соединения: Nested Loop с большим внешним набором почти всегда вырастает из недооценки строк.
Самая частая ошибка чтения плана

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

Суть: Seq Scan побеждает, когда выбирается заметная доля таблицы (грубо больше 5–10 %): последовательное чтение на порядок дешевле случайного. Bitmap Scan — промежуточный режим: сначала собрать все ctid в битовую карту, отсортировать её по номерам страниц и прочитать heap по возрастанию страниц, превратив случайный доступ в почти последовательный.

Узлы доступа

УзелЧто делаетКогда выбирается
Seq Scanчитает всю таблицу подряднет индекса, либо выбирается большая доля строк, либо таблица мала
Index Scanидёт по индексу и за каждой строкой — в heapмало строк, нужен порядок сортировки
Index Only Scanберёт данные прямо из индексавсе колонки в индексе + свежая visibility map
Bitmap Index Scan + Bitmap Heap Scanсобирает битовую карту страниц, затем читает heap по порядкусредняя селективность; несколько индексов через BitmapAnd/BitmapOr
Nested Loopдля каждой строки внешней — поиск во внутреннейвнешний набор мал и есть индекс по ключу соединения
Hash Joinстроит хеш меньшей таблицы, проходит большуюбольшие наборы, соединение по равенству
Merge Joinслияние двух отсортированных потоковобе стороны уже отсортированы (индексы) или очень велики
Append / Gather / Materializeсклейка партиций, сбор с параллельных воркеров, кэш промежуточного наборапартиционирование, parallel query, повторное использование подзапроса

Почему Seq Scan бывает правильным выбором

Случайное чтение страницы дороже последовательного: в модели планировщика это random_page_cost = 4.0 против seq_page_cost = 1.0. Если по индексу нужно вытащить 30 % строк, придётся прочитать 30 % страниц heap в случайном порядке плюс сам индекс, а это дороже, чем прочитать таблицу целиком подряд. Поэтому «планировщик игнорирует мой индекс» чаще всего означает «планировщик прав».

Про random_page_cost на SSD

Дефолтные 4.0 достались от вращающихся дисков, где перемещение головки стоило дорого. На NVMe разница между случайным и последовательным чтением почти исчезла, поэтому на проде обычно ставят random_page_cost = 1.1. Это одна из немногих настроек, которая массово чинит «почему не берётся индекс», и хороший ответ, если спрашивают про тюнинг.

Проверить гипотезу можно принудительно: SET enable_seqscan = off; и сравнить планы. Это инструмент диагностики, а не решение, и на проде такие флаги не оставляют: они лишь показывают, во сколько планировщик оценивает альтернативу.

Суть: это вопрос не на знание, а на порядок действий. Правильный ответ начинается со слов «сначала измерю»: подтвердить проблему метриками → снять план → искать причину в плане → чинить по возрастанию инвазивности: статистика, индекс, переписывание запроса, денормализация, партиционирование, вынос в другое хранилище. Худший ответ — «добавлю индекс».

Шаг 0. Уточнить вопрос

  • Тормозит всегда или стало? Если «стало» — что изменилось: релиз, объём данных, план, нагрузка, железо?
  • Тормозит этот запрос или вся база? Если вся, смотреть надо не на запрос, а на pg_stat_activity, блокировки, диск, чекпоинты.
  • Какая доля запросов страдает: p50 или p99? Деградация только хвоста часто означает блокировки или холодный кэш, а не план.

Шаг 1. Найти виновника объективно

-- Кто съедает базу суммарно (а не кто медленнее всех в единичном вызове)
SELECT queryid, calls, total_exec_time, mean_exec_time, rows, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

-- Что происходит прямо сейчас и кто кого ждёт
SELECT pid, state, wait_event_type, wait_event, now() - query_start AS dur, query
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY dur DESC;

Шаг 2. План

Снять EXPLAIN (ANALYZE, BUFFERS) и искать три вещи: расхождение rows и actual rows, большие Rows Removed by Filter, узлы с высоким loops. На терабайтной таблице почти всегда обнаруживается либо Seq Scan, либо Nested Loop с сотнями тысяч итераций.

Шаг 3. Лечение по возрастанию цены

  1. Статистика. ANALYZE; поднять default_statistics_target для колонки с перекошенным распределением; CREATE STATISTICS для коррелированных колонок (город и страна). Самое дешёвое и на удивление часто помогает.
  2. Индекс. Прицельный: составной в правильном порядке, частичный под предикат, покрывающий с INCLUDE ради Index Only Scan. Только CREATE INDEX CONCURRENTLY. Помнить о цене записи.
  3. Переписать запрос. Убрать функцию с колонки, заменить OR на UNION ALL, NOT IN на NOT EXISTS, OFFSET на keyset, убрать SELECT *, вынести тяжёлую агрегацию в отдельный проход.
  4. Сузить данные. Нужны ли реально все 3 года? Обычно 95 % запросов идут за последний месяц, а холодные данные можно унести в архивную таблицу или в отдельное хранилище.
  5. Партиционирование по времени. Даёт pruning для запросов с датой и, что не менее важно, снова делает обслуживание управляемым: VACUUM, REINDEX, удаление старого через DROP TABLE.
  6. Денормализация / материализованное представление / инкрементальный счётчик — если запрос аналитический и считает агрегаты по всей истории.
  7. Другое хранилище. Аналитике по миллиардам строк место в ClickHouse, а не в PostgreSQL. Сказать это прямо не стыдно, это сильный ответ.
Что добавит вес ответу

«Отдельно проверю, не деградация ли это из-за bloat: на терабайтной таблице с активным UPDATE мёртвые версии могут занимать половину объёма, и тогда Seq Scan читает вдвое больше, чем нужно. Смотрю pg_stat_user_tables.n_dead_tup, настройки autovacuum для этой таблицы (дефолтные 20 % от таблицы в терабайт — это 200 ГБ мусора до старта уборки, что абсурдно; ставлю autovacuum_vacuum_scale_factor = 0.01 и явный threshold).»

Суть: один запрос за списком плюс по одному запросу на каждый элемент списка. Каждый запрос сам по себе быстрый — 1 мс, — но их 500, и это 500 сетевых round-trip: полсекунды там, где хватило бы двух запросов и 3 мс. Лечится батчингом: JOIN, WHERE id = ANY($1) или dataloader.

Как выглядит

// N+1: 1 запрос за заказами + 100 запросов за клиентами
orders, _ := repo.ListOrders(ctx, 100)
for i := range orders {
    orders[i].Customer, _ = repo.GetCustomer(ctx, orders[i].CustomerID)
}

// Батч: 2 запроса всего
orders, _ := repo.ListOrders(ctx, 100)
ids := lo.Uniq(lo.Map(orders, func(o Order, _ int) int64 { return o.CustomerID }))
customers, _ := repo.GetCustomersByIDs(ctx, ids)   // WHERE id = ANY($1)
byID := make(map[int64]*Customer, len(customers))
for _, c := range customers { byID[c.ID] = c }
for i := range orders { orders[i].Customer = byID[orders[i].CustomerID] }

Как обнаружить

  • Трассировка. В OpenTelemetry N+1 виден мгновенно: «лестница» из сотни коротких одинаковых спанов внутри одного HTTP-запроса. Самый убедительный способ.
  • pg_stat_statements. Запрос с гигантским calls и микроскопическим mean_exec_time, но большим total_exec_time — классическая подпись N+1.
  • Счётчик запросов на HTTP-запрос. Простейшая метрика db_queries_per_request с алертом на p99 > 20 ловит регрессии до прода.
  • Тест. В интеграционном тесте посчитать число обращений к БД через обёртку над драйвером и зафиксировать ожидаемое: «список из 50 заказов = ровно 2 запроса».
  • Косвенный признак: «эндпоинт линейно замедляется от размера страницы» — 20 элементов 40 мс, 100 элементов 200 мс.

Варианты лечения

ПриёмКогдаМинус
JOIN одним запросомсвязь «многие к одному», нужны все поля сразудублирование данных родителя в каждой строке; при нескольких «один ко многим» — декартов взрыв
Два запроса + WHERE id = ANY($1)универсальный дефолт, особенно для коллекцийсклейка в памяти приложения — писать руками
Dataloader (батчинг с окном 1–5 мс)GraphQL и любые деревья резолверов, где вызовы разбросаны по кодусложность, кэш на время запроса, легко словить утечку между пользователями
LATERAL / оконная функция«топ-N дочерних для каждого родителя»сложный SQL
Preload/Eager loading в ORMGORM Preload, ent With…работает, только если не забыть; забывают постоянно
Обратная сторона: JOIN-взрыв

Совет «всегда джойнить» ошибочен точно так же. Заказ с 10 позициями и 5 платежами при джойне обеих коллекций даёт 50 строк вместо 15, и сумма по платежам оказывается впятеро больше правды. Правило: одна коллекция — JOIN, две и больше — отдельные батчевые запросы.

Суть: OFFSET N не «пропускает» строки дёшево — сервер честно читает и выбрасывает N строк. Страница 10 000 по 20 элементов означает чтение 200 020 строк ради 20. Keyset (cursor) вместо номера страницы запоминает последнее значение сортировки и делает WHERE (created_at, id) < ($1, $2) — это диапазонный поиск по индексу, стоимость постоянна.
-- OFFSET: стоимость растёт линейно с номером страницы
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 200000;      -- прочитано 200 020 строк, отдано 20

-- Keyset: стоимость постоянна
SELECT id, title, created_at FROM posts
WHERE (created_at, id) < ($1, $2)     -- курсор с прошлой страницы
ORDER BY created_at DESC, id DESC
LIMIT 20;                              -- прочитано 20 строк

CREATE INDEX posts_feed_idx ON posts (created_at DESC, id DESC);

Две проблемы OFFSET, а не одна

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

Обязательные детали реализации

  • Тай-брейкер. Сортировка должна быть по уникальному набору колонок, иначе строки с одинаковым created_at будут теряться или повторяться. Всегда добавляй id последним.
  • Кортежное сравнение. (created_at, id) < ($1, $2) сравнивает строки целиком, ровно как нужно, и PostgreSQL умеет использовать под это индекс. Разворачивать его в created_at < $1 OR (created_at = $1 AND id < $2) не нужно, план от этого только хуже.
  • Индекс в том же порядке, включая направления DESC.
  • Курсор — непрозрачный токен. Кодировать base64 и не обещать клиенту его структуру, иначе поменять сортировку станет ломающим изменением API.
  • limit + 1 вместо отдельного COUNT(*), чтобы узнать, есть ли следующая страница.
Когда OFFSET всё-таки нормален

Админка на 30 страниц, где нужна навигация «перейти на страницу 7», и данных объективно мало. Правило простое: OFFSET допустим, пока глубина ограничена. Если продукт требует номера страниц на большом объёме, глубину ограничивают («не более 100 страниц, уточните фильтр»), как делают все поисковики.

Суть: четыре независимые причины — ломает Index Only Scan, тянет TOAST и лишний трафик, делает код хрупким к ALTER TABLE, скрывает реальные зависимости от схемы.
  • Index Only Scan становится невозможен. Даже идеально покрывающий индекс бесполезен: раз нужны все колонки, придётся идти в heap за каждой строкой. Это буквально разница между 1 чтением и 500.
  • TOAST. Одна колонка jsonb или text на мегабайт, которая никому в этом запросе не нужна, превращает лёгкую выборку в чтение внешних чанков из TOAST-таблицы с разжатием.
  • Трафик и память приложения. На выборке в 1000 строк разница между 4 нужными колонками и 40 измеряется десятками мегабайт по сети и в куче Go, а это ещё и давление на GC.
  • Хрупкость. Кто-то добавил колонку — и rows.Scan с фиксированным набором приёмников падает: «expected 12 destination arguments, not 11». Хуже, если типы совпали и данные тихо разъехались по полям.
  • Непрозрачность. По коду невозможно понять, какие поля реально используются; удалить колонку становится страшно, потому что неизвестно, кто её читает.

Отдельная история с SELECT count(*): он проверяет видимость каждой строки, поэтому на больших таблицах дорог всегда. Для «примерно сколько» есть reltuples из pg_class, для точного — отдельный счётчик.

Где SELECT * уместен

Интерактивная отладка в psql; EXISTS (SELECT * FROM ...): список колонок там вообще не вычисляется, это идиома; SELECT * FROM unnest(...) и подобные конструкции, где набор колонок задан тут же. В репозиторном коде — нет.

Суть: вставка по одной строке — это миллион round-trip, миллион разборов SQL и миллион коммитов с fsync. Multi-row INSERT убирает round-trip и коммиты (10–50×), COPY дополнительно убирает разбор SQL и часть накладных расходов протокола (ещё 2–5×). В Go это pgx.CopyFrom.
СпособПорядок скоростиОсобенности
INSERT по одной, autocommitхудший случай: round-trip + fsync на строку
INSERT по одной в одной транзакции~5–10×убрали fsync, round-trip остались
Multi-row INSERT по 1000~30–50×лимит 65535 параметров на запрос; текст запроса меняется с размером батча
INSERT ... SELECT unnest($1,$2,$3)~30–50×текст запроса постоянный → один план в кэше; удобно для prepared statements
COPY / pgx.CopyFrom~100×самый быстрый; нет ON CONFLICT, нет RETURNING

Практические правила

  • Батч 1000–10 000 строк. Если больше, получится долгая транзакция: блокировки, удержание горизонта VACUUM, откат всей работы при ошибке в конце.
  • Загрузка «с нуля»: сначала данные, потом индексы и FK. Индекс на готовой таблице строится быстрее, чем поддерживается при вставке.
  • Для идемпотентности (повторная доставка из брокера) есть ON CONFLICT DO NOTHING. С COPY он не работает, поэтому типичный компромисс: COPY во временную таблицу, затем INSERT ... SELECT ... ON CONFLICT из неё.
  • При конкурентных апсертах сортируй строки батча по ключу: ON CONFLICT DO UPDATE берёт блокировку на конфликтующую строку, и разный порядок ключей в параллельных батчах даёт дедлоки.
  • pgx.Batch нужен для pipelining разных запросов одним обменом, а не для массовой вставки одинаковых; для однородных строк CopyFrom быстрее.
Суть: разбиение одной логической таблицы на физические части по ключу — RANGE (обычно дата), LIST (регион, тенант), HASH (равномерно). Partition pruning — отсечение партиций, которые заведомо не содержат нужных строк, по условию на ключ. Всё это в пределах одного сервера: партиционирование не решает задачу горизонтального масштабирования.

Что реально даёт

  • Pruning на чтении — но только для запросов с ключом партиционирования в WHERE.
  • Мгновенное удаление старого. DROP TABLE events_2025_08 вместо DELETE на 300 млн строк, который создаст 300 млн мёртвых версий и вгонит autovacuum в многочасовую работу. Это главная причина партиционировать по времени.
  • Управляемое обслуживание. VACUUM, ANALYZE, REINDEX идут по партициям независимо и параллельно, каждая укладывается в окно.
  • Меньше индексы. У каждой партиции своё дерево индекса, пониже, и оно целиком помещается в кэш.
  • Разные носители. Свежие партиции на NVMe, архивные — в дешёвый tablespace.

Ограничения, которые обязательно назвать

  • Ключ партиционирования обязан входить в PRIMARY KEY и любой UNIQUE: глобального уникального индекса поверх всех партиций нет. Отсюда типичный PRIMARY KEY (id, created_at).
  • Запросы без ключа партиционирования читают все партиции, и выходит хуже, чем без партиционирования.
  • Функция над ключом (date_trunc('month', created_at) = ...) убивает pruning так же, как убивает индекс.
  • Сотни партиций планировщик переваривает, тысячи — заметно удлиняют планирование.
  • Нарезку будущих партиций надо автоматизировать (pg_partman, cron), иначе всё поедет в DEFAULT-партицию или вставка упадёт.
  • Чтобы перевести существующую большую таблицу в партиционированную, нужна отдельная миграция без даунтайма (новая таблица + ATTACH старой как партиции, либо постепенное копирование с двойной записью).
Частая ошибка в ответе

Сказать «партиционирование ускоряет запросы». Оно ускоряет только те запросы, которые фильтруют по ключу партиционирования, а остальные замедляет. И оно не заменяет индекс: внутри партиции по-прежнему нужен индекс, иначе получишь Seq Scan по 2 ГБ вместо 800 ГБ — лучше, но всё ещё плохо.

Суть: либо индекс неприменим (функция над колонкой, приведение типа, неподходящий оператор, порядок колонок в составном), либо он невыгоден (низкая селективность, маленькая таблица), либо планировщик ошибается (устаревшая статистика, коррелированные колонки, неверный random_page_cost).
ПричинаПризнак в планеЧто делать
Функция над колонкой: lower(email), date(ts)Seq Scan с Filter по выражениюфункциональный индекс или переписать условие в диапазон
Неявное приведение типа: id::text = '5', varchar против text с разным collationто жеприводить параметр к типу колонки, а не наоборот
LIKE '%abc%'Seq ScanGIN + pg_trgm или полнотекстовый поиск
Составной индекс (a, b), а фильтр только по bSeq Scan или неэффективный сканиндекс с b первым; помнить правило «слева направо»
Низкая селективность: status='active' у 80 % строкSeq Scan, и он правничего, либо частичный индекс на редкое значение
Маленькая таблицаSeq Scanничего, всё в порядке
Устаревшая статистикаrows и actual rows расходятся в разыANALYZE, поднять default_statistics_target
Коррелированные колонки (город и страна)перемноженная и потому заниженная оценкаCREATE STATISTICS ... (dependencies, ndistinct)
random_page_cost = 4 на NVMeиндекс проигрывает по cost на границеснизить до 1.1
Индекс только что созданANALYZE после CREATE INDEX
Индекс невалиден после сбоя CREATE INDEX CONCURRENTLYплан его не видитнайти indisvalid = false в pg_index, пересоздать
Как проверить гипотезу

SET enable_seqscan = off; и снова EXPLAIN. Если план с индексом оказался дороже по cost — планировщик прав, проблема не в индексе. Если дешевле по факту, но дороже по оценке — проблема в статистике или в стоимостных константах. Это диагностика: на проде такие флаги не оставляют.

6.6Практические SQL-задачи

Эти семь задач кочуют из собеседования в собеседование почти без изменений, и писать их надо с листа, без автодополнения и без запуска. Память на синтаксис тут почти не проверяют, смотрят на три навыка: антиджойны, оконные функции и рекурсивные CTE. Сначала идёт памятка с шаблонами, потом каждая задача целиком: решение, построчное объяснение, альтернативы, почему они хуже, и что скажет план.

Памятка: пять шаблонов, из которых собирается почти всё

-- 1. Антиджойн: «те, у кого нет связанных строк»
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
--    эквивалент через LEFT JOIN:
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
--    но не NOT IN (SELECT ...): ломается о NULL

-- 2. Дедупликация: найти и удалить лишние копии, оставив одну
SELECT email, count(*) FROM users GROUP BY email HAVING count(*) > 1;

DELETE FROM users u
USING (
  SELECT id, row_number() OVER (PARTITION BY email ORDER BY id) AS rn
  FROM users
) d
WHERE u.id = d.id AND d.rn > 1;

-- 3. Топ-N в группе: «три лучших в каждой категории»
WITH ranked AS (
  SELECT category_id, product_id, revenue,
         row_number() OVER (PARTITION BY category_id ORDER BY revenue DESC) AS rn
  FROM sales
)
SELECT * FROM ranked WHERE rn <= 3;

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

-- 5. Окно вместо коррелированного подзапроса
-- было: подзапрос выполняется для каждой строки
SELECT * FROM employees e
WHERE salary > (SELECT avg(salary) FROM employees WHERE dept_id = e.dept_id);
-- стало: один проход, агрегат считается окном
SELECT * FROM (
  SELECT e.*, avg(salary) OVER (PARTITION BY dept_id) AS dept_avg
  FROM employees e
) t WHERE salary > dept_avg;
Оконная функцияЧто делаетНа дубликатах значения
row_number()сквозная нумерация 1,2,3,4разные номера — детерминизм только при уникальном ORDER BY
rank()1,2,2,4одинаковый ранг, следующий номер «прыгает»
dense_rank()1,2,2,3одинаковый ранг, без пропусков — то, что нужно для «второй по величине»
lag()/lead()значение из предыдущей/следующей строкидельты, «предыдущий статус», разрывы в последовательности
sum() OVER (ORDER BY ...)нарастающий итогпо умолчанию рамка RANGE UNBOUNDED PRECEDING
first_value()/nth_value()значение из позиции в рамкевнимательно с рамкой окна
Три правила, которые спасают на этих задачах
  • Оконные функции нельзя использовать в WHERE: окно вычисляется после WHERE и GROUP BY. Поэтому фильтр по row_number() всегда живёт во внешнем запросе или в CTE. Исключение — QUALIFY, но его в PostgreSQL нет (есть в ClickHouse, Snowflake, BigQuery).
  • ORDER BY внутри окна должен быть детерминированным. Если сортируешь по неуникальной колонке, добавь id тай-брейкером, иначе «второй заказ клиента» будет прыгать между запусками.
  • Проговаривай план вслух. На этих задачах интервьюер часто спрашивает «а какой индекс нужен», и ответ на него стоит на голову выше самого решения.

Вопросы

7
Суть: два правильных способа — NOT EXISTS и LEFT JOIN ... WHERE o.id IS NULL; в PostgreSQL оба дают один и тот же план Anti Join и работают одинаково быстро. NOT IN (SELECT ...) — ловушка: если подзапрос вернёт хоть один NULL, результат будет пустым всегда, и это молчаливый баг.
-- Способ 1: NOT EXISTS, предпочтительный и самый читаемый
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id
);

-- Способ 2: LEFT JOIN + IS NULL, классический антиджойн
SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;         -- фильтр только по NOT NULL-колонке правой таблицы

-- Способ 3: EXCEPT, когда нужны только идентификаторы
SELECT id FROM customers
EXCEPT
SELECT customer_id FROM orders;

-- Ловушка, так не писать:
SELECT * FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);   -- пусто, если есть NULL

Построчно

  • SELECT 1 в EXISTS ничего не вычисляет: планировщику важно только «есть ли хоть одна строка». Что писать, SELECT * или SELECT 1, дело вкуса, разницы в плане нет.
  • Условие связи o.customer_id = c.id делает подзапрос коррелированным, и как раз поэтому PostgreSQL может превратить его в Anti Join: один проход вместо подзапроса на каждую строку.
  • В варианте с LEFT JOIN фильтр WHERE o.id IS NULL должен указывать на колонку, которая в самой таблице orders не бывает NULL, обычно на первичный ключ. Если взять o.comment IS NULL, в выборку попадут ещё и клиенты с заказами без комментария.

Почему NOT IN ломается

SQL живёт в трёхзначной логике. id NOT IN (1, 2, NULL) раскрывается в id <> 1 AND id <> 2 AND id <> NULL. Последнее сравнение даёт UNKNOWN, а TRUE AND UNKNOWN = UNKNOWN, и строка не проходит фильтр WHERE. Итог: если в подзапросе есть хотя бы один NULL, результат пуст для всех строк, причём без ошибки и без предупреждения. А колонка orders.customer_id вполне может быть nullable (гостевые заказы).

Второй минус известен меньше: NOT IN с подзапросом PostgreSQL в Anti Join не превращает вовсе — мешает семантика NULL — и выполняет как подплан: хешированный, если результат влез в work_mem, иначе построчный. «Починить» можно через WHERE customer_id IS NOT NULL в подзапросе, но правильный вывод другой: для антиджойна используем NOT EXISTS, а NOT IN оставляем для списков констант.

ВариантNULL-безопасенПлан в PostgreSQLКомментарий
NOT EXISTSдаHash Anti Joinдефолтный выбор
LEFT JOIN + IS NULLдаHash Anti Joinто же самое; в MySQL исторически быстрее
EXCEPTдаHashSetOpещё и дедуплицирует; только один столбец-ключ
NOT INнетhashed SubPlan или SubPlanтихо возвращает пусто на NULL
Про индекс и план

Нужен индекс по orders(customer_id): на внешний ключ он не создаётся автоматически. С ним при небольшом числе клиентов план будет Nested Loop Anti Join с Index Scan; при больших объёмах — Hash Anti Join, который строит хеш по orders и один раз проходит customers. Если нужно «клиенты без заказов за последний месяц», условие по дате идёт внутрь EXISTS (или в ON при LEFT JOIN), но не во внешний WHERE, иначе антиджойн превратится в обычный join и логика сломается.

Суть: найти — GROUP BY email HAVING count(*) > 1. Удалить — пронумеровать строки внутри группы через row_number() OVER (PARTITION BY email ORDER BY id) и снести всё, где rn > 1. И обязательно добавить UNIQUE-индекс после чистки, иначе дубликаты вернутся.
-- 1. Найти: какие email задублированы и сколько раз
SELECT lower(email) AS email, count(*) AS n, min(id) AS keep_id, array_agg(id) AS all_ids
FROM users
GROUP BY lower(email)
HAVING count(*) > 1
ORDER BY n DESC;

-- 2. Посмотреть сами строки-дубликаты целиком
SELECT * FROM users
WHERE lower(email) IN (SELECT lower(email) FROM users GROUP BY lower(email) HAVING count(*) > 1)
ORDER BY lower(email), id;

-- 3. Удалить, оставив в каждой группе самую раннюю (по created_at, при равенстве по меньшему id)
DELETE FROM users u
USING (
    SELECT id,
           row_number() OVER (PARTITION BY lower(email) ORDER BY created_at, id) AS rn
    FROM users
) d
WHERE u.id = d.id AND d.rn > 1;

-- 4. Закрепить результат, чтобы проблема не вернулась
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq ON users (lower(email));

Построчно

  • PARTITION BY lower(email) группирует по нормализованному значению. Дубликаты почти всегда выглядят как Ivan@mail.ru и ivan@mail.ru, и без lower() ты их не найдёшь.
  • ORDER BY created_at, id внутри окна задаёт, какая запись выживет. Решает тут бизнес, а не техника: оставить самую старую (сохранить исходный id) или самую свежую (сохранить актуальные данные). Тай-брейкер id обязателен, иначе при одинаковом created_at выбор недетерминирован.
  • DELETE ... USING даёт join в DELETE (синтаксис PostgreSQL). Подзапрос вычисляется один раз, а на больших объёмах это важно.
  • Уникальный индекс строим по lower(email), чтобы он совпадал с логикой дедупликации, и с CONCURRENTLY, чтобы не блокировать запись на проде.

Альтернативы и почему они хуже

-- Через ctid: работает без суррогатного ключа, но некрасиво и небезопасно при UPDATE
DELETE FROM users a USING users b
WHERE a.email = b.email AND a.ctid > b.ctid;
-- ctid: физический адрес версии строки, он меняется при UPDATE и VACUUM FULL.
-- Годится только для одноразовой чистки, но не для хранения ссылок.

-- Через min(id): проще, но нельзя выбрать «оставить последнюю» и медленнее на больших таблицах
DELETE FROM users
WHERE id NOT IN (SELECT min(id) FROM users GROUP BY email);
-- Плюс здесь снова NOT IN: попади в список NULL, и не удалится ничего, причём без ошибки.

-- Самый безопасный вариант для очень больших таблиц: пересоздание
CREATE TABLE users_new AS
SELECT DISTINCT ON (lower(email)) * FROM users ORDER BY lower(email), created_at, id;
-- затем переключаем таблицы в одной транзакции
Как не потерять данные
  • Сначала SELECT с тем же условием, потом DELETE. Всегда. Или BEGIN; DELETE ...; SELECT count(*); ROLLBACK; — так видно, сколько удалилось бы.
  • Скопировать удаляемое в архивную таблицу: CREATE TABLE users_dupes_backup AS SELECT ....
  • У дубликатов часто есть потомки: заказы висят на разных id одного человека. Прежде чем удалять, перевесь связанные строки на выживший id, иначе ON DELETE CASCADE унесёт заказы вместе с дублем.
  • Миллионы строк одним DELETE означают долгую транзакцию, распухание и нагрузку на репликацию. Режь батчами по 10–50 тысяч в цикле, с паузами.
Продолжение вопроса, которое почти всегда следует

«А как сделать, чтобы дубликаты не появлялись?» Ответ: уникальный индекс в БД плюс INSERT ... ON CONFLICT DO NOTHING в коде. Проверка «сначала SELECT, потом INSERT» в приложении не работает при конкурентных запросах: два процесса одновременно не найдут запись и оба вставят. Гарантию даёт только констрейнт.

Суть: два шага — агрегация выручки по категориям за период, затем отбор трёх лучших. Если нужен просто топ-3 глобально, хватит ORDER BY ... LIMIT 3; если топ-3 внутри каждой группы (например, по каждому региону) — нужна оконная функция row_number() в CTE и фильтр во внешнем запросе.
-- Вариант A: топ-3 категории всего, хватит LIMIT
SELECT c.id, c.name, sum(oi.price * oi.qty) AS revenue
FROM order_items oi
JOIN orders     o ON o.id = oi.order_id
JOIN categories c ON c.id = oi.category_id
WHERE o.status = 'paid'
  AND o.created_at >= date_trunc('month', now()) - interval '1 month'
  AND o.created_at <  date_trunc('month', now())
GROUP BY c.id, c.name
ORDER BY revenue DESC
LIMIT 3;

-- Вариант B: топ-3 категории в каждом регионе, нужно окно
WITH revenue AS (
    SELECT o.region_id,
           c.id   AS category_id,
           c.name AS category_name,
           sum(oi.price * oi.qty) AS revenue
    FROM order_items oi
    JOIN orders     o ON o.id = oi.order_id
    JOIN categories c ON c.id = oi.category_id
    WHERE o.status = 'paid'
      AND o.created_at >= date_trunc('month', now()) - interval '1 month'
      AND o.created_at <  date_trunc('month', now())
    GROUP BY o.region_id, c.id, c.name
),
ranked AS (
    SELECT *,
           row_number() OVER (PARTITION BY region_id ORDER BY revenue DESC, category_id) AS rn
    FROM revenue
)
SELECT region_id, category_id, category_name, revenue
FROM ranked
WHERE rn <= 3
ORDER BY region_id, rn;

Построчно

  • Границы периода задавай полуинтервалом >= начало AND < следующее начало. Это единственный корректный способ: BETWEEN с '2026-07-31 23:59:59' теряет последнюю секунду, а с '2026-08-01' захватывает лишнюю строку ровно в полночь.
  • Условие по дате ставь на колонку, а не на функцию от неё. WHERE date_trunc('month', o.created_at) = ... убивает индекс и, если таблица партиционирована, ещё и pruning.
  • o.status = 'paid' нужен почти всегда: выручку считают по оплаченным, а не по созданным заказам. Скажи это вслух: на этом держится половина впечатления от ответа.
  • row_number() нумерует внутри PARTITION BY region_id, сортировка по revenue DESC с тай-брейкером category_id для детерминизма.
  • Фильтр WHERE rn <= 3 живёт во внешнем запросе: оконные функции вычисляются после WHERE, поэтому внутри того же уровня фильтровать по rn нельзя.

Альтернативы и почему они хуже

ВариантОценка
rank() вместо row_number()не хуже, а другое: при равной выручке вернёт 4 строки вместо 3. Уточни у интервьюера, что делать с ничьёй — это ожидаемый вопрос
Коррелированный подзапрос «посчитай, сколько категорий с большей выручкой»O(n²), на реальных данных неприемлемо
LATERAL с LIMIT 3 на каждый регионхорош, если регионов мало, а категорий в каждом очень много и есть индекс: читает только 3 строки на регион вместо сортировки всех
Считать в приложениивытянуть все агрегаты и отсортировать в Go — приемлемо только если групп единицы; иначе лишний трафик
Про план и индексы

Нужен индекс orders(created_at) WHERE status = 'paid' (частичный, под предикат) или orders(status, created_at), плюс order_items(order_id) для join. В плане ожидаем: Index Scan по датам, Hash Join с позициями, HashAggregate, затем WindowAgg с сортировкой. Если сортировка уходит на диск (Sort Method: external merge), поднимаем work_mem для этой сессии. На больших объёмах вопрос «а если такой отчёт нужен каждый час?» решается материализованным представлением с REFRESH MATERIALIZED VIEW CONCURRENTLY.

Суть: каноническое решение — CTE с row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC, id DESC) и фильтр rn <= 5 снаружи. Но у задачи есть второе решение — LATERAL, — и умение объяснить, когда какое быстрее, ценится выше самого SQL.
-- Вариант A: оконная функция. Один проход по orders.
WITH ranked AS (
    SELECT o.*,
           row_number() OVER (PARTITION BY o.customer_id
                              ORDER BY o.created_at DESC, o.id DESC) AS rn
    FROM orders o
)
SELECT id, customer_id, created_at, total
FROM ranked
WHERE rn <= 5
ORDER BY customer_id, rn;

-- Вариант B: LATERAL делает для каждого клиента точечный index scan на 5 строк.
SELECT c.id AS customer_id, o.id, o.created_at, o.total
FROM customers c
CROSS JOIN LATERAL (
    SELECT o.id, o.created_at, o.total
    FROM orders o
    WHERE o.customer_id = c.id
    ORDER BY o.created_at DESC, o.id DESC
    LIMIT 5
) o
ORDER BY c.id, o.created_at DESC;

-- Индекс, без которого ни один вариант не работает хорошо:
CREATE INDEX orders_cust_created_idx ON orders (customer_id, created_at DESC, id DESC);

Построчно

  • PARTITION BY customer_id начинает нумерацию заново для каждого клиента.
  • ORDER BY created_at DESC, id DESC ставит свежие первыми; id нужен как тай-брейкер, иначе два заказа с одинаковой меткой времени будут нумероваться случайным образом и результат станет нестабильным между запусками.
  • WHERE rn <= 5 обязан быть уровнем выше: окно вычисляется после WHERE.
  • В варианте B CROSS JOIN LATERAL позволяет подзапросу ссылаться на c.id из внешней таблицы, в этом и смысл LATERAL. Если нужны и клиенты без заказов, пиши LEFT JOIN LATERAL ... ON true.

Что выбрать: важная развилка

Оконная функцияLATERAL
ЧитаетВСЕ заказы, нумерует, отбрасывает лишнееровно 5 строк на клиента через индекс
Когда быстрееклиентов много, заказов у каждого малоклиентов немного, заказов у каждого тысячи
ПланSeq Scan + Sort + WindowAggNested Loop + Index Scan с LIMIT внутри
Порядок величин10 млн заказов → 10 млн строк через сортировку1000 клиентов → 5000 строк

В цифрах: если у 1000 клиентов по 10 000 заказов, оконный вариант отсортирует 10 млн строк, чтобы выбросить 9 995 000. LATERAL с индексом (customer_id, created_at DESC) сделает 1000 index scan-ов по 5 строк. Выходят секунды против миллисекунд. Если же клиентов миллион и у каждого по 3 заказа, выигрывает окно: один проход дешевле миллиона nested loop.

Частая ошибка

Написать GROUP BY customer_id ORDER BY created_at DESC LIMIT 5 значит получить «5 строк всего», а не «5 на клиента». Вторая по частоте ошибка — DISTINCT ON: SELECT DISTINCT ON (customer_id) * FROM orders ORDER BY customer_id, created_at DESC отлично решает задачу для одного последнего заказа, но для пяти не годится: DISTINCT ON оставляет ровно одну строку на группу.

Суть: классика на замену коррелированного подзапроса оконной функцией. Коррелированный вариант выполняет агрегат для каждой строки — O(n·m); окно считает средние за один проход — O(n log n) на сортировку и всё. Отвечать надо оконным вариантом, а коррелированный упомянуть как «то, как это обычно пишут, и почему это плохо».
-- Правильно: оконная функция, один проход по таблице
SELECT id, name, dept_id, salary, dept_avg
FROM (
    SELECT e.id, e.name, e.dept_id, e.salary,
           avg(e.salary) OVER (PARTITION BY e.dept_id) AS dept_avg
    FROM employees e
) t
WHERE salary > dept_avg
ORDER BY dept_id, salary DESC;

-- Так обычно пишут: коррелированный подзапрос
SELECT e.* FROM employees e
WHERE e.salary > (
    SELECT avg(salary) FROM employees WHERE dept_id = e.dept_id
);

-- Третий вариант: JOIN с агрегатом. Читается лучше окна, работает почти так же.
SELECT e.id, e.name, e.dept_id, e.salary, a.dept_avg
FROM employees e
JOIN (
    SELECT dept_id, avg(salary) AS dept_avg
    FROM employees GROUP BY dept_id
) a ON a.dept_id = e.dept_id
WHERE e.salary > a.dept_avg;

Построчно

  • avg(salary) OVER (PARTITION BY dept_id) пишется без ORDER BY внутри окна. Добавишь ORDER BY и получишь не среднее по отделу, а нарастающее среднее: рамка по умолчанию станет «от начала раздела до текущей строки». Ошибка тихая и очень частая.
  • Оконная функция не схлопывает строки: каждая строка сохраняется, к ней просто приписывается агрегат её раздела. Поэтому salary и dept_avg можно сравнить в одной строке.
  • Фильтр снова уходит во внешний запрос (окно вычисляется после WHERE).
  • Подзапрос можно оформить как CTE: так читается лучше, а в PostgreSQL 12+ он инлайнится и плана не портит.

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

Формально современный планировщик PostgreSQL иногда умеет его развернуть, но полагаться на это нельзя: в общем случае подзапрос выполняется на каждую строку внешней таблицы. Для 100 000 сотрудников в 50 отделах это 100 000 агрегаций вместо 50. В плане это видно как SubPlan с большим loops, и время равно actual time × loops, а не тому, что показано в узле.

Тонкости, о которых спросят следом
  • NULL в salary. avg() игнорирует NULL, а сравнение salary > dept_avg с NULL-зарплатой даст UNKNOWN, и строка выпадет. Обычно это правильно, но проговорить стоит.
  • Отдел из одного человека. Его зарплата равна средней по отделу, значит строгое > его не вернёт. Уточни, ожидается ли это.
  • Медиана вместо среднего считается через percentile_cont(0.5) WITHIN GROUP (ORDER BY salary). На зарплатах медиана честнее среднего, и это хороший бонусный ответ.
  • Индекс здесь не поможет: считается агрегат по всем строкам, будет Seq Scan — и это нормально. Скорее пригодится work_mem, чтобы сортировка для окна не ушла на диск.
Суть: рекурсивный CTE состоит из якоря (стартовая строка), UNION ALL и рекурсивной части, которая ссылается на сам CTE. Выполняется итеративно: результат прошлого шага подаётся на вход следующему, пока шаг не вернёт пустоту. Циклы в данных (A → B → A) дают бесконечный цикл, поэтому нужна защита: ограничение глубины или массив пройденных узлов.
-- Вниз по дереву: все подчинённые сотрудника $1, на любую глубину
WITH RECURSIVE subordinates AS (
    -- Якорь: с кого начинаем
    SELECT e.id, e.name, e.manager_id, 1 AS depth,
           ARRAY[e.id] AS path
    FROM employees e
    WHERE e.id = $1

    UNION ALL

    -- Рекурсивная часть: те, чей менеджер уже найден на прошлом шаге
    SELECT e.id, e.name, e.manager_id, s.depth + 1,
           s.path || e.id
    FROM employees e
    JOIN subordinates s ON e.manager_id = s.id
    WHERE NOT e.id = ANY(s.path)    -- защита от цикла
      AND s.depth < 50              -- страховка на всякий случай
)
SELECT repeat('  ', depth - 1) || name AS tree, id, depth, path
FROM subordinates
ORDER BY path;

-- Вверх по дереву: вся цепочка руководителей конкретного сотрудника
WITH RECURSIVE chain AS (
    SELECT id, name, manager_id, 1 AS lvl FROM employees WHERE id = $1
    UNION ALL
    SELECT e.id, e.name, e.manager_id, c.lvl + 1
    FROM employees e JOIN chain c ON e.id = c.manager_id
    WHERE c.lvl < 50
)
SELECT * FROM chain ORDER BY lvl;

-- Число подчинённых на каждом уровне: тот же WITH RECURSIVE subordinates,
-- только финальный SELECT другой (отдельным запросом CTE не виден)
SELECT depth, count(*) FROM subordinates GROUP BY depth ORDER BY depth;

Как это выполняется на самом деле

  1. Выполняется якорь, результат кладётся в рабочую таблицу и в итоговую.
  2. Рекурсивная часть выполняется, но subordinates внутри неё означает только результат предыдущей итерации, а не весь накопленный результат. На этой тонкости и ловят.
  3. Новые строки добавляются к итогу и становятся рабочей таблицей следующего шага.
  4. Шаги повторяются, пока очередной не вернёт ноль строк.

Защита от циклов — три способа

  • Массив пути path + WHERE NOT e.id = ANY(s.path) точнее всех: отсекает именно повторное посещение и заодно даёт готовый путь для сортировки дерева.
  • Ограничение глубины WHERE depth < 50 — грубо, но надёжно, и защищает ещё и от «слишком глубокого» легального дерева.
  • Синтаксис CYCLE (PostgreSQL 14+): ... CYCLE id SET is_cycle USING path. Это стандартный SQL, он делает то же самое декларативно.
Три ловушки рекурсивных CTE
  • UNION вместо UNION ALL сам по себе устраняет дубликаты и потому «спасает» от простых циклов, но ценой дедупликации на каждой итерации и потери строк, которые легально встречаются дважды (в DAG один узел достижим разными путями). Выбирать его надо осознанно, а не на автомате.
  • Без ограничения глубины и без path цикл в данных даёт запрос, который выполняется, пока не съест диск под временные файлы. Один кривой UPDATE employees SET manager_id = ... — и прод лежит.
  • Рекурсивная часть не может содержать LEFT JOIN с CTE справа, агрегаты, оконные функции и ORDER BY/LIMIT напрямую: разрешена только простая ссылка на CTE. Лечится выносом во внешний запрос.
Про производительность и альтернативы

Нужен индекс по employees(manager_id), иначе каждая итерация делает Seq Scan, и на глубине 10 выйдет 10 полных проходов. Если дерево читается на порядки чаще, чем меняется, рекурсию заменяют денормализацией: materialized path ('1.5.17.42' в текстовой колонке с индексом по префиксу), nested sets (lft/rgt, читается за один диапазонный запрос, но любая вставка перестраивает половину дерева) или расширение ltree. Правило: рекурсивный CTE — когда дерево меняется часто; materialized path — когда читается часто.

Суть: ключевой вопрос — что считать «вторым» при дубликатах. Если 100 000 и 100 000 занимают первое место, второе — это 90 000 (второе значение, dense_rank() = 2) или снова 100 000 (вторая строка, row_number() = 2)? Уточнить это — и есть половина ответа. Обычно имеют в виду второе уникальное значение.
-- Вариант 1: DENSE_RANK даёт второе по величине значение (правильный дефолт)
SELECT DISTINCT salary
FROM (SELECT salary, dense_rank() OVER (ORDER BY salary DESC) AS dr FROM employees) t
WHERE dr = 2;

-- Вариант 2: подзапрос с MAX работает без оконных функций, годится и для MySQL 5.x
SELECT max(salary) FROM employees
WHERE salary < (SELECT max(salary) FROM employees);

-- Вариант 3: коррелированный подзапрос ищет «значение, больше которого ровно одно значение»
SELECT DISTINCT e.salary FROM employees e
WHERE (SELECT count(DISTINCT e2.salary) FROM employees e2 WHERE e2.salary > e.salary) = 1;

-- Вариант 4: если нужна вторая строка, а не второе значение
SELECT id, name, salary
FROM (SELECT e.*, row_number() OVER (ORDER BY salary DESC, id) AS rn FROM employees e) t
WHERE rn = 2;

-- Для N-го места: dense_rank() = N. Второй вариант так не обобщается.

Разбор вариантов

ВариантСложностьОбобщается на N-йКомментарий
dense_rank()сортировка O(n log n)да, dr = Nдефолтный правильный ответ
вложенный max()два прохода O(n)нет — на третьем месте превращается в нечитаемую матрёшкусамый быстрый; хорош, если оконных функций «нельзя»
NOT EXISTS/countO(n²) в худшем случаеда, = N-1красиво звучит, медленно работает
row_number()O(n log n)дадругой ответ на другой вопрос — вторая строка, а не второе значение

Что проверяют на самом деле

  • Уточняешь ли требования. Кандидат, который сразу пишет LIMIT 1 OFFSET 1, не задумываясь о дубликатах, отвечает хуже того, кто спросил «а если максимум встречается дважды?».
  • Понимаешь ли разницу rank / dense_rank / row_number. Ради неё вопрос и задают.
  • Что вернётся, если сотрудников меньше двух. Оконные варианты вернут ноль строк, вариант с max()одну строку со значением NULL. Разница критична для кода: QueryRow().Scan() в первом случае отдаст sql.ErrNoRows, во втором — успешно просканирует NULL и упадёт, если приёмник int, а не sql.NullInt64.
  • Зачем вообще запрет на LIMIT. Ограничение искусственное: его ставят, чтобы вытащить из тебя оконные функции. Скажи это прямо: «в продакшене я бы написал ORDER BY salary DESC LIMIT 1 OFFSET 1 с индексом по salary, но раз нельзя — вот вариант через dense_rank».
Продолжение: второй по зарплате в каждом отделе

Ровно тот же шаблон плюс PARTITION BY dept_id: dense_rank() OVER (PARTITION BY dept_id ORDER BY salary DESC) и фильтр = 2. Это стандартное продолжение вопроса, и оно же показывает, почему LIMIT/OFFSET не универсален: «второй в каждой группе» через LIMIT без LATERAL вообще не выражается.

6.7Работа с БД из Go

Здесь проверяют не API, а то, что стоит за каждой строчкой: что такое соединение и почему оно дорогое, что физически произойдёт, когда пул кончится, почему запрос продолжает жить в базе после отмены контекста, почему rows.Err() нельзя не проверять и как выкатить NOT NULL на таблицу в 200 млн строк, не уронив прод.

database/sql, pgx, sqlx, GORM — что выбрать

database/sql не драйвер, а абстракция стандартной библиотеки: пул соединений, интерфейсы Driver/Conn/Stmt, управление транзакциями. Говорить с PostgreSQL он не умеет, под ним всегда работает драйвер (lib/pq, pgx/v5/stdlib). Вокруг этого выбора и строится всё остальное.

ИнструментЧто этоПлюсыМинусыКогда брать
database/sql + драйверстандартная библиотеканоль зависимостей, полный контроль, единый API для любой СУБДмного рутины: Scan по полям, rows.Close, ErrNoRowsкогда важна переносимость между СУБД или минимум зависимостей
pgx (нативный режим)драйвер PostgreSQL со своим API и пуломбинарный протокол, нативные типы PG, CopyFrom, Batch, LISTEN/NOTIFY, свой быстрый пул pgxpoolтолько PostgreSQL, свой API — sqlmock и часть библиотек не подходятдефолт для нового сервиса на PostgreSQL
pgx через stdlibpgx как драйвер для database/sqlстандартный API + актуальный драйвертеряется часть возможностей pgxкогда нужен стандартный интерфейс, но не нужен lib/pq
sqlxтонкая обёртка над database/sqlStructScan, Get, Select, NamedExec, sqlx.In — убирает 80 % рутиныэто всё ещё ручной SQL; развивается медленнолюбимый компромисс: контроль над SQL без боли со сканированием
GORMполноценная ORMбыстрый старт, связи, хуки, автомиграции, soft deleteнепредсказуемый SQL, лёгкий N+1, магия рефлексии, тяжёлая отладка, автомиграции опасны на продеCRUD-админки, прототипы, команды без сильной SQL-экспертизы
sqlcгенератор кода из SQLпишешь SQL — получаешь типобезопасные функции и структуры; ошибки в SQL ловятся на компиляциикодогенерация в сборке, сложно с динамическими запросамисильный современный выбор, особенно в паре с pgx
squirrel / goquбилдеры запросовдинамические фильтры без склейки строкSQL становится Go-кодом и хуже читаетсятам, где реально много опциональных условий
Как отвечать на «что выбрать»

Не «GORM плохой», а размен: ORM экономит время на простом CRUD и отбирает контроль там, где он нужнее всего — в сложных запросах и в производительности. Рабочая формулировка: «Для сервиса на PostgreSQL беру pgx напрямую — ради бинарного протокола, CopyFrom и нативных типов; сканирование закрываю pgx.CollectRows или sqlc. ORM рассматриваю для админок, где 90 % занимает примитивный CRUD. И в любом варианте мне нужно уметь посмотреть, какой именно SQL уходит в базу.»

Чем конкретно хорош pgx

  • Бинарный протокол. lib/pq гоняет всё текстом: timestamptz сериализуется в строку и парсится обратно, int8 едет как «123456». pgx шлёт бинарное представление — отсюда меньше трафика и заметно меньше аллокаций на больших выборках.
  • Нативные типы PostgreSQL. jsonb, массивы, hstore, диапазоны, inet, uuid, композитные типы сканируются напрямую, без []byte и ручного json.Unmarshal.
  • CopyFrom использует протокол COPY, самый быстрый способ массовой вставки.
  • Batch включает pipelining: несколько разных запросов уходят одним сетевым обменом. На сети с RTT 1 мс сотня запросов укладывается в ~2 мс вместо 100 мс.
  • pgxpool даёт собственный пул с health-check соединений, ленивым подключением и настройками, которых нет в database/sql.
  • LISTEN/NOTIFY, курсоры, QueryRewriter, кастомные типы — всё, что не влезает в общий интерфейс database/sql.
  • Из бытового: pgconn.PgError с полями Code, ConstraintName, Detail, по которым нарушение уникальности точно отличается от нарушения внешнего ключа.
UUID: с Go 1.27 внешний пакет для этого не нужен

Раз уж речь про типы: годами стандартным ответом на «где взять UUID» был github.com/google/uuid. С Go 1.27 генератор въехал в стандартную библиотеку — пакет uuid, реализующий RFC 9562. Что в нём есть: uuid.New() (общий случай), uuid.NewV4() (случайный), uuid.NewV7(), uuid.Parse и uuid.MustParse, uuid.Nil(). Тип uuid.UUID устроен как [16]byte: сравнивается через ==, годится ключом мапы, умеет MarshalText/UnmarshalText, а значит и JSON.

Две практические детали. (1) Для первичного ключа бери NewV7(), а не NewV4(): v7 начинается с миллисекундного времени, поэтому такие ключи монотонны и ложатся в правый край B-tree, а не рвут его страницы по всему индексу (подробно разобрано в главе 6.4). (2) В database/sql его можно передавать напрямую — и как параметр запроса, и как приёмник в Scan. Интерфейсы driver.Valuer и sql.Scanner тип при этом не реализует: поддержка зашита в саму стандартную библиотеку отдельным случаем (database/sql/driver/types.go и convert.go). Проверено на go1.27.0: параметр уезжает в драйвер строкой, а Scan принимает и строку, и 16 сырых байт. Писать u.String() или свою обёртку не нужно.

Пул соединений: четыре ручки и что за ними стоит

Соединение с PostgreSQL означает отдельный процесс на сервере БД (не поток), TCP-сессию, TLS-handshake и аутентификацию. Установка стоит миллисекунды и десятки мегабайт памяти на сервере, и делать её на каждый запрос непозволительно дорого.

Поэтому соединения не открывают и не закрывают, а переиспользуют: держат наготове небольшой набор уже установленных и раздают их горутинам по мере надобности. Этот набор и называется пулом соединений (connection pool). Работает он как стойка с ключами: горутине нужен запрос — она берёт свободное соединение, выполняет запрос, возвращает его в пул. Если свободных нет, а лимит открытых исчерпан, горутина ждёт, пока кто-нибудь вернёт своё. Отсюда все дальнейшие эффекты: «запросы стали медленными» часто означает не медленную базу, а очередь за соединением.

В database/sql объект *sql.DB и есть пул, а не одно соединение. Его создают один раз на весь процесс, передают всюду и не закрывают после каждого запроса; он потокобезопасен. В pgxpool ту же роль играет *pgxpool.Pool.

ПараметрЧто означаетЧто будет, если задать неверно
SetMaxOpenConns(n)максимум одновременно открытых соединений (занятых + простаивающих)0 = без лимита, дефолт: всплеск нагрузки открывает сотни соединений и упирается в max_connections сервера — «FATAL: sorry, too many clients»
SetMaxIdleConns(n)сколько держать простаивающих для переиспользованиядефолт всего 2: при MaxOpen=50 пул постоянно закрывает и открывает соединения — тихая потеря производительности, которую почти никто не замечает
SetConnMaxLifetime(d)максимальный возраст соединения, после — закрыть и открыть новое0 = вечно: соединения не переезжают после failover и не перебалансируются между репликами; рекомендуется 30 мин – 1 час
SetConnMaxIdleTime(d)сколько соединение может простаивать до закрытияпомогает отдавать ресурсы БД в тихие часы; типично 5–15 минут
db, err := sql.Open("pgx", dsn)   // не подключается, только валидирует DSN
if err != nil { return err }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)            // держим равным MaxOpen, иначе пойдут переоткрытия
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {   // соединение по-настоящему устанавливается здесь
    return fmt.Errorf("db unreachable: %w", err)
}

// в pgxpool то же самое, но под своими именами
cfg, _ := pgxpool.ParseConfig(dsn)
cfg.MaxConns          = 25
cfg.MinConns          = 5                 // держать прогретыми
cfg.MaxConnLifetime   = 30 * time.Minute
cfg.MaxConnIdleTime   = 5 * time.Minute
cfg.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
Жизненный цикл соединения и исчерпание пула Нормальный режим: MaxOpenConns = 25 горутина db.QueryContext пул database/sql свободных: 7 · занятых: 18 есть свободное → выдать сразу нет свободного и открыто < Max → открыть PostgreSQL backend-процесс после Close/Commit соединение возвращается в пул, а не закрывается Пул исчерпан: все 25 заняты, приходит 26-я горутина горутина #26 QueryContext ОЖИДАНИЕ в очереди блокируется, НЕ ошибка выход из ожидания освободилось ИЛИ ctx done Как это выглядит на графиках: • latency растёт «ступенькой», хотя БД по CPU и диску спокойна • db.WaitCount и db.WaitDuration из DBStats уходят вверх — главный признак • ошибки не «too many connections», а context deadline exceeded в приложении Лечение: не «увеличить пул», а найти, кто держит соединения долго — медленный запрос, забытый rows.Close, долгая транзакция
Исчерпанный пул не даёт ошибки. Вызов в database/sql блокируется и ждёт свободного соединения, а выйти из ожидания может только тогда, когда соединение освободится или истечёт context. Поэтому в проде он выглядит не как «too many connections», а как рост p99 и таймауты запросов при спокойной базе.
// DBStats обязательно экспортируем в метрики
s := db.Stats()
s.MaxOpenConnections   // лимит
s.OpenConnections      // сейчас открыто
s.InUse                // сейчас занято
s.Idle                 // простаивает
s.WaitCount            // сколько раз ждали свободного соединения ← главный сигнал
s.WaitDuration         // суммарное время ожидания          ← и его цена
s.MaxIdleClosed        // закрыто из-за MaxIdleConns  ← если растёт, MaxIdle слишком мал
s.MaxLifetimeClosed    // закрыто по возрасту
Как подбирать размер пула
  • Считать от базы, а не от приложения. У PostgreSQL max_connections обычно 100–200. Если 10 подов сервиса поставят себе по 50 соединений, выйдет 500, и база откажет. Бюджет делят на все инстансы всех сервисов с запасом на миграции, мониторинг и подключение человека.
  • Больше не значит быстрее. PostgreSQL при числе активных соединений заметно выше числа ядер деградирует: растёт конкуренция за LWLock, страдают кэши. Отправная формула примерно такая: 2 × CPU + число дисков, то есть на 8-ядерном сервере речь о ~20 активных соединениях, а не о 200.
  • По закону Литтла: нужное число соединений ≈ RPS × средняя длительность запроса. 400 RPS × 10 мс = 4 соединения. Если по факту уходит 50, проблема в длительности запросов, а не в размере пула.
  • MaxIdleConns = MaxOpenConns почти всегда. Дефолтные 2 дают самую частую незамеченную проблему производительности в Go-сервисах.
  • ConnMaxLifetime обязателен при работе через балансировщик или в облаке: без него соединения намертво прилипают к одному инстансу БД и не переезжают после failover.
  • Сотни инстансов сервиса уже требуют pgbouncer, тюнинга клиентского пула тут мало.

pgbouncer: зачем и какой ценой

Пул внутри приложения решает задачу одного процесса. Но подов у сервиса двадцать, у каждого свой пул на 25 соединений, итого 500 соединений к базе, а max_connections у неё обычно 100–200, и поднимать этот лимит бессмысленно: на каждое соединение PostgreSQL заводит процесс со своей памятью. Нужен ещё один пул, общий для всех подов.

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

Главная его настройка, режим пулинга, задаёт момент, когда серверное соединение освобождается и уходит следующему клиенту. Вариантов три, и различаются они только этим моментом. В transaction mode (рабочий выбор по умолчанию) серверное соединение закрепляется за клиентом только на время транзакции: сделал COMMIT — соединение тут же ушло другому. Экономия большая, но с ней приходит правило, которое ломает половину привычек: ничего нельзя оставлять «в сессии», потому что следующий запрос почти наверняка уедет на другое соединение.

pgbouncer между приложением и PostgreSQL под #1 · пул 25 под #2 · пул 25 под #3 · пул 25 под #20 · пул 25 итого 500 клиентских соединений pgbouncer лёгкий, однопоточный, держит тысячи клиентов transaction mode 20 реальных соединений PostgreSQL процесс на соединение max_connections = 100 session mode: соединение закреплено на всю сессию клиента — работает всё, экономии почти нет transaction mode: соединение отдаётся на время транзакции — главный режим; ломает SET, temp-таблицы, LISTEN, prepared statements — до 1.21
pgbouncer мультиплексирует: раскладывает 500 клиентских соединений на 20 реальных. Ставят его не «для скорости», а потому, что каждому соединению PostgreSQL нужен процесс на сервере, а их число ограничено. Особенно в serverless и Kubernetes, где число подов меняется на ходу.
РежимКогда соединение возвращается в пулЧто ломаетсяЭкономия
sessionпри отключении клиентаничегоминимальная — только на переустановке соединений
transactionпо COMMIT/ROLLBACKprepared statements, SET, temp-таблицы, advisory locks на сессию, LISTEN/NOTIFY, курсоры вне транзакциимаксимальная и практичная — стандартный выбор
statementпосле каждого операторавсё вышеперечисленное плюс многооператорные транзакции вообщепредельная; применим только там, где транзакций нет по определению
Почему в transaction mode ломаются prepared statements

Prepared statement («подготовленный запрос») устроен так: текст запроса с плейсхолдерами $1, $2 уходит в базу один раз, база его разбирает, строит план выполнения и запоминает под именем; дальше клиент шлёт только имя и значения параметров. На каждом вызове экономятся разбор и планирование, а параметры принципиально не могут «склеиться» с текстом запроса — отсюда и защита от инъекций. Драйверы Go делают это автоматически: любой db.Query(sql, args...) под капотом готовит запрос и кэширует его на соединении.

Transaction mode эту схему ломает: PREPARE создаёт именованный план в конкретной серверной сессии. Клиент готовит stmt_1 на соединении A, потом транзакция коммитится и соединение возвращается в пул; следующий EXECUTE stmt_1 уезжает уже на соединение B, где такого плана нет: «prepared statement stmt_1 does not exist». Хуже: план может существовать, но означать другой запрос, если другой клиент занял то же имя.

Как обходят. (1) В pgx ставят QueryExecMode: pgx.QueryExecModeSimpleProtocol или QueryExecModeExec: параметры отправляются вместе с запросом, prepared statements не создаются (защита от инъекций сохраняется, потому что параметры всё равно не склеиваются с текстом). (2) В lib/pq исторически помогал параметр DSN binary_parameters=yes, но чаще просто переходят на pgx. (3) В pgbouncer 1.21+ появилась поддержка protocol-level prepared statements (max_prepared_statements), которая отслеживает подготовленные запросы и переигрывает их на новом соединении. (4) Радикальный вариант: два пути к БД — прямой для фоновых воркеров с длинными транзакциями и через pgbouncer для API.

Связь с клиентским пулом в Go

Пулов становится два, и они не знают друг о друге. Правило: суммарный MaxOpenConns всех подов ≤ max_client_conn pgbouncer, а default_pool_size pgbouncer ≤ max_connections PostgreSQL с запасом. При этом клиентский пул можно и нужно держать небольшим: соединение до pgbouncer дешёвое, но каждое занятое соединение всё равно держит серверную сессию на время транзакции. ConnMaxLifetime стоит оставить и здесь, тогда клиенты будут перераспределяться между инстансами pgbouncer.

Параметризованные запросы и SQL-инъекция

// Дыра: склейка строк
name := r.URL.Query().Get("name")
q := "SELECT id, email FROM users WHERE name = '" + name + "'"
rows, _ := db.Query(q)
// Вход:  ' OR '1'='1
// Запрос: SELECT id, email FROM users WHERE name = '' OR '1'='1'   → вся таблица
// Вход:  '; DROP TABLE users; --
// Вход:  ' UNION SELECT id, password_hash FROM admins --           → утечка хешей

// Правильно: параметр едет отдельно от текста запроса
rows, err := db.QueryContext(ctx,
    `SELECT id, email FROM users WHERE name = $1`, name)
// Сервер получает текст запроса и значения по отдельности. Значение никогда
// не парсится как SQL: что бы в нём ни лежало, это остаётся строкой.

// Список значений
rows, err := db.QueryContext(ctx,
    `SELECT * FROM users WHERE id = ANY($1)`, pq.Array(ids))   // или pgx: ids напрямую
Где параметры НЕ работают: ORDER BY и имена объектов

Плейсхолдер подставляет значение, а не кусок синтаксиса. Нельзя подставить параметром имя таблицы, имя колонки, направление сортировки, LIMIT без приведения типа. ORDER BY $1 синтаксически допустим, но означает «сортировать по константе» и работает не так, как задумано.

// Дыра: сортировка из запроса пользователя
q := "SELECT * FROM users ORDER BY " + r.URL.Query().Get("sort")
// Вход: (CASE WHEN (SELECT substr(password,1,1) FROM admins)='a' THEN id ELSE name END)
// Так данные вытаскивают посимвольно даже без вывода ошибок: это blind SQL injection.

// Правильно: только белый список, никаких «экранирую кавычки».
var sortWhitelist = map[string]string{
    "name_asc":   "name ASC",
    "name_desc":  "name DESC",
    "date_asc":   "created_at ASC, id ASC",
    "date_desc":  "created_at DESC, id DESC",
}
order, ok := sortWhitelist[r.URL.Query().Get("sort")]
if !ok {
    order = "created_at DESC, id DESC"   // безопасный дефолт, а не ошибка
}
q := fmt.Sprintf(`SELECT id, name, created_at FROM users ORDER BY %s LIMIT $1`, order)

// Если имя объекта всё же динамическое (мультитенантные схемы), квотируем
// идентификатор, а не строку:
tbl := pgx.Identifier{schema, "orders"}.Sanitize()   // "tenant_42"."orders"

То же касается LIKE: параметр защищает от инъекции, но не от спецсимволов % и _ внутри значения. Их экранируют отдельно (ESCAPE '\'), иначе поиск по 100% найдёт лишнее.

Транзакции в Go

func (r *Repo) Transfer(ctx context.Context, from, to int64, amount int64) (err error) {
    tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    // Rollback после Commit возвращает sql.ErrTxDone: это не ошибка, её глотаем.
    defer func() {
        if p := recover(); p != nil {
            _ = tx.Rollback()
            panic(p)              // паника не должна ни съесть соединение, ни потеряться
        }
        if err != nil {
            _ = tx.Rollback()     // именованный err виден в defer
        }
    }()

    if _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`,
        amount, from); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
        return fmt.Errorf("credit: %w", err)
    }
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit: %w", err)
    }
    return nil
}
Правила, которые проверяют почти всегда
  • defer tx.Rollback() ставится сразу после успешного BeginTx — до любого return.
  • После успешного Commit отложенный Rollback вернёт sql.ErrTxDone. Это ожидаемо, ошибку игнорируем явно (_ =), а не «забываем».
  • Внутри транзакции используем только tx. Один вызов r.db.Exec вместо tx.Exec уйдёт по другому соединению, окажется вне транзакции и заодно может намертво заблокироваться на строке, которую держит собственная же транзакция.
  • Ошибка в середине не откатывает транзакцию сама по себе, но в PostgreSQL переводит её в состояние aborted: все последующие операторы будут отвечать «current transaction is aborted». Спасает только ROLLBACK (или ROLLBACK TO SAVEPOINT).
  • Коварнее всего ошибка Commit: её результат неизвестен. Коммит мог пройти на сервере, а ответ потеряться в сети. Поэтому операции делают идемпотентными, а на бизнес-действие заводят уникальный ключ.
  • Транзакция должна быть короткой. Никаких HTTP-вызовов, ожиданий брокера и долгих вычислений внутри: транзакция держит соединение из пула, блокировки и горизонт VACUUM.

Как передавать tx между слоями

Задача бытовая, но её постоянно спрашивают. Транзакция живёт в объекте *sql.Tx, привязанном к одному соединению, а бизнес-операция обычно трогает несколько репозиториев. Значит, все они должны выполнять свои запросы через один и тот же tx. При этом сам репозиторий не должен решать, открывать транзакцию или нет, иначе «создать заказ и его позиции атомарно» просто не собрать из готовых методов.

Отсюда два стандартных приёма. В первом репозиторий принимает не конкретный тип, а узкий интерфейс (ниже он назван DBTX), который одинаково реализуют и пул, и транзакция: репозиторию всё равно, что под ним. Второй — UnitOfWork (дословно «единица работы», в Go-коде его чаще зовут Transactor): объект, умеющий ровно одно — выполнить переданную функцию целиком внутри одной транзакции, закоммитив её при успехе и откатив при ошибке. Термин из книги Фаулера про паттерны корпоративных приложений, а смысл в том, что границу транзакции задаёт слой сервиса: он знает бизнес-операцию целиком, а отдельный репозиторий нет.

// 1. Интерфейс, который одинаково реализуют *sql.DB и *sql.Tx (в pgx такой же, с Exec/Query/QueryRow)
type DBTX interface {
    ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error)
    QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error)
    QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row
}
type UserRepo struct {
    db DBTX
}
func NewUserRepo(db DBTX) *UserRepo { return &UserRepo{db: db} }
// Репозиторий не знает, транзакция под ним или нет, и так и надо.

// 2. UnitOfWork / Transactor: транзакцией управляет слой сервиса
type Transactor interface {
    WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
}

func (s *OrderService) Create(ctx context.Context, in Input) error {
    return s.tx.WithinTx(ctx, func(ctx context.Context) error {
        id, err := s.orders.Insert(ctx, in)          // репозитории получают tx из ctx
        if err != nil { return err }
        if err := s.items.InsertBulk(ctx, id, in.Items); err != nil { return err }
        return s.outbox.Enqueue(ctx, OrderCreated{ID: id})   // outbox в той же транзакции
    })
}

// 3. Реализация через context (удобно, но спорно)
type txKey struct{}

func (t *PgTransactor) WithinTx(ctx context.Context, fn func(context.Context) error) error {
    tx, err := t.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()
    if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
        return err
    }
    return tx.Commit()
}

func (r *UserRepo) exec(ctx context.Context) DBTX {
    if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
        return tx          // внутри транзакции
    }
    return r.db            // вне транзакции
}
Чем спорен tx в контексте

За: сигнатуры методов не меняются, транзакция «прозрачно» протекает через слои, не нужно тащить tx параметром через пять уровней. Против: зависимость становится скрытой, и по сигнатуре Insert(ctx, user) error не понять, участвует ли метод в транзакции; компилятор ничего не проверит; забыть WithinTx легко, и код будет «работать», просто без атомарности; тестировать тяжелее; context по соглашению предназначен для request-scoped данных и отмены, а не для внедрения зависимостей. На собеседовании лучше назвать оба варианта и критерий: явно передавать DBTX первым аргументом честнее и для нового кода лучше; tx в контексте оправдан, когда слоёв много и переписать сигнатуры невозможно.

context, rows и NULL — четыре места, где режут

// 1. context: таймаут на каждый запрос
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, q, args...)

// 2. rows: обязательны и Close, и Err
rows, err := db.QueryContext(ctx, q)
if err != nil { return nil, err }
defer rows.Close()               // без него при раннем выходе из цикла соединение не вернётся в пул

out := make([]User, 0, 32)
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name); err != nil {
        return nil, err
    }
    out = append(out, u)
}
// Если rows.Next() вернул false, это может быть ошибка, а не конец данных:
// оборвалась сеть, сервер убил запрос, истёк ctx. Без этой проверки
// неполный результат молча уйдёт наверх как успешный.
if err := rows.Err(); err != nil {
    return nil, fmt.Errorf("iterate: %w", err)
}

// 3. NULL: обычный тип не примет NULL
var name string
err := row.Scan(&name)           // "converting NULL to string is unsupported"

var name sql.NullString          // вариант A: явная обёртка
_ = row.Scan(&name)
if name.Valid { use(name.String) }

var namePtr *string              // вариант B: указатель, nil == NULL
_ = row.Scan(&namePtr)

var nameStr string               // вариант C: решить в SQL
_ = row.Scan(&nameStr)           // SELECT coalesce(name, '') FROM ...

// 4. ErrNoRows: не ошибка инфраструктуры, а нормальный доменный случай
var u User
err := db.QueryRowContext(ctx, `SELECT id, name FROM users WHERE id = $1`, id).
    Scan(&u.ID, &u.Name)
switch {
case errors.Is(err, sql.ErrNoRows):        // в pgx: errors.Is(err, pgx.ErrNoRows)
    return nil, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
case err != nil:
    return nil, fmt.Errorf("get user: %w", err)
}
Что происходит с запросом в БД при отмене context

Отмена контекста не останавливает запрос мгновенно. Go отправляет серверу сообщение CancelRequest: открывает отдельное соединение к PostgreSQL и просит прервать текущий запрос указанного backend-процесса. Сервер выполнит просьбу, когда дойдёт до точки проверки прерываний (CHECK_FOR_INTERRUPTS). Пока этого не случилось, запрос продолжает жечь CPU и держать блокировки. Отсюда четыре следствия: (1) отмена остаётся асинхронной просьбой, а не гарантией; (2) уже выполненная часть UPDATE откатывается транзакцией, а не отменой; (3) серверный statement_timeout надёжнее клиентского, поэтому его выставляют и на уровне БД/роли, а не полагаются только на context; (4) само соединение после отмены Go считает грязным и может закрыть, а при массовых таймаутах это даёт шторм переподключений.

Миграции и изменение схемы без даунтайма

# goose
goose -dir ./migrations postgres "$DSN" up
goose -dir ./migrations postgres "$DSN" down      # откат одной последней
goose -dir ./migrations create add_users_phone sql # 20260826120000_add_users_phone.sql

# golang-migrate
migrate -path ./migrations -database "$DSN" up
migrate -path ./migrations -database "$DSN" force 20260826120000   # снять dirty-флаг
Expand / contract: NOT NULL колонка на таблице в 200 млн строк время 1 · EXPAND ALTER TABLE users ADD COLUMN phone text; nullable, без DEFAULT — метаданная операция, мгновенно 2 · КОД ПИШЕТ ОБА новый код заполняет phone, старый код всё ещё работает — колонка nullable, ему всё равно. Откат релиза безопасен. 3 · BACKFILL UPDATE ... WHERE id BETWEEN $1 AND $2 батчами по 10k, с паузами, следя за lag и autovacuum 4 · CONTRACT ADD CONSTRAINT ... NOT VALID; VALIDATE CONSTRAINT; валидация без ACCESS EXCLUSIVE, только SHARE UPDATE EXCLUSIVE Как НЕ надо — и что именно ляжет: ALTER TABLE users ADD COLUMN phone text NOT NULL DEFAULT ''; до PG 11 — переписывание всей таблицы под ACCESS EXCLUSIVE: прод стоит десятки минут ALTER TABLE users ALTER COLUMN phone SET NOT NULL; полный скан под ACCESS EXCLUSIVE — блокирует даже SELECT; плюс очередь блокировок за ним
Expand/contract годится для любых ломающих изменений схемы: сначала расширяем схему так, чтобы работали и старый, и новый код, потом переносим данные и только затем убираем старое. Между шагами проходит полный цикл выката, чтобы в любой момент можно было откатиться.
-- Индекс на живой таблице: только CONCURRENTLY и только вне транзакции
CREATE INDEX CONCURRENTLY users_phone_idx ON users (phone);
-- В отличие от обычного CREATE INDEX не блокирует запись, но делает два прохода и может упасть,
-- оставив невалидный индекс. Проверка:
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
DROP INDEX CONCURRENTLY users_phone_idx;   -- и создать заново

-- NOT NULL без долгой блокировки (PG 12+)
ALTER TABLE users ADD CONSTRAINT users_phone_nn CHECK (phone IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_phone_nn;   -- лёгкая блокировка
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;      -- теперь мгновенно: констрейнт доказан

-- Любой ALTER на живой таблице запускаем с коротким lock_timeout и ретраями,
-- иначе он встанет в очередь и заблокирует все запросы за собой
SET lock_timeout = '3s';
ALTER TABLE users ...;
Колонку переименовывают минимум за два релиза
  1. Релиз 1: добавить новую колонку, начать писать в обе (в коде или триггером), читать пока из старой.
  2. Backfill: скопировать данные батчами.
  3. Релиз 2: переключить чтение на новую колонку. Пишем всё ещё в обе.
  4. Релиз 3: перестать писать в старую.
  5. Релиз 4 (через недели): DROP COLUMN.
Конфликты версий миграций в команде

Классическая ситуация: две ветки создали миграции с номерами 014 и 014, обе смёржились. Что делать: (1) перейти на timestamp вместо последовательного номера — goose и migrate это умеют, и коллизия становится маловероятной; (2) даже при timestamp остаётся риск порядка: миграция, смёрженная позже, но с более ранним timestamp, на проде выполнится, а на машине разработчика — нет, потому что версия уже «пройдена»; goose лечит это флагом allow-missing или -s-режимом, у migrate такого нет, и тогда миграцию лучше переименовать перед мержем; (3) dirty state в golang-migrate: если миграция упала посередине, таблица версий помечается грязной и все дальнейшие запуски отказываются работать; чинится force после ручной проверки, что именно применилось; (4) миграции запускать одним процессом (init-контейнер или отдельная джоба), а не каждым подом на старте, иначе десять подов запустят миграцию одновременно; goose и migrate берут advisory lock, но полагаться на гонку не стоит; (5) down-миграции на проде почти не применяют: откат обычно делают «вперёд» новой миграцией, потому что down, удаляющий колонку, уничтожает данные. Писать down всё равно надо — для локальной разработки и тестов.

Вопросы

13
Суть: database/sql — абстракция стандартной библиотеки (пул + интерфейсы), сама по себе с базой не говорит. pgx — драйвер PostgreSQL, который умеет работать и под database/sql, и напрямую со своим API и пулом. sqlx — тонкая обёртка, убирающая рутину сканирования. GORM — ORM. Для нового сервиса на PostgreSQL — pgx напрямую, сканирование через pgx.CollectRows или sqlc.

Аргументация выбора

  • pgx нативно даёт бинарный протокол (меньше трафика и аллокаций), нативные типы PostgreSQL (jsonb, массивы, диапазоны, uuid без промежуточного []byte), CopyFrom, Batch, LISTEN/NOTIFY и типизированные ошибки pgconn.PgError с кодом и именем констрейнта.
  • Цена: привязка к PostgreSQL и свой API — часть экосистемы (например, sqlmock) рассчитана на database/sql. Для меня это не проблема: интеграционные тесты я всё равно пишу на testcontainers с настоящей базой, а не на моках драйвера.
  • sqlx разумно брать, если нужен стандартный интерфейс и переносимость: StructScan, Get, Select, sqlx.In закрывают 80 % боли database/sql почти без магии.
  • GORM уместен там, где действительно много однотипного CRUD и мало сложных запросов. Проблемы: сгенерированный SQL непредсказуем и его надо логировать, Preload легко забыть и получить N+1, AutoMigrate на проде опасен (он не умеет откатываться и делает неожиданные ALTER).
  • sqlc стоит упомянуть отдельно: SQL остаётся SQL-ом, но проверяется ещё при генерации и превращается в типизированные Go-функции. Это лучший из известных компромиссов между контролем и рутиной.
Чем добить

«Критерий выбора я формулирую так: смогу ли я в любой момент увидеть точный SQL, который уходит в базу, и объяснить его план. Если инструмент этому мешает, он не подходит для сервиса, где база и есть узкое место. Отсюда и порядок предпочтений: pgx/sqlc → sqlx → database/sql → ORM.»

Суть: MaxOpenConns — потолок одновременных соединений, MaxIdleConns — сколько держать наготове, ConnMaxLifetime — когда принудительно пересоздать, ConnMaxIdleTime — когда закрыть простаивающее. Дефолты вредны: MaxOpenConns = 0 (без лимита) и MaxIdleConns = 2. Подбирается от бюджета max_connections базы, поделённого на все инстансы, а не «побольше».

Почему дефолты плохие

  • С MaxOpenConns = 0 при всплеске Go откроет столько соединений, сколько горутин пришло, упрётся в max_connections сервера и получит «FATAL: sorry, too many clients already». Причём упадёт не один этот сервис, а все, кто ходит в ту же базу, включая мониторинг.
  • MaxIdleConns = 2 при MaxOpenConns = 50: как только нагрузка спадает, пул закрывает всё сверх двух, а на следующем всплеске снова устанавливает соединения — TCP + TLS + аутентификация, миллисекунды на каждое, и всё это в hot path. Симптом: MaxIdleClosed в DBStats растёт тысячами в минуту.

Как считать размер

  1. Сверху: max_connections (обычно 100–200) минус резерв (superuser_reserved_connections, мониторинг, миграции, человек с psql), делить на число инстансов всех сервисов, ходящих в эту базу.
  2. Снизу, по закону Литтла: нужное число = RPS × средняя длительность запроса. 400 RPS × 10 мс = 4 соединения на инстанс. Если получается 50, лечить надо длительность запросов.
  3. Проверка здравого смысла: PostgreSQL хорошо работает, когда активных соединений порядка 2 × CPU. Сверх этого начинается деградация из-за конкуренции за LWLock и вытеснения кэшей, и пропускная способность падает, а не растёт.
  4. MaxIdleConns = MaxOpenConns почти всегда подходит сервису под постоянной нагрузкой.
  5. ConnMaxLifetime 30–60 минут обязателен, если между приложением и БД есть балансировщик, VIP или облачный прокси: без пересоздания соединения не переедут после failover и не перебалансируются.
Что мониторить

Экспортировать db.Stats() целиком. WaitCount и WaitDuration растут, если пул мал или запросы долгие; InUse против MaxOpenConnections показывает запас до потолка; MaxIdleClosed подскажет, не мал ли MaxIdleConns. Без этих метрик исчерпание пула ищут часами.

Суть: не ошибка, а ожидание. Горутина блокируется на получении соединения и стоит в очереди, пока кто-то не освободит соединение или пока не истечёт context. Поэтому в проде это видно как рост p99 и context deadline exceeded при абсолютно спокойной базе — самый частый неверный диагноз «база тормозит».

Последовательность событий

  1. Все MaxOpenConns соединений заняты.
  2. Очередная горутина вызывает QueryContext и блокируется внутри database/sql в ожидании освобождения.
  3. Растут WaitCount и WaitDuration.
  4. Если у запроса есть context с таймаутом, по истечении вернётся context deadline exceeded. Если контекста нет, горутина будет ждать вечно.
  5. Очередь растёт, горутины копятся, растёт потребление памяти, HTTP-хендлеры не отвечают, health-check падает, под перезапускается — и трафик уходит на соседние поды, которые падают следом. Каскад.

Типичные первопричины

  • Забытый rows.Close() — соединение не возвращается в пул. Классика: return из середины цикла rows.Next() без defer rows.Close(). Симптом: InUse монотонно растёт до потолка, и всё залипает.
  • Долгие транзакции: HTTP-вызов или запись в брокер внутри BeginTx/Commit. Соединение занято всё это время.
  • Медленный запрос без индекса: 30 запросов по 2 секунды намертво выедают пул из 25.
  • N+1 при росте страницы: 500 быстрых запросов вместо двух — и пул не справляется.
  • Блокировки в БД: транзакции ждут друг друга, соединения заняты ожиданием.
Что делать
  • Всегда ставить context с таймаутом на запрос: бесконечное зависание станет быстрой видимой ошибкой.
  • Найти держателя: pg_stat_activity с сортировкой по now() - query_start, отдельно посмотреть state = 'idle in transaction' — это почти всегда баг приложения.
  • Поставить idle_in_transaction_session_timeout на стороне БД как страховку от забытого Commit.
  • Пул увеличивают в последнюю очередь: так обычно лечат симптом и перекладывают проблему на саму базу.
  • На уровне архитектуры ограничить конкурентность (семафор, worker pool) раньше, чем на уровне пула БД, чтобы отказ был контролируемым.
Суть: в PostgreSQL каждое соединение — отдельный процесс на сервере (несколько мегабайт памяти плюс накладные расходы), поэтому max_connections ограничен сотнями. pgbouncer мультиплексирует: тысячи клиентских соединений он раскладывает на десятки серверных. Три режима: session (соединение занято на всю сессию), transaction (на время транзакции — главный рабочий режим), statement (на один оператор).

Когда действительно нужен

  • Много инстансов сервиса: 30 подов × 20 соединений = 600 при max_connections = 200.
  • Kubernetes с автоскейлингом и serverless — число клиентов меняется на ходу, заранее поделить бюджет невозможно.
  • Клиенты, которые не умеют пулить (короткоживущие процессы, cron-скрипты, PHP).
  • Нужен единый шлюз для смены мастера при failover: приложения не переподключаются, pgbouncer переключает PAUSE/RESUME.

Почему ломаются prepared statements

PREPARE создаёт именованный план в конкретной серверной сессии. В transaction mode после COMMIT серверное соединение уходит другому клиенту, и следующий EXECUTE попадёт на другое соединение, где плана нет: «prepared statement "lrupsc_1_0" does not exist». Драйверы Go по умолчанию используют extended protocol с неявной подготовкой, поэтому проблема вылезает сразу, а не в экзотике.

Как обходят

  • pgx: config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol (или QueryExecModeExec) — параметры отправляются вместе с запросом, именованных планов не создаётся. Защита от инъекций сохраняется, но каждый запрос планируется заново.
  • pgbouncer 1.21+ умеет отслеживать protocol-level prepared statements (max_prepared_statements > 0) и переигрывать Parse на новом серверном соединении — самое удобное современное решение.
  • Два пути к БД: API-трафик через pgbouncer, а фоновые воркеры с длинными транзакциями, LISTEN/NOTIFY и advisory-локами ходят напрямую.

Что ещё ломается в transaction mode

  • SET / SET LOCAL: сессионные настройки утекают чужому клиенту или теряются. SET LOCAL внутри транзакции безопасен.
  • Temp-таблицы: создаются в сессии, следующей транзакции их не видно.
  • LISTEN/NOTIFY живёт только в сессии.
  • Advisory locks уровня сессии (pg_advisory_lock): могут «залипнуть» на чужом соединении. Использовать транзакционные pg_advisory_xact_lock.
  • Курсоры WITH HOLD вне транзакции.
Связь с клиентским пулом

Пулов становится два. Правило: Σ MaxOpenConns всех подов ≤ max_client_conn, а default_pool_sizemax_connections минус резерв. Клиентский пул при этом держат небольшим: соединение до pgbouncer дёшево, но пока идёт транзакция, оно всё равно удерживает серверную сессию. И ConnMaxLifetime оставляем, чтобы клиенты перераспределялись между инстансами bouncer.

Суть: инъекция возможна там, где пользовательские данные склеиваются с текстом запроса и потому парсятся как SQL. Лечение — параметризованные запросы: текст и значения едут по протоколу отдельно, значение никогда не становится синтаксисом. Но параметром нельзя подставить имя таблицы, имя колонки или направление сортировки — там спасает только белый список.
// Уязвимо
q := "SELECT id, email FROM users WHERE name = '" + name + "'"
// name = "' OR '1'='1"                 → отдаст всю таблицу
// name = "' UNION SELECT id, password_hash FROM admins --"  → утечка
// name = "'; UPDATE users SET is_admin = true WHERE email='me@x.y'; --"

// Закрыто
rows, err := db.QueryContext(ctx, `SELECT id, email FROM users WHERE name = $1`, name)

Почему параметризация действительно защищает

В extended query protocol PostgreSQL сообщение Parse несёт текст запроса с плейсхолдерами, а Bindзначения. Сервер уже построил дерево разбора к моменту, когда увидел значение; подставить в него новый оператор невозможно физически. Защищает разделение каналов, а не экранирование или фильтрация. Поэтому «я экранирую кавычки» заведомо проигрывает: есть многобайтные кодировки, есть числовые контексты без кавычек, есть комментарии.

Динамическая сортировка и имена объектов

// ORDER BY $1 синтаксически валиден, но означает «сортировать по константе».
// Работать как задумано не будет. А склейка открывает дыру, причём blind-типа:
// злоумышленник читает данные посимвольно через CASE в ORDER BY,
// даже не видя текста ошибок.

var allowed = map[string]string{
    "date_desc": "created_at DESC, id DESC",
    "date_asc":  "created_at ASC, id ASC",
    "name":      "name ASC",
}
order, ok := allowed[req.Sort]
if !ok { order = "created_at DESC, id DESC" }
q := fmt.Sprintf(`SELECT id, name FROM users ORDER BY %s LIMIT $1 OFFSET $2`, order)

// Динамическое имя схемы (мультитенантность): квотируем идентификатор
tbl := pgx.Identifier{tenantSchema, "orders"}.Sanitize()
q := fmt.Sprintf(`SELECT id FROM %s WHERE created_at > $1`, tbl)
Эшелонированная защита
  • Параметризация обязательна как базовый уровень.
  • Белый список для всего, кроме значений.
  • Отдельная роль в БД с минимальными правами: сервису не нужен DROP TABLE, миграции катятся другой ролью.
  • Row Level Security для мультитенантности — даже при ошибке в запросе чужие строки не уйдут.
  • Не отдавать пользователю текст ошибки БД: он подсказывает структуру схемы.
  • Линтеры и статический анализ (gosec, правило про fmt.Sprintf в SQL) в CI.
  • Логировать запросы с параметрами отдельно от текста — тогда видны аномалии.
Суть: BeginTx → сразу defer с Rollback → работа только через txCommit. Ошибка в середине переводит транзакцию PostgreSQL в состояние aborted: все следующие операторы будут отвечать «current transaction is aborted», спасает только ROLLBACK или откат к SAVEPOINT. Ошибка Commitнеопределённость: коммит мог пройти, а ответ потеряться.
func (r *Repo) Do(ctx context.Context) (err error) {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil { return fmt.Errorf("begin: %w", err) }
    defer func() {
        if p := recover(); p != nil { _ = tx.Rollback(); panic(p) }
        if err != nil { _ = tx.Rollback() }     // err здесь именованный результат
    }()

    if _, err = tx.ExecContext(ctx, q1, a...); err != nil { return err }
    if _, err = tx.ExecContext(ctx, q2, b...); err != nil { return err }

    if err = tx.Commit(); err != nil { return fmt.Errorf("commit: %w", err) }
    return nil
}

// SAVEPOINT: когда часть работы допустимо потерять, не теряя всю транзакцию
if _, err := tx.ExecContext(ctx, "SAVEPOINT sp1"); err != nil { return err }
if _, err := tx.ExecContext(ctx, riskyQuery); err != nil {
    _, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT sp1")   // транзакция снова рабочая
} else {
    _, _ = tx.ExecContext(ctx, "RELEASE SAVEPOINT sp1")
}

Разбор деталей

  • Именованный возврат (err error) остаётся единственным способом дать defer увидеть ошибку, с которой функция выходит. Без него откат придётся ставить на каждом return, и однажды его забудут.
  • Rollback после Commit вернёт sql.ErrTxDone. Это нормально, ошибку явно игнорируем.
  • recover в defer нужен, чтобы паника не оставила транзакцию открытой (соединение зависнет в состоянии idle in transaction до таймаута). После отката панику пробрасываем дальше — глотать её нельзя.
  • Только tx. Случайный вызов r.db.Exec внутри транзакции уйдёт по другому соединению: изменения окажутся вне транзакции, а при неудаче ещё и заблокируются на строке, которую держит собственная транзакция. Такую самоблокировку диагностируют часами.
  • Isolation. &sql.TxOptions{Isolation: sql.LevelRepeatableRead} — и не забыть, что на Repeatable Read и Serializable нужен ретрай при 40001 (serialization failure).
Ошибка Commit — самое важное место

Если Commit вернул сетевую ошибку, ты не знаешь, применилась транзакция или нет: сервер мог зафиксировать её и упасть до отправки ответа. Наивный ретрай приведёт к двойному списанию. Правильные ответы: (1) сделать операцию идемпотентной через уникальный ключ на бизнес-действие (idempotency_key) плюс ON CONFLICT DO NOTHING; (2) перед ретраем проверить состояние — «а есть ли уже такой перевод?»; (3) вернуть наверх ошибку «неизвестно» и разбирать сверкой, если операция денежная. Это, кстати, ровно та же проблема, что и в 2PC.

Суть: три подхода — (1) интерфейс DBTX, который одинаково реализуют пул и транзакция, и репозиторий не знает, что под ним; (2) UnitOfWork/Transactor: сервисный слой оборачивает работу в WithinTx(ctx, func(ctx) error); (3) прокинуть tx в context. Первые два предпочтительнее; третий удобен, но прячет зависимость.

Почему это вообще проблема

Транзакция принадлежит сценарию (создать заказ, списать со склада, положить событие в outbox), а репозитории пишутся по одной сущности. Значит, границей транзакции управляет слой выше репозитория, а репозитории должны уметь работать как внутри неё, так и вне. Если этого не решить, получается либо репозиторий, который сам открывает транзакцию (и тогда атомарности между сущностями нет), либо tx в каждой сигнатуре.

ПодходПлюсыМинусы
Интерфейс DBTX в конструктореявно, тестируемо, ноль магиинужно уметь создать «репозиторий на транзакции» — обычно метод WithTx(tx) *Repo
UnitOfWork / Transactorграница транзакции видна в одном месте; легко добавить ретраи на 40001ещё одна абстракция; при вложенных вызовах нужны savepoint-ы
tx в contextсигнатуры не меняются, транзакция «протекает» через слоискрытая зависимость, компилятор не помогает, легко забыть обёртку
Явный tx первым аргументоммаксимально честнопротекание инфраструктуры в доменные сигнатуры на всех уровнях

Аргументы против tx в context

  • По сигнатуре Insert(ctx, user) error невозможно понять, участвует ли вызов в транзакции. Код перестаёт читаться локально.
  • WithinTx легко забыть, и код будет работать, просто без атомарности. Такой баг находят в проде.
  • context по соглашению Go отвечает за отмену, дедлайны и request-scoped данные, а не за внедрение зависимостей. Это прямо написано в документации context.WithValue.
  • Тесты сложнее: для репозитория «в транзакции» нужно собрать правильный контекст.
  • Вложенность: если WithinTx вызвать внутри WithinTx, наивная реализация откроет вторую транзакцию на другом соединении и заблокируется сама на себе. Нужно уметь распознать существующую и сделать savepoint.
Что отвечать

«В новом коде беру Transactor с явным DBTX в репозиториях: граница транзакции видна, компилятор проверяет. В большом легаси, где переписать сигнатуры нереально, tx в контексте — прагматичный компромисс, но тогда я закрываю его линтером и тестом, который проверяет, что репозиторий внутри WithinTx действительно попадает в транзакцию.»

Суть: драйвер отправляет серверу CancelRequest — отдельное соединение с просьбой прервать текущий запрос конкретного backend-процесса. Это асинхронная просьба, а не гарантия: сервер прервёт работу, когда дойдёт до ближайшей точки проверки прерываний. До этого момента запрос продолжает потреблять CPU и держать блокировки.

Что важно понимать

  • Отмена ≠ откат. Прерванный UPDATE откатывается не потому, что его отменили, а потому что транзакция не была зафиксирована. Если автокоммит и оператор успел завершиться до прихода отмены, изменения останутся.
  • Задержка. Тяжёлая сортировка, длинный COPY и ожидание блокировки реагируют на отмену каждое в свой момент. Некоторые операции (например, fsync при коммите) вообще не прерываются.
  • Соединение считается грязным. Go после отмены обычно закрывает соединение, а не возвращает в пул: неизвестно, в каком оно состоянии. При массовых таймаутах это даёт шторм переподключений, который добивает базу — типичная вторая волна инцидента.
  • Отмена HTTP-запроса пользователем тоже отменяет контекст, а с ним и запрос в БД. Это хорошо для чтения и опасно для записи: сценарий «пользователь нажал Escape в середине оформления заказа» должен работать предсказуемо, поэтому для записи часто берут context.WithoutCancel (Go 1.21+) или отдельный контекст с собственным таймаутом.
Практика
  • Клиентский таймаут дублировать серверным: SET statement_timeout на роль сервиса или SET LOCAL statement_timeout в транзакции. Серверный надёжнее: он действует, даже если приложение умерло.
  • idle_in_transaction_session_timeout спасает от забытых Commit.
  • lock_timeout не даст миграции встать в очередь и заблокировать всё за собой.
  • Таймаут должен быть меньше у нижележащего слоя, чем у вышестоящего, иначе отмена не успевает сработать до того, как клиент уже ушёл.
  • Убить зависший запрос вручную: pg_cancel_backend(pid) просит вежливо, pg_terminate_backend(pid) грубо рвёт соединение.
Суть: rows.Close() возвращает соединение в пул — без него утекают соединения и пул исчерпывается. rows.Err() отличает «данные кончились» от «итерация оборвалась ошибкой» — без него вы вернёте неполный результат как успешный, и это самая тихая из возможных ошибок работы с БД.
rows, err := db.QueryContext(ctx, q, args...)
if err != nil { return nil, err }
defer rows.Close()          // (1) обязательно и сразу

var out []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name); err != nil { return nil, err }
    out = append(out, u)
}
if err := rows.Err(); err != nil {   // (2) обязательно после цикла
    return nil, fmt.Errorf("iterate rows: %w", err)
}
return out, nil

Детали, которые проверяют

  • rows.Next() возвращает false в двух разных случаях: строки закончились или произошла ошибка (обрыв сети, серверная ошибка в середине выдачи, истёкший контекст, отвалившаяся реплика). Различить их можно только через rows.Err().
  • Next(), дойдя до конца, сам вызывает Close(). Но при раннем return из цикла этого не произойдёт, поэтому defer rows.Close() нужен всегда.
  • Повторный Close() безопасен, он идемпотентен.
  • QueryRow не требует Close: Scan закрывает набор сам. Но именно поэтому QueryRow нельзя «отложить» — ошибка запроса приезжает только в Scan.
  • Nested query trap: открыть rows и внутри цикла делать другой запрос через тот же *sql.DB значит занять два соединения на горутину одновременно. При пуле в 25 и 25 параллельных обработчиках получается дедлок на пуле: все держат по одному соединению и ждут второго. Лечится так: сначала вычитать результат в память, потом делать вложенные запросы (а обычно вложенный запрос вообще не нужен — это N+1).
  • В pgx rows.Close() ничего не возвращает, а ошибку отдаёт rows.Err(); сегодня проще взять pgx.CollectRows с RowToStructByName, он сделает всё правильно за тебя.
Как выглядит утечка

DBStats.InUse монотонно растёт и не падает даже в тишине; в pg_stat_activity копятся сессии в состоянии idle; через какое-то время каждый запрос начинает ждать. В коде причина почти всегда одна: return внутри цикла rows.Next() без defer rows.Close(). Ловят это линтеры rowserrcheck и sqlclosecheck — их стоит включить в CI.

Суть: в обычный string NULL не просканируется — будет «converting NULL to string is unsupported». Три способа: обёртки sql.NullString/sql.NullInt64, указатели (*string, где nil = NULL), или убрать NULL на стороне SQL через coalesce. Разница между первыми двумя — вопрос стиля и JSON.
sql.NullString*string
Читаемость проверкиif v.Valid — явноif p != nil — привычно для Go
Риск паникинетесть — разыменование nil
JSON по умолчаниюсериализуется как объект {"String":"","Valid":false} — почти всегда не то, нужен свой MarshalJSONсериализуется как null — обычно ровно то, что нужно
Аллокациинетесть — указатель на heap
Доменная модельтащит зависимость от database/sql в доменчистый Go
pgxподдерживаетсяподдерживается, плюс есть pgtype.Text с тем же смыслом

Практический подход

  • В слое репозитория живут sql.Null* или pgtype.*, в доменной модели — указатели или отдельное значение по умолчанию. Конвертирует маппер, и домен про SQL не знает.
  • Часто чище всего убрать NULL в запросе: SELECT coalesce(middle_name, '') FROM users. Годится, когда пустая строка и NULL семантически неразличимы. Не годится, когда различимы: NULL «телефон не указан» и пустая строка «указан пустым» означают разные факты.
  • Ещё лучше не заводить NULL в схеме там, где он не несёт смысла: NOT NULL DEFAULT ''. Каждый nullable-столбец навсегда добавляет ветвление в код.
  • Помнить про агрегаты: sum() по пустому набору вернёт NULL, а не 0. Классический баг: SELECT sum(amount) FROM orders WHERE ... с приёмником int64 падает, когда заказов не нашлось. Лечится coalesce(sum(amount), 0).
Суть: «строк не найдено» — это нормальный доменный исход, а не сбой инфраструктуры. Репозиторий должен превратить sql.ErrNoRows в доменную ошибку (ErrNotFound), чтобы верхние слои не зависели от database/sql и чтобы транспортный слой смог отдать 404, а не 500.
// domain
var ErrNotFound = errors.New("not found")

// repository
func (r *Repo) GetUser(ctx context.Context, id int64) (*User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, email FROM users WHERE id = $1`, id).Scan(&u.ID, &u.Email)
    switch {
    case errors.Is(err, sql.ErrNoRows):       // pgx: errors.Is(err, pgx.ErrNoRows)
        return nil, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
    case err != nil:
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &u, nil
}

// transport
if errors.Is(err, domain.ErrNotFound) {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

Почему именно так

  • Слои не должны знать про драйвер. Если хендлер проверяет sql.ErrNoRows, то при замене database/sql на pgx или переезде в другое хранилище придётся править транспорт.
  • 404 против 500. Проброшенная как есть ошибка почти всегда превращается в 500 и в алерт, хотя ничего нештатного в «пользователь не найден» нет.
  • errors.Is, а не ==. Ошибка почти всегда обёрнута через %w; сравнение на равенство перестанет работать при первом же добавлении контекста.
  • У pgx своя pgx.ErrNoRows, и она не равна sql.ErrNoRows — типичная ловушка при миграции с lib/pq.
  • Не для всех случаев это ошибка. Для «есть ли такой email» правильнее вернуть (bool, error) или (*User, error) с nil, nil, чем заставлять вызывающего ловить ошибку ради нормального ответа. Ошибка уместна там, где отсутствие записи действительно нарушает ожидания.
Смежная тема, которую спрашивают следом

Так же маппят и нарушение уникальности: pgconn.PgError с Code == "23505"domain.ErrAlreadyExists → HTTP 409. И "23503" (foreign key), "40001" (serialization failure, ретраить), "40P01" (deadlock, тоже ретраить), "57014" (query canceled). Если назовёшь пару кодов SQLSTATE и покажешь, что различаешь «ретраить» и «не ретраить», это очень хорошо смотрится.

Суть: миграции — версионированные SQL-файлы, применяемые по порядку; инструмент хранит применённые версии в служебной таблице (goose_db_version, schema_migrations) и накатывает недостающие. Ключевые практики: timestamp вместо порядкового номера, один процесс-мигратор, обратно совместимые изменения, откат «вперёд», а не через down.

Что важно знать про инструменты

  • goose: файлы с секциями -- +goose Up / -- +goose Down; поддерживает Go-миграции для сложной логики; есть -- +goose NO TRANSACTION — обязательно для CREATE INDEX CONCURRENTLY, который не работает внутри транзакции.
  • golang-migrate: пары файлов *.up.sql / *.down.sql; понятие dirty state — если миграция упала посередине, база помечается грязной и дальнейшие запуски отказываются работать до force.
  • Каждая миграция по умолчанию выполняется в транзакции. В PostgreSQL DDL транзакционен, и это большое преимущество: неудачная миграция откатывается целиком. Исключения — CREATE INDEX CONCURRENTLY, ALTER TYPE ... ADD VALUE (до PG 12), VACUUM.

Конфликты версий и как их гасить

  • Одинаковые номера из двух веток лечатся переходом на timestamp-именование.
  • Порядок при мерже. Даже с timestamp бывает так: ветку с более ранним timestamp смёржили позже. На проде она применится, а у разработчика, у которого более поздняя версия уже записана, — нет. goose умеет -allow-missing, у migrate этого нет; универсальное решение — перед мержем переименовать миграцию, чтобы она стала последней.
  • Гонка при старте. Десять подов одновременно запускают миграции. Правильно — отдельная джоба или init-контейнер в единственном экземпляре. Инструменты берут advisory lock, но осознанно полагаться на это не стоит.
  • Долгая миграция блокирует деплой. Тяжёлый backfill выносится из миграции в отдельную фоновую задачу, миграция остаётся быстрой и только меняет схему.
  • Ревью миграций обязательно. Отдельным глазом смотреть на любой ALTER, на отсутствующий CONCURRENTLY и на lock_timeout.
Про down-миграции

На проде их почти не применяют. down, который делает DROP COLUMN, уничтожает данные — релиз откатить можно, а данные уже не вернуть. Поэтому откатываются обычно новой миграцией вперёд, а down пишут для локальной разработки и тестов. И проектировать миграции надо так, чтобы откат кода без отката схемы был безопасен — в этом и смысл expand/contract.

Суть: паттерн expand/contract: (1) добавить колонку nullable — это метаданная операция; (2) выкатить код, который пишет в неё, оставаясь совместимым со старым; (3) backfill батчами; (4) навесить NOT NULL через CHECK ... NOT VALID + VALIDATE. Индексы — только CREATE INDEX CONCURRENTLY. Каждый шаг — отдельный релиз, между шагами возможен откат.
-- Шаг 1 (мгновенно): nullable-колонка меняет только системный каталог
ALTER TABLE users ADD COLUMN phone text;

-- Шаг 2: релиз кода, который пишет phone. Старая версия кода тоже работает.

-- Шаг 3: backfill батчами, а не одним UPDATE
UPDATE users SET phone = derive(...)
WHERE phone IS NULL AND id BETWEEN $1 AND $2;   -- по 10k, с паузой, следя за lag

-- Шаг 4: NOT NULL без ACCESS EXCLUSIVE на весь скан
ALTER TABLE users ADD CONSTRAINT users_phone_nn CHECK (phone IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_phone_nn;   -- SHARE UPDATE EXCLUSIVE
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;      -- мгновенно (PG 12+)
ALTER TABLE users DROP CONSTRAINT users_phone_nn;       -- опционально

-- Индекс
CREATE INDEX CONCURRENTLY users_phone_idx ON users (phone);
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;  -- проверить успех

Что именно ломается при «прямом» подходе

  • ADD COLUMN ... NOT NULL DEFAULT 'x' до PostgreSQL 11 переписывал всю таблицу под ACCESS EXCLUSIVE. С PG 11 значение по умолчанию хранится в каталоге и операция мгновенна, но знать историю надо: на PG 10 это десятки минут простоя.
  • ALTER COLUMN ... SET NOT NULL напрямую запускает полный скан таблицы под ACCESS EXCLUSIVE, который блокирует даже SELECT.
  • CREATE INDEX без CONCURRENTLY блокирует запись на всё время построения.
  • Очередь блокировок. Самое коварное: ALTER, ожидающий ACCESS EXCLUSIVE, встаёт в очередь за долгим SELECT, а все запросы, пришедшие после него, встают уже за ним. Одна миграция при одном долгом отчёте кладёт весь сервис. Лечится SET lock_timeout = '3s' и ретраями.
Правила безопасных миграций
  • Схема должна быть совместима с двумя версиями кода — предыдущей и текущей. Иначе rolling deploy невозможен в принципе.
  • Никогда не переименовывать и не удалять то, что читает работающий код. Переименовывают минимум за два релиза, удаляют через недели.
  • CREATE INDEX CONCURRENTLY вне транзакции; проверять indisvalid после — при неудаче остаётся невалидный индекс, который занимает место и обновляется при записи.
  • Backfill делают фоновой задачей, а не миграцией: батчи, паузы, контроль replication lag и autovacuum.
  • lock_timeout на любом ALTER.
  • DROP COLUMN в PostgreSQL мгновенен (колонка помечается удалённой), но место освободит только VACUUM FULL или pg_repack.

6.8Масштабирование и отказоустойчивость БД

Последняя глава темы, и отсюда разговор почти всегда уходит в системный дизайн. Перечислить термины мало, нужно показать, что ты понимаешь размены: за синхронную репликацию платят latency, за чтение с реплик — консистентностью, за шардирование — джойнами и транзакциями, за автоматический failover — риском split brain.

Сначала — словарь: копия, отставание, переключение, шард

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

1. Мастер, реплика, репликация

Репликация держит живую копию базы на другом сервере. Сервер, принимающий запись, называют мастером (primary, лидер); серверы с копиями — репликами (replica, standby, follower), и они по умолчанию доступны только на чтение. Копия нужна ради двух совершенно разных вещей: снять с мастера часть чтений и иметь готовую замену, если мастер умрёт. Механика в PostgreSQL в обоих случаях одна: мастер пишет журнал изменений (тот самый WAL из главы 6.3), реплика его получает и повторяет у себя.

2. Лаг — насколько копия отстаёт

Лагом репликации (replication lag, «отставание») называют промежуток между моментом, когда изменение зафиксировано на мастере, и моментом, когда его видно на реплике. Меряют его и во времени (миллисекунды), и в байтах ещё не применённого журнала. Нулевым он не бывает никогда: журнал надо успеть передать по сети и применить. Отсюда весь класс сюрпризов вида «записал и тут же не увидел собственную запись» — им посвящён отдельный раздел ниже.

3. Failover — переключение ролей при отказе

Failover (дословно «переход при отказе») переводит одну из реплик в роль мастера, когда прежний мастер отказал; бывает ручным и автоматическим. Нетривиальной эту процедуру делает один факт: узел не может отличить «сосед умер» от «до соседа не доходит сеть». Отсюда два слова рядом. Кворум — правило «решение принимает только та часть кластера, в которой находится большинство узлов»; оно существует ровно затем, чтобы меньшинство не могло действовать от имени целого. Split brain («расщепление сознания») называют аварию, когда правило нарушено и мастеров одновременно стало два.

4. Шард — кусок самих данных

Реплики не помогают с записью: каждая копия повторяет у себя всю запись мастера, то есть делает ровно ту же работу. Масштабировать запись можно только шардированием: разрезать данные на непересекающиеся куски (шарды) по какому-то ключу и разложить по разным серверам, у каждого свои диск, память и процессор. Цена — всё то, что внутри одной базы бесплатно: JOIN между шардами, глобальная уникальность и транзакция, задевающая два шарда сразу.

Репликация: четыре оси выбора

Механику мы уже назвали: мастер пишет WAL, реплика его получает и применяет. Дальше начинаются варианты, и по каждой оси выбор чем-то оплачивается.

Физическая (streaming)Логическая
Что передаётсябайты WAL: «в блоке 42 файла 16384 изменить такие байты»логические изменения строк: INSERT/UPDATE/DELETE с данными
Гранулярностьвся база целиком (кластер)отдельные таблицы, публикации/подписки
Репликапобайтовая копия, только чтениеобычная база, можно писать, можно иметь свои индексы и таблицы
Версии PGобязаны совпадатьмогут различаться — отсюда апгрейд мажорной версии без даунтайма
Накладные расходыминимальныевыше: декодирование WAL, применение построчно
Ограничениянельзя реплицировать частьне реплицирует DDL, нужны PK или REPLICA IDENTITY, последовательности не синхронизируются
ПрименениеHA, read-реплики, бэкапыCDC, интеграции, миграция между версиями, выборочная репликация в аналитику
АсинхроннаяСинхронная
Когда COMMIT отвечает клиентусразу после записи в локальный WALпосле подтверждения от реплики
Latency записине растёт+ RTT до реплики (в другой AZ это 1–2 мс, в другом регионе — десятки)
Потеря данных при падении мастеравозможна — всё, что не доехалонет (для подтверждённых коммитов)
Что если реплика недоступнаничего, мастер работаетзапись встаёт, пока подтверждение не придёт от нужного числа реплик (кворума) — доступность падает
Где применяютдефолт; read-реплики, гео-репликиденьги, платежи — всё, где нельзя потерять ни одного подтверждённого коммита (это и называется RPO = 0, определение ниже)
-- Уровни надёжности коммита: размен скорости на сохранность
SET synchronous_commit = off;          -- не ждать даже локального fsync:
                                       -- быстро, но при крахе теряем до 3× wal_writer_delay.
                                       -- При этом транзакция не «порвётся», база останется
                                       -- целостной, потеряются только последние коммиты.
SET synchronous_commit = local;        -- ждать локальный fsync, не ждать реплику
SET synchronous_commit = remote_write; -- реплика приняла и записала в ОС (не fsync)
SET synchronous_commit = on;           -- реплика зафиксировала на диск (по умолчанию)
SET synchronous_commit = remote_apply; -- реплика применила: чтение с неё сразу актуально

-- Список синхронных реплик и кворум задаются так:
synchronous_standby_names = 'ANY 1 (replica_a, replica_b, replica_c)';

-- Смотреть лаг
SELECT client_addr, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;
-- на самой реплике:
SELECT now() - pg_last_xact_replay_timestamp() AS lag_time;
Гибкость, о которой мало кто помнит

synchronous_commit настраивается на уровне транзакции, так что глобально выбирать не нужно: перевод денег выполняем с SET LOCAL synchronous_commit = 'remote_apply', а запись в лог активности — с 'off'. Один и тот же сервер, разные гарантии для разных операций. Кто вспомнит об этом на собеседовании, сразу выделится.

Master–replica, replication lag и read-your-writes

Чаще всего реплики используют так: отправляют на них чтение, оставив мастеру только запись. Выглядит как бесплатное ускорение, но вместе с ним в систему въезжает лаг, и первое, обо что спотыкаются, называется read-your-writes («прочитай свою запись»). Это гарантия, что пользователь сразу после собственной записи видит именно её, а не то, что было до. Асинхронная репликация такой гарантии не даёт. Вот как это выглядит на практике.

Replication lag и проблема «не вижу собственную запись» приложение POST /profile MASTER все записи REPLICA 1 lag 40 ms REPLICA 2 lag 2 s (долгий запрос) 1. UPDATE WAL, асинхронно 2. Сразу после ответа клиент делает GET /profile → уходит на REPLICA 2 3. Там ещё старые данные → пользователь видит, что изменение «не сохранилось» 4. Нажимает «сохранить» ещё раз → дубль, или пишет в поддержку Формально это не баг репликации, а следствие асинхронности. Чинится на уровне приложения. Что делают на практике • Sticky-to-master: N секунд после записи этот пользователь читает только с мастера • LSN-токен: запомнили lsn коммита, ждём на реплике pg_last_wal_replay_lsn() >= lsn, иначе мастер • Критичное читаем с мастера всегда (баланс, статус платежа), некритичное — с реплик • Оптимистичный UI: показать записанное значение из ответа, не перечитывая • synchronous_commit = remote_apply для критичных транзакций • Роутер должен исключать реплики с лагом выше порога из балансировки
Read-your-writes чаще всего и ломается, когда вводят read-реплики. Бесплатно реплики базу не «ускоряют»: они меняют модель консистентности, и приложение обязано учитывать это явно.
Что ещё нужно знать про лаг
  • Лаг не постоянен. Он взлетает при массовом UPDATE, миграции, VACUUM большой таблицы, при чекпоинте. Мониторить нужно не среднее, а максимум, и алертить по нему.
  • Реплика может «сопротивляться» применению WAL. Если на ней идёт длинный отчётный запрос, конфликтующие изменения (например, VACUUM удалил версии строк, которые нужны запросу) приводят либо к отмене запроса («canceling statement due to conflict with recovery»), либо, при hot_standby_feedback = on, к задержке очистки на мастере и росту bloat.
  • Реплики не масштабируют запись. Ни одна реплика не снимает нагрузку с мастера по записи, наоборот, мастеру прибавляется работа по отправке WAL. Запись масштабирует только шардирование.
  • Каскадная репликация (реплика от реплики) снижает нагрузку на мастер, но складывает лаги.

Failover и split brain

Ручной или автоматический (Patroni + etcd/Consul, repmgr, облачные managed-решения), failover идёт по одной схеме: обнаружение отказа → выбор кандидата (обычно реплика с максимальным LSN) → promote → переключение трафика (VIP, DNS, pgbouncer, service discovery) → переподключение приложений.

Split brain: два мастера одновременно

Сценарий: сеть между узлами разорвалась, но оба живы. Кластер решил, что мастер умер, и повысил реплику. Старый мастер об этом не знает и продолжает принимать запись, тем более если часть клиентов осталась по его сторону разрыва. Теперь две базы расходятся: в одной заказ №100 принадлежит Ивану, в другой — Петру. Слить их обратно автоматически невозможно: конфликты по одним и тем же первичным ключам, и нет объективного критерия, чья версия правильная. Кончается это ручным разбором с потерей данных.

Защита. (1) Кворум: решение о повышении принимает только та часть кластера, в которой большинство узлов (отсюда нечётное число узлов и внешний DCS вроде etcd, Consul, ZooKeeper). (2) Fencing / STONITH: прежде чем повысить нового мастера, старый принудительно изолируют — отключают сеть, останавливают процесс, забирают VIP. (3) Демоция по потере кворума: узел, потерявший связь с DCS, сам переводит себя в read-only, не дожидаясь чужого решения; так работает Patroni. (4) Единая точка входа: клиенты ходят через прокси, который знает, кто мастер; тогда «второй мастер» просто не получает трафика. (5) Синхронная репликация дополнительно защищает от потери подтверждённых коммитов при failover.

Про RPO и RTO — терминология, которую ждут

RPO (Recovery Point Objective) задаёт, сколько данных допустимо потерять, и меряется во времени: «не более 5 минут». RTO (Recovery Time Objective) задаёт, за сколько система обязана вернуться в строй: «не более 15 минут». Асинхронная репликация даёт RPO порядка величины лага и RTO в десятки секунд при автоматическом failover; синхронная — RPO = 0; ежесуточный pg_dump — RPO до 24 часов и RTO, равное времени восстановления дампа (на терабайте это часы). Эти две буквы переводят разговор из «надёжно/ ненадёжно» в цифры, и именно этого ждут на системном дизайне.

Шардирование против партиционирования

Партиционирование режет большую таблицу на части внутри одного сервера; приложение этого не замечает, склейкой занимается сама СУБД. Шардирование раскладывает данные по разным серверам, у каждого свой процессор, память и диск. Кусок данных называют шардом, а колонку, по которой строку направляют в шард, — ключом шардирования. Прозрачности нет: маршрут выбирает приложение или прокси, и один запрос к одной базе может превратиться в четыре запроса к четырём.

Шардирование по ключу: как работает и где ломается приложение shard = f(user_id) SHARD 0 users 0–999k · 300 ГБ SHARD 1 users 1M–2M · 310 ГБ SHARD 2 + tenant «Магнит» · 4 ТБ SHARD 3 users 3M–4M · 295 ГБ HOT SHARD: один крупный клиент перекосил распределение — 80 % нагрузки на один узел, остальные простаивают Что легко, что дорого легко: SELECT ... WHERE user_id = 42 легко: транзакция внутри одного шарда дорого: JOIN users × orders разных шардов дорого: SELECT count(*) по всем — scatter-gather дорого: ORDER BY … LIMIT 10 глобально дорого: транзакция через два шарда → 2PC/сага нет: глобального AUTO_INCREMENT нет: глобального UNIQUE по email Решардинг — самая дорогая операция hash(key) mod N: при добавлении шарда переезжает почти ВСЁ (N=4→5 сдвигает 80 % ключей) consistent hashing: переезжает ~1/N данных виртуальные шарды: 1024 логических «бакета» на 4 физических узла — переезд = перенос бакетов, карта «бакет → узел» меняется в одном месте; это стандартное решение, закладывать сразу
Партиционирование делит таблицу внутри одного сервера, прозрачно для приложения; шардирование — между серверами, без прозрачности, зато только так масштабируется запись.
ПартиционированиеШардирование
Где живут частиодин сервер, одна БДразные серверы
Что масштабируетразмер таблицы и обслуживаниезапись, объём, CPU, память — всё
Прозрачностьполная, делает СУБДнет, логика в приложении или в прокси (Citus, Vitess, YDB)
Транзакции между частямиобычные ACID2PC или сага
JOIN между частямиобычныйдорогой scatter-gather или невозможен
Уникальностьв пределах ключа партиционированиятолько внутри шарда; глобальная — отдельный сервис или UUID
Сложность эксплуатациинизкаявысокая: N кластеров, N бэкапов, N failover
Главное решение при шардировании: какой взять ключ
  • Критерий №1: большинство запросов должны попадать в один шард. Ключом обычно становится user_id, tenant_id или account_id (то, вокруг чего строится продукт).
  • Равномерность. Шардирование по стране гарантирует перекос, а по дате сгоняет весь свежий трафик на один шард.
  • Стабильность. Ключ не должен меняться: сменить строке шард значит физически её перевезти.
  • Связанные данные держат на одном шарде. Заказы и позиции заказов шардируем по user_id, а не по order_id, чтобы их можно было соединить и обновить в одной транзакции.
  • Hot shard на практике создаёт больше всего проблем: один крупный тенант съедает узел. Лечат составным ключом (tenant_id + bucket), персональными шардами для крупных клиентов, виртуальными шардами с ручным переносом «горячих» бакетов.
  • Закладывать виртуальные шарды сразу. 1024 логических бакета на 4 физических узла ничего не стоят на старте и превращают решардинг из катастрофы в рутину.

Распределённые транзакции: 2PC

Как только данные разъехались по нескольким базам (шардам или микросервисам), встаёт вопрос: как сделать атомарной операцию, которая трогает сразу две? Внутри одной базы это решает обычный COMMIT: журнал общий, решение одно. Между базами общего журнала нет, и каждая может закоммитить или упасть независимо от соседа. Классически это решает 2PC (two-phase commit, двухфазная фиксация) с отдельным участником, координатором: сначала он спрашивает у всех «готов зафиксировать?» и, только получив «да» от каждого, командует фиксировать.

Two-Phase Commit: билеты + отель КООРДИНАТОР БД «Авиабилеты» БД «Отели» ФАЗА 1 · PREPARE «готов зафиксировать?» «готов зафиксировать?» блокирует места, пишет PREPARED в WAL, отвечает YES блокирует номер, пишет PREPARED в WAL, отвечает YES ФАЗА 2 · COMMIT все ответили YES → COMMIT всем; хоть один NO → ABORT всем ТОЧКА ОТКАЗА: координатор упал МЕЖДУ фазами. Участники уже сказали YES и держат блокировки — снять их сами они не имеют права: решение принимает координатор. Это блокирующий протокол.
2PC покупает атомарность блокировками. После PREPARE участник ждёт решения сколько угодно долго и сам принять его не может, поэтому падение координатора между фазами подвешивает ресурсы у всех сразу.
-- 2PC в PostgreSQL, если max_prepared_transactions > 0
BEGIN;
INSERT INTO tickets (...) VALUES (...);
PREPARE TRANSACTION 'booking-42';    -- фаза 1: транзакция «зависает» подготовленной,
                                     -- переживает рестарт сервера, держит блокировки

COMMIT PREPARED 'booking-42';        -- фаза 2
-- или
ROLLBACK PREPARED 'booking-42';

-- Забытая подготовленная транзакция как мина: вечно держит блокировки и горизонт VACUUM
SELECT gid, prepared, owner, database FROM pg_prepared_xacts;
Почему 2PC избегают в микросервисах
  • Блокирующий протокол. Если координатор упал между фазами, участники остаются с заблокированными ресурсами и без права решать. В PostgreSQL забытая PREPARED-транзакция вдобавок останавливает очистку мёртвых версий — база пухнет, пока кто-нибудь не заметит.
  • Доступность падает мультипликативно. Транзакция пройдёт, только если живы все участники и координатор: 0.999⁴ ≈ 0.996 — простой вчетверо больше, чем у каждого узла по отдельности.
  • Latency складывается минимум из двух кругов сетевого обмена со всеми участниками и двух fsync у каждого.
  • Гетерогенность. HTTP-сервис, Kafka или платёжный шлюз стороннего банка в 2PC не участвуют в принципе, а в микросервисах участники как раз такие.
  • Связность. 2PC требует, чтобы сервисы знали внутренние транзакции друг друга, а это прямо нарушает автономность, ради которой микросервисы и заводили.
// Вместо 2PC берут сагу: последовательность локальных транзакций
// с компенсирующими действиями на случай отказа.
//
//   1. bookFlight()   → ok      компенсация: cancelFlight()
//   2. bookHotel()    → ok      компенсация: cancelHotel()
//   3. chargeCard()   → ошибка
//   → выполняем компенсации в обратном порядке: cancelHotel(), cancelFlight()
//
// Свойства, о которых надо сказать:
//  • нет изоляции: между шагами другие видят промежуточное состояние
//    (место уже занято, но оплата ещё не прошла), лечится статусом «pending»
//    и таймаутом резервирования;
//  • компенсация не откат: за отменённый билет могли взять сбор;
//  • все шаги и компенсации обязаны быть идемпотентными: их будут повторять;
//  • оркестрация (отдельный сервис-дирижёр, Temporal) читается лучше,
//    чем хореография (сервисы реагируют на события друг друга);
//  • события из локальной транзакции надёжно отправляет transactional outbox.
Как отвечать на «транзакция над несколькими БД»

Сильный ответ идёт по шагам: (1) «Сначала спрошу, точно ли нужны две базы: если это два сервиса одной команды и одна консистентная сущность, возможно, границы сервисов проведены неверно, и решать надо архитектурой». (2) «Если границы верны, 2PC технически возможен: вот его механика и вот почему он блокирующий». (3) «На практике беру сагу с компенсациями плюс transactional outbox для надёжной публикации событий, идемпотентность на каждом шаге, статусы вместо блокировок». (4) «И проговорю с продуктом, что консистентность станет eventual: пользователь какое-то время видит „бронируется“, а не готовый результат».

Consistency и eventual consistency

При strong consistency любое чтение после подтверждённой записи видит эту запись: так ведут себя одиночный PostgreSQL и чтение с мастера. При eventual consistency чтение может вернуть устаревшие данные, но без новых записей все копии сходятся к одному состоянию. Так работают чтение с асинхронных реплик, кэш, Cassandra и DynamoDB с настройками по умолчанию.

Буква «C» в двух аббревиатурах значит разное, и на этом ловят. В ACID Consistency означает соблюдение инвариантов схемы (констрейнты, FK, CHECK), а в CAP — что все узлы видят одни и те же данные.

УровеньГарантияКак получить в PostgreSQL
Strong / linearizableчтение видит последнюю записьчитать с мастера; либо synchronous_commit = remote_apply
Read-your-writesсвои записи видны сразу, чужие могут отставатьsticky-to-master на N секунд или ожидание LSN на реплике
Monotonic readsвремя не «идёт назад» между запросамизакрепить пользователя за одной репликой
Eventualрано или поздно сойдётсялюбое чтение с асинхронной реплики
Как подать ответ

Консистентность выбирают не для системы целиком, а для каждого сценария. Баланс счёта и статус платежа читаем с мастера, строго. Ленту новостей, счётчик лайков, список товаров берём с реплики, eventual: отставание в секунду никого не волнует. Хотят услышать такую формулировку: «мы сознательно допускаем задержку в N секунд вот здесь, потому что за строгую консистентность пришлось бы платить ростом latency записи на M мс и падением доступности при отказе реплики».

Бэкапы: pg_dump против PITR

pg_dump / pg_dumpallPITR (базовый бэкап + WAL)
Что этологический дамп: SQL или архив с даннымифизическая копия каталога + непрерывный архив WAL
Гранулярность восстановлениямомент снятия дампалюбой момент времени — «состояние на 14:32:07»
RPOинтервал между дампами (часы, сутки)секунды
RTO на 1 ТБчасы: данные заливаются INSERT-ами, индексы строятся зановодесятки минут: копирование файлов + накат WAL
Восстановление частида — одну таблицу, одну схему, одну БДнет, только кластер целиком
Между версиями PGда, это ещё и способ мажорного апгрейданет, версия и архитектура обязаны совпадать
Влияние на проддолгая транзакция: держит горизонт VACUUMминимальное, обычно снимается с реплики
Инструментыpg_dump -Fc, pg_restore -j 8pgBackRest, WAL-G, barman
Что обязательно сказать про бэкапы
  • Нужны оба. PITR закрывает отказ железа и «удалили не ту строку в 14:31». pg_dump закрывает «нужна одна таблица из прошлого месяца», перенос между версиями и хранение долгих архивов.
  • Реплика не бэкап. DELETE FROM users доедет до реплики за миллисекунды. Реплика защищает от отказа узла, бэкап — от ошибки человека и от порчи данных.
  • Непроверенный бэкап не существует. Узнать заранее, что бэкап рабочий, можно только так: регулярно и автоматически восстанавливать его в отдельное окружение и прогонять контрольные запросы.
  • Формулировать в RPO/RTO. «PITR с архивом WAL раз в минуту даёт RPO около минуты; восстановление терабайта из базового бэкапа с накатом даёт RTO порядка часа; если бизнесу нужно 5 минут, добавляем горячую реплику и переключение на неё».
  • Хранить отдельно от прода, в другом регионе или облаке, с ограниченными правами на удаление. Иначе один скомпрометированный доступ уносит и базу, и бэкапы.

Вопросы

12
Суть: физическая передаёт байты WAL и делает побайтовую копию всего кластера — реплика идентична мастеру и доступна только на чтение. Логическая декодирует WAL в логические изменения строк и передаёт их подписчику — можно реплицировать отдельные таблицы, между разными версиями PostgreSQL, и подписчик остаётся полноценной пишущей базой.

Физическая (streaming)

  • Реплика повторяет мастер блок в блок, поэтому версии PostgreSQL и архитектура обязаны совпадать.
  • Минимальные накладные расходы: реплика просто применяет WAL.
  • Нельзя выбрать часть данных, нельзя писать на реплике, нельзя иметь на ней свои индексы.
  • Её ставят ради HA и failover, read-реплик и бэкапов без нагрузки на мастер.

Логическая

  • CREATE PUBLICATION на источнике, CREATE SUBSCRIPTION на приёмнике; передаются строки, а не блоки.
  • Работает между мажорными версиями, поэтому через неё обычно апгрейдят PostgreSQL с минимальным даунтаймом: поднимают новую версию подписчиком, ждут синхронизации, переключают трафик.
  • Реплицировать можно выборочно: например, только справочники в аналитический контур или CDC в Kafka через Debezium/wal2json.
  • Ограничения, которые обязательно назвать: не реплицируется DDL (схему надо накатывать на обе стороны руками), нужен первичный ключ или REPLICA IDENTITY FULL для UPDATE/DELETE, не синхронизируются последовательности, конфликты (например, дубль по уникальному ключу на подписчике) останавливают репликацию до ручного вмешательства.
Общая для обоих мина: replication slot

Слот не даёт мастеру удалить WAL, пока реплика его не забрала. Если реплика умерла, а слот остался — WAL копится бесконечно, пока на мастере не кончится диск, и база встаёт. Инцидент очень частый. Смотреть pg_replication_slots, ограничивать max_slot_wal_keep_size, алертить на неактивные слоты.

Суть: асинхронная — COMMIT отвечает сразу после локальной записи, latency не растёт, но при падении мастера теряется всё, что не доехало. Синхронная — COMMIT ждёт подтверждения реплики: потерь нет, но каждая запись оплачивает сетевой круг, а при недоступности реплики запись останавливается. Это прямой размен доступности на сохранность.

Уровни synchronous_commit

ЗначениеЧто ждёт COMMITЧто теряем при сбое
offничего, даже локальный fsyncпоследние коммиты (до ~3× wal_writer_delay); целостность БД сохраняется
localлокальный fsyncничего локально; всё, что не доехало до реплики
remote_writeреплика приняла и отдала ОСпри одновременном падении ОС реплики и мастера
on (дефолт)реплика записала на дискничего из подтверждённого
remote_applyреплика примениланичего; плюс чтение с этой реплики сразу актуально

Ключевые тонкости

  • off не ломает базу. Вопреки частому заблуждению, теряются только последние транзакции: база остаётся целостной, потому что WAL всё равно пишется последовательно и восстановление корректно. Поэтому off годится для логов, метрик и импорта, где потеря секунды данных безразлична, а выигрыш в throughput кратный.
  • Настраивается на транзакцию. SET LOCAL synchronous_commit = 'remote_apply' для перевода денег и 'off' для записи в журнал активности уживаются в одной базе.
  • Кворум. С synchronous_standby_names = 'ANY 2 (r1, r2, r3)' мастер ждёт подтверждения любых двух из трёх: сохранность есть, а падение одной реплики запись не останавливает. Для прода это разумный компромисс.
  • Опасность одной синхронной реплики: если она недоступна, мастер заблокирует запись до её возвращения. Поэтому синхронных реплик держат минимум две с кворумом ANY 1 или включают автоматический перевод в асинхронный режим.
  • Гео-распределение. Синхронная реплика в другом регионе добавляет 50–100 мс к каждому коммиту, и обычно это неприемлемо. Типовая схема: синхронная в соседней AZ, асинхронная в другом регионе.
Суть: лаг — отставание реплики от мастера, измеряется в байтах WAL и во времени. При асинхронной репликации он есть всегда: обычно единицы миллисекунд, но при массовом UPDATE, миграции или долгом запросе на реплике улетает в секунды и минуты. Приложение обязано это учитывать: часть чтений остаётся на мастере, реплики с большим лагом исключаются из балансировки.
-- На мастере: кто отстаёт и насколько
SELECT client_addr, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)   AS sent_lag,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag
FROM pg_stat_replication;

-- На реплике: лаг во времени
SELECT now() - pg_last_xact_replay_timestamp() AS lag;
SELECT pg_last_wal_replay_lsn();     -- нужен для ожидания собственной записи

Причины всплесков лага

  • При массовой записи (backfill, миграция, импорт) WAL генерируется быстрее, чем применяется.
  • Применение WAL на реплике однопоточное, и при большом объёме реплика физически не успевает за параллельной записью мастера.
  • Долгий запрос на реплике конфликтует с применением: без hot_standby_feedback применение ждёт запрос до max_standby_streaming_delay, а потом его отменяет («canceling statement due to conflict with recovery»). С hot_standby_feedback = on конфликта нет, зато bloat растёт на мастере.
  • Сеть между дата-центрами, диск реплики слабее мастера.
  • VACUUM большой таблицы пишет гигабайты WAL за минуты.
Как читать с реплик
  • Явно классифицировать запросы: «должно быть свежим» → мастер; «допустима задержка» → реплика. Решают это в коде осознанно, а не «все SELECT на реплику».
  • Роутер (pgpool, приложение, sidecar) должен исключать реплики с лагом выше порога — иначе одна отстающая реплика раздаёт всем старые данные.
  • Мониторить максимум лага, а не среднее, и алертить по порогу.
  • max_standby_streaming_delay задаёт, сколько реплика ждёт, прежде чем убить конфликтующий запрос. Значение подбирают под то, что важнее — свежесть или отчёты.
  • Помнить: реплики не масштабируют запись. Если упёрлись в неё, поможет шардирование, а не ещё одна реплика.
Суть: классическая проблема read-your-writes: запись ушла на мастер, а следующее чтение попало на реплику, которая ещё не применила эту транзакцию. Не баг репликации, а следствие асинхронности, и решается на уровне приложения — одним из четырёх стандартных приёмов.
РешениеКак работаетЦена
Sticky to masterпосле записи этот пользователь N секунд читает только с мастера (флаг в сессии, cookie, Redis)часть чтений возвращается на мастер; надо подобрать N по реальному лагу
Ожидание LSNзапомнили pg_current_wal_lsn() на коммите, отдали клиенту токен; при чтении реплика проверяет pg_last_wal_replay_lsn() >= токен, иначе идём на мастерсамое точное решение; требует прокидывать токен через API
Критичное — всегда с мастерабаланс, статус платежа, только что созданный объект читаем с мастера по определениюпростое и надёжное; требует классификации запросов
Оптимистичный ответAPI возвращает записанное состояние из самой операции записи, клиент не перечитываетне решает проблему для другого устройства или после перезагрузки страницы
synchronous_commit = remote_applyкоммит ждёт применения на реплике — читать с неё сразу безопаснозаметный рост latency записи; применять точечно
Как ответить «на грейд выше»

«Сначала уточню, действительно ли данные не сохранились: обычный баг надо исключить. Дальше смотрю pg_stat_replication: если лаг в секундах, ищу причину всплеска (миграция, backfill, конфликт с долгим запросом). Потом решаю на уровне продукта: профилю хватит sticky-to-master на 5 секунд, а баланс всегда читаем только с мастера. И отдельно поставлю алерт на лаг, иначе такие обращения будут повторяться, а причину каждый раз будут искать заново.»

Суть: failover — повышение реплики до мастера при отказе текущего: обнаружение → выбор кандидата (максимальный LSN) → promote → переключение трафика. Split brain — ситуация, когда мастеров становится два: сеть разорвалась, кластер повысил реплику, а старый мастер продолжает принимать запись. Данные расходятся, и автоматически слить их обратно невозможно.

Шаги failover

  1. Обнаружение. Health-check не отвечает N раз подряд. С порогом ищут компромисс: слишком чувствительный даёт ложные срабатывания на сетевом моргании, слишком терпимый увеличивает RTO.
  2. Выбор кандидата. Берут реплику с наибольшим применённым LSN: она потеряет меньше всего. Если есть синхронная реплика, выбирают её.
  3. Fencing старого мастера делают до promote, а не после.
  4. Promote. Реплика заканчивает восстановление и начинает принимать запись.
  5. Переключение трафика: VIP, обновление DNS, PAUSE/ RESUME в pgbouncer, обновление service discovery.
  6. Перестройка кластера на нового мастера. Остальные реплики просто переходят на его линию времени, а бывший мастер возвращают репликой через pg_rewind: у него могут быть «лишние» транзакции, до реплик не доехавшие.

Защита от split brain

  • Кворум и внешний DCS. Решение принимает только часть кластера с большинством узлов (etcd/Consul/ZooKeeper). Поэтому узлов нужно нечётное число, а DCS должен быть отдельной, независимо доступной системой.
  • Fencing / STONITH. Старый мастер физически изолируют: снимают VIP, останавливают сервис, блокируют порт, а в облаке отключают сетевой интерфейс. Только после этого повышают нового.
  • Самодемоция. Узел, потерявший связь с DCS и не подтвердивший свою роль в течение TTL лидерской аренды, сам переводит себя в read-only. Так работает Patroni: лидерство там хранится как ключ с TTL, который надо продлевать.
  • Единая точка входа. Клиенты ходят через прокси, знающий текущего мастера, и «второй мастер» просто не получает трафика.
  • Синхронная репликация дополнительно гарантирует, что у нового мастера есть все подтверждённые коммиты.
Почему split brain страшнее простоя

Простой виден сразу и лечится восстановлением. Split brain не виден: обе базы работают, обе отвечают, обе выдают заказы с одинаковыми id разным клиентам. Замечают его через часы, когда сети восстановились и что-то не сошлось. Автоматически расхождение не слить: критерия правоты нет, и часть данных придётся потерять или разбирать руками. Отсюда общее правило: лучше отказать в записи, чем принять её в неизвестное состояние. В терминах CAP это и есть выбор CP.

Суть: партиционирование — разбиение таблицы внутри одного сервера, прозрачно для приложения, делает сама СУБД. Шардирование — разбиение по разным серверам, каждый со своими CPU, памятью и диском; прозрачности нет, маршрутизацию делает приложение или прокси. Только шардирование масштабирует запись.

Ключевые различия

  • Что масштабируется. Партиционирование решает проблему размера таблицы и обслуживания, шардирование — проблему ресурсов сервера, когда одна машина не тянет запись, объём или память.
  • Прозрачность. Партиции остаются деталью реализации: приложение работает с одной таблицей. Шарды входят в архитектуру: приложению нужно знать, куда идти.
  • Транзакции. Между партициями работает обычный ACID, между шардами нужен 2PC или сага.
  • JOIN. Между партициями он обычный. Между шардами остаётся scatter-gather с агрегацией в приложении или дублирование справочников на все шарды.
  • Уникальность. Партиция требует ключ в PK. Шардирование вообще лишает глобального UNIQUE: чтобы email был уникален по всей системе, нужен либо отдельный сервис-реестр, либо шардирование именно по email.
  • Эксплуатация. N шардов означают N кластеров, N наборов реплик, N бэкапов, N процедур failover. Сопровождение дорожает линейно.
В каком порядке масштабировать

В хорошем ответе есть последовательность: сначала индексы и переписывание запросов → вертикальное масштабирование (железо дешевле инженеров) → read-реплики для чтения → партиционирование по времени → архивация холодных данных → и только затем шардирование. Оно идёт последним, потому что необратимо усложняет всё остальное. Кандидат, который начинает ответ с «пошардируем», отвечает хуже того, кто дошёл до этого шестым пунктом.

Суть: ключ выбирается так, чтобы (1) большинство запросов попадало в один шард, (2) данные распределялись равномерно, (3) ключ не менялся, (4) связанные сущности жили вместе. Hot shard — перекос, когда один шард получает непропорционально много данных или нагрузки; лечится составным ключом, выделенными шардами для крупных клиентов и виртуальными шардами.

Критерии выбора

  • Локальность запросов. Если 90 % запросов про «данные пользователя X», ключом берут user_id, в мультитенантном продукте tenant_id. Правильный ключ подсказывают паттерны доступа, а не структура данных.
  • Равномерность. Хеш от ключа обычно лучше диапазона: диапазон по дате отправляет весь свежий трафик на последний шард, диапазон по алфавиту — на шард с буквой «А».
  • Неизменяемость. Смена значения ключа = физический переезд строки между серверами со всеми проблемами консистентности.
  • Совместное размещение. Заказы и позиции заказов шардируем по одному ключу (user_id), чтобы JOIN и транзакция остались локальными.
  • Кардинальность. Ключ с сотней различных значений не разложится на тысячу бакетов.

Hot shard: причины и лечение

ПричинаЛечение
Один крупный тенант (в B2B это норма: «Магнит» в 1000 раз больше среднего клиента)выделить ему персональный шард; либо составной ключ tenant_id + bucket, разложив его данные по нескольким шардам
Знаменитость / популярный объект в соцсетиотдельный путь для «горячих» сущностей: кэш, отдельное хранилище, денормализация
Шардирование по датесменить ключ на хеш; дату оставить для партиционирования внутри шарда
Перекос естественного распределения (страна, регион)хеш вместо списка; либо ручная карта «ключ → шард» с балансировкой

Проблемы, о которых спросят следом

  • Кросс-шардовые запросы. «Топ-10 заказов по всей системе» превращается в scatter-gather: запросить топ-10 у каждого шарда, слить и отсортировать в приложении. Работает, но latency равна самому медленному шарду, и OFFSET по всем шардам корректно не выражается.
  • Агрегации. count(*) по всей системе требует либо обойти все шарды, либо держать отдельную аналитическую копию (ClickHouse, витрина), куда данные стекаются через CDC. На практике почти всегда второе.
  • Глобальные идентификаторы. Автоинкремента нет: UUIDv7 (сортируемый по времени), Snowflake ID или отдельный сервис, выдающий диапазоны.
  • Решардинг. При hash mod N добавление шарда переносит почти все ключи. Спасают consistent hashing (переезжает ~1/N) или виртуальные шарды — 1024 логических бакета на 4 физических узла, переезд = перенос бакетов, карта меняется в одном месте. Закладывать их сразу: потом это крайне дорого.
  • Справочники. Мелкие общие таблицы дублируют на все шарды и синхронизируют отдельно, чтобы не делать кросс-шардовый JOIN.
Суть: 2PC — протокол с координатором. Фаза 1 (prepare): координатор спрашивает всех участников «готов зафиксировать?», каждый выполняет работу, пишет PREPARED в свой журнал, берёт блокировки и отвечает YES/NO. Фаза 2 (commit): если все ответили YES — команда COMMIT всем, иначе ABORT всем. Даёт атомарность, но является блокирующим протоколом.

Разбор задачи «билеты + отель»

Координатор                    БД «Билеты»              БД «Отели»
    |-- PREPARE 'trip-42' ------->|                        |
    |                             | блокирует место        |
    |                             | пишет PREPARED в WAL   |
    |<----------- YES ------------|                        |
    |-- PREPARE 'trip-42' ---------------------------------->|
    |                                                      | блокирует номер
    |<--------------------------- YES ---------------------|
    |                    оба YES → решение зафиксировано у координатора
    |-- COMMIT PREPARED --------->|                        |
    |-- COMMIT PREPARED ------------------------------------>|

Где ломается

  • Координатор упал между фазами. Участники сказали YES, держат блокировки и не имеют права решать сами: они не знают, ответил ли второй участник YES. Ресурсы заблокированы до возвращения координатора. Поэтому 2PC и называют блокирующим.
  • Участник упал после YES. Он обязан после перезапуска восстановить подготовленную транзакцию из журнала и дождаться решения. В PostgreSQL PREPARE TRANSACTION переживает рестарт, и это же делает забытую подготовленную транзакцию миной: она вечно держит блокировки и горизонт VACUUM (pg_prepared_xacts).
  • Доступность. Живы должны быть все участники и координатор, поэтому доступности перемножаются.
  • Latency. Два круга обмена плюс fsync у каждого участника в каждой фазе.

Что делают вместо 2PC

  • Сага разбивает операцию на цепочку локальных транзакций с компенсациями: bookFlightbookHotelcharge; при отказе выполняем cancelHotel, cancelFlight. Изоляции нет: промежуточное состояние видно, поэтому вводят статус «pending» и таймаут резервирования.
  • Transactional outbox пишет событие в ту же локальную транзакцию, что и бизнес-данные, а в брокер его отправляет отдельный процесс. Так закрывается «записали в БД, но не отправили в Kafka» без всякого 2PC.
  • Идемпотентность каждого шага и каждой компенсации обязательна, потому что доставка at-least-once.
  • Оркестратор (Temporal, Camunda, свой сервис-дирижёр) берут вместо хореографии, когда шагов больше трёх, иначе логика размазывается по сервисам и её невозможно отладить.
С чего начать ответ на такой вопрос

Спросить, точно ли нужны две базы. Часто «билеты» и «отели» оказываются двумя сервисами одной команды, разделёнными по недоразумению, и тогда нужна не распределённая транзакция, а пересмотр границ. Если границы верны, берут сагу. 2PC остаётся для случаев, когда участники живут в одной СУБД или классическом XA-стеке и бизнес готов платить доступностью.

Суть: eventual consistency — гарантия, что при отсутствии новых записей все копии данных рано или поздно сойдутся к одному состоянию; до этого чтение может вернуть устаревшее. C в ACID — про соблюдение инвариантов схемы (констрейнты, FK, CHECK) внутри одной транзакции. C в CAP — про то, что все узлы видят одни и те же данные. Это разные вещи, названные одинаково.

Спектр гарантий

  • Linearizable / strong. Чтение всегда видит последнюю подтверждённую запись: одиночный PostgreSQL, чтение с мастера.
  • При read-your-writes свои записи видны сразу, чужие могут отставать. Обычно это минимум для пользовательского интерфейса.
  • Monotonic reads не дают данным «идти назад» между двумя запросами одного пользователя. Гарантия ломается, если запросы попадают на разные реплики с разным лагом.
  • Causal consistency — если B зависит от A, никто не увидит B раньше A. Комментарий не появляется раньше поста.
  • Eventual гарантирует только сходимость, без обещаний по времени.

Где встречается в обычном бэкенде

  • Чтение с асинхронных реплик (самый частый случай).
  • Кэш: Redis отдаёт то, что положили минуту назад.
  • Поисковый индекс (Elasticsearch), обновляемый из БД через CDC.
  • Материализованные представления и витрины.
  • Асинхронная обработка через брокер: заказ создан, письмо ещё не отправлено.
Как формулировать выбор

Не «наша система eventual consistent», а по сценариям: «баланс и статус платежа читаем с мастера — там нужна строгая консистентность; ленту и счётчики читаем с реплик, где допустима задержка до секунды, и это осознанное решение, потому что строгая консистентность здесь стоила бы роста latency записи и падения доступности при отказе реплики». Плюс уметь назвать, что делают, когда eventual «протекает» в UI: оптимистичный рендер, статус «обрабатывается», sticky-to-master после записи.

Суть: pg_dump — логический снимок на момент времени; восстановление возвращает состояние ровно на момент дампа, зато можно достать отдельную таблицу и перенести между версиями PostgreSQL. PITR — физический базовый бэкап плюс непрерывный архив WAL; позволяет восстановиться на любой момент и даёт RPO в секунды, но только кластер целиком и только на ту же версию.

RPO и RTO

  • RPO (Recovery Point Objective) измеряют во времени: сколько данных допустимо потерять. Ежесуточный дамп → RPO до 24 часов. PITR с архивацией WAL раз в минуту → RPO около минуты. Синхронная реплика → RPO = 0.
  • RTO (Recovery Time Objective) говорит, за сколько система обязана вернуться. Терабайт из pg_dump восстанавливается часами (данные заливаются построчно, индексы строятся заново), PITR укладывается в десятки минут, переключение на горячую реплику занимает секунды.
  • Эти две цифры задаёт бизнес, а не инженер: «сколько денег стоит час простоя и сколько — потеря часа данных». Всё остальное из них следует.
Что сказать про бэкапы
  • Нужны оба типа. PITR спасает от отказа железа и от «удалили не то в 14:31». pg_dump выручает, когда «нужна одна таблица за прошлый месяц», при мажорном апгрейде и для долгих архивов.
  • Реплика не бэкап. DELETE доедет до неё за миллисекунды. Реплика защищает от отказа узла, а от человека спасает только бэкап.
  • Непроверенный бэкап не существует. Что он рабочий, покажет только регулярное автоматическое восстановление в тестовое окружение с проверочными запросами.
  • Хранить отдельно: другой регион/аккаунт, ограниченные права на удаление, версионирование объектов. Иначе один скомпрометированный доступ уносит и прод, и бэкапы.
  • Инструменты: pgBackRest, WAL-G, barman. Они умеют инкрементальные базовые бэкапы, сжатие, шифрование и параллельное восстановление, и свой скрипт архивации WAL писать не стоит.
  • pg_dump держит долгую транзакцию и удерживает горизонт VACUUM, поэтому снимать его лучше с реплики.
Суть: вопрос на инженерную дисциплину. Порядок — от самого дешёвого и обратимого к самому дорогому и необратимому: измерить → оптимизировать запросы → кэш → вертикально → read-реплики → партиционирование и архивация → и только в конце шардирование. И обязательно уточнить, во что упёрлись: чтение, запись, объём или блокировки — лечения совершенно разные.
  1. Измерить. Что именно упёрлось: CPU, диск (IOPS, latency), память, соединения, блокировки? pg_stat_statements, pg_stat_activity, метрики хоста. Без этого шага любой ответ превращается в угадывание.
  2. Оптимизировать запросы. Индексы, переписывание, устранение N+1, убрать SELECT *, keyset вместо OFFSET. Обычно даёт кратный эффект бесплатно и обратимо.
  3. Настройки. shared_buffers, work_mem, effective_cache_size, autovacuum для горячих таблиц, random_page_cost под SSD.
  4. Кэш. Redis перед горячими и редко меняющимися данными. Дёшево, но добавляет инвалидацию и eventual consistency.
  5. Вертикально. Больше CPU, памяти, быстрее диск. Инженерное время дороже железа, и этот шаг зря стесняются называть: часто он самый разумный.
  6. Read-реплики. Если упёрлись в чтение, разгружаем мастер. Помним про лаг и read-your-writes.
  7. Пул соединений / pgbouncer, если упёрлись в число соединений.
  8. Партиционирование и архивация. Если проблема в объёме: отсечение по времени, удаление старого через DROP, вынос холодных данных.
  9. Разделение нагрузок. Аналитику уводят в ClickHouse через CDC, чтобы отчёты не мешали OLTP, поиск переносят в Elasticsearch.
  10. CQRS / отдельные модели чтения в виде денормализованных витрин под конкретные экраны.
  11. Шардирование. Только если упёрлись в запись и всё предыдущее исчерпано.
Чем добить

«Отдельно проверю, не искусственная ли нагрузка: ретраи без backoff, которые множат трафик при деградации; отсутствие таймаутов; N+1, выросший вместе с размером страницы; фоновая джоба, запущенная в пик. Довольно часто „база не справляется“ означает, что приложение стреляет в неё вдвое чаще, чем нужно, и чинить надо клиента, а не масштабировать базу.»

Суть: при сетевом разделении (P) распределённая система вынуждена выбрать между консистентностью (C) и доступностью (A). Формулировка «выбери два из трёх» неточна: P — это не выбор, а данность, сети рвутся. Реальный выбор делается только в момент разделения: отказать в обслуживании ради корректности (CP) или ответить возможно устаревшими данными (AP).

Как это выглядит в PostgreSQL

  • На одном сервере распределённой системы нет, CAP не применяется, есть ACID.
  • Мастер + синхронная реплика дают CP: при потере реплики мастер останавливает запись, чтобы не потерять подтверждённые коммиты. Консистентность сохранена, доступность на запись потеряна.
  • Мастер + асинхронные реплики на чтении ближе к AP: реплики отвечают устаревшими данными, но отвечают. На запись остаётся CP — мастер один.
  • Автоматический failover с кворумом — CP по построению: меньшая часть кластера сама себя переводит в read-only, чтобы не устроить split brain.
  • Cassandra, DynamoDB по умолчанию AP: отвечают всегда. У Cassandra консистентность настраивается уровнями кворума на чтение и запись, у DynamoDB — флагом строго консистентного чтения.
Чем дополнить ответ: PACELC

CAP описывает только поведение при разделении, а систему надо характеризовать и в нормальном режиме. PACELC: «если Partition — выбор между A и C; Else — выбор между Latency и Consistency». PostgreSQL с синхронной репликацией попадает в PC/EC: и при разделении, и в норме предпочитает консистентность, платя задержкой. С асинхронной он PC/EL: в норме выбирает низкую задержку. Упомянув PACELC, покажешь, что понимаешь CAP как модель, а не как лозунг.