Базы данных
По частоте это тема номер один: её спрашивают и системщики, и продуктовые команды, и на секции алгоритмов вместо алгоритмов. Причина простая — в бэкенде почти любой инцидент в итоге упирается в БД: медленный запрос, блокировка, распухшая таблица, потерянное обновление. Поэтому здесь глубина не «на уровне 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, Aerospike | O(1) доступ, простейшее шардирование | запрос только по ключу, нет джойнов |
| Документные | ключ → JSON-документ | MongoDB, Couchbase, Elasticsearch | гибкая схема, агрегат целиком в одном месте | дублирование данных, ручная поддержка целостности |
| Колоночные | данные по колонкам, а не по строкам | ClickHouse, Vertica, BigQuery | аналитика: сканы и агрегаты по миллиардам строк | плохо с точечным UPDATE/DELETE и OLTP-нагрузкой |
| Графовые | узлы и рёбра как объекты первого класса | Neo4j, ArangoDB, JanusGraph | обход связей произвольной глубины | узкая ниша, слабее на «плоских» выборках |
| Time-series | метрика + время + теги | TimescaleDB, InfluxDB, VictoriaMetrics | сжатие, ретеншн, оконные агрегаты по времени | только про временные ряды |
«Класс БД выбирается под паттерн доступа, а не под объём данных.» Когда запросы известны заранее и всегда по одному ключу, подходит key-value. Произвольные запросы со связями просят реляционную, а если 95 % нагрузки уходит на агрегаты по огромным диапазонам, выбирай колоночную. В конце добавь, что в типичном продукте живут несколько БД одновременно (Postgres как источник истины + Redis как кэш + ClickHouse под аналитику). Это называется polyglot persistence. «Решить всё одним классом» обычно можно, но по одному из измерений потеряешь на порядок.
PostgreSQL против MySQL
Вопрос звучит на каждом втором собесе, и отвечать надо разницей философий, а не списком фич. PostgreSQL вырос как «правильная СУБД академического происхождения»: расширяемость, строгость, соответствие стандарту. MySQL начинался как «быстрое хранилище для веба», и его годами докручивали до нормальной СУБД (докрутили, но следы остались).
| Аспект | PostgreSQL | MySQL / InnoDB |
|---|---|---|
| Движки хранения | один, встроенный | подключаемые (InnoDB, MyISAM, Memory); транзакции есть только у InnoDB |
| MVCC | старые версии строк лежат в самой таблице (heap), нужен VACUUM | старые версии — в undo-логе, откуда чистятся purge-потоком |
| Строгость типов | жёсткая: 'abc'::int — ошибка, переполнение — ошибка | исторически «мягкая» (усечение и предупреждения); строгость появилась со strict mode |
| Уровень по умолчанию | READ COMMITTED | REPEATABLE READ |
| Кластеризация таблицы | heap: строки лежат в произвольном порядке | clustered index: строки физически лежат в порядке PK |
| Вторичные индексы | хранят ctid — прямой адрес версии строки | хранят значение PK → двойной поиск, если индекс не покрывающий |
| Типы и расширения | массивы, jsonb, диапазоны, PostGIS, свои типы и операторы | беднее; JSON есть, но без индексируемости уровня GIN |
| DDL в транзакции | да: CREATE TABLE откатывается | нет: DDL делает неявный commit |
| Оконные функции, CTE | давно и полноценно, включая рекурсию | с 8.0 |
Из «heap + ctid» против «clustered index + PK в листьях» растут разные оптимизации. В MySQL выгодно делать PK коротким и монотонным (иначе раздувается каждый вторичный индекс и рвутся страницы при вставке случайного UUID). В PostgreSQL размер PK на вторичные индексы не влияет, зато есть VACUUM, bloat и HOT-обновления, которых нет в MySQL. Это на порядок сильнее ответа «в постгресе лучше JSON».
DDL, DML, DCL, TCL
| Группа | Расшифровка | Команды | Что делает |
|---|---|---|---|
| DDL | Data Definition Language | CREATE, ALTER, DROP, TRUNCATE, COMMENT | меняет структуру объектов и системный каталог |
| DML | Data Manipulation Language | INSERT, UPDATE, DELETE, MERGE, (SELECT) | меняет содержимое таблиц построчно |
| DCL | Data Control Language | GRANT, REVOKE | права доступа |
| TCL | Transaction Control Language | BEGIN, COMMIT, ROLLBACK, SAVEPOINT, SET TRANSACTION | границы транзакции |
SELECT формально относят то к DML, то к отдельной группе DQL (Data Query Language).
На собесе хватит фразы «читающая часть DML, в некоторых классификациях выделяют в DQL».
TRUNCATE не удаляет строки по одной. Он подменяет физическое хранилище
таблицы: создаёт новый пустой файл и переписывает relfilenode в системном каталоге,
а старый файл выбрасывает целиком. Никакой построчной работы, никаких триггеров
BEFORE/AFTER DELETE, никаких записей в WAL на каждую строку. Меняется
описание объекта в каталоге, значит, операция относится к определению данных, то есть к DDL.
На практике TRUNCATE берёт ACCESS EXCLUSIVE на таблицу
(блокирует даже SELECT) и сбрасывает SERIAL-счётчик, если попросить
RESTART IDENTITY. В PostgreSQL TRUNCATE при этом
транзакционен и откатывается, а в MySQL/Oracle нет: там он делает неявный commit.
| DELETE | TRUNCATE | DROP | |
|---|---|---|---|
| Группа | DML | DDL | DDL |
| Что остаётся | таблица, индексы, права | таблица, индексы, права | ничего |
| WHERE | да | нет | нет |
| Скорость на 100 млн строк | минуты-часы, WAL на каждую строку | доли секунды | доли секунды |
| Триггеры | DELETE-триггеры срабатывают | только TRUNCATE-триггеры | нет |
| Место на диске | не освобождается сразу (мёртвые версии, нужен VACUUM) | освобождается сразу | освобождается сразу |
| Блокировка | построчная | ACCESS EXCLUSIVE на таблицу | ACCESS EXCLUSIVE |
| Откат в PG | да | да | да |
| FK на таблицу | работает по правилам FK | ошибка, если нет CASCADE | ошибка, если нет CASCADE |
-- построчно, с триггерами, оставит мёртвые версии строк
DELETE FROM events WHERE created_at < NOW() - INTERVAL '30 days';
-- мгновенно, всю таблицу, вместе со связанными по FK
TRUNCATE TABLE events, event_tags RESTART IDENTITY CASCADE;
-- удалить объект целиком
DROP TABLE IF EXISTS events;
Ключи: PRIMARY KEY и FOREIGN KEY
Первичным ключом называют минимальный набор колонок, который однозначно идентифицирует строку.
Он автоматически даёт NOT NULL + UNIQUE, а PostgreSQL под капотом
создаёт под него уникальный B-tree индекс. Внешний ключ декларирует: «значения этой
колонки обязаны существовать в другой таблице». СУБД проверяет это при INSERT/UPDATE
дочерней таблицы и при DELETE/UPDATE родительской.
PostgreSQL создаёт индекс под PK и под UNIQUE, но не создаёт под FK. Последствий два.
Первое: любой JOIN от родителя к детям идёт по неиндексированной колонке — Seq Scan
на каждой выборке. Второе, менее очевидное: при DELETE строки родителя СУБД обязана
проверить, нет ли ссылающихся детей, и без индекса это полный скан дочерней таблицы на каждое
удаление. На таблице в сотни миллионов строк удаление одной строки родителя может занимать
минуты. Правило: под каждый FK заводи индекс руками, если только колонка уже не стоит
левым префиксом в существующем составном индексе.
CREATE TABLE orders (
id bigserial PRIMARY KEY, -- индекс создан автоматически
customer_id bigint NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
status text NOT NULL DEFAULT 'new',
total numeric(12,2) NOT NULL CHECK (total >= 0),
created_at timestamptz NOT NULL DEFAULT NOW()
);
-- индекса под customer_id нет, создаём сами:
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
-- найти все FK без покрывающего индекса (запрос стоит держать под рукой)
SELECT c.conrelid::regclass AS "таблица", a.attname AS "колонка"
FROM pg_constraint c
JOIN LATERAL unnest(c.conkey) k(attnum) ON TRUE
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid
AND (i.indkey::smallint[])[0] = k.attnum);
NULL и трёхзначная логика
NULL в SQL означает «значение неизвестно», а не «пусто» и не «ноль».
Из этого вытекает всё остальное: любое сравнение с неизвестным даёт вместо TRUE или
FALSE третье значение, UNKNOWN. А WHERE пропускает
строку, только если предикат равен TRUE. Так что WHERE col = NULL
формально работает: честно возвращает UNKNOWN для каждой строки, и в результат
ничего не попадает.
FALSE AND U = FALSE
(поэтому AND может «спасти»), но TRUE AND U = UNKNOWN — поэтому NOT IN
с единственным NULL в списке обнуляет весь результат.| Где | Как ведёт себя NULL |
|---|---|
WHERE col = NULL | UNKNOWN → строк нет. Нужен IS NULL |
col <> 'x' | строки с col IS NULL не попадут в результат — почти всегда сюрприз |
COUNT(*) | считает строки, NULL не важен |
COUNT(col) | считает только строки, где col IS NOT NULL |
SUM/AVG/MIN/MAX | игнорируют NULL; AVG делит на число НЕ-NULL. На пустом наборе SUM даёт NULL, а не 0 |
GROUP BY | все NULL попадают в одну группу (хотя «неизвестно = неизвестно» логически неверно) |
ORDER BY | в PostgreSQL NULL по умолчанию «больше всех»: последние при ASC, первые при DESC. Управляется NULLS FIRST/LAST |
UNIQUE | несколько NULL допускаются: они «разные». С PG 15 есть UNIQUE NULLS NOT DISTINCT |
JOIN ... ON a.x = b.x | строки с NULL не соединяются ни с чем — при LEFT JOIN дадут NULL-хвост |
NOT IN (подзапрос) | если подзапрос вернул хоть один NULL — результат пуст |
NOT EXISTS | NULL-безопасен, работает как настоящий anti-join |
DISTINCT, UNION | здесь NULL считаются равными друг другу |
-- Ловушка «неравенство теряет NULL»
SELECT count(*) FROM users; -- 100
SELECT count(*) FROM users WHERE status = 'ban'; -- 10
SELECT count(*) FROM users WHERE status <> 'ban'; -- 60, а не 90 (у 30 status IS NULL)
SELECT count(*) FROM users WHERE status IS DISTINCT FROM 'ban'; -- 90, вот это правильно
-- Безопасные инструменты
COALESCE(col, 0) -- первое не-NULL значение
NULLIF(a, b) -- NULL, если a = b (защита от деления на ноль)
a IS NOT DISTINCT FROM b -- «равно», где NULL = NULL истинно
count(*) FILTER (WHERE ok) -- условный подсчёт вместо count(CASE WHEN ...)
Соединения: логика и физика
Логический тип JOIN отвечает на вопрос «какие строки попадут в результат»,
а физический алгоритм — «как сервер их найдёт». Это два независимых слоя: один и тот же
LEFT JOIN может выполниться и как Nested Loop, и как Hash Join.
INNER дал 2 строки из 3+3: соединение умеет
и уменьшать число строк, и увеличивать его. Дубли Ann появились, потому что справа
нашлись две подходящие строки. Так чаще всего и появляются «внезапно удвоившиеся суммы» в отчётах.-- «Клиенты без заказов»: три способа, два хороших
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL; -- 1) anti-join через LEFT + IS NULL
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); -- 2) NULL-безопасно
SELECT c.* FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders); -- 3) ловушка: один NULL, и ответ пуст
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid' оставит всех
клиентов, просто там, где заказ не подошёл, будут NULL-хвосты. А
LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'paid' уже
превратит LEFT JOIN в INNER: WHERE применяется после соединения,
NULL = 'paid' даёт UNKNOWN, и все «пустые» клиенты отфильтруются. Пожалуй, это
самая частая ошибка в SQL-задачах на собесе.
work_mem (чтобы Hash Join
не уходил на диск), а не переписыванием запроса.| Алгоритм | Сложность | Когда выбирается | Условия соединения |
|---|---|---|---|
| Nested Loop | O(N × стоимость поиска) | внешняя выборка маленькая, по внутренней есть индекс | любые, включая <, BETWEEN, функции |
| Hash Join | O(N + M), память под хеш | обе стороны крупные, нет полезной сортировки | только эквисоединение (=) |
| Merge Join | O(N + M) + сортировка | обе стороны уже отсортированы (индексы, ORDER BY) | только равенство |
Логический порядок выполнения запроса
WHERE
внутрь подзапроса). Но все правила видимости имён выводятся именно из этой цепочки.-- Неправильно: алиас cnt ещё не существует на шаге WHERE
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE cnt > 5 -- ERROR: column "cnt" does not exist
GROUP BY customer_id;
-- Правильно: агрегат фильтруют в HAVING
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01' -- сначала выбросили лишние строки: дёшево
GROUP BY customer_id
HAVING count(*) > 5 -- потом отфильтровали группы
ORDER BY cnt DESC -- а здесь алиас уже виден
LIMIT 10;
Подзапросы, CTE, оконные функции
Скалярный подзапрос возвращает одно значение и может стоять где угодно вместо выражения.
Коррелированный ссылается на колонки внешнего запроса, а значит, логически
выполняется для каждой строки внешнего запроса. Отсюда и репутация: миллион внешних
строк даёт миллион выполнений подзапроса. Планировщик PostgreSQL умеет разворачивать
(decorrelate) часть таких конструкций в полноценные соединения, но далеко не все,
особенно если внутри есть LIMIT, агрегаты по внешним колонкам или
volatile-функции.
-- Коррелированный подзапрос выполняется на каждую строку e
SELECT e.name, e.salary,
(SELECT avg(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id) AS dept_avg
FROM employees e;
-- То же самое оконной функцией: один проход, одна сортировка
SELECT e.name, e.salary,
avg(e.salary) OVER (PARTITION BY e.dept_id) AS dept_avg
FROM employees e;
-- Или соединением с агрегатом
SELECT e.name, e.salary, d.dept_avg
FROM employees e
JOIN (SELECT dept_id, avg(salary) AS dept_avg FROM employees GROUP BY dept_id) d
ON d.dept_id = e.dept_id;
До PostgreSQL 12 любой WITH был барьером оптимизации: CTE всегда
материализовался (выполнялся целиком во временный набор), и условия из внешнего запроса
внутрь не проталкивались. Это было и багом (медленно), и фичей (так специально
«замораживали» подзапрос). С PG 12 CTE, который используется ровно один раз, не
рекурсивен и не содержит побочных эффектов, по умолчанию инлайнится: ведёт
себя как обычный подзапрос, и предикаты в него проталкиваются. Поведение задаётся и явно:
WITH x AS MATERIALIZED (...) и AS NOT MATERIALIZED (...).
Такой ответ показывает, что ты знаешь и синтаксис, и историю оптимизатора.
-- Рекурсивный CTE: якорь UNION ALL рекурсивная часть
WITH RECURSIVE subs AS (
SELECT id, name, manager_id, 1 AS depth -- якорь
FROM employees WHERE id = 1
UNION ALL
SELECT e.id, e.name, e.manager_id, s.depth + 1 -- шаг
FROM employees e
JOIN subs s ON e.manager_id = s.id
WHERE s.depth < 10 -- страховка от цикла в данных
)
SELECT * FROM subs;
-- CTE как DML-конвейер: переносим строки в архив одним запросом
WITH moved AS (
DELETE FROM events WHERE created_at < NOW() - INTERVAL '90 days'
RETURNING *
)
INSERT INTO events_archive SELECT * FROM moved;
Оконные функции отличаются от агрегатов тем, что не схлопывают строки: каждая
строка остаётся в результате, но получает значение, вычисленное по «окну» соседей.
PARTITION BY задаёт разбиение окна, ORDER BY внутри
OVER — порядок внутри окна, а ROWS/RANGE — рамку.
| Функция | Что делает | Ничьи |
|---|---|---|
ROW_NUMBER() | сквозная нумерация 1, 2, 3… | ничьи разрываются произвольно |
RANK() | одинаковый ранг у равных, дальше пропуск | 1, 2, 2, 4 |
DENSE_RANK() | одинаковый ранг, без пропусков | 1, 2, 2, 3 |
LAG(x, n) / LEAD(x, n) | значение из строки на n назад/вперёд | дельты между соседними событиями |
SUM(x) OVER (ORDER BY ...) | нарастающий итог | рамка по умолчанию — от начала окна до текущей строки |
NTILE(n) | разбиение на n примерно равных корзин | перцентили |
VIEW, materialized view, нормализация
VIEW хранит только текст запроса, данных в нём нет: при обращении планировщик подставляет тело представления в запрос и оптимизирует всё вместе. Вьюха инкапсулирует логику и права, это плюс. Минус: тяжёлая вьюха тяжела при каждом обращении, а вьюхи поверх вьюх дают нечитаемые планы.
MATERIALIZED VIEW уже хранит результат запроса в физической таблице, и обновлять её
надо явно, командой REFRESH MATERIALIZED VIEW. Обычный REFRESH
берёт ACCESS EXCLUSIVE, и на время обновления вьюха недоступна.
REFRESH ... CONCURRENTLY не блокирует чтение, но требует
уникального индекса на вьюхе и работает дольше. По сути, это ручной кэш внутри БД со всеми
вопросами кэша: когда инвалидировать и насколько устаревшие данные допустимы.
| Форма | 1НФ | 2НФ | 3НФ |
|---|---|---|---|
| Требование | атомарные значения, нет повторяющихся групп и массивов «в одной ячейке» | 1НФ + каждый неключевой атрибут зависит от всего ключа, а не от его части | 2НФ + нет транзитивных зависимостей: неключевые атрибуты не зависят друг от друга |
| Нарушение | колонка phones = '+7..., +7...' | ключ (order_id, product_id), а в строке лежит order_date | в orders лежат city_id и city_name |
| Лечение | отдельная таблица phones | вынести order_date в orders | оставить city_id, имя брать джойном |
«Нормализация убирает аномалии обновления: один факт хранится в одном месте, поэтому его невозможно обновить наполовину. Денормализация означает сознательный размен целостности на скорость чтения, и он оправдан, когда: (1) план доказывает, что узкое место в джойне, (2) дублируемые данные меняются редко или не меняются вовсе (снапшот цены в заказе вообще не денормализация, а исторический факт), (3) согласованность что-то поддерживает: триггер, материализованная вьюха или пересчёт по расписанию. Без пункта (3) денормализация превращается в будущий баг.»
-- Констрейнты защищают данные декларативно, из приложения их не обойти
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id bigint NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
qty int NOT NULL CHECK (qty > 0),
price numeric(12,2) NOT NULL CHECK (price >= 0),
PRIMARY KEY (order_id, product_id) -- составной PK = ещё и UNIQUE
);
-- UNIQUE с условием: email уникален только среди неудалённых
CREATE UNIQUE INDEX users_email_uniq ON users (LOWER(email)) WHERE deleted_at IS NULL;
-- Именованный CHECK легче искать в логах ошибок
ALTER TABLE accounts ADD CONSTRAINT accounts_balance_nonneg CHECK (balance >= 0);
-- NOT VALID: добавить констрейнт мгновенно, проверить старые строки потом
ALTER TABLE orders ADD CONSTRAINT orders_total_pos CHECK (total > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_pos; -- без ACCESS EXCLUSIVE
| Действие FK | Что происходит при удалении родителя | Когда уместно |
|---|---|---|
NO ACTION (по умолчанию) | ошибка, проверка откладывается до конца операции | дефолт, самый безопасный |
RESTRICT | ошибка сразу, без отсрочки | когда удаление родителя — всегда ошибка бизнес-логики |
CASCADE | дети удаляются автоматически | строгая композиция: order → order_items |
SET NULL | ссылка обнуляется | необязательная связь: у сотрудника пропал менеджер |
SET DEFAULT | ставится значение по умолчанию | редко; требует, чтобы дефолт существовал в родителе |
Удаление одной строки может каскадом снести миллионы строк в пяти таблицах, взять блокировки
на всё это и надолго подвесить соседей. Вдобавок каскад живёт своей жизнью: в MySQL он не вызывает триггеры дочерних таблиц, в PostgreSQL вызывает, и «мягкое удаление» с аудитом легко срабатывают не так, как ждёт бизнес. Поэтому на больших системах чаще
берут RESTRICT и явно удаляют батчами в коде, а CASCADE оставляют
мелким служебным таблицам.
Вопросы
16В реляционной модели данные лежат в отношениях со строгой схемой, а к ним прилагаются декларативный язык запросов и транзакции. Ценны тут не таблицы, а две вещи: ты можешь задать запрос, о котором не думал на этапе проектирования схемы, и сервер сам гарантирует целостность через констрейнты и ACID. За это платишь тем, что запись тяжело масштабировать горизонтально: джойны и транзакции через сеть между узлами стоят дорого.
Зонтичный термин NoSQL на собесе лучше определить так: «NoSQL — это не отсутствие SQL, а отказ от какой-то реляционной гарантии». Redis отказался от запросов не по ключу, MongoDB — от строгой схемы и (исторически) от кросс-документных транзакций, ClickHouse — от точечного UPDATE и от OLTP, Cassandra — от сильной консистентности. Взамен каждый получил своё: обычно линейное масштабирование или на порядок большую пропускную способность в своём сценарии.
Как выбирать
- Смотри на паттерн доступа, а не на объём. 500 ГБ прекрасно живут в PostgreSQL; 10 ГБ, к которым ходят 200 тыс. раз в секунду по одному ключу, — уже не живут.
- Запросы известны заранее и всегда по ключу → key-value.
- Запросы произвольные, нужны связи и отчёты → реляционная.
- Нагрузка состоит из агрегатов по сотням миллионов строк → колоночная.
- Данные образуют граф, и нужны обходы произвольной глубины → графовая.
Ответ на «можно ли всё одним классом»
Формально да: в PostgreSQL есть jsonb с GIN-индексами (документная модель),
hstore и UNLOGGED-таблицы (почти key-value), расширения
TimescaleDB и Citus, полнотекстовый поиск, PostGIS. Для 90 % продуктов «просто Postgres»
будет правильным дефолтом. Это сильный ответ: он экономит команде операционную
сложность. Но на конкретных нагрузках специализированный движок выигрывает на порядок:
Redis отдаёт ключ за 50 мкс, ClickHouse считает агрегат по миллиарду строк за секунду.
Поэтому зрелый продукт почти всегда держит несколько хранилищ, это и есть polyglot persistence:
Postgres как источник истины, Redis как кэш, ClickHouse под аналитику, S3 под файлы.
«Начинал бы с PostgreSQL по умолчанию и выносил бы данные в другое хранилище только под доказанную проблему, с цифрами из мониторинга. Каждое новое хранилище приносит отдельный бэкап, отдельный failover, отдельную точку рассинхронизации и новый класс багов “в базе одно, в кэше другое”.»
Key-value
Redis, etcd, DynamoDB, Aerospike. Значение для БД непрозрачно, операция одна: «дай по ключу». Отсюда O(1) доступ и тривиальное шардирование: ключ хешируется, узел находится без координации. Сценарии: кэш, сессии, счётчики, rate limiter, распределённые локи, feature-флаги. А вот «найди все сессии пользователей из Москвы» так не спросишь: вторичный индекс придётся строить руками.
Документные
MongoDB, Couchbase, Elasticsearch. Значение хранится как JSON-документ, и БД умеет заглядывать внутрь: индексировать поля, фильтровать по вложенным путям. Схема гибкая, документы одной коллекции могут отличаться. Годятся для каталогов товаров с разнородными атрибутами, event store, CMS, поисковых индексов. Платишь тем, что целостность между документами поддерживаешь сам, а гибкая схема на второй год превращается в «в этой коллекции восемь разных форматов, и каждый парсер знает про них по-своему».
Колоночные
ClickHouse, Vertica, BigQuery, Druid. Данные лежат по колонкам, значения одной колонки
идут подряд. Соседние значения похожи, так что данные сильно сжимаются, а с диска
читаются только нужные запросу колонки. SELECT sum(amount) FROM events по
миллиарду строк прочитает одну колонку, а не миллиард строк целиком. Сценарии: аналитика,
логи, метрики, витрины. Точечный UPDATE строки и OLTP с транзакциями сюда не ложатся.
Графовые
Neo4j, ArangoDB, JanusGraph. Рёбра хранятся как объекты первого класса с физическими указателями, поэтому переход «сосед соседа» стоит константу, а не джойн. Подходят для соцграфа, антифрода («связан ли этот аккаунт с заблокированным через 4 перехода»), рекомендаций, графов прав. В реляционной БД такой обход пишут рекурсивным CTE, и на глубине 5+ он взрывается.
Wide-column (Cassandra, HBase, ScyllaDB) часто путают с колоночными, но это другое: строка с динамическим набором колонок, партиционирование по ключу, настраиваемая консистентность через кворумы. Их берут под тяжёлый поток записи, если готов жить с eventual consistency. И time-series (TimescaleDB, VictoriaMetrics, InfluxDB), заточенные под «метрика + время + теги», со сжатием и автоматическим ретеншном.
Философия
PostgreSQL вырос из академического проекта: расширяемость (свои типы, операторы, индексные методы, языки функций), строгое следование стандарту, приоритет корректности над скоростью. MySQL создавался как быстрое хранилище для веба и годами доращивался до полноценной СУБД: подключаемые движки, изначально «прощающее» поведение при некорректных данных, огромная экосистема хостингов и репликации.
MVCC — самое важное различие
В PostgreSQL старые версии строк лежат в самой таблице. UPDATE всегда
вставляет новую версию кортежа и помечает старую. Отсюда растёт целый пласт эксплуатации,
которого в MySQL нет: VACUUM, autovacuum, table bloat, HOT-обновления, transaction ID
wraparound. Зато откат транзакции в PG почти бесплатен: новые версии просто никто не увидит.
В InnoDB строка обновляется на месте, а старая версия уезжает в undo-лог. Поэтому чтение «в прошлое» собирает строку по цепочке undo-записей (долгие транзакции удлиняют цепочку и замедляют чтения), а откат большой транзакции стоит дорого: изменения надо физически откатывать. Undo при этом чистит отдельный purge-поток.
Физическое хранение
PostgreSQL хранит таблицу как heap: строки лежат в файле в произвольном порядке, вторичный индекс
хранит ctid (номер страницы + смещение) и ведёт прямо к версии.
В InnoDB таблица сама и есть clustered index, B-tree по первичному ключу: строки лежат
в листьях в порядке PK, а вторичный индекс хранит значение PK. Поэтому поиск по вторичному
индексу всегда спускается по дереву дважды. На практике в MySQL PK должен быть
коротким и монотонным (случайный UUID раздувает каждый вторичный индекс и рвёт страницы при
вставке), а в PostgreSQL размер PK на вторичные индексы не влияет.
Строгость типов и мелочи
- PG падает с ошибкой на
'abc'::int, на переполненииintи на вставке слишком длинной строки вvarchar(10). MySQL исторически усекал и выдавал warning; strict mode (дефолт с 5.7) это исправил, но «мягкое» наследие встречается до сих пор. - Уровень изоляции по умолчанию: PG — READ COMMITTED, MySQL/InnoDB — REPEATABLE READ.
- DDL в транзакции: PG умеет откатывать
CREATE TABLE, MySQL — нет (DDL делает неявный commit). Это критично для миграций: в PG упавшая миграция откатывается целиком. - Типы: в PG массивы,
jsonbс GIN, диапазоны, PostGIS, свои типы.
Так отвечают джуны. Сильный ответ строится на MVCC и heap/clustered index, потому что из них выводятся все практические различия в эксплуатации: почему в PG есть autovacuum, почему в MySQL важен порядок PK, почему долгая транзакция вредит обеим базам, но по разным причинам.
TRUNCATE не удаляет строки, а подменяет файл таблицы и правит каталог —
значит DDL.- DDL —
CREATE,ALTER,DROP,TRUNCATE,COMMENT: структура и системный каталог. - DML —
INSERT,UPDATE,DELETE,MERGE: содержимое, построчно.SELECTиногда выделяют в DQL. - DCL —
GRANT,REVOKE: права. - TCL —
BEGIN,COMMIT,ROLLBACK,SAVEPOINT,SET TRANSACTION: границы транзакции.
Механика TRUNCATE
DELETE идёт по строкам: на каждую пишет запись в WAL, помечает версию мёртвой,
вызывает триггеры. Индексы при этом не трогаются, а место не освобождается: мёртвые версии и записи в индексах потом уберёт VACUUM. TRUNCATE вместо этого создаёт для таблицы новый пустой файл и
меняет relfilenode в pg_class, а старый файл выбрасывает целиком.
Ни одной построчной операции, ни одной записи в WAL на строку. А раз меняется запись об
объекте в каталоге, операция относится к определению данных.
Практические следствия
- Работает за доли секунды независимо от размера таблицы и сразу освобождает диск.
- Берёт
ACCESS EXCLUSIVE, а она блокирует дажеSELECT. На проде это может встать в очередь за долгим чтением и «застопорить» таблицу целиком. - Не вызывает
BEFORE/AFTER DELETE-триггеры, толькоTRUNCATE-триггеры. Мягкое удаление и аудит на триггерах будут молча пропущены. - Падает с ошибкой, если на таблицу ссылаются по FK: нужен
CASCADE. RESTART IDENTITYсбрасывает счётчики последовательностей.
Частый миф: «TRUNCATE нельзя откатить». В PostgreSQL можно:
BEGIN; TRUNCATE t; ROLLBACK; вернёт данные, потому что PG умеет транзакционный
DDL. В MySQL и Oracle нельзя: там DDL делает неявный commit. Если СУБД не уточнили,
так и отвечай: «зависит от движка, и вот почему».
DELETE — построчно и с условием,
TRUNCATE — вся таблица мгновенно, но структура остаётся,
DROP — объекта больше нет.| Критерий | DELETE | TRUNCATE | DROP |
|---|---|---|---|
| Класс | DML | DDL | DDL |
| WHERE | да | нет | нет |
| Стоимость на 100 млн строк | минуты-часы | миллисекунды | миллисекунды |
| WAL | запись на каждую строку | одна запись о смене файла | одна запись |
| Триггеры | DELETE-триггеры | только TRUNCATE-триггеры | нет |
| Диск освобождается | нет, нужен VACUUM | сразу | сразу |
| Блокировка | построчная, чтения не мешает | ACCESS EXCLUSIVE | ACCESS EXCLUSIVE |
| Остаётся после | таблица с данными по фильтру | пустая таблица, индексы, права | ничего |
Как отвечать про выбор
- Часть строк удаляют только через
DELETE, причём батчами: одна транзакция на 10 млн строк держит блокировки, раздувает WAL и оставляет гору мёртвых версий. - Очистить таблицу целиком (тестовое окружение, перезалив справочника) —
TRUNCATE, но помни проACCESS EXCLUSIVE. - Сущность из схемы убирает
DROP, в миграции сIF EXISTS. - «Почти всё удалить, чуть-чуть оставить» выгоднее без
DELETE: «создать новую таблицу с нужным подмножеством → переименовать» дешевле и не оставляет bloat.
-- Удаляем батчами: транзакции короткие, autovacuum успевает
DELETE FROM events
WHERE ctid IN (
SELECT ctid FROM events
WHERE created_at < NOW() - INTERVAL '90 days'
LIMIT 10000
);
-- в цикле, пока затронуто больше 0 строк, с паузой между итерациями
«Удалил половину таблицы через DELETE — почему диск не освободился?» Потому что мёртвые
версии остаются в файле; обычный VACUUM пометит место переиспользуемым, но файл не сожмёт — разве что отрежет с конца целиком пустые страницы. Вернуть место операционной системе может только
VACUUM FULL (переписывает таблицу, берёт ACCESS EXCLUSIVE) или
pg_repack (то же самое, но почти без блокировки).
PRIMARY KEY задаёт минимальный набор колонок, который однозначно идентифицирует строку, и в таблице такой ключ один. PostgreSQL под него автоматически создаёт уникальный B-tree индекс (иначе проверять уникальность на каждой вставке пришлось бы сканом). UNIQUE отличается от PK тем, что их может быть несколько и они допускают NULL.
FOREIGN KEY требует, чтобы «значение этой колонки существовало в другой таблице».
Проверка идёт в обе стороны: при INSERT/UPDATE ребёнка СУБД ищет
родителя, при DELETE/UPDATE родителя смотрит, не осиротеют ли дети.
Технически это системные триггеры.
Почему отсутствие индекса на FK так больно
- JOIN. Соединение от родителя к детям идёт по неиндексированной колонке: планировщик вынужден брать Hash Join с полным сканом дочерней таблицы либо Nested Loop с Seq Scan внутри, а это уже катастрофа.
- DELETE родителя. Чтобы проверить
ON DELETE RESTRICT/CASCADE, СУБД выполняетSELECT 1 FROM child WHERE fk = $1. Без индекса это полный скан дочерней таблицы на каждую удаляемую строку. Классический инцидент: «удаление одного пользователя занимает 4 минуты». - То же самое при
UPDATEключевой колонки родителя.
CREATE TABLE orders (
id bigserial PRIMARY KEY, -- индекс есть
customer_id bigint NOT NULL REFERENCES customers(id) -- индекса нет
);
CREATE INDEX orders_customer_id_idx ON orders (customer_id); -- заводим сами
Стоит ли вообще использовать FK
Минусы у FK есть: он замедляет запись (проверка на каждую строку), мешает шардированию
(родитель и ребёнок на разных узлах) и усложняет массовые загрузки. Но это
единственная гарантия целостности, которую нельзя обойти кривым скриптом, забытым
условием или второй версией сервиса. Поэтому в OLTP-монолите FK держат почти всегда,
а выключают точечно: в шардированных таблицах, в аналитических витринах и на время
массовой загрузки (ALTER TABLE ... DROP CONSTRAINT → загрузка → добавить
обратно NOT VALID и потом VALIDATE).
Суррогатный (bigserial, UUID) стабилен и не зависит от бизнеса, это дефолт.
Естественный (ИНН, email) даёт бесплатную уникальность, но меняется, а PK меняться не должен.
Про UUID стоит знать: uuid_v4 случаен, поэтому в PostgreSQL раздувает B-tree
(вставки идут в случайные страницы, WAL растёт из-за full-page writes), а в MySQL ещё и
каждый вторичный индекс. Лечится это UUIDv7 (сортируемым по времени): в PG 18 он доступен как
uuidv7(), раньше брали расширение или генерировали на стороне приложения.
UNKNOWN, а WHERE пропускает строку только при
TRUE. Отсюда выводится всё остальное поведение.
col = NULL честно возвращает UNKNOWN для каждой строки, включая
строки, где col IS NULL: неизвестное не равно неизвестному, ведь это
могут быть разные неизвестные. Сравнивать нужно через IS NULL,
IS NOT NULL, IS [NOT] DISTINCT FROM.
Агрегаты
COUNT(*)считает строки, NULL не важен.COUNT(col)считает только строки сcol IS NOT NULL. Разница между ними сразу показывает, насколько заполнена колонка.SUM,AVG,MIN,MAXигнорируют NULL.AVGделит на количество НЕ-NULL, а это не то же самое, что «считать NULL нулём».- На пустом наборе
SUMвозвращает NULL, а не 0, и в отчётах вылезают NULL. ЛечитсяCOALESCE(SUM(x), 0). GROUP BYсобирает все NULL в одну группу;DISTINCTиUNIONтоже считают NULL равными друг другу. Это исключения из общего правила.
Неравенство и JOIN
Самая тихая ловушка: WHERE status <> 'ban' не вернёт строки, где status
IS NULL. Люди ждут «все, кроме забаненных», а получают «все, у кого статус известен и не
ban». Правильно писать WHERE status IS DISTINCT FROM 'ban'. В JOIN ... ON a.x =
b.x строки с NULL в ключе не соединяются ни с чем: при INNER они
исчезают, при LEFT дают NULL-хвост.
NOT IN — самая опасная конструкция
-- Если подзапрос вернул хотя бы один NULL, результат всегда пустой
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
-- раскрывается в id <> v1 AND id <> v2 AND ... AND id <> NULL
-- последний конъюнкт = UNKNOWN, а TRUE AND UNKNOWN = UNKNOWN → строка отброшена
-- NULL-безопасно и обычно быстрее (настоящий anti-join)
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
NOT IN с NULL не падает с ошибкой, а молча возвращает пустой набор.
Отчёт показывает ноль, рассылка не уходит, миграция не переносит строки, и никто не видит
проблемы месяцами. Правило простое: для anti-join всегда NOT EXISTS.
COALESCE(a, b, 0) — первое не-NULL; NULLIF(a, b) — NULL, если равны
(защита от деления на ноль); a IS NOT DISTINCT FROM b — равенство, где
NULL = NULL истинно; count(*) FILTER (WHERE cond) — условный подсчёт;
ORDER BY x NULLS LAST — в PG по умолчанию NULL «больше всех».
И проектное правило: ставь NOT NULL везде, где сомневаешься. Ослабить колонку
можно всегда, а вот вычищать накопившиеся NULL больно.
LEFT JOIN + IS NULL или NOT EXISTS.- INNER оставляет только совпавшие пары. Строк может стать и меньше (нет пары), и больше (несколько пар справа).
- LEFT OUTER — все строки левой таблицы; где пары нет, правая часть заполняется NULL.
- RIGHT OUTER работает зеркально. На практике его почти не пишут: читается тяжелее, проще переставить таблицы и написать LEFT.
- FULL OUTER — все строки обеих таблиц. Нужен для сверок «что есть здесь и нет там, и наоборот».
- CROSS даёт декартово произведение, без условия. Для генерации сеток (все дни × все товары) это законно, а вот случайный CROSS из-за забытого условия легко положит сервер.
- SELF JOIN — таблица с самой собой (сотрудник → менеджер).
- LATERAL — правая часть видит колонки левой: подзапрос выполняется для каждой строки слева. Отлично подходит для «top-N на группу».
-- 1) Anti-join через LEFT + IS NULL: соединяем и оставляем только «непришитые»
SELECT c.*
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;
-- 2) NOT EXISTS: NULL-безопасно, планировщик строит настоящий Anti Join,
-- и он умеет останавливаться на первом совпадении
SELECT c.*
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- 3) EXCEPT: работает, но требует одинаковых наборов колонок и убирает дубли
SELECT id FROM customers EXCEPT SELECT customer_id FROM orders;
-- 4) NOT IN: ловушка. Один NULL в orders.customer_id, и результат пуст
SELECT * FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid' оставит всех
клиентов (у неоплаченных будет NULL-хвост). А тот же фильтр, вынесенный в
WHERE o.status = 'paid', превращает LEFT JOIN в INNER: WHERE применяется
после соединения, а NULL = 'paid' даёт UNKNOWN. Ровно на этом валится половина
SQL-задач на собеседовании. WHERE o.id IS NULL тут исключение: это уже
сознательный anti-join.
Если джойнишь orders сразу с order_items и с payments,
SUM(o.total) посчитается столько раз, сколько получилось строк после
размножения, и сумма вырастет в разы. Агрегируй каждую ветку отдельно
(подзапрос или CTE) и только потом джойни. Есть ещё
SUM(DISTINCT ...), но он почти всегда намекает, что запрос стоит
переписать.
work_mem.Nested Loop
Для каждой строки внешней выборки ищем совпадения во внутренней. Стоимость равна
N × стоимость_поиска. Хорош, когда внешняя выборка маленькая (десятки строк),
а по внутренней есть индекс: получается N быстрых спусков по B-tree. Катастрофа,
когда внешних строк много или внутри Seq Scan. Единственный алгоритм, который умеет
неэквисоединения (<, BETWEEN, функции) и обязательный для
LATERAL.
Hash Join
Фаза build: по меньшей стороне строится хеш-таблица в памяти. Фаза probe: один проход по
большей стороне с проверкой по хешу. Сложность O(N + M): лучший выбор для двух
крупных таблиц, но только по равенству. Главный риск: если хеш не влезает в work_mem,
PostgreSQL разбивает данные на batches и сбрасывает их на диск. В плане это видно как
Batches: 16 вместо Batches: 1 у узла Hash, и запрос проседает в разы.
Merge Join
Обе стороны читаются в отсортированном по ключу порядке и сливаются одним проходом, как merge в сортировке слиянием. Дёшев, если сортировка достаётся даром (index scan по нужному порядку); иначе к стоимости добавляются две сортировки. Часто выигрывает на очень больших наборах, где хеш не влез бы в память.
| Алгоритм | Стоимость | Когда выбирается | Условия | Что ломает |
|---|---|---|---|---|
| Nested Loop | N × поиск | внешняя выборка мала, внутри индекс | любые | недооценка кардинальности внешней стороны |
| Hash Join | O(N+M) + память | обе стороны крупные | только = | нехватка work_mem → batches на диск |
| Merge Join | O(N+M) + сортировки | сортировка уже есть | только = | нужна явная Sort большого набора |
Как это использовать на практике
- Nested Loop с
loops=200000и Seq Scan внутри почти всегда просит индекс по колонке соединения внутренней таблицы. - При Hash Join с
Batches > 1поднимиwork_mem(можно локально:SET LOCAL work_mem = '256MB') или уменьши объём данных до соединения. - Если
rowsиactual rowsсильно расходятся, планировщик выбрал алгоритм по неверной оценке. ПомогаютANALYZE, повышениеdefault_statistics_targetили расширенная статистикаCREATE STATISTICSдля коррелированных колонок.
«Отключать алгоритмы через enable_hashjoin = off в проде нельзя, но для
диагностики приём отличный: если с выключенным hash join запрос стал быстрее, значит,
проблема в оценке кардинальности, а не в алгоритме, и чинить надо статистику.»
Ещё стоит знать про Memoize (с PG 14): этот узел-кэш над внутренней стороной Nested
Loop превращает повторяющиеся поиски по одному ключу в попадания в кэш.
FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT →
ORDER BY → LIMIT. WHERE фильтрует строки до группировки, HAVING — группы после.
Запрос пишется в одном порядке, а выполняется в другом: SELECT стоит
первым, а выполняется почти последним. Из этой цепочки механически выводятся все правила
видимости имён, зубрить их не надо.
Что следует
- Алиас из SELECT недоступен в WHERE, GROUP BY и HAVING: на момент их выполнения
SELECT ещё не отработал. В
ORDER BYдоступен: он идёт после. (PostgreSQL допускает алиас вGROUP BYкак расширение стандарта, но опираться на это не стоит.) - Оконные функции считаются на шаге SELECT, поэтому их нельзя писать ни в
WHERE, ни вHAVING: нужен подзапрос или CTE. Так что «топ-3 в группе» всегда пишется в два уровня. - WHERE дешевле HAVING. Всё, что можно отфильтровать до группировки, надо
фильтровать до неё: меньше строк доедет до
GROUP BY, меньше памяти на хеш-агрегат. LIMITприменяется последним, после сортировки, так что «взять 10 строк» не значит «прочитать 10 строк».
-- Ошибка: cnt ещё не существует, а агрегат в WHERE запрещён
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE cnt > 5
GROUP BY customer_id;
-- Правильно
SELECT customer_id, count(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01' -- фильтр строк: дёшево, до группировки
GROUP BY customer_id
HAVING count(*) > 5 -- фильтр групп: только по агрегату
ORDER BY cnt DESC -- здесь алиас уже виден
LIMIT 10;
Работает и означает «весь набор — одна группа»:
SELECT count(*) FROM t HAVING count(*) > 100 вернёт либо одну строку, либо
ни одной. А вот HAVING с условием на обычную колонку (HAVING status =
'new') почти всегда выдаёт ошибку автора: логически это WHERE, только
выполненный позже и дороже.
Планировщик волен переставлять шаги, если результат не меняется: проталкивать предикаты
в подзапросы (predicate pushdown), выполнять LIMIT раньше через
индексный порядок, схлопывать DISTINCT в группировку. Логический порядок
нужен, чтобы рассуждать о семантике, а не о производительности.
Скалярный подзапрос возвращает ровно одно значение и может стоять вместо выражения:
SELECT (SELECT max(id) FROM orders). Если он вдруг вернёт больше одной строки,
получишь ошибку времени выполнения, а не пустой результат; на этом ловят.
Коррелированный использует колонки внешнего запроса, а значит, его нельзя
вычислить один раз заранее.
-- Коррелированный: для каждой строки employees считаем среднее по её отделу
SELECT e.name, e.salary,
(SELECT avg(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id) AS dept_avg
FROM employees e;
-- на 1 млн сотрудников подзапрос выполнится до 1 млн раз
-- Оконная функция: один проход, одна сортировка/хеш
SELECT e.name, e.salary,
avg(e.salary) OVER (PARTITION BY e.dept_id) AS dept_avg
FROM employees e;
-- Или JOIN с предагрегатом: каждый отдел посчитан ровно один раз
SELECT e.name, e.salary, d.dept_avg
FROM employees e
JOIN (SELECT dept_id, avg(salary) AS dept_avg FROM employees GROUP BY dept_id) d
ON d.dept_id = e.dept_id;
Когда планировщик спасает, а когда нет
PostgreSQL умеет декоррелировать часть подзапросов: EXISTS и
IN обычно превращаются в Semi Join, NOT EXISTS — в Anti Join, и
это работает быстро. Но декорреляция не срабатывает, если внутри есть LIMIT,
ORDER BY с ограничением, агрегат по внешней колонке, volatile-функция
или подзапрос стоит в списке SELECT. В плане это видно как
SubPlan с большим loops, и это верный признак, что запрос пора
переписать.
Что чем заменять
| Было | Стало | Почему |
|---|---|---|
| скалярный подзапрос в SELECT | оконная функция | один проход вместо N |
WHERE x IN (SELECT ...) | обычно оставить | планировщик делает Semi Join |
WHERE x NOT IN (SELECT ...) | NOT EXISTS | NULL-безопасность + Anti Join |
| подзапрос «последняя строка на группу» | LATERAL + LIMIT 1 | индексный доступ, без сортировки всей таблицы |
| агрегат по связанной таблице в SELECT | JOIN с предагрегатом | группа считается один раз |
-- LATERAL: последний заказ каждого клиента, с попаданием в индекс
SELECT c.id, c.name, o.id AS last_order, o.created_at
FROM customers c
LEFT JOIN LATERAL (
SELECT o.id, o.created_at
FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.created_at DESC
LIMIT 1
) o ON TRUE;
-- нужен индекс (customer_id, created_at DESC)
Зачем
- Читаемость: длинный запрос разбивается на именованные шаги вместо вложенности в пять уровней.
- Результат можно переиспользовать внутри одного запроса.
- Рекурсия: без неё иерархии и графы в SQL не обойти.
- DML-конвейеры:
WITH ... AS (DELETE ... RETURNING *) INSERT ...переносит данные одним атомарным запросом.
Что изменилось в PG 12
До PostgreSQL 12 любой WITH был барьером оптимизации: он выполнялся
целиком во временный набор, и предикаты внешнего запроса внутрь не проталкивались. Если
снаружи стоял WHERE id = 5, а CTE выбирал миллион строк, сначала считался
миллион, а потом отбрасывалось всё, кроме одной. С PG 12 CTE, который используется ровно
один раз, не рекурсивен и не содержит DML и volatile-функций, по умолчанию
инлайнится и ведёт себя как обычный подзапрос. Явно поведение задают через
AS MATERIALIZED и AS NOT MATERIALIZED.
-- MATERIALIZED явно — для барьера оптимизации; CTE с двумя и больше ссылками PG 12+ материализует и сам
WITH heavy AS MATERIALIZED (
SELECT ... FROM big_table WHERE ...
)
SELECT * FROM heavy a JOIN heavy b ON ... ;
-- Рекурсивный CTE: якорь UNION ALL шаг рекурсии
WITH RECURSIVE subs AS (
SELECT id, name, manager_id, 1 AS depth, ARRAY[id] AS path
FROM employees WHERE id = 1 -- якорь
UNION ALL
SELECT e.id, e.name, e.manager_id, s.depth + 1, s.path || e.id
FROM employees e
JOIN subs s ON e.manager_id = s.id -- шаг
WHERE NOT e.id = ANY(s.path) -- защита от цикла в данных
AND s.depth < 50 -- страховка от бесконечности
)
SELECT * FROM subs ORDER BY depth, id;
Первый: UNION вместо UNION ALL добавляет дедупликацию на каждом
шаге. Это дорого, зато спасает от зацикливания — если в строках нет счётчика глубины или пути: с ними строки всегда разные, и UNION не поможет. Выбор осознанный, а не «пиши UNION ALL всегда». Второй: цикл в данных (A — менеджер B, B — менеджер A) вешает запрос
насмерть, если нет ни массива-пути, ни ограничения глубины. В проде ставь и то,
и другое. И третий, менее известный: рекурсия в PG работает через очередь, то есть это
обход в ширину; порядок строк — не глубина дерева, если явно не сортировать по пути.
Есть модный антипаттерн: «разбить всё на 12 CTE ради красоты». Если каждый шаг фильтрует только на следующем уровне, планировщик после инлайна справится, но читать план из двенадцати уровней невозможно. Хороший критерий: CTE оправдан, когда у шага есть самостоятельный смысл, который можно назвать словом.
ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) в подзапросе и снаружи взять
rn <= N.
От агрегата окно отличается так: GROUP BY превращает 1000 строк в 10, а окно
оставляет все 1000 и каждой приписывает значение, посчитанное по её группе.
PARTITION BY режет строки на окна, ORDER BY внутри
OVER задаёт порядок внутри окна, ROWS/RANGE задают рамку
(если есть ORDER BY, по умолчанию она идёт от начала окна до текущей строки, отсюда «нарастающий
итог»).
| Функция | Что даёт | На равных значениях |
|---|---|---|
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 (... ORDER BY revenue DESC LIMIT 3)» с подходящим индексом отработает
на порядки быстрее: он возьмёт по три строки из индекса и не будет сортировать миллионы.
На собесе это ответ выше среднего.
REFRESH.VIEW
При обращении планировщик подставляет тело представления в запрос и оптимизирует всё вместе
(view expansion). Значит, данные всегда свежие, места не занимают, но каждая выборка стоит
столько, сколько стоит исходный запрос. Вьюха инкапсулирует логику, служит слоем
совместимости при переименовании колонок и разграничивает права («этой роли — только вьюху
без персональных данных»). Но вьюхи поверх вьюх дают планы, которые невозможно
читать, а тяжёлая вьюха тяжела на каждом обращении. Простая вьюха обновляема напрямую; сложная — только через
INSTEAD OF-триггеры или CREATE RULE.
MATERIALIZED VIEW
Хранит результат физически, поддерживает свои индексы, читается как обычная таблица. Обновляется только явно:
REFRESH MATERIALIZED VIEW mvберётACCESS EXCLUSIVE: на всё время обновления вьюха недоступна даже для чтения.REFRESH MATERIALIZED VIEW CONCURRENTLY mvне блокирует читателей, но требует уникального индекса на вьюхе и работает заметно дольше (обновление идёт через сравнение со старой версией).- С
WITH NO DATAвьюха создаётся пустой и до первогоREFRESHне читается.
В PostgreSQL нет инкрементального обновления «из коробки»: REFRESH пересчитывает
всё. Если данных много, обычно заводят расписание (pg_cron / джоб в приложении) или
собственную «сводную таблицу», которую обновляют триггерами или батчами.
VIEW берут, когда логика повторяется, а исходный запрос дешёвый. MATERIALIZED VIEW — когда запрос дорогой (тяжёлые агрегаты, отчёты), а устаревшие на N минут данные допустимы. Раз материализованная вьюха служит ручным кэшем внутри БД, к ней применимы все вопросы кэша: какой TTL, кто и когда инвалидирует, что показывать, пока идёт refresh. Если данные должны быть строго актуальны, материализация не подходит: нужен либо индекс/переписывание запроса, либо сводная таблица, обновляемая триггерами.
Формы своими словами
- 1НФ: в ячейке лежит одно атомарное значение, повторяющихся групп нет.
Нарушение:
phones = '+7999..., +7495...'одной строкой или колонкиphone1, phone2, phone3. Лечится отдельной таблицей. - 2НФ: 1НФ + каждый неключевой атрибут зависит от всего составного ключа, а
не от его части. Нарушение: ключ
(order_id, product_id), а в строке лежитorder_date, который зависит только отorder_id. Актуальна только при составном ключе. - 3НФ: 2НФ + нет транзитивных зависимостей: неключевые атрибуты не зависят друг от
друга. Нарушение: в
ordersлежатcity_idиcity_name, а имя зависит от id, не от заказа. Переименовали город, и надо обновлять миллион строк: часть обновится, а часть нет.
Мнемоника: «каждый неключевой атрибут зависит от ключа, от всего ключа и ни от чего, кроме ключа». Дальше идут НФБК, 4НФ, 5НФ; на собесе достаточно упомянуть, что они существуют и лечат более экзотические аномалии.
Что именно чинит нормализация
Не «красоту», а три аномалии: обновления (одно и то же имя города в миллионе строк обновится не везде), вставки (нельзя завести город, пока нет заказа в нём) и удаления (удалили последний заказ — потеряли факт существования города).
Когда денормализовать
- Джойн доказанно стал узким местом: есть план и цифры, а не ощущение.
- Дублируемые данные меняются редко или не меняются вовсе.
- Есть механизм поддержания согласованности: триггер, материализованная вьюха, пересчёт по расписанию, событие в брокере.
Цена товара, скопированная в order_items.price на момент заказа, не дублирует
справочник, а фиксирует исторический факт: цена в каталоге завтра поменяется, а в заказе
должна остаться прежней. То же с адресом доставки и названием компании в счёте.
Умение отличать «денормализовали ради скорости» от «зафиксировали состояние на момент
события» выдаёт зрелого проектировщика.
«Мы денормализовали, чтобы было быстрее» без пункта (3) описывает будущий баг.
Через полгода счётчик comments_count в постах разъедется с реальным
количеством комментариев, потому что кто-то удалил их скриптом мимо приложения.
UPDATE из psql. Проверка в приложении —
не гарантия, а надежда.- NOT NULL — самый дешёвый и самый недооценённый. Убирает целый класс багов с трёхзначной логикой. Ставь по умолчанию, ослабляй осознанно.
- UNIQUE реализуется уникальным индексом. Помни: несколько NULL допускаются
(они «разные»); с PG 15 есть
UNIQUE NULLS NOT DISTINCT. - CHECK проверяет предикат на каждой строке. Именуй явно: имя приходит в текст ошибки, и по нему
удобно ветвиться в коде и искать в логах. Не может ссылаться на другие таблицы и должен
вести себя как
IMMUTABLE:CHECK (created_at <= now())PostgreSQL примет, но проверит только при записи, и восстановление из дампа может на нём упасть. - EXCLUDE, ещё один недооценённый констрейнт PostgreSQL, требует: «никакие две строки не должны
конфликтовать по оператору». Каноничный кейс: запрет пересечения интервалов броней,
EXCLUDE USING gist (room_id WITH =, period WITH &&).
-- Частичный уникальный индекс: email уникален только среди неудалённых
CREATE UNIQUE INDEX users_email_uniq ON users (LOWER(email)) WHERE deleted_at IS NULL;
-- Именованный CHECK: имя попадёт в текст ошибки
ALTER TABLE accounts ADD CONSTRAINT accounts_balance_nonneg CHECK (balance >= 0);
-- NOT VALID: добавить мгновенно, старые строки проверить потом без тяжёлой блокировки
ALTER TABLE orders ADD CONSTRAINT orders_total_pos CHECK (total > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_pos;
-- Отложенная проверка: до COMMIT можно временно нарушить (циклические ссылки)
ALTER TABLE a ADD CONSTRAINT a_b_fk FOREIGN KEY (b_id) REFERENCES b(id)
DEFERRABLE INITIALLY DEFERRED;
| ON DELETE | Что происходит | Когда уместно |
|---|---|---|
NO ACTION (дефолт) | ошибка, проверка может быть отложена до COMMIT | самый безопасный дефолт |
RESTRICT | ошибка сразу, отложить нельзя | удаление родителя — всегда ошибка бизнес-логики |
CASCADE | дети удаляются автоматически | строгая композиция: orders → order_items |
SET NULL | ссылка обнуляется | необязательная связь: у сотрудника уволился менеджер |
SET DEFAULT | ставится дефолт | редко; дефолт обязан существовать в родителе |
Удаление одной строки может каскадом снести миллионы строк в пяти таблицах, забрать на всё
это блокировки и надолго встать поперёк соседей. Каскад живёт своей жизнью: в MySQL он не вызывает триггеры дочерних таблиц, в PostgreSQL вызывает, и мягкое удаление с аудитом легко срабатывают не так, как ждёт бизнес. На больших системах чаще выбирают
RESTRICT плюс явное удаление батчами в коде, а CASCADE оставляют
для мелких служебных таблиц. И помни: под каждый FK нужен индекс, иначе каскад превращается в
Seq Scan дочерней таблицы.
«И там, и там, но по разным причинам. В приложении — ради понятной ошибки пользователю и раннего отказа. В БД — ради гарантии, потому что в базу пишет не только этот сервис: есть миграции, ручные фиксы, второй инстанс со старым кодом и внешние интеграции. Приложение проверяет удобно, база проверяет надёжно.»
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;
«Спорнее всего в ACID буква C. Atomicity, Isolation и Durability обеспечивают конкретные механизмы
СУБД. А Consistency описывает прикладной инвариант: “сумма денег в системе не меняется при
переводе”. СУБД проверит только то, что ей объявили: типы, NOT NULL,
CHECK, FK. Если разработчик написал только первый
UPDATE, транзакция будет атомарной, изолированной и долговечной — и при этом
оставит базу в бизнес-несогласованном состоянии.» Дальше можно добавить, что в теореме CAP
буква C означает совсем другое (линеаризуемость), и путать их не надо.
Аномалии конкурентного доступа
Напомню критерий: аномалию нельзя получить ни при каком последовательном выполнении тех же транзакций. Ниже пять классических аномалий, каждая на временной диаграмме двух транзакций. Читай диаграммы слева направо: по горизонтали время, две строки отданы параллельным транзакциям T1 и T2, прямоугольник обозначает операцию, которую та или другая выполняет в этот момент.
Уровни изоляции по стандарту SQL
| Уровень | Грязное чтение | Неповторяющееся чтение | Фантомы | Потерянное обновление | Write skew |
|---|---|---|---|---|---|
| READ UNCOMMITTED | возможно | возможно | возможно | возможно | возможно |
| READ COMMITTED | нет | возможно | возможно | возможно | возможно |
| REPEATABLE READ | нет | нет | по стандарту возможно, в PG нет | в PG нет (ошибка 40001) | возможно |
| SERIALIZABLE | нет | нет | нет | нет | нет |
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 UPDATE ... WHERE ведёт себя хитро. Если строку поменяла
другая транзакция и успела закоммититься, пока мы ждали блокировку, PostgreSQL
не откатывает нас, а перечитывает строку заново (EvalPlanQual) и повторно проверяет
на ней условие WHERE. То есть один оператор может работать с «более свежими»
данными, чем его собственный снапшот. Следствие: UPDATE t SET x = x + 1 на Read
Committed безопасен и не теряет обновления, а вот «прочитал SELECTом,
посчитал в Go, записал UPDATEом» — теряет. Здесь и проходит граница, за которой нужны
FOR UPDATE или version-колонка.
| СУБД | Уровень по умолчанию | Чем реализовано |
|---|---|---|
| PostgreSQL | READ COMMITTED | снапшот на каждый оператор, MVCC в heap |
| MySQL / InnoDB | REPEATABLE READ | consistent read из undo-лога + gap locks и next-key locks против фантомов |
| Oracle | READ COMMITTED | undo-сегменты; SERIALIZABLE есть, но это snapshot isolation, а не настоящая сериализуемость |
| SQL Server | READ 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 | молча пропустить занятые строки | очередь на таблице: каждый воркер берёт свою пачку |
-- Очередь: воркер атомарно забирает пачку задач и помечает их своими
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 SHARE | SELECT | только с ACCESS EXCLUSIVE |
ROW SHARE | SELECT FOR UPDATE/SHARE | с EXCLUSIVE и выше |
ROW EXCLUSIVE | INSERT, UPDATE, DELETE | с SHARE и выше |
SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY | сам с собой и выше — DML не мешает |
SHARE | CREATE INDEX (без CONCURRENTLY) | с любой записью |
SHARE ROW EXCLUSIVE | CREATE TRIGGER, часть ALTER | с записью и сам с собой |
EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | со всем, кроме ACCESS SHARE |
ACCESS EXCLUSIVE | DROP, 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
deadlock_timeout: так процессор не тратится на граф при каждом ожидании.- Единый порядок захвата. Всегда обновляй строки в одном и том же порядке, например
по возрастанию
id. В переводе денег это буквальноORDER BY idперед блокировкой обоих счетов; без него переводы A→B и B→A под нагрузкой гарантированно поймают дедлок. - Короткие транзакции. Чем меньше окно удержания блокировки, тем меньше шанс пересечения.
- Никаких сетевых вызовов внутри транзакции. HTTP-запрос на 2 секунды внутри
BEGINдержит все блокировки те же 2 секунды. - Блокировать всё нужное сразу, одним
SELECT ... FOR UPDATEсIN (...) ORDER BY id, а не по одной строке за раз. - И всё равно ретраить 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Перевод 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, либо
явная блокировка того, что читали, либо материализация инварианта в одну строку-счётчик.
Частный случай неповторяющегося чтения на нескольких строках: T1 читает баланс счёта A
(500), T2 переводит 100 с A на B и коммитит, T1 читает баланс B и видит его уже с
переводом. Сумма по двум чтениям не сходится, хотя каждое чтение по отдельности корректно.
Ради этого в PostgreSQL отчёты и гоняют на REPEATABLE READ:
один снапшот на всю транзакцию гарантирует «согласованный срез базы».
| Уровень | Dirty read | Non-repeatable read | Phantom read | Lost update | Write 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 почти всегда оказывается преждевременной
пессимизацией.
Почему 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 видит один согласованный срез. Это
так для 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 использовал statement-based репликацию: на реплику передавался текст
запроса, который там выполнялся заново. При Read Committed порядок применения операторов
мог дать на реплике другой результат, поэтому потребовался более строгий уровень плюс
gap locks — блокировки «промежутков» между значениями индекса, которые не дают
вставить строку в диапазон, прочитанный чужой транзакцией. Gap locks и закрывают
фантомы в InnoDB, и они же порождают знаменитые дедлоки MySQL на, казалось бы, невинных
вставках.
| PostgreSQL | MySQL / InnoDB | |
|---|---|---|
| Дефолт | READ COMMITTED | REPEATABLE READ |
| Как убираются фантомы на RR | снапшот на всю транзакцию | gap locks / next-key locks |
| Цена | ошибка 40001, нужен ретрай | ожидание и дедлоки на вставках |
| Read Uncommitted | не отличим от RC | реально работает, грязное чтение возможно |
| Serializable | SSI, настоящая сериализуемость | все чтения превращаются в FOR SHARE |
Код, написанный под 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 оно ушло бы столько раз, сколько было попыток.
Что должно быть в реализации ретрая
- Повторять всю транзакцию с нуля — старый снапшот невалиден.
- Экспоненциальная пауза с джиттером. Без разброса конкуренты синхронно повторят конфликт и снова столкнутся.
- Ограничение числа попыток и осмысленная финальная ошибка.
- Никаких внешних побочных эффектов внутри: ни писем, ни платежей, ни сообщений в Kafka. Только чистая работа с БД; всё остальное после коммита или через outbox.
- Различать ретраибельные и нет.
23505 unique_violationили23514 check_violationповторять бессмысленно, они просто повторятся. - Метрика на число ретраев. Растёт доля конфликтов, значит, горячая точка разрастается и пора менять схему (шардировать счётчик, перейти на атомарный 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': ждать, но не бесконечно.
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 вместо «проверил, есть ли строка, потом
вставил». Хороший ответ на собесе всегда включает этот вариант: лучшая блокировка та,
которая не понадобилась.
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' и повторяй в цикле — миграция сдастся, а не положит
сервис.
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, а в лог пишется, какие
процессы и запросы участвовали. Остальные продолжают работу. Проверка запускается
по таймауту, а не при каждом ожидании, чтобы не жечь процессор — обычное ожидание
блокировки ещё не дедлок.
Как проектировать
- Единый порядок захвата. Главное правило: если операция трогает несколько строк, блокируй их в детерминированном порядке (по возрастанию первичного ключа), независимо от бизнес-направления операции.
- Захватывать всё сразу одним
SELECT ... WHERE id IN (...) ORDER BY id FOR UPDATE, а не последовательно по одной строке. - Короткие транзакции и никаких сетевых вызовов внутри — окно пересечения меньше.
- Осторожно с
ON CONFLICTи FK: параллельные вставки в таблицу с уникальным индексом и разным порядком значений тоже дают дедлоки. - Всё равно ретраить. Полностью исключить дедлоки в системе со сложными
транзакциями нельзя, поэтому обработка
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. Причём вредит даже транзакция, которая ничего не делает.Пять последствий
- Блокировка очистки. Autovacuum не имеет права удалить мёртвую версию строки, если она может быть нужна самому старому активному снапшоту. Одна транзакция, висящая часами, останавливает уборку по всей базе. Отсюда table bloat: таблица на 10 ГБ данных занимает 60 ГБ, индексы распухают, всё сканируется медленнее, кэш забит мусором.
- Удержание блокировок. Все блокировки живут до конца транзакции. Если в начале был
ALTERилиFOR UPDATE, соседи будут ждать до самогоCOMMIT— а из-за честной FIFO-очереди за ними встанут и невинныеSELECT. - Проблемы с репликами. При
hot_standby_feedback = onдолгий запрос на реплике удерживаетxminна мастере и мешает VACUUM уже там. При выключенном фидбеке запрос на реплике убьют сcanceling statement due to conflict with recovery. Выбор между двумя неприятностями, и об этом полезно сказать вслух. - Transaction ID wraparound. Счётчик транзакций 32-битный; чтобы старые данные не «стали будущими», VACUUM замораживает старые кортежи. Самая старая транзакция задаёт горизонт заморозки, и если она живёт сутками, база приближается к аварийному режиму, когда PostgreSQL перестаёт принимать новые транзакции.
- Больше конфликтов. Чем шире окно, тем выше вероятность дедлока и ошибки сериализации.
Особый случай: 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нумеруют команды внутри транзакции, чтобы транзакция корректно видела собственные изменения.
xmin/xmax каждой версии со своим снапшотом и сама решает, какую
видеть. Платить приходится мёртвыми версиями, которые кто-то потом должен убрать.Что физически происходит при INSERT / UPDATE / DELETE
| Операция | Что делает движок | Что остаётся мусором |
|---|---|---|
INSERT | новая версия с xmin = мой xid, xmax = 0; записи во все индексы | ничего (если транзакция закоммичена) |
UPDATE | новая версия целиком + старой проставляется xmax; записи во все индексы таблицы, а не только в те, чьи колонки менялись (новая версия лежит по новому ctid) — если это не HOT-обновление | старая версия строки + старые индексные записи |
DELETE | только проставляется xmax; строка физически на месте | вся версия строки + все её индексные записи |
ROLLBACK | ничего не откатывается физически: транзакция помечается aborted | всё, что она успела записать |
«В PostgreSQL UPDATE — это по сути DELETE + INSERT: старая версия строки не меняется, а помечается устаревшей, и создаётся новая целиком, даже если изменилось одно поле из тридцати.» Из этой фразы выводится всё: почему обновление широкой строки дорогое, почему таблица растёт при «просто обновлениях», почему нужен VACUUM, почему счётчик, обновляемый 1000 раз в секунду, создаёт 1000 мёртвых версий в секунду, и почему такой счётчик лучше держать в Redis.
fillfactor ниже 100 (например 85) оставляет в странице место под будущие
версии и резко повышает долю HOT-обновлений на часто обновляемых таблицах.VACUUM, autovacuum и bloat
Мёртвой версией становится кортеж, которому уже проставили xmax закоммиченной
транзакции и который больше не нужен ни одному живому снапшоту. Сам он не исчезает:
UPDATE и DELETE в PostgreSQL физически ничего не стирают. Убирает
мёртвые версии VACUUM, и только тогда, когда версия перестала быть нужна
самому старому активному снапшоту во всей базе. Отсюда прямая связь «долгая транзакция →
VACUUM не может ничего почистить → таблицы пухнут»: одна забытая открытая транзакция копит
мусор по всей базе, а не только в тех таблицах, которых она касалась.
Попутно VACUUM обновляет карту видимости (visibility map): по два бита на каждую страницу таблицы: «в этой странице все версии видны всем и мёртвых нет» и «все версии заморожены». Карта понадобится в главе про индексы: с ней ответ можно отдать прямо из индекса, вообще не заглядывая в таблицу.
| Форма | Что делает | Блокировка | Возвращает место ОС |
|---|---|---|---|
VACUUM | помечает место мёртвых версий свободным для переиспользования, чистит индексы, обновляет visibility map | SHARE 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).
Таблица на 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);
Идентификатор транзакции 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).
synchronous_commit, разменяв последние миллисекунды
транзакций на пропускную способность.
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 | нет | нет | типы фиксированной длины |
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(); -- обнулить перед экспериментом
Запрос на 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 — оптимизация,
когда новая версия влезла в ту же страницу и индексируемые колонки не менялись.Пошагово
- Движок находит текущую версию строки и берёт на неё блокировку.
- Формирует новую версию целиком — со всеми колонками, даже неизменёнными.
- Пишет её в ту же страницу, если есть место, иначе в другую.
- Старой версии проставляет
xmax= мой xid и связывает еёctidс новой. - Добавляет записи во все индексы, если это не HOT-обновление.
- Пишет всё это в WAL (а после контрольной точки — ещё и полную страницу целиком).
Отсюда неочевидные следствия: обновление одного boolean в строке с
jsonb на 500 КБ создаёт новую версию строки (правда, неизменённые
TOAST-чанки переиспользуются); таблица, где 1000 раз в секунду инкрементируется счётчик,
растёт со скоростью 1000 мёртвых версий в секунду; ROLLBACK ничего не
откатывает физически — записанные версии просто остаются мусором.
HOT (Heap-Only Tuple)
Если новая версия помещается в ту же страницу и ни одна проиндексированная колонка не изменилась, PostgreSQL не трогает индексы вообще: индексная запись продолжает указывать на старую версию, а внутри страницы выстраивается HOT-цепочка, по которой движок доходит до актуальной. Так получается меньше записи в WAL, индексы не пухнут, а очистка цепочки может произойти прямо при обращении к странице, без полноценного VACUUM.
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, битовую карту «в этой странице все версии видны всем», без которой index-only scan теряет смысл: ему приходится ходить в таблицу.
- Замораживает старые кортежи, отодвигая угрозу transaction ID wraparound.
- Попутно (через autovacuum) запускается
ANALYZE, обновляющий статистику.
Обычный VACUUM берёт SHARE UPDATE EXCLUSIVE и не мешает
SELECT/INSERT/UPDATE. VACUUM FULL
устроен совсем иначе: переписывает таблицу в новый файл, возвращает место ОС, но берёт
ACCESS EXCLUSIVE, то есть таблица недоступна полностью. На проде вместо него
используют pg_repack.
Почему возникает bloat
- Долгая транзакция или
idle in transactionудерживает горизонт, и VACUUM не имеет права удалять новые мёртвые версии. - Забытый replication slot держит
xminбессрочно. Причина самая коварная: реплики уже может не быть, а слот остался. - Дефолтный
autovacuum_vacuum_scale_factor = 0.2на большой таблице: уборка начнётся, когда мёртвых накопится 20 %. - Autovacuum душат настройками (
cost_delay, мало воркеров) — он не успевает за нагрузкой. - Массовые
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, и только потом может быть сброшено в
файл данных. Страницы меняются в 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 и позволяет
восстановиться на любой момент времени. Один механизм закрывает три задачи, и это хороший
способ показать системное понимание.
Теряются целые последние транзакции (до wal_writer_delay × 3, при значении по умолчанию — до ~600 мс), но база остаётся согласованной — не бывает «половина транзакции
применилась». Поэтому настройка годится для потоков вроде метрик и логов, где
потерять секунду данных не страшно, и категорически не годится для платежей.
Настройку можно менять на уровне отдельной транзакции: SET LOCAL
synchronous_commit = off.
Строка в 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+-дерево: значения хранятся только в листьях, внутренние узлы содержат
разделители. Листья связаны в двусвязный список, поэтому 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 хранит не значения, а для каждой группы страниц (по умолчанию 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 область поиска в отсортированном списке не сузить.
(a, b, c) обслуживает запросы по
(a), (a, b) и (a, b, c), но искать по
(b) или (c) отдельно не помогает. Поэтому три отдельных индекса и один
составной ведут себя совершенно по-разному.- Сначала колонки из условий равенства (
=,IN), потом колонки из диапазонов (>,BETWEEN), потом колонки изORDER BY. Причина: после первого диапазонного условия дальнейшие колонки индекса уже не сужают поиск, а только фильтруют. - Среди равенств первыми идут более селективные, если запросы разные; если запрос всегда один и тот же, порядок среди равенств почти не важен.
- Учитывай направление сортировки: для
ORDER BY a, b DESCнужен индекс(a, b DESC), иначе получишь дополнительную Sort. - Один составной индекс обычно лучше трёх отдельных: 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.
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_statistic | ANALYZE, поднять 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 лежат пары «ключ → 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.
Монотонный ключ (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 берёт всё, что имеет порядок:
=,<,>,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.
Почему не 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 на сфероиде (медленнее, зато 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 | придётся сортировать |
Правило порядка колонок
- Сначала колонки с равенством (
=,IN): они сужают поиск до непрерывного участка индекса. - Затем колонка диапазона (
>,BETWEEN). Полезной она бывает только одна и только последней среди «поисковых». - Затем колонки сортировки, в том же направлении, что в
ORDER BY. - Селективность внутри группы равенств вторична: она влияет на размер индекса и на то, сколько других запросов он покроет, но не на то, применим ли он вообще.
«Равенства → диапазон → сортировка». Если помнить только это правило, 80 % задач на
составные индексы решаются с листа. И второе: индекс (a, b) делает индекс
(a) избыточным — отдельный индекс по первой колонке можно удалить,
сэкономив на записи.
Совет «ставь самую селективную колонку первой» популярен, но неточен. Если самая селективная колонка участвует в запросе диапазоном, а менее селективная — равенством, правильный порядок обратный. Проверять надо планом, а не эвристикой.
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' — тогда хватит
обычного индекса по колонке, без функционального.
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 constraint | UNIQUE 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 в колонке. Классический баг мягкого удаления:
UNIQUE (email, deleted_at) не мешает вставить двух живых пользователей с одним
email, если у обоих deleted_at IS NULL. Лечится частичным уникальным индексом
или, с PG 15, синтаксисом UNIQUE NULLS NOT DISTINCT.
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. В прод так, разумеется, не катят, это только диагностика.
Конкретная цена
- 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
- Первый проход: строит индекс по снапшоту, параллельно записи продолжаются.
- Ждёт завершения всех транзакций, начавшихся до первого прохода.
- Второй проход: добирает строки, изменившиеся за время первого прохода.
- Ждёт транзакции, которые могли не видеть индекс, и помечает его валидным.
Ограничения, о которых спрашивают
- Нельзя внутри транзакционного блока, так что миграция должна уметь выполнять такой шаг
вне транзакции (в 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.
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;
Как читать план: снизу вверх и изнутри наружу
План устроен как дерево. В текстовом выводе оно записано с отступами: чем глубже отступ, тем ниже узел в дереве. Данные текут снизу вверх: самые вложенные узлы читают таблицы и индексы, отдают строки родителю, тот соединяет или агрегирует, и так до корня, который отдаёт результат клиенту. Поэтому читать нужно от самых глубоких строк, а не сверху.
Узлы, которые надо узнавать
| Узел | Что делает | Когда это нормально / плохо |
|---|---|---|
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 можно только между планами одного запроса.rowsvsactual 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: если планирование сравнимо с выполнением, а запрос простой, смотри в сторону избытка индексов или подготовленных выражений.
- Найти узел с наибольшим собственным временем (время узла минус время детей, умноженное на loops).
- Посмотреть на его
rowsпротивactual rows. Если оценка врёт, проблема в статистике, а не в запросе. - Посмотреть
Rows Removed by Filter— если много, нужен индекс/предикат. - Проверить
Buffers read: при сотнях тысяч мы просто читаем слишком много данных. - Проверить признаки нехватки памяти:
Batches > 1,external merge,lossy,Disk Usage.
Кейс: запрос на таблице в несколько терабайт тормозит
Это любимый сценарий на собеседовании, потому что он проверяет порядок действий, а не знание команд. Правильный ответ начинается со слов «сначала измерю», а не «добавлю индекс».
- Понять, что именно тормозит. Не «сайт медленный», а конкретный запрос:
pg_stat_statementsс сортировкой поtotal_exec_time(не поmean: главным вредителем часто оказывается быстрый запрос, который выполняется 50 000 раз в минуту) иpg_stat_activityдля того, что висит прямо сейчас. - Снять план.
EXPLAIN (ANALYZE, BUFFERS)на репрезентативных параметрах. Если запроса не дождаться, хватитEXPLAINбез ANALYZE плюсauto_explainсlog_min_durationв проде. - Проверить, не блокировки ли это. Медленный не значит «плохой план»: посмотреть
wait_event_type = 'Lock'вpg_stat_activity. Если ждём блокировку, план тут ни при чём. - Проверить статистику.
rowsпротивactual rows. Если оценка врёт на порядки, запуститьANALYZEи снять план заново. Половина «загадочных» проблем закрывается здесь. - Уменьшить объём читаемых данных. В порядке предпочтения:
- индекс под предикат (составной, частичный, покрывающий);
- переписать запрос: убрать функцию с колонки, заменить
ORнаUNION ALL, коррелированный подзапрос — на джойн или оконную функцию,DISTINCT— наEXISTS, вынести тяжёлую агрегацию в CTE сMATERIALIZEDили наоборот; - убрать
SELECT *, чтобы стал возможен index-only scan.
- Партиционирование. Если таблица многотерабайтная и запросы всегда ограничены по
времени или тенанту, подойдёт RANGE по дате или HASH по tenant_id. Заодно решается проблема обслуживания:
удаление старых данных превращается из многочасового
DELETEв мгновенныйDETACH PARTITION. - Изменить модель. Предагрегировать (материализованное представление, счётчики, роллапы), вынести холодные данные в архивную таблицу, денормализовать нужную колонку, чтобы избавиться от джойна. Это уже не «оптимизация запроса», а изменение схемы — но на терабайтах именно оно обычно и работает.
- Только потом — железо и параметры.
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 мс + разбор + планирование) вместо одного.
Как обнаружить
- Логи БД: временно
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,
чтобы одинаковые ключи не ходили в БД дважды.
Чинить N+1 одним большим JOIN через несколько связей «один ко многим» опасно:
заказ с 10 позициями и 5 платежами превращается в 50 строк, а поля заказа дублируются 50 раз.
Это «декартово раздувание». Правильнее либо сделать два-три отдельных батч-запроса и склеить
результат в коде, либо собрать вложенные коллекции в JSON на стороне БД.
Пагинация: OFFSET/LIMIT против keyset
OFFSET n не умеет «перепрыгнуть» n строк. База обязана их найти, материализовать
и выбросить. Значит, стоимость страницы растёт линейно с её номером: первая стоит 20 строк,
пятитысячная — 100 020 строк работы ради тех же 20. Отсюда чаще всего и берётся «у нас в админке
последние страницы отваливаются по таймауту».
-- Плохо: деградирует линейно
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/LIMIT | Keyset (cursor) | |
|---|---|---|
| Стоимость страницы N | O(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 | перечисление значений | по региону, по тенанту, по стране |
| HASH | hash(ключ) 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;
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».
Вопросы
10EXPLAIN только планирует и показывает оценки,
запрос не выполняется. 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для конкретной сессии.
Порядок разбора
- Найти узел с наибольшим собственным временем (общее минус время детей); на верхний узел не смотреть, он суммарный.
- Сравнить
rowsиactual rowsв этом узле и его детях. - Посмотреть, откуда пришло расхождение: устаревшая статистика →
ANALYZE; коррелированные колонки →CREATE STATISTICS; выражение вместо колонки → функциональный индекс. - Проверить
Rows Removed by FilterиHeap Fetches. - Посмотреть на тип соединения: Nested Loop с большим внешним набором почти всегда вырастает из недооценки строк.
Смотреть на cost и делать выводы. Cost оценивает запрос до выполнения,
и если оценка неверна, весь план построен на песке. Диагноз ставят по паре
«оценка против факта», а не по абсолютной величине cost. Вторая по частоте ошибка: забыть
умножить время на loops.
Узлы доступа
| Узел | Что делает | Когда выбирается |
|---|---|---|
| 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 в случайном
порядке плюс сам индекс, а это дороже, чем прочитать таблицу целиком подряд.
Поэтому «планировщик игнорирует мой индекс» чаще всего означает «планировщик прав».
Дефолтные 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. Лечение по возрастанию цены
- Статистика.
ANALYZE; поднятьdefault_statistics_targetдля колонки с перекошенным распределением;CREATE STATISTICSдля коррелированных колонок (город и страна). Самое дешёвое и на удивление часто помогает. - Индекс. Прицельный: составной в правильном порядке, частичный под предикат,
покрывающий с
INCLUDEради Index Only Scan. ТолькоCREATE INDEX CONCURRENTLY. Помнить о цене записи. - Переписать запрос. Убрать функцию с колонки, заменить
ORнаUNION ALL,NOT INнаNOT EXISTS,OFFSETна keyset, убратьSELECT *, вынести тяжёлую агрегацию в отдельный проход. - Сузить данные. Нужны ли реально все 3 года? Обычно 95 % запросов идут за последний месяц, а холодные данные можно унести в архивную таблицу или в отдельное хранилище.
- Партиционирование по времени. Даёт pruning для запросов с датой и, что не менее
важно, снова делает обслуживание управляемым: VACUUM, REINDEX, удаление старого
через
DROP TABLE. - Денормализация / материализованное представление / инкрементальный счётчик — если запрос аналитический и считает агрегаты по всей истории.
- Другое хранилище. Аналитике по миллиардам строк место в ClickHouse, а не в PostgreSQL. Сказать это прямо не стыдно, это сильный ответ.
«Отдельно проверю, не деградация ли это из-за bloat: на терабайтной таблице с активным
UPDATE мёртвые версии могут занимать половину объёма, и тогда Seq Scan читает вдвое
больше, чем нужно. Смотрю pg_stat_user_tables.n_dead_tup, настройки
autovacuum для этой таблицы (дефолтные 20 % от таблицы в терабайт — это 200 ГБ мусора
до старта уборки, что абсурдно; ставлю autovacuum_vacuum_scale_factor = 0.01
и явный threshold).»
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 в ORM | GORM Preload, ent With… | работает, только если не забыть; забывают постоянно |
Совет «всегда джойнить» ошибочен точно так же. Заказ с 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, то есть пользователь увидит её дважды, а какую-то другую не увидит вовсе. При бесконечной прокрутке это заметный баг, и 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(*), чтобы узнать, есть ли следующая страница.
Админка на 30 страниц, где нужна навигация «перейти на страницу 7», и данных объективно мало. Правило простое: OFFSET допустим, пока глубина ограничена. Если продукт требует номера страниц на большом объёме, глубину ограничивают («не более 100 страниц, уточните фильтр»), как делают все поисковики.
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, для точного — отдельный счётчик.
Интерактивная отладка в psql; EXISTS (SELECT * FROM ...): список
колонок там вообще не вычисляется, это идиома; SELECT * FROM unnest(...) и
подобные конструкции, где набор колонок задан тут же. В репозиторном коде — нет.
COPY дополнительно убирает разбор SQL и часть накладных расходов
протокола (ещё 2–5×). В Go это pgx.CopyFrom.| Способ | Порядок скорости | Особенности |
|---|---|---|
| INSERT по одной, autocommit | 1× | худший случай: 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быстрее.
Что реально даёт
- 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 Scan | GIN + pg_trgm или полнотекстовый поиск |
Составной индекс (a, b), а фильтр только по b | Seq 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тай-брейкером, иначе «второй заказ клиента» будет прыгать между запусками.- Проговаривай план вслух. На этих задачах интервьюер часто спрашивает «а какой индекс нужен», и ответ на него стоит на голову выше самого решения.
Вопросы
7NOT 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» в приложении не работает при конкурентных запросах: два процесса
одновременно не найдут запись и оба вставят. Гарантию даёт только констрейнт.
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.
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 + WindowAgg | Nested 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 оставляет ровно
одну строку на группу.
-- Правильно: оконная функция, один проход по таблице
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, чтобы сортировка для окна не ушла на диск.
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;
Как это выполняется на самом деле
- Выполняется якорь, результат кладётся в рабочую таблицу и в итоговую.
- Рекурсивная часть выполняется, но
subordinatesвнутри неё означает только результат предыдущей итерации, а не весь накопленный результат. На этой тонкости и ловят. - Новые строки добавляются к итогу и становятся рабочей таблицей следующего шага.
- Шаги повторяются, пока очередной не вернёт ноль строк.
Защита от циклов — три способа
- Массив пути
path+WHERE NOT e.id = ANY(s.path)точнее всех: отсекает именно повторное посещение и заодно даёт готовый путь для сортировки дерева. - Ограничение глубины
WHERE depth < 50— грубо, но надёжно, и защищает ещё и от «слишком глубокого» легального дерева. - Синтаксис
CYCLE(PostgreSQL 14+):... CYCLE id SET is_cycle USING path. Это стандартный SQL, он делает то же самое декларативно.
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 —
когда читается часто.
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/count | O(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 через stdlib | pgx как драйвер для database/sql | стандартный API + актуальный драйвер | теряется часть возможностей pgx | когда нужен стандартный интерфейс, но не нужен lib/pq |
| sqlx | тонкая обёртка над database/sql | StructScan, 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» был
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)
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 — соединение
тут же ушло другому. Экономия большая, но с ней приходит правило, которое ломает половину
привычек: ничего нельзя оставлять «в сессии», потому что следующий запрос почти наверняка
уедет на другое соединение.
| Режим | Когда соединение возвращается в пул | Что ломается | Экономия |
|---|---|---|---|
| session | при отключении клиента | ничего | минимальная — только на переустановке соединений |
| transaction | по COMMIT/ROLLBACK | prepared statements, SET, temp-таблицы, advisory locks на сессию, LISTEN/NOTIFY, курсоры вне транзакции | максимальная и практичная — стандартный выбор |
| statement | после каждого оператора | всё вышеперечисленное плюс многооператорные транзакции вообще | предельная; применим только там, где транзакций нет по определению |
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.
Пулов становится два, и они не знают друг о друге. Правило: суммарный
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 напрямую
Плейсхолдер подставляет значение, а не кусок синтаксиса. Нельзя подставить параметром
имя таблицы, имя колонки, направление сортировки, 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 параметром через пять уровней.
Против: зависимость становится скрытой, и по сигнатуре
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)
}
Отмена контекста не останавливает запрос мгновенно. 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-флаг
-- Индекс на живой таблице: только 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: добавить новую колонку, начать писать в обе (в коде или триггером), читать пока из старой.
- Backfill: скопировать данные батчами.
- Релиз 2: переключить чтение на новую колонку. Пишем всё ещё в обе.
- Релиз 3: перестать писать в старую.
- Релиз 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 всё равно надо — для локальной разработки и тестов.
Вопросы
13database/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растёт тысячами в минуту.
Как считать размер
- Сверху:
max_connections(обычно 100–200) минус резерв (superuser_reserved_connections, мониторинг, миграции, человек с psql), делить на число инстансов всех сервисов, ходящих в эту базу. - Снизу, по закону Литтла: нужное число = RPS × средняя длительность запроса. 400 RPS × 10 мс = 4 соединения на инстанс. Если получается 50, лечить надо длительность запросов.
- Проверка здравого смысла: PostgreSQL хорошо работает, когда активных
соединений порядка
2 × CPU. Сверх этого начинается деградация из-за конкуренции за LWLock и вытеснения кэшей, и пропускная способность падает, а не растёт. - MaxIdleConns = MaxOpenConns почти всегда подходит сервису под постоянной нагрузкой.
- ConnMaxLifetime 30–60 минут обязателен, если между приложением и БД есть балансировщик, VIP или облачный прокси: без пересоздания соединения не переедут после failover и не перебалансируются.
Экспортировать db.Stats() целиком.
WaitCount и WaitDuration растут, если пул мал или
запросы долгие; InUse против MaxOpenConnections показывает запас
до потолка; MaxIdleClosed подскажет, не мал ли MaxIdleConns.
Без этих метрик исчерпание пула ищут часами.
context. Поэтому в проде это видно как рост p99 и
context deadline exceeded при абсолютно спокойной базе — самый частый
неверный диагноз «база тормозит».Последовательность событий
- Все
MaxOpenConnsсоединений заняты. - Очередная горутина вызывает
QueryContextи блокируется внутриdatabase/sqlв ожидании освобождения. - Растут
WaitCountиWaitDuration. - Если у запроса есть
contextс таймаутом, по истечении вернётсяcontext deadline exceeded. Если контекста нет, горутина будет ждать вечно. - Очередь растёт, горутины копятся, растёт потребление памяти, 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) раньше, чем на уровне пула БД, чтобы отказ был контролируемым.
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_size ≤
max_connections минус резерв. Клиентский пул при этом держат
небольшим: соединение до pgbouncer дёшево, но пока идёт транзакция, оно всё
равно удерживает серверную сессию. И ConnMaxLifetime оставляем,
чтобы клиенты перераспределялись между инстансами bouncer.
// Уязвимо
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 → работа только через tx → Commit.
Ошибка в середине переводит транзакцию 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 вернул сетевую ошибку, ты не знаешь, применилась
транзакция или нет: сервер мог зафиксировать её и упасть до отправки ответа.
Наивный ретрай приведёт к двойному списанию. Правильные ответы: (1) сделать
операцию идемпотентной через уникальный ключ на бизнес-действие
(idempotency_key) плюс ON CONFLICT DO NOTHING;
(2) перед ретраем проверить состояние — «а есть ли уже такой перевод?»;
(3) вернуть наверх ошибку «неизвестно» и разбирать сверкой, если операция денежная.
Это, кстати, ровно та же проблема, что и в 2PC.
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 действительно попадает в транзакцию.»
Что важно понимать
- Отмена ≠ откат. Прерванный
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 и
покажешь, что различаешь «ретраить» и «не ретраить», это очень хорошо смотрится.
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, который делает
DROP COLUMN, уничтожает данные — релиз откатить можно, а данные уже
не вернуть. Поэтому откатываются обычно новой миграцией вперёд,
а down пишут для локальной разработки и тестов. И проектировать
миграции надо так, чтобы откат кода без отката схемы был безопасен —
в этом и смысл expand/contract.
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 («прочитай свою запись»). Это гарантия, что пользователь сразу после собственной записи видит именно её, а не то, что было до. Асинхронная репликация такой гарантии не даёт. Вот как это выглядит на практике.
- Лаг не постоянен. Он взлетает при массовом
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) → переподключение приложений.
Сценарий: сеть между узлами разорвалась, но оба живы. Кластер решил, что мастер умер, и повысил реплику. Старый мастер об этом не знает и продолжает принимать запись, тем более если часть клиентов осталась по его сторону разрыва. Теперь две базы расходятся: в одной заказ №100 принадлежит Ивану, в другой — Петру. Слить их обратно автоматически невозможно: конфликты по одним и тем же первичным ключам, и нет объективного критерия, чья версия правильная. Кончается это ручным разбором с потерей данных.
Защита. (1) Кворум: решение о повышении принимает только та часть кластера, в которой большинство узлов (отсюда нечётное число узлов и внешний DCS вроде etcd, Consul, ZooKeeper). (2) Fencing / STONITH: прежде чем повысить нового мастера, старый принудительно изолируют — отключают сеть, останавливают процесс, забирают VIP. (3) Демоция по потере кворума: узел, потерявший связь с DCS, сам переводит себя в read-only, не дожидаясь чужого решения; так работает Patroni. (4) Единая точка входа: клиенты ходят через прокси, который знает, кто мастер; тогда «второй мастер» просто не получает трафика. (5) Синхронная репликация дополнительно защищает от потери подтверждённых коммитов при failover.
RPO (Recovery Point Objective) задаёт, сколько данных допустимо потерять, и меряется во
времени: «не более 5 минут». RTO (Recovery Time Objective) задаёт, за сколько система
обязана вернуться в строй: «не более 15 минут». Асинхронная репликация даёт RPO порядка
величины лага и RTO в десятки секунд при автоматическом failover; синхронная — RPO = 0;
ежесуточный pg_dump — RPO до 24 часов и RTO, равное времени восстановления
дампа (на терабайте это часы). Эти две буквы переводят разговор из «надёжно/
ненадёжно» в цифры, и именно этого ждут на системном дизайне.
Шардирование против партиционирования
Партиционирование режет большую таблицу на части внутри одного сервера; приложение этого не замечает, склейкой занимается сама СУБД. Шардирование раскладывает данные по разным серверам, у каждого свой процессор, память и диск. Кусок данных называют шардом, а колонку, по которой строку направляют в шард, — ключом шардирования. Прозрачности нет: маршрут выбирает приложение или прокси, и один запрос к одной базе может превратиться в четыре запроса к четырём.
| Партиционирование | Шардирование | |
|---|---|---|
| Где живут части | один сервер, одна БД | разные серверы |
| Что масштабирует | размер таблицы и обслуживание | запись, объём, CPU, память — всё |
| Прозрачность | полная, делает СУБД | нет, логика в приложении или в прокси (Citus, Vitess, YDB) |
| Транзакции между частями | обычные ACID | 2PC или сага |
| 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, двухфазная фиксация) с отдельным участником,
координатором: сначала он спрашивает у всех «готов зафиксировать?» и, только
получив «да» от каждого, командует фиксировать.
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;
- Блокирующий протокол. Если координатор упал между фазами, участники остаются
с заблокированными ресурсами и без права решать. В 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_dumpall | PITR (базовый бэкап + WAL) | |
|---|---|---|
| Что это | логический дамп: SQL или архив с данными | физическая копия каталога + непрерывный архив WAL |
| Гранулярность восстановления | момент снятия дампа | любой момент времени — «состояние на 14:32:07» |
| RPO | интервал между дампами (часы, сутки) | секунды |
| RTO на 1 ТБ | часы: данные заливаются INSERT-ами, индексы строятся заново | десятки минут: копирование файлов + накат WAL |
| Восстановление части | да — одну таблицу, одну схему, одну БД | нет, только кластер целиком |
| Между версиями PG | да, это ещё и способ мажорного апгрейда | нет, версия и архитектура обязаны совпадать |
| Влияние на прод | долгая транзакция: держит горизонт VACUUM | минимальное, обычно снимается с реплики |
| Инструменты | pg_dump -Fc, pg_restore -j 8 | pgBackRest, WAL-G, barman |
- Нужны оба. PITR закрывает отказ железа и «удалили не ту строку в 14:31».
pg_dumpзакрывает «нужна одна таблица из прошлого месяца», перенос между версиями и хранение долгих архивов. - Реплика не бэкап.
DELETE FROM usersдоедет до реплики за миллисекунды. Реплика защищает от отказа узла, бэкап — от ошибки человека и от порчи данных. - Непроверенный бэкап не существует. Узнать заранее, что бэкап рабочий, можно только так: регулярно и автоматически восстанавливать его в отдельное окружение и прогонять контрольные запросы.
- Формулировать в RPO/RTO. «PITR с архивом WAL раз в минуту даёт RPO около минуты; восстановление терабайта из базового бэкапа с накатом даёт RTO порядка часа; если бизнесу нужно 5 минут, добавляем горячую реплику и переключение на неё».
- Хранить отдельно от прода, в другом регионе или облаке, с ограниченными правами на удаление. Иначе один скомпрометированный доступ уносит и базу, и бэкапы.
Вопросы
12Физическая (streaming)
- Реплика повторяет мастер блок в блок, поэтому версии PostgreSQL и архитектура обязаны совпадать.
- Минимальные накладные расходы: реплика просто применяет WAL.
- Нельзя выбрать часть данных, нельзя писать на реплике, нельзя иметь на ней свои индексы.
- Её ставят ради HA и failover, read-реплик и бэкапов без нагрузки на мастер.
Логическая
CREATE PUBLICATIONна источнике,CREATE SUBSCRIPTIONна приёмнике; передаются строки, а не блоки.- Работает между мажорными версиями, поэтому через неё обычно апгрейдят PostgreSQL с минимальным даунтаймом: поднимают новую версию подписчиком, ждут синхронизации, переключают трафик.
- Реплицировать можно выборочно: например, только справочники в аналитический контур или CDC в Kafka через Debezium/wal2json.
- Ограничения, которые обязательно назвать: не реплицируется DDL (схему
надо накатывать на обе стороны руками), нужен первичный ключ или
REPLICA IDENTITY FULLдля UPDATE/DELETE, не синхронизируются последовательности, конфликты (например, дубль по уникальному ключу на подписчике) останавливают репликацию до ручного вмешательства.
Слот не даёт мастеру удалить 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, асинхронная в другом регионе.
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задаёт, сколько реплика ждёт, прежде чем убить конфликтующий запрос. Значение подбирают под то, что важнее — свежесть или отчёты.- Помнить: реплики не масштабируют запись. Если упёрлись в неё, поможет шардирование, а не ещё одна реплика.
| Решение | Как работает | Цена |
|---|---|---|
| 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
- Обнаружение. Health-check не отвечает N раз подряд. С порогом ищут компромисс: слишком чувствительный даёт ложные срабатывания на сетевом моргании, слишком терпимый увеличивает RTO.
- Выбор кандидата. Берут реплику с наибольшим применённым LSN: она потеряет меньше всего. Если есть синхронная реплика, выбирают её.
- Fencing старого мастера делают до promote, а не после.
- Promote. Реплика заканчивает восстановление и начинает принимать запись.
- Переключение трафика: VIP, обновление DNS,
PAUSE/RESUMEв pgbouncer, обновление service discovery. - Перестройка кластера на нового мастера. Остальные реплики просто переходят на его линию времени, а бывший мастер возвращают репликой через
pg_rewind: у него могут быть «лишние» транзакции, до реплик не доехавшие.
Защита от split brain
- Кворум и внешний DCS. Решение принимает только часть кластера с большинством узлов (etcd/Consul/ZooKeeper). Поэтому узлов нужно нечётное число, а DCS должен быть отдельной, независимо доступной системой.
- Fencing / STONITH. Старый мастер физически изолируют: снимают VIP, останавливают сервис, блокируют порт, а в облаке отключают сетевой интерфейс. Только после этого повышают нового.
- Самодемоция. Узел, потерявший связь с DCS и не подтвердивший свою роль в течение TTL лидерской аренды, сам переводит себя в read-only. Так работает Patroni: лидерство там хранится как ключ с TTL, который надо продлевать.
- Единая точка входа. Клиенты ходят через прокси, знающий текущего мастера, и «второй мастер» просто не получает трафика.
- Синхронная репликация дополнительно гарантирует, что у нового мастера есть все подтверждённые коммиты.
Простой виден сразу и лечится восстановлением. Split brain не виден: обе базы работают, обе отвечают, обе выдают заказы с одинаковыми id разным клиентам. Замечают его через часы, когда сети восстановились и что-то не сошлось. Автоматически расхождение не слить: критерия правоты нет, и часть данных придётся потерять или разбирать руками. Отсюда общее правило: лучше отказать в записи, чем принять её в неизвестное состояние. В терминах CAP это и есть выбор CP.
Ключевые различия
- Что масштабируется. Партиционирование решает проблему размера таблицы и обслуживания, шардирование — проблему ресурсов сервера, когда одна машина не тянет запись, объём или память.
- Прозрачность. Партиции остаются деталью реализации: приложение работает с одной таблицей. Шарды входят в архитектуру: приложению нужно знать, куда идти.
- Транзакции. Между партициями работает обычный ACID, между шардами нужен 2PC или сага.
- JOIN. Между партициями он обычный. Между шардами остаётся scatter-gather с агрегацией в приложении или дублирование справочников на все шарды.
- Уникальность. Партиция требует ключ в PK. Шардирование вообще лишает
глобального
UNIQUE: чтобы email был уникален по всей системе, нужен либо отдельный сервис-реестр, либо шардирование именно по email. - Эксплуатация. N шардов означают N кластеров, N наборов реплик, N бэкапов, N процедур failover. Сопровождение дорожает линейно.
В хорошем ответе есть последовательность: сначала индексы и переписывание запросов → вертикальное масштабирование (железо дешевле инженеров) → read-реплики для чтения → партиционирование по времени → архивация холодных данных → и только затем шардирование. Оно идёт последним, потому что необратимо усложняет всё остальное. Кандидат, который начинает ответ с «пошардируем», отвечает хуже того, кто дошёл до этого шестым пунктом.
Критерии выбора
- Локальность запросов. Если 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.
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
- Сага разбивает операцию на цепочку локальных транзакций с компенсациями:
bookFlight→bookHotel→charge; при отказе выполняемcancelHotel,cancelFlight. Изоляции нет: промежуточное состояние видно, поэтому вводят статус «pending» и таймаут резервирования. - Transactional outbox пишет событие в ту же локальную транзакцию, что и бизнес-данные, а в брокер его отправляет отдельный процесс. Так закрывается «записали в БД, но не отправили в Kafka» без всякого 2PC.
- Идемпотентность каждого шага и каждой компенсации обязательна, потому что доставка at-least-once.
- Оркестратор (Temporal, Camunda, свой сервис-дирижёр) берут вместо хореографии, когда шагов больше трёх, иначе логика размазывается по сервисам и её невозможно отладить.
Спросить, точно ли нужны две базы. Часто «билеты» и «отели» оказываются двумя сервисами одной команды, разделёнными по недоразумению, и тогда нужна не распределённая транзакция, а пересмотр границ. Если границы верны, берут сагу. 2PC остаётся для случаев, когда участники живут в одной СУБД или классическом XA-стеке и бизнес готов платить доступностью.
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, поэтому снимать его лучше с реплики.
- Измерить. Что именно упёрлось: CPU, диск (IOPS, latency), память,
соединения, блокировки?
pg_stat_statements,pg_stat_activity, метрики хоста. Без этого шага любой ответ превращается в угадывание. - Оптимизировать запросы. Индексы, переписывание, устранение N+1, убрать
SELECT *, keyset вместо OFFSET. Обычно даёт кратный эффект бесплатно и обратимо. - Настройки.
shared_buffers,work_mem,effective_cache_size, autovacuum для горячих таблиц,random_page_costпод SSD. - Кэш. Redis перед горячими и редко меняющимися данными. Дёшево, но добавляет инвалидацию и eventual consistency.
- Вертикально. Больше CPU, памяти, быстрее диск. Инженерное время дороже железа, и этот шаг зря стесняются называть: часто он самый разумный.
- Read-реплики. Если упёрлись в чтение, разгружаем мастер. Помним про лаг и read-your-writes.
- Пул соединений / pgbouncer, если упёрлись в число соединений.
- Партиционирование и архивация. Если проблема в объёме: отсечение по
времени, удаление старого через
DROP, вынос холодных данных. - Разделение нагрузок. Аналитику уводят в ClickHouse через CDC, чтобы отчёты не мешали OLTP, поиск переносят в Elasticsearch.
- CQRS / отдельные модели чтения в виде денормализованных витрин под конкретные экраны.
- Шардирование. Только если упёрлись в запись и всё предыдущее исчерпано.
«Отдельно проверю, не искусственная ли нагрузка: ретраи без backoff, которые множат трафик при деградации; отсутствие таймаутов; N+1, выросший вместе с размером страницы; фоновая джоба, запущенная в пик. Довольно часто „база не справляется“ означает, что приложение стреляет в неё вдвое чаще, чем нужно, и чинить надо клиента, а не масштабировать базу.»
Как это выглядит в PostgreSQL
- На одном сервере распределённой системы нет, CAP не применяется, есть ACID.
- Мастер + синхронная реплика дают CP: при потере реплики мастер останавливает запись, чтобы не потерять подтверждённые коммиты. Консистентность сохранена, доступность на запись потеряна.
- Мастер + асинхронные реплики на чтении ближе к AP: реплики отвечают устаревшими данными, но отвечают. На запись остаётся CP — мастер один.
- Автоматический failover с кворумом — CP по построению: меньшая часть кластера сама себя переводит в read-only, чтобы не устроить split brain.
- Cassandra, DynamoDB по умолчанию AP: отвечают всегда. У Cassandra консистентность настраивается уровнями кворума на чтение и запись, у DynamoDB — флагом строго консистентного чтения.
CAP описывает только поведение при разделении, а систему надо характеризовать и в нормальном режиме. PACELC: «если Partition — выбор между A и C; Else — выбор между Latency и Consistency». PostgreSQL с синхронной репликацией попадает в PC/EC: и при разделении, и в норме предпочитает консистентность, платя задержкой. С асинхронной он PC/EL: в норме выбирает низкую задержку. Упомянув PACELC, покажешь, что понимаешь CAP как модель, а не как лозунг.