Внутреннее устройство

1. Архитектура PostgreSQL

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

Компоненты системы

Client Applications Клиентские приложения подключаются к PostgreSQL через различные драйверы и интерфейсы (psql, JDBC для Java, libpq для C). Когда приложение отправляет запрос, оно обращается к слою соединений.

Connection Layer Это критичная часть архитектуры, состоящая из двух компонентов:

  • Postmaster (главный процесс) — служит "привратником" для всей системы. Постоянно слушает входящие соединения на определённом порту (по умолчанию 5432). Когда клиент подключается, Postmaster создаёт новый backend процесс специально для этого соединения. Postmaster также управляет всеми фоновыми процессами, отслеживает их состояние и перезапускает их при необходимости.

  • Backend процессы — создаются Postmaster'ом для каждого соединения. Один процесс = одно соединение. Это означает, что если у вас есть 100 активных соединений, работает 100+ процессов (плюс системные). Backend обрабатывает все запросы от одного клиента, выполняет аутентификацию, парсинг, оптимизацию и исполнение.

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

Background Processes Это служебные процессы, работающие "в фоне" независимо от соединений:

  • WAL Writer — периодически сбрасывает Write-Ahead Log буфер на диск. Это гарантирует, что даже если сервер упадёт, все закоммиченые данные сохранятся.
  • Checkpointer — выполняет контрольные точки (checkpoints). Во время checkpoint'а все грязные страницы из памяти записываются на диск, создается точка восстановления.
  • Autovacuum — автоматически запускает VACUUM для удаления мертвых версий строк (по этому подробнее в разделе про VACUUM).
  • Stats Collector — собирает статистику по таблицам, индексам, запросам. Эта информация используется планировщиком для оптимизации.
  • Logger — логирует события сервера в файлы логов.
  • Archiver — архивирует старые WAL файлы для репликации и PITR (Point-In-Time Recovery).

Shared Memory Это область памяти, доступная всем процессам:

  • Shared Buffers — кэш страниц данных и индексов. Если страница нужна, PostgreSQL сначала ищет её здесь. Это главное, что влияет на производительность.
  • WAL Buffers — буфер для WAL записей перед их сбросом на диск.
  • Lock Tables — таблицы блокировок для синхронизации между процессами.

Storage Layer Физическое хранилище:

  • Data Files — сами файлы таблиц и индексов в $PGDATA/base/
  • WAL Files — лог-файлы с записями об изменениях
  • Control Files — метаинформация кластера

Процессы: подробнее

Postmaster Один процесс на весь кластер PostgreSQL. Его PID хранится в файле postmaster.pid в $PGDATA. Когда вы останавливаете PostgreSQL, убивается именно Postmaster, который затем "убирает" за собой все backend процессы.

Зачем Postmaster нужен? Почему просто не запускать backend процессы напрямую? Потому что:

  1. Нужен централизованный управитель для перезапуска упавших процессов
  2. Нужен координатор для фоновых процессов (Checkpointer, WAL Writer и т.д.)
  3. Нужна единая точка аутентификации (pg_hba.conf правила применяются Postmaster'ом)

Backend процессы Каждый backend полностью изолирован от других. Он не знает о других соединениях, не разделяет с ними локальную память. Это означает:

  • Если backend зависнет на долгом запросе, он не будет блокировать другие соединения
  • Если backend упадёт, не упадут другие
  • Но это требует больше памяти (каждому backend нужен свой буфер работы - work_mem и т.д.)

2. Система хранения

Структура каталогов

PGDATA/
├── postgresql.conf          # Главный конфиг (порт, shared_buffers, wal_level и т.д.)
├── pg_hba.conf             # Правила аутентификации (какие IP могут подключаться, как)
├── pg_ident.conf           # Маппирование ОС-пользователей на DB-пользователей
├── base/                   # Каталог с базами данных
│   └── 16384/              # Конкретная БД (16384 - это её OID)
│       ├── 16385           # Таблица (16385 - её OID)
│       ├── 16385_fsm       # Free Space Map для этой таблицы
│       ├── 16385_vm        # Visibility Map для этой таблицы
│       └── 16385.1, 16385.2... # Если таблица > 1GB, разбита на сегменты
├── pg_wal/                 # Write-Ahead Log файлы
├── pg_xact/                # Информация о статусе транзакций
├── global/                 # Глобальные объекты (pg_database, pg_tablespace)
├── pg_multixact/           # Для блокировок (обычно игнорируем)
└── postmaster.pid          # PID главного процесса

Почему всё хранится по OID? Потому что в PostgreSQL всё - таблицы, индексы, типы, функции - это объекты с OID. При создании таблицы ей выдаётся уникальный OID, и её данные хранятся в файле с этим именем.

Физическая структура: Страницы

PostgreSQL хранит данные страницами размером 8KB (это жёстко закодировано при компиляции). Каждая страница имеет структуру:

Страница (8192 bytes)
├── Page Header (24 bytes)
│   ├── pd_lsn (8 bytes) - Log Sequence Number (позиция в WAL)
│   ├── pd_checksum (2 bytes) - контрольная сумма
│   ├── pd_lower (2 bytes) - где заканчивается массив item pointers
│   ├── pd_upper (2 bytes) - где начинаются данные tuple'ов
│   └── ... ещё метаданные
├── Item Pointer Array
│   └── Указатели на каждый tuple в этой странице (4 bytes каждый)
├── Free Space (свободное место между item pointers и tuple данными)
└── Tuple Data (сами данные строк)

Это очень важно понимать. Когда вы выполняете UPDATE, PostgreSQL не изменяет старую строку на месте (в отличие от некоторых других СУБД). Вместо этого, создаётся новая версия строки с новыми значениями, а старая версия помечается как "удалённая" (это часть MVCC).

Tuple Header каждой строки содержит:

t_xmin       // XID (Transaction ID) транзакции, которая создала эту версию
t_xmax       // XID транзакции, которая удалила эту версию (0 если не удалена)
t_ctid       // "Current Tuple ID" - где находится последняя версия этой логической строки
t_infomask   // Флаги (HEAP_XMIN_COMMITTED, HEAP_XMAX_COMMITTED и т.д.)
t_hoff       // Смещение до начала данных (разные типы данных требуют разного выравнивания)

Почему это важно для Java разработчика? Потому что это объясняет, почему UPDATE и DELETE дорогие операции в PostgreSQL:

  • UPDATE создаёт новую версию строки
  • Старая версия остаётся в таблице до VACUUM
  • Если много UPDATE'ов, таблица растёт в размере
  • Индексы должны быть обновлены
  • Нужно больше I/O

OID система

В PostgreSQL практически всё имеет OID (Object Identifier). Это уникальный идентификатор.

-- Получить OID таблицы
SELECT oid, relname FROM pg_class WHERE relname = 'users';
-- Результат: 16385, 'users'

-- Получить OID типа данных
SELECT oid, typname FROM pg_type WHERE typname = 'integer';
-- Результат: 23, 'integer'

Главные системные каталоги:

  • pg_class — таблицы, индексы, последовательности (всё, что имеет relation)
  • pg_attribute — колонки таблиц
  • pg_type — типы данных
  • pg_proc — функции и процедуры
  • pg_namespace — схемы (namespace'ы)
  • pg_index — информация об индексах
  • pg_database — базы данных

Это инструмент отладки. Если хотите узнать размер таблицы:

SELECT pg_total_relation_size(16385);  -- по OID или по имени
-- или
SELECT pg_total_relation_size('users');

3. MVCC (Multi-Version Concurrency Control) и Транзакции

MVCC — это механизм, который позволяет PostgreSQL достичь высокого уровня конкурентности без блокирования читающих запросов пишущими (в большинстве случаев).

Как работает MVCC: основная идея

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

UPDATE users SET email = 'new@example.com' WHERE id = 1;

PostgreSQL не изменяет старую строку. Вместо этого:

  1. Создаётся новая версия строки с новыми значениями
  2. Старая версия помечается как удалённая (в её t_xmax записывается XID текущей транзакции)
  3. Обе версии существуют в таблице одновременно
  4. Какую версию видеть - решается на основе правил видимости

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

-- Текущий XID
SELECT txid_current();
-- Результат: 12345

Видимость Tuple'ов

Каждая транзакция использует Snapshot — снимок состояния БД на момент начала транзакции. Snapshot содержит:

  • xmin — минимальный активный XID на момент начала
  • xmax — максимальный выданный XID + 1
  • xip[] — массив активных XID'ов

На основе Snapshot'а PostgreSQL решает, видима ли строка для текущей транзакции:

Tuple с xmin=100, xmax=105

Транзакция 103 видит эту строку? ДА
  - xmin (100) committed и < 103
  - xmax (105) не в активных XID'ах или не committed

Транзакция 110 видит эту строку? НЕТ
  - xmax (105) committed и < 110
  - Значит, строка уже удалена

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

Transaction ID (XID): более подробно

-- Информация о строке
SELECT xmin, xmax, ctid FROM users WHERE id = 1;
-- xmin=100, xmax=0, ctid=(0,1)
-- Строка была создана транзакцией 100, не удалена, находится в блоке 0 позиция 1
  • xmin=100 — эта строка была создана транзакцией 100
  • xmax=0 — не удалена (0 означает "не удалено")
  • ctid=(0,1) — находится в блоке 0, строка номер 1

Если вы выполните UPDATE этой строки транзакцией 102:

UPDATE users SET email = 'new@example.com' WHERE id = 1;
-- Старая версия: xmin=100, xmax=102, ctid=(0,1)
-- Новая версия: xmin=102, xmax=0, ctid=(0,2)  <- указывает на себя

Это объясняет, почему UPDATE дорогой:

  1. Нужно создать новую запись (I/O)
  2. Обновить индексы (указать на новую версию)
  3. Со временем нужен VACUUM для удаления старой версии

Уровни изоляции

PostgreSQL поддерживает три уровня (на самом деле их больше, но три главные):

READ COMMITTED (по умолчанию)

  • Транзакция видит только данные, закоммиченные до выполнения текущего statement
  • Новый snapshot создаётся для каждого statement
  • Это означает, что разные statement'ы в одной транзакции могут видеть разные версии данных
BEGIN;
SELECT COUNT(*) FROM users;  -- Snapshot A, видим 100 пользователей
-- Другая транзакция добавляет 10 пользователей и коммитит
SELECT COUNT(*) FROM users;  -- Snapshot B, видим 110 пользователей (!)

Это может привести к "phantom read" и "non-repeatable read".

REPEATABLE READ

  • Одна транзакция = один snapshot, созданный в начале
  • Транзакция не видит изменения, сделанные другими транзакциями после её начала
  • Защищает от non-repeatable read
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM users;  -- Snapshot A, видим 100 пользователей
-- Другая транзакция добавляет 10 пользователей и коммитит
SELECT COUNT(*) FROM users;  -- Тот же Snapshot A, видим 100 пользователей (!)

Но может быть "phantom read" — новые строки могут появиться в результате range query'ей.

SERIALIZABLE

  • Самый строгий уровень
  • PostgreSQL гарантирует, что результат выглядит так, как если бы все транзакции выполнялись последовательно
  • Использует SSI (Serializable Snapshot Isolation)
  • Может завершиться с ошибкой serialization_failure если обнаружен конфликт
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Может выкинуть ошибку, если транзакция конфликтует с другой

4. Write-Ahead Logging (WAL)

WAL — это фундамент надёжности (durability) в PostgreSQL. Основная идея: прежде чем записать данные в основную таблицу, записать описание изменения в лог.

Зачем это нужно?

Представьте сценарий:

  1. Вы выполняете UPDATE, который меняет 100 MB данных
  2. 50 MB уже записано на диск
  3. Сервер ПАДАЕТ

Без WAL все 100 MB будут потеряны, и БД будет в несогласованном состоянии.

С WAL:

  1. ВСЕ изменения СНАЧАЛА записываются в маленький последовательный лог (WAL)
  2. ПОТОМ данные пишутся на диск (может быть медленно, с задержкой)
  3. Если сервер упадёт, при восстановлении PostgreSQL переиграет WAL лог и восстановит консистентное состояние

Структура WAL

WAL состоит из файлов размером 16 MB (по умолчанию):

000000010000000000000001  <- Timeline + номер
000000010000000000000002
000000010000000000000003
...

Каждый WAL файл содержит WAL records — описания операций:

WAL Record структура:
├── xl_tot_len  (4 bytes)   // Полная длина записи
├── xl_xid      (4 bytes)   // XID транзакции
├── xl_prev     (8 bytes)   // Указатель на предыдущую запись (для проверки)
├── xl_rmid     (1 byte)    // Resource Manager ID (что менялось: heap, index и т.д.)
├── xl_info     (1 byte)    // Дополнительная информация
└── ... data ...             // Данные самого изменения

Механизм синхронизации

-- Настройки в postgresql.conf:
fsync = on                            -- Вообще писать на диск (ВАЖНО!)
synchronous_commit = on               -- Ждать записи на диск перед ответом
wal_level = replica                   -- Уровень логирования (minimal/replica/logical)

synchronous_commit имеет несколько режимов:

  • on — ждём, пока WAL запишется на диск главного сервера перед тем, как ответить клиенту (медленно, но надёжно)
  • local — ждём записи в ОС буфер (быстрее, но чуть менее безопасно)
  • off — не ждём вообще (быстро, но может потерять последние несколько транзакций при краше)

Checkpoint

Checkpoint — это операция, при которой все грязные страницы из shared buffers записываются на диск. Это важно для восстановления, потому что:

  1. После checkpoint'а все данные, которые были до него, гарантированно на диске
  2. При восстановлении нужно переиграть только WAL после последнего checkpoint'а
  3. Это ускоряет восстановление
-- Ручной checkpoint
CHECKPOINT;

-- Мониторинг
SELECT * FROM pg_stat_bgwriter;
-- checkpoints_timed — плановые checkpoint'ы
-- checkpoints_req — внеплановые (когда WAL вырос слишком большой)

Настройки:

checkpoint_timeout = 5min              -- Максимум времени между checkpoint'ами
max_wal_size = 1GB                    -- Если WAL вырос больше - принудительный checkpoint
checkpoint_completion_target = 0.5    -- На сколько процентов во время checkpoint'а писать

5. Система индексов

Индексы ускоряют поиск, но замедляют INSERT/UPDATE (потому что нужно обновлять индекс). Различные типы индексов для разных задач.

B-tree (по умолчанию)

Структура:

        Корневая страница (средние значения)
        /                    \
    [Левое поддерево]    [Правое поддерево]
    /          \         /          \
Листья    Листья    Листья    Листья
(данные)  (данные)  (данные)  (данные)

Каждый узел B-tree содержит:

  • Ключи (значения для индексирования)
  • Указатели на листья (для листовых узлов) — TID'ы (tuple identifiers, где найти данные)
  • Указатели на дочерние узлы (для промежуточных узлов)

Поиск работает так:

  1. Начинаем с корня
  2. Выбираем диапазон, в который попадает наш ключ
  3. Спускаемся к дочернему узлу
  4. Повторяем, пока не достигнем листа
  5. На листе находим TID и по нему получаем данные

Это очень эффективно: O(log n) операций вместо O(n) при полном сканировании.

Когда использовать:

  • Равенство: WHERE email = 'john@example.com'
  • Диапазоны: WHERE age BETWEEN 25 AND 35
  • ORDER BY (индекс уже отсортирован)
-- Информация об индексе
SELECT * FROM pg_stat_user_indexes WHERE indexrelname = 'idx_users_email';
-- idx_scan — сколько раз использовался индекс
-- idx_tup_read — сколько кортежей прочитано через индекс

-- Размер индекса
SELECT pg_size_pretty(pg_relation_size('idx_users_email'));

GIN (Generalized Inverted Index)

Инвертированный индекс — вместо "ключ → позиция" хранит "значение → список ключей".

Используется для:

  • Массивы: WHERE tags @> ARRAY['postgres']
  • Полнотекстовый поиск: WHERE to_tsvector(content) @@ plainto_tsquery('postgres')
-- Для массивов
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    tags TEXT[]
);
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);

-- Поиск
SELECT * FROM posts WHERE tags @> ARRAY['database'];

GIN отлично подходит, когда много значений в одной колонке (как массив или JSON).

GiST (Generalized Search Tree)

Для многомерных данных и геометрических операций.

Используется для:

  • Геометрия: точки, линии, многоугольники
  • PostGIS (геоинформационные системы)
  • Диапазоны
-- Для геометрии (требует PostGIS расширение)
CREATE INDEX idx_locations_point ON locations USING GiST (point);
SELECT * FROM locations WHERE point <@ box '((0,0),(100,100))';

BRIN (Block Range Index)

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

Когда использовать:

  • Таблицы > 100GB с отсортированными данными (дата, ID)
  • Когда размер индекса критичен
-- Для таблицы логов с временными метками
CREATE TABLE logs (
    id BIGSERIAL PRIMARY KEY,
    timestamp TIMESTAMPTZ,
    message TEXT
);
CREATE INDEX idx_logs_timestamp ON logs USING BRIN (timestamp);

-- Поиск за последний день
SELECT * FROM logs WHERE timestamp > NOW() - INTERVAL '1 day';

Hash индексы

Для простого равенства. Редко используются (обычно B-tree быстрее).

CREATE INDEX idx_users_id ON users USING HASH (id);
-- Работает только для ==, не для <, >, диапазонов
SELECT * FROM users WHERE id = 123;

6. Планировщик запросов

Это мозг PostgreSQL. Планировщик решает, как лучше всего выполнить запрос.

Этапы обработки запроса

SQL запрос
    ↓
Parser (парсинг)
    ↓
Analyzer (семантический анализ, проверка таблиц/колонок)
    ↓
Rewriter (применение правил, VIEW'ов)
    ↓
Planner (оптимизация, выбор best plan)
    ↓
Executor (исполнение плана)
    ↓
Результат

Статистика: сердце планировщика

Планировщик полагается на статистику, чтобы выбрать хороший план. Статистику собирает Autovacuum процесс (или вы вручную).

-- Обновление статистики
ANALYZE users;

-- Просмотр статистики
SELECT * FROM pg_stats WHERE tablename = 'users';
-- n_distinct — сколько уникальных значений
-- correlation — корреляция между логическим порядком и физическим
-- most_common_vals — самые частые значения

Если статистика устаревшая, планировщик может выбрать плохой план!

-- Увеличить детальность статистики для важной колонки
ALTER TABLE users ALTER COLUMN email SET STATISTICS 1000;
ANALYZE users;

Типы сканирования

Sequential Scan Полный перебор всех строк в таблице. Медленно для больших таблиц, но единственный вариант для некоторых условий (WHERE contains unusual logic).

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE age > 30;
-- Seq Scan on users  (cost=0.00..35.50 rows=500 width=32)

Index Scan Использует индекс для быстрого поиска. Работает, если условие точно соответствует индексу.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE email = 'john@example.com';
-- Index Scan using idx_users_email on users  (cost=0.29..8.30 rows=1)

Bitmap Scan Комбинирует несколько индексов или когда диапазон не очень узкий. Индекс создаёт битовую карту кандидатов, потом хип сканирует по этой карте.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE age BETWEEN 25 AND 35;
-- Bitmap Heap Scan on users  (cost=50.00..100.00 rows=1000)
--   Bitmap Index Scan using idx_users_age  (cost=0.00..50.00)

Типы JOIN

Nested Loop Для каждой строки левой таблицы, скан правой таблицы. Медленно, но работает всегда.

FOR EACH row in left_table:
    FOR EACH row in right_table:
        IF match:
            emit result

Когда работает: когда правая таблица маленькая или есть индекс на условие JOIN'а.

Hash Join Создаёт хеш-таблицу из меньшей таблицы, потом для каждой строки большей таблицы смотрит в хеш. Очень быстро, если хеш-таблица влезает в memory.

-- Для этого нужно достаточно work_mem
work_mem = 256MB

Merge Join Обе таблицы должны быть отсортированы по условию JOIN'а. Дорого если сортировать, но если данные уже отсортированы (например, по индексу), это очень эффективно.

Как читать EXPLAIN

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM users WHERE id = 1;

-- Query Plan:

-- Seq Scan on users (cost=0.00..35.50 rows=1 width=100)
--   Filter: (id = 1)
--   Planning Time: 0.123 ms
--   Execution Time: 0.456 ms
--   Buffers: shared hit=5 read=2
  • cost=0.00..35.50 — оценённая стоимость (начальная..конечная). Меньше = лучше.
  • rows=1 — оценённое количество строк. Если реальное кол-во сильно отличается, статистика нужно обновить.
  • width=100 — средняя ширина строки в байтах
  • ANALYZE — реально выполнить запрос и показать actual values
  • BUFFERS — показать, сколько блоков прочитано из кэша vs диска

7. Система памяти

PostgreSQL разделяет память на shared (общую для всех процессов) и local (для каждого процесса).

Shared Memory (1-го процесса на инстанс)

Shared Buffers Кэш страниц данных и индексов. Это главная настройка для производительности.

shared_buffers = 2GB              -- Рекомендация: 25% RAM

Как это работает:

  1. Backend хочет прочитать страницу
  2. Проверяет shared buffers (быстро, в памяти)
  3. Если нет — читает с диска (медленно)
  4. Помещает страницу в shared buffers для будущего использования
-- Мониторинг
SELECT * FROM pg_buffercache_summary();
-- buffers_used — сколько буферов используется
-- buffers_unused — свободные

Слишком маленький shared_buffers = много disk I/O, медленно. Слишком большой shared_buffers = ОС не может кэшировать, может быть медленнее.

WAL Buffers Буфер для WAL записей перед сбросом на диск.

wal_buffers = 16MB                -- Обычно по умолчанию нормально

Process Memory (отдельно для каждого backend процесса)

work_mem Память для операций сортировки и хеша (ORDER BY, GROUP BY, JOIN).

work_mem = 4MB                        -- На одну операцию!
-- Если в памяти не влезает, PostgreSQL сортирует "блоками" на диске

Критично: это не максимум на процесс, а максимум на одну операцию. Если в запросе 10 операций GROUP BY, может быть 10 * work_mem.

-- Увеличить для конкретной сессии
SET work_mem = '256MB';

-- Выполнить запрос
SELECT column1, COUNT(*) FROM huge_table GROUP BY column1;

-- Вернуть обратно
RESET work_mem;

maintenance_work_mem Память для VACUUM, CREATE INDEX, ALTER TABLE и т.д.

maintenance_work_mem = 256MB          -- Обычно больше, чем work_mem

effective_cache_size ЭТО НЕ ВЫДЕЛЯЕТ ПАМЯТЬ! Это подсказка планировщику о том, сколько памяти доступно для кэша. Помогает планировщику выбрать между Sequential Scan и Index Scan.

effective_cache_size = 6GB            -- Рекомендация: 75% RAM

Если вы скажете планировщику, что available cache 1GB, а на самом деле 10GB, планировщик будет выбирать Sequential Scan когда можно использовать индекс.

8. VACUUM и AUTOVACUUM: очистка мёртвых версий

Одна из самых важных операций в PostgreSQL. Без VACUUM таблица растёт, старые версии не удаляются, производительность деградирует.

Почему нужен VACUUM?

Когда вы выполняете UPDATE, в PostgreSQL (из-за MVCC):

  1. Старая версия помечается как удалённая (xmax=текущий XID)
  2. Создаётся новая версия
  3. Старая версия остаётся в таблице!

Со временем таблица заполняется мёртвыми версиями. VACUUM удаляет их.

-- Таблица до VACUUM
SELECT COUNT(*) FROM users;
-- 1000 живых строк, но физически в таблице 5000 версий!

VACUUM users;

-- Таблица после VACUUM
-- Те же 1000 живых строк, мёртвые версии удалены

Типы VACUUM

Обычный VACUUM Удаляет мёртвые версии, но не меняет размер файла таблицы.

VACUUM users;                  -- Обычный, может выполняться параллельно с запросами
VACUUM ANALYZE users;         -- VACUUM + обновление статистики
VACUUM (VERBOSE) users;       -- С подробным логированием

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

VACUUM FULL Перестраивает таблицу, освобождая место. Очень медленно, полностью блокирует таблицу.

VACUUM FULL users;            -- ОПАСНО! Заблокирует таблицу на долгое время

Когда использовать: когда таблица серьёзно раздулась и нужно освободить место. НО это опасно для production систем.

Free Space Map

PostgreSQL отслеживает свободное место в каждой странице. Это хранится в файле _fsm.

-- Посмотреть свободное место в таблице
SELECT *, round(avail*100.0/8192, 2) as free_percent
FROM pg_freespace('users')
LIMIT 5;

-- blkno — номер блока (страницы)
-- avail — свободных байтов в блоке
-- free_percent — процент свободного места

Если много блоков с free_percent = 0, нужен VACUUM FULL.

Visibility Map

Отслеживает, какие страницы содержат только "видимые" (живые) версии. Используется для оптимизации "Index-Only Scan".

SELECT blkno, all_visible, all_frozen
FROM pg_visibility_map('users')
LIMIT 5;

-- blkno — номер блока
-- all_visible — все версии на этом блоке видимы всем транзакциям?
-- all_frozen — все версии заморожены (очень старые)?

AUTOVACUUM: автоматическая очистка

Autovacuum процесс автоматически запускает VACUUM когда нужно.

-- Включить/выключить
autovacuum = on                       -- По умолчанию включён

-- Сколько worker процессов
autovacuum_max_workers = 3

-- Как часто проверять
autovacuum_naptime = 1min

-- Пороги для запуска VACUUM
autovacuum_vacuum_threshold = 50             -- Абсолютно мёртвых версий
autovacuum_vacuum_scale_factor = 0.2         -- Или 20% от размера таблицы

Формула: VACUUM запустится если n_dead_tup > threshold + scale_factor * n_live_tup.

-- Настроить для конкретной таблицы (например, часто обновляемой)
ALTER TABLE active_logs SET (
    autovacuum_vacuum_threshold = 1000,      -- Больше допуска
    autovacuum_vacuum_scale_factor = 0.1,    -- 10% вместо 20%
    autovacuum_vacuum_cost_delay = 1         -- Быстрее (мм по умолчанию 2)
);

Мониторинг VACUUM

-- Текущий процесс VACUUM
SELECT * FROM pg_stat_progress_vacuum;

-- История по таблицам
SELECT schemaname, tablename,
       last_vacuum, last_autovacuum,
       vacuum_count, autovacuum_count,
       n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC;

-- Если n_dead_tup растёт, autovacuum не справляется
-- Нужно или увеличить aggressiveness или добавить workers

9. Мониторинг и диагностика

Умение мониторить PostgreSQL — это критичный скилл для production систем.

Активные запросы и блокировки

-- Что сейчас выполняется?
SELECT pid, usename, state, query_start,
       NOW() - query_start AS duration,
       query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

-- Если duration часы, что-то не так. Может быть:

-- - Плохой query план
-- - Блокировка
-- - Просто долгая операция
-- Блокировки: что блокирует что?
SELECT
    blocked_locks.pid AS blocked_pid,
    blocked_activity.query AS blocked_query,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
    JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
    JOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
        AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
        AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
        AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
        AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
        AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
        AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
        AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
        AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
        AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
        AND blocking_locks.pid != blocked_locks.pid
    JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

Это покажет, какой процесс блокирует какой.

Размеры объектов

-- Размер всей БД
SELECT pg_size_pretty(pg_database_size(current_database()));

-- Размер таблицы (с индексами)
SELECT pg_size_pretty(pg_total_relation_size('users'));

-- Только сама таблица (без индексов)
SELECT pg_size_pretty(pg_relation_size('users'));

-- Топ 10 самых больших таблиц
SELECT schemaname, tablename,
       pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 10;

Если таблица слишком большая, может потребоваться:

  • Партиционирование
  • Архивирование старых данных
  • Оптимизация схемы

Производительность запросов: pg_stat_statements

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

-- Включить
CREATE EXTENSION pg_stat_statements;

-- Самые долгие запросы
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 10;

-- Очистить статистику
SELECT pg_stat_statements_reset();

Это золотая жила информации для оптимизации. Видите, что какой-то запрос потребляет 50% CPU? Надо оптимизировать.

Deadlocks и конфликты

-- Сколько было deadlock'ов?
SELECT datname, deadlocks FROM pg_stat_database;

-- Если deadlocks > 0, нужно анализировать, почему они происходят
-- Обычно — плохой порядок блокировок в приложении

На production нужно логировать deadlock'и:

log_lock_waits = on
log_statement = 'all'  -- ИЛИ 'ddl' для только DDL

Практические рекомендации для интервью

  1. Помните про MVCC — это главное отличие PostgreSQL. Update не меняет данные на месте.

  2. Индексы всегда имеют цену — они ускоряют SELECT, но замедляют INSERT/UPDATE. Нужно баланс.

  3. Статистика критична — если ANALYZE не запускался, планировщик выбирает плохие планы.

  4. VACUUM нужен — без него таблица разбухает, Performance падает.

  5. Shared buffers > RAM CPU — одна из главных настроек для Performance.

  6. EXPLAIN ANALYZE — ваш друг — если запрос медленный, смотрите EXPLAIN. Это покажет план и actual execution.

  7. Блокировки часто — проблема в приложении — обычно это плохой порядок UPDATE'ов или долгие транзакции.

  8. Уровни изоляции — READ COMMITTED может привести к phantom reads. REPEATABLE READ безопаснее. SERIALIZABLE может откатиться.

Оптимизация

1. Анализ производительности

EXPLAIN команда и план исполнения

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

EXPLAIN SELECT * FROM users WHERE age > 30;

EXPLAIN ANALYZE выполняет запрос и собирает реальную статистику:

EXPLAIN (ANALYZE, BUFFERS, TIMING true, FORMAT JSON)
SELECT * FROM users u 
JOIN orders o ON u.id = o.user_id 
WHERE o.total > 1000;

Параметры EXPLAIN:

  • ANALYZE - выполнить запрос и показать реальные метрики против оценок планировщика
  • BUFFERS - показать использование памяти (shared_buffers, временные файлы на диске)
  • TIMING - время выполнения каждого узла (может замедлить сам запрос на 1-10%)
  • VERBOSE - дополнительная информация о полях и фильтрах
  • FORMAT - TEXT (по умолчанию), XML, JSON, YAML

Чтение плана исполнения:

Nested Loop (cost=0.29..856.44 rows=100 width=68) (actual time=0.123..5.234 rows=95 loops=1)
│ cost=0.29..856.44      -- Оценка: startup_cost..total_cost (в условных единицах)
│ rows=100               -- Ожидаемое количество строк
│ actual time=0.123..5.234 -- Реальное: первая строка..последняя строка (миллисекунды)
│ actual rows=95         -- Фактическое количество строк
│ loops=1                -- Количество итераций узла

Buffers: shared hit=234 read=45 dirtied=2 written=1
│ hit=234    -- Найдено в shared_buffers (горячий кеш, быстро)
│ read=45    -- Прочитано с диска (медленно)
│ dirtied=2  -- Модифицировано в памяти (не писалось на диск)
│ written=1  -- Написано на диск

Диагностика проблем в плане:

  • Seq Scan вместо Index Scan - отсутствует индекс или неправильная selectivity
  • Filter после Index Scan - индекс не использует все условия WHERE
  • actual rows >> rows - планировщик недооценил размер результата
  • actual rows << rows - планировщик переоценил размер результата

pg_stat_statements для профилирования

Расширение отслеживает все выполненные запросы и собирает статистику:

CREATE EXTENSION pg_stat_statements;

-- Топ медленных запросов по абсолютному времени
SELECT 
    query,
    calls,
    total_exec_time::numeric(10,2) as total_ms,
    mean_exec_time::numeric(10,2) as avg_ms,
    max_exec_time::numeric(10,2) as max_ms,
    rows
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 10;

-- Топ запросов по среднему времени (частые, но медленные)
SELECT 
    query,
    calls,
    mean_exec_time::numeric(10,2) as avg_ms,
    stddev_exec_time::numeric(10,2) as stddev_ms,
    (SELECT count(*) FROM pg_stat_statements) as total_queries
FROM pg_stat_statements
WHERE calls > 100
ORDER BY mean_exec_time DESC
LIMIT 10;

-- Сброс статистики (начать отсчет заново)
SELECT pg_stat_statements_reset();

Мониторинг активности и блокировок

-- Активные долгие запросы
SELECT 
    pid,
    usename,
    application_name,
    state,
    query_start,
    now() - query_start as duration,
    query
FROM pg_stat_activity
WHERE state = 'active' 
  AND now() - query_start > interval '5 seconds'
ORDER BY query_start;

-- Заблокированные запросы и блокировщики
SELECT 
    blocked_locks.pid AS blocked_pid,
    blocked_activity.usename AS blocked_user,
    blocked_activity.query AS blocked_statement,
    blocking_locks.pid AS blocking_pid,
    blocking_activity.usename AS blocking_user,
    blocking_activity.query AS blocking_statement,
    blocked_activity.application_name
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
    AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
    AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
    AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
    AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
    AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
    AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
    AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
    AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
    AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
    AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

-- Информация о текущих открытых транзакциях
SELECT 
    pid,
    usename,
    xact_start,
    now() - xact_start as transaction_duration,
    state_change,
    state,
    query_start,
    query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

2. Оптимизация индексов

Типы индексов и их применение

B-tree (по умолчанию) - универсальный индекс для большинства случаев:

-- Одноколонный индекс
CREATE INDEX idx_users_email ON users (email);

-- Составной индекс для фильтрации и сортировки
CREATE INDEX idx_users_status_created ON users (status, created_at);

-- Включение дополнительных колонок (covering index, PostgreSQL 11+)
-- Используется Index Only Scan - не нужно обращаться к основной таблице
CREATE INDEX idx_users_email_covering ON users (email) 
INCLUDE (name, created_at, phone);

Partial Index - индекс только для части данных:

-- Часто фильтруем по статусу 'active', имеет смысл индексировать только активные записи
CREATE INDEX idx_users_active_email ON users (email) 
WHERE status = 'active';

-- Экономит место, ускоряет поиск на часто используемых условиях
-- Обычно меньше на 50-90% чем полный индекс

Функциональный индекс - индекс по выражению:

-- Поиск по email регистронезависимо
CREATE INDEX idx_users_lower_email ON users (lower(email));

-- Теперь этот запрос использует индекс:
SELECT * FROM users WHERE lower(email) = 'john@example.com';

-- Без индекса пришлось бы сканировать всю таблицу

GIN (Generalized Inverted Index) - для полнотекстового поиска и массивов:

-- Полнотекстовый поиск
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

-- Поиск в JSON данных
CREATE INDEX idx_users_tags ON users USING GIN (tags);

-- Поиск - будет быстрым
SELECT * FROM users WHERE tags @> ARRAY['admin', 'moderator'];

GiST (Generalized Search Tree) - для пространственных данных и диапазонов:

-- Поиск по координатам (геолокация)
CREATE INDEX idx_locations_point ON locations USING GiST (point);

-- Поиск в диапазоне дат (tsrange)
CREATE INDEX idx_events_when ON events USING GiST (when);

BRIN (Block Range INdex) - компактный индекс для больших отсортированных данных:

-- Для логов и временных рядов (данные отсортированы по timestamp)
CREATE INDEX idx_logs_timestamp ON logs USING BRIN (timestamp);
CREATE INDEX idx_metrics_created ON metrics USING BRIN (created_at);

-- Очень компактный, но требует данных отсортированных по индексируемой колонке

Анализ и оптимизация использования индексов

-- Статистика по индексам
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch,
    pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

-- Неиспользуемые индексы (кандидаты на удаление)
SELECT 
    schemaname,
    tablename,
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) as size,
    idx_scan,
    idx_tup_read
FROM pg_stat_user_indexes
WHERE idx_scan = 0 
  AND schemaname != 'pg_catalog'
ORDER BY pg_relation_size(indexrelid) DESC;

-- Дублирующиеся индексы
SELECT 
    pg_size_pretty(SUM(pg_relation_size(idx))) as total_size,
    (array_agg(idx))[1] as idx1,
    (array_agg(idx))[2] as idx2
FROM (
    SELECT 
        indexrelname::text as idx,
        (indrelid::text ||E'\n'|| indclass::text ||E'\n'|| 
         indkey::text ||E'\n'|| COALESCE(indexprs::text,'')||E'\n' ||
         COALESCE(indpred::text,'')) AS key
    FROM pg_index
) sub
GROUP BY key HAVING COUNT(*) > 1;

-- Размер всех индексов по таблице
SELECT 
    tablename,
    SUM(pg_relation_size(indexrelid))::bigint as total_index_size,
    COUNT(*) as index_count
FROM pg_indexes
JOIN pg_class ON pg_class.relname = tablename
JOIN pg_index ON pg_index.indrelid = pg_class.oid
WHERE schemaname = 'public'
GROUP BY tablename
ORDER BY total_index_size DESC;

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

WHERE условия и selectivity

Правило SARGEABLE: условие в WHERE должно быть "поддерживаемым индексом" (Search ARGument ABLE).

-- ❌ Не использует индекс (функция на колонке)
SELECT * FROM users WHERE UPPER(email) = 'JOHN@EXAMPLE.COM';
SELECT * FROM users WHERE EXTRACT(YEAR FROM created_at) = 2024;

-- ✓ Использует индекс (колонка без функции)
SELECT * FROM users WHERE email = 'john@example.com';
SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

-- ✓ Использует функциональный индекс (если создан)
CREATE INDEX idx_users_upper_email ON users (UPPER(email));
SELECT * FROM users WHERE UPPER(email) = 'JOHN@EXAMPLE.COM';

Selectivity (селективность) индекса:

-- Высокая selectivity - индекс вернет мало строк (хорошо)
SELECT * FROM users WHERE id = 123;           -- selectivity ~0.001%
SELECT * FROM users WHERE email = '...';      -- selectivity ~0.01%

-- Низкая selectivity - индекс вернет много строк (плохо для индекса, но хорошо проверить)
SELECT * FROM users WHERE status = 'active';  -- selectivity ~80% - может быть полезен partial индекс
SELECT * FROM users WHERE is_deleted = false; -- selectivity ~95% - индекс почти бесполезен

-- Планировщик может выбрать Seq Scan вместо Index Scan, если selectivity плохая

LIKE, IN и OR условия

-- LIKE: работает с индексом если нет % в начале
-- ✓ Использует индекс
SELECT * FROM users WHERE email LIKE 'john%';
SELECT * FROM users WHERE email LIKE 'john@example.%';

-- ❌ Не использует индекс (% в начале)
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- Решение: использовать специальные индексы или полнотекстовый поиск

-- IN лучше, чем множество OR
-- ✓ Хорошо оптимизируется
SELECT * FROM orders WHERE status IN ('pending', 'processing', 'shipped');

-- ❌ Медленнее
SELECT * FROM orders WHERE status = 'pending' 
  OR status = 'processing' 
  OR status = 'shipped';

-- UNION вместо OR для разных индексов
-- Если у нас есть индекс на status и на priority, лучше использовать UNION
SELECT * FROM orders WHERE status = 'pending'
UNION ALL
SELECT * FROM orders WHERE priority = 'high' AND status != 'pending';

JOIN оптимизация

Планировщик выбирает лучший алгоритм JOIN:

-- Nested Loop - для маленьких таблиц или когда есть хороший индекс
-- Для каждой строки внешней таблицы ищет строки во внутренней таблице
-- Хорошо когда внутренняя таблица имеет индекс на JOIN колонке

-- Hash Join - когда одна таблица помещается в памяти
-- Создается хеш-таблица из меньшей таблицы, затем ищутся совпадения
-- Быстро, но требует много памяти (параметр work_mem)

-- Merge Join - для больших отсортированных таблиц
-- Обе таблицы отсортированы, затем происходит слияние
-- Требует сортировки, но может быть эффективным

-- Принудительное отключение для тестирования
SET enable_nestloop = off;
SET enable_hashjoin = off;
SET enable_mergejoin = off;

-- Хороший JOIN
SELECT u.id, u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > now() - interval '90 days'
GROUP BY u.id, u.name;

-- Нужна оптимизация - слишком много строк до JOIN
-- Лучше сначала отфильтровать, потом JOINить
SELECT u.id, u.name, COUNT(o.id) as order_count
FROM (
    SELECT id, name 
    FROM users 
    WHERE created_at > now() - interval '90 days'
) u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

Подзапросы vs EXISTS vs JOIN

-- ❌ Медленно: подзапрос для каждой строки (может быть материализован как IN)
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000);

-- ✓ Лучше: EXISTS (ранний выход, не загружает все результаты)
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o 
              WHERE o.user_id = u.id AND o.total > 1000);

-- ✓ Часто лучше: JOIN (позволяет параллелизм и оптимизацию)
SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.total > 1000;

-- Выбор стратегии:

-- - EXISTS для больших таблиц с малым количеством совпадений
-- - JOIN для обычного случая
-- - IN когда подзапрос возвращает мало строк (до 100-1000)

Пагинация и LIMIT/OFFSET

-- ❌ Плохо: OFFSET требует пропустить много строк
-- Для страницы 1000 нужно пропустить 99,000 строк
SELECT * FROM orders 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 99000;
-- Execution time: 850ms

-- ✓ Хорошо: cursor-based пагинация (keyset pagination)
-- Помнит последний ID/значение, находит следующие
SELECT * FROM orders 
WHERE created_at < '2024-11-10 15:30:00'  -- Фильтруем по известной позиции
ORDER BY created_at DESC 
LIMIT 21;  -- Берем на 1 больше для определения next_cursor
-- Execution time: 5ms

-- Или с ID для еще лучшей производительности
SELECT * FROM orders 
WHERE id < 15842  -- Последний ID с предыдущей страницы
ORDER BY id DESC 
LIMIT 21;
-- Execution time: 1ms

CTE (Common Table Expressions) и оптимизация

-- CTE может улучшить читаемость, но планировщик материализует результаты
-- Это может быть медленнее, чем один большой запрос

-- ❌ Может быть медленнее
WITH expensive_calculation AS (
    SELECT id, complex_calculation() as result
    FROM large_table
)
SELECT * FROM expensive_calculation
WHERE result > 100;

-- ✓ Может быть быстрее (использует фильтр до вычисления)
SELECT id, complex_calculation() as result
FROM large_table
WHERE complex_calculation() > 100;

-- Совет: используйте CTE для улучшения читаемости, 
-- но проверяйте план исполнения чтобы убедиться в эффективности

4. Настройка параметров PostgreSQL

Память и буферизация

-- shared_buffers: кеш для блоков таблиц и индексов
-- Рекомендация: 25% от общей оперативной памяти, но не более 40GB
-- На 32GB RAM: 8GB
shared_buffers = 8GB

-- work_mem: память для операций сортировки и хеширования на сессию
-- Рекомендация: total_memory / (max_connections * 2)
-- На 32GB с 200 соединениями: 32GB / (200 * 2) = ~80MB, но минимум 4MB
work_mem = 80MB

-- maintenance_work_mem: память для VACUUM, CREATE INDEX, ALTER TABLE
-- Рекомендация: 10% от RAM или до 2GB
maintenance_work_mem = 2GB

-- effective_cache_size: подсказка планировщику о доступном кеше (OS cache + shared_buffers)
-- Рекомендация: 75% от RAM (общая подсказка, не выделяется реально)
-- На 32GB: 24GB
effective_cache_size = 24GB

-- wal_buffers: буфер для Write-Ahead Log перед записью на диск
-- Рекомендация: 16MB для большинства случаев
wal_buffers = 16MB

-- Установка на сессию (пример для особо требовательной операции)
SET work_mem = '1GB';
SET maintenance_work_mem = '4GB';
SELECT * FROM complex_query_requiring_lots_of_memory();

Checkpoint и WAL

-- checkpoint_timeout: как часто делать checkpoint (сохранять состояние)
-- Слишком часто = много IO, слишком редко = долгое восстановление после краша
checkpoint_timeout = 15min

-- checkpoint_completion_target: желаемая часть интервала для завершения checkpoint
-- 0.9 = checkpoint должен закончиться за 90% времени интервала
checkpoint_completion_target = 0.9

-- max_wal_size: максимальный размер WAL перед принудительным checkpoint
# На 32GB: 4GB (даст время на восстановление, но не съест всю память)
max_wal_size = 4GB

-- min_wal_size: минимальный размер WAL перед его переработкой
min_wal_size = 1GB

-- synchronous_commit: когда считать транзакцию закоммиченной
-- on: медленнее, но гарантирует сохранение на диск
-- remote_apply: реплика получила данные
-- local: данные в wal_buffers (еще на диске сервера)
-- off: асинхронно (быстро, но может потеряться при краше)
synchronous_commit = on

-- Мониторинг checkpoint активности
SELECT * FROM pg_stat_bgwriter;
-- checkpoints_timed: количество плановых checkpoints
-- checkpoints_req: количество принудительных checkpoints (когда max_wal_size достигнут)
-- buffers_checkpoint: количество буферов записано при checkpoint

Планировщик и оптимизация

-- seq_page_cost: стоимость чтения одной страницы последовательно
# Для SSD может быть меньше (1.0 вместо 4.0)
seq_page_cost = 1.0

-- random_page_cost: стоимость чтения случайной страницы
-- Для SSD: 1.1 (почти как последовательное на SSD)
-- Для HDD: 4.0 (очень медленно)
random_page_cost = 1.1

-- cpu_tuple_cost: стоимость обработки одной строки
cpu_tuple_cost = 0.01

-- cpu_index_tuple_cost: стоимость обработки одной строки в индексе
cpu_index_tuple_cost = 0.005

-- cpu_operator_cost: стоимость операции (сравнение, математика)
cpu_operator_cost = 0.0025

-- default_statistics_target: сколько образцов брать для ANALYZE
-- Больше = точнее оценки, но ANALYZE медленнее
-- 100 по умолчанию, может быть до 10000 для важных таблиц
default_statistics_target = 100

-- Для отдельной таблицы
ALTER TABLE important_table SET (n_distinct_inherited = 10000);

Параллелизм

-- max_parallel_workers: максимум рабочих процессов глобально
# На 8-ядерном сервере: 8
max_parallel_workers = 8

-- max_parallel_workers_per_gather: рабочих на одну операцию Gather
-- Рекомендация: половина от max_parallel_workers
max_parallel_workers_per_gather = 4

-- max_parallel_maintenance_workers: рабочих для VACUUM, CREATE INDEX
max_parallel_maintenance_workers = 4

-- min_parallel_table_scan_size: минимальный размер таблицы для параллелизма
-- Слишком маленькие таблицы не параллелятся (не стоит овердхед)
min_parallel_table_scan_size = 8MB

-- parallel_tuple_cost: стоимость обработки одной строки в параллельном режиме
parallel_tuple_cost = 0.1

-- parallel_setup_cost: овердхед запуска параллельных рабочих
parallel_setup_cost = 500.0

5. VACUUM и управление мусором

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

VACUUM удаляет "мертвые" строки, оставленные UPDATE и DELETE. PostgreSQL использует MVCC (Multi-Version Concurrency Control), поэтому старые версии строк не удаляются немедленно.

-- VACUUM (не блокирует чтение)
VACUUM users;

-- VACUUM ANALYZE (повторно собрать статистику)
VACUUM ANALYZE users;

-- VACUUM FULL (полная перестройка, блокирует все операции)
-- Используется редко, может быть медленным
VACUUM FULL users;

-- Асинхронный VACUUM (не блокирует, но медленнее)
VACUUM ANALYZE VERBOSE users;

Настройка Autovacuum

-- Включить автоматический VACUUM (по умолчанию включен)
autovacuum = on

-- Максимум рабочих процессов autovacuum
autovacuum_max_workers = 6

-- Как часто проверять нужен ли vacuum (в секундах)
autovacuum_naptime = 15s

-- Запустить VACUUM если삭제/обновлено более 50 строк
autovacuum_vacuum_threshold = 50

-- ИЛИ если обновлено более 10% таблицы
autovacuum_vacuum_scale_factor = 0.1

-- Стоимость операции VACUUM (чтобы не убить производительность)
autovacuum_vacuum_cost_limit = 2000
autovacuum_vacuum_cost_delay = 10ms

-- Аналогично для ANALYZE
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.05

Настройка VACUUM для конкретных таблиц

-- Таблица часто изменяется (много UPDATE/DELETE), нужно чаще чистить
ALTER TABLE active_logs SET (
    autovacuum_vacuum_threshold = 10,
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_vacuum_cost_delay = 5
);

-- Таблица только добавления (append-only), редко меняется
ALTER TABLE audit_logs SET (
    autovacuum_vacuum_threshold = 1000,
    autovacuum_vacuum_scale_factor = 0.5,
    autovacuum_analyze_scale_factor = 0.1
);

Мониторинг Bloat (вздутие таблиц)

-- Bloat в таблицах (мертвые строки)
SELECT 
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as total_size,
    n_live_tup,
    n_dead_tup,
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) as dead_percent,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000 OR (n_live_tup + n_dead_tup > 0 AND 
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) > 10)
ORDER BY n_dead_tup DESC;

-- Bloat в индексах
SELECT 
    schemaname,
    tablename,
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
    idx_scan
FROM pg_stat_user_indexes
WHERE pg_relation_size(indexrelid) > 100 * 1024 * 1024
ORDER BY pg_relation_size(indexrelid) DESC;

-- Прогресс текущей операции VACUUM
SELECT 
    pid,
    datname,
    relname,
    phase,
    heap_blks_scanned,
    heap_blks_total,
    ROUND(100.0 * heap_blks_scanned / NULLIF(heap_blks_total, 0), 1) as percent_complete
FROM pg_stat_progress_vacuum;

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

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

-- Range партиционирование (по диапазонам значений)
CREATE TABLE orders (
    id SERIAL,
    customer_id INTEGER,
    order_date DATE,
    amount DECIMAL(10,2)
) PARTITION BY RANGE (order_date);

CREATE TABLE orders_2024_q1 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_2024_q2 PARTITION OF orders
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

-- List партиционирование (по списку значений)
CREATE TABLE sales (
    id SERIAL,
    country VARCHAR,
    amount NUMERIC
) PARTITION BY LIST (country);

CREATE TABLE sales_eu PARTITION OF sales
    FOR VALUES IN ('DE', 'FR', 'IT', 'ES');
CREATE TABLE sales_us PARTITION OF sales
    FOR VALUES IN ('US', 'CA', 'MX');

-- Hash партиционирование (равномерное распределение)
CREATE TABLE metrics (
    id SERIAL,
    server_id INTEGER,
    value NUMERIC,
    ts TIMESTAMP
) PARTITION BY HASH (server_id);

CREATE TABLE metrics_0 PARTITION OF metrics FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE metrics_1 PARTITION OF metrics FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE metrics_2 PARTITION OF metrics FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE metrics_3 PARTITION OF metrics FOR VALUES WITH (MODULUS 4, REMAINDER 3);

-- Partition Pruning: автоматическое исключение ненужных партиций из плана
-- Работает только когда условие в WHERE может быть вычислено на этапе планирования
EXPLAIN SELECT * FROM orders WHERE order_date = '2024-05-15';
-- Покажет, что сканируется только orders_2024_q2

-- Для эффективного pruning используйте констант или параметры:

-- ✓ Хорошо
SELECT * FROM orders WHERE order_date = '2024-05-15';
SELECT * FROM orders WHERE order_date >= '2024-05-01' AND order_date < '2024-06-01';

-- ❌ Не работает pruning (функция)
SELECT * FROM orders WHERE order_date = CURRENT_DATE;

-- ✓ Работает если явно кастировать
SELECT * FROM orders WHERE order_date = CURRENT_DATE::date;

7. Connection Pooling

-- Параметры подключения
max_connections = 200              -- Максимум соединений в PostgreSQL
superuser_reserved_connections = 3 -- Зарезервировано для администратора

-- Таймауты
statement_timeout = 300s           -- Максимум на один запрос (5 минут)
idle_in_transaction_session_timeout = 60s  -- Максимум неиспользуемой транзакции

-- Для Connection Pooling используйте PgBouncer или pgpool-II
-- PgBouncer конфиг (/etc/pgbouncer/pgbouncer.ini):
# [databases]
# mydb = host=localhost port=5432 dbname=mydb
#
# [pgbouncer]
# pool_mode = transaction
# max_client_conn = 1000
# default_pool_size = 25
# min_pool_size = 10
# reserve_pool_size = 5
# reserve_pool_timeout = 3

8. Материализованные представления (Materialized Views)

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

-- Создание материализованного представления
CREATE MATERIALIZED VIEW user_order_summary AS
SELECT 
    u.id,
    u.name,
    COUNT(o.id) as total_orders,
    SUM(o.amount) as total_spent,
    AVG(o.amount) as avg_order
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

-- Индекс для быстрого поиска
CREATE INDEX idx_user_order_summary_id ON user_order_summary (id);

-- Использование
SELECT * FROM user_order_summary WHERE id = 123;

-- Обновление (блокирует читающие запросы)
REFRESH MATERIALIZED VIEW user_order_summary;

-- Обновление без блокировки (PostgreSQL 9.5+)
REFRESH MATERIALIZED VIEW CONCURRENTLY user_order_summary;

-- Регулярное обновление через pg_cron расширение
SELECT cron.schedule('refresh_user_order_summary', '0 2 * * *', 
    'REFRESH MATERIALIZED VIEW CONCURRENTLY user_order_summary');

9. Денормализация данных

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

-- Вместо JOIN каждый раз:
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;

-- Хранить денормализованно в отдельной таблице:
CREATE TABLE user_summary (
    user_id INTEGER PRIMARY KEY,
    name VARCHAR,
    order_count INTEGER,
    total_spent NUMERIC,
    last_updated TIMESTAMP
);

-- Обновлять через триггер или batch job
UPDATE user_summary SET 
    order_count = (SELECT COUNT(*) FROM orders WHERE user_id = user_id),
    total_spent = (SELECT SUM(amount) FROM orders WHERE user_id = user_id),
    last_updated = now()
WHERE user_id = NEW.user_id;

10. Подготовленные запросы (Prepared Statements)

-- Prepared statements предотвращают SQL injection и кешируют план
-- Приложение (например, Python):

import psycopg2

conn = psycopg2.connect("dbname=mydb user=postgres")
cur = conn.cursor()

# Подготовить запрос
cur.execute("""
    PREPARE get_user (INTEGER) AS
    SELECT * FROM users WHERE id = $1;
""")

# Выполнить несколько раз (план переиспользуется)
cur.execute("EXECUTE get_user (%s)", (123,))
user1 = cur.fetchone()

cur.execute("EXECUTE get_user (%s)", (456,))
user2 = cur.fetchone()

# Мониторинг prepared statements
SELECT 
    name,
    statement,
    calls,
    total_time / calls as avg_time
FROM pg_prepared_statements
ORDER BY calls DESC;

11. Изоляция транзакций и производительность

-- READ COMMITTED (по умолчанию) - быстро, но может быть грязные чтения между операциями
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- REPEATABLE READ - строже, гарантирует консистентность
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- SERIALIZABLE - максимальная изоляция, самая медленная
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Для обычных OLTP операций используйте READ COMMITTED
-- Для аналитики/отчетов используйте REPEATABLE READ
-- SERIALIZABLE нужен редко и может привести к конфликтам

12. Пакетные операции и BULK INSERT

-- ❌ Медленно: каждый INSERT в отдельной транзакции (1000 операций в синхронизацией с диском)
INSERT INTO users (name, email) VALUES ('John', 'john@example.com');
INSERT INTO users (name, email) VALUES ('Jane', 'jane@example.com');
-- ~1000 миллисекунд для 1000 строк

-- ✓ Быстро: все в одной транзакции (синхронизация только в конце)
BEGIN;
INSERT INTO users (name, email) VALUES ('John', 'john@example.com');
INSERT INTO users (name, email) VALUES ('Jane', 'jane@example.com');
-- ... еще 998 INSERT
COMMIT;
-- ~10-50 миллисекунд

-- ✓ Еще быстрее: VALUES с множественными строками
INSERT INTO users (name, email) VALUES 
    ('John', 'john@example.com'),
    ('Jane', 'jane@example.com'),
    -- ... еще 998 строк
;

-- ✓ Максимально быстро: COPY (для очень больших объемов)
COPY users (name, email) FROM STDIN;
John    john@example.com
Jane    jane@example.com
\.

-- Пример Python с batch вставками:
def bulk_insert_users(users_list, batch_size=1000):
    for i in range(0, len(users_list), batch_size):
        batch = users_list[i:i + batch_size]
        placeholders = ','.join(['(%s, %s)'] * len(batch))
        query = f"INSERT INTO users (name, email) VALUES {placeholders}"
        values = []
        for user in batch:
            values.extend([user['name'], user['email']])
        cursor.execute(query, values)
    conn.commit()

13. Мониторинг и установка базовых показателей

-- Установить baseline для мониторинга
-- Эти метрики нужно отслеживать регулярно

-- 1. Размер базы данных
SELECT 
    datname,
    pg_size_pretty(pg_database_size(datname)) as size
FROM pg_database
WHERE datname = 'mydb';

-- 2. Размер таблиц
SELECT 
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

-- 3. Загруженность кеша (hit ratio должна быть > 99%)
SELECT 
    'index hit rate' as name,
    ROUND(100.0 * sum(CASE WHEN idx_blks_hit > 0 THEN idx_blks_hit ELSE 0 END) / 
        NULLIF(sum(idx_blks_hit + idx_blks_read), 0), 2) as ratio
FROM pg_statio_user_indexes
UNION
SELECT 
    'table hit rate' as name,
    ROUND(100.0 * sum(heap_blks_hit) / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0), 2)
FROM pg_statio_user_tables;

-- 4. Активные соединения
SELECT 
    state,
    COUNT(*) as count
FROM pg_stat_activity
GROUP BY state;

-- 5. Блокировки и deadlocks
SELECT 
    deadlocks,
    conflicts
FROM pg_stat_database
WHERE datname = 'mydb';

PostgreSQL Запросы

1. Основы SELECT

Синтаксис и семантика

SELECT column1, column2
FROM table_name
WHERE condition
GROUP BY column1
HAVING condition
ORDER BY column1 ASC|DESC
LIMIT count OFFSET start;

SELECT определяет, какие колонки вернуть. Важно: обработка идёт в этом порядке — WHERE фильтрует строки ДО GROUP BY, HAVING фильтрует ПОСЛЕ группировки. Это критично для правильности результатов.

Базовые примеры:

SELECT * FROM users;                          -- Все колонки
SELECT id, name, email FROM users;            -- Конкретные колонки
SELECT id AS user_id, name AS full_name FROM users;  -- Переименование

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

SELECT DISTINCT country FROM users;           -- Уникальные значения

DISTINCT удаляет дубликаты. На больших таблицах это дорогостоящая операция, т.к. требует сортировки или хеширования всех значений.

SELECT * FROM users LIMIT 10 OFFSET 20;      -- Пагинация

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

WHERE условия: операторы сравнения

SELECT * FROM users WHERE age > 18;
SELECT * FROM users WHERE age BETWEEN 18 AND 65;
SELECT * FROM users WHERE country IN ('USA', 'Canada');
SELECT * FROM users WHERE email IS NOT NULL;

BETWEEN включает оба края (18 AND 65 включает 18 и 65). IN проверяет принадлежность списку и оптимизируется лучше, чем множество OR. IS NULL необходим — простое сравнение = NULL всегда возвращает NULL (неизвестно), а не true.

WHERE условия: текстовые операторы

SELECT * FROM users WHERE name LIKE 'John%';          -- John*, регистрозависимо
SELECT * FROM users WHERE name ILIKE '%john%';        -- Любая часть, регистронезависимо
SELECT * FROM users WHERE email ~ '^[a-z]+@';         -- Регулярное выражение

LIKE работает в порядке O(n) на каждой строке. На больших таблицах лучше использовать индексы (например, триграм-индексы для LIKE/ILIKE). ILIKE медленнее LIKE из-за преобразования регистра. Регулярные выражения (~) самые медленные, но мощные.

WHERE условия: логические операторы

SELECT * FROM users WHERE age > 18 AND country = 'USA';
SELECT * FROM users WHERE age < 18 OR age > 65;

AND имеет выше приоритет, чем OR. Без скобок a OR b AND c парсится как a OR (b AND c). Используйте скобки для ясности. PostgreSQL применяет short-circuit evaluation: если первое условие AND — false, второе не проверяется.

2. Агрегатные функции

Базовые агрегаты

SELECT COUNT(*) FROM users;                    -- Общее количество строк
SELECT COUNT(DISTINCT country) FROM users;     -- Уникальные значения

COUNT(*) считает все строки включая NULL-значения. COUNT(column) игнорирует NULL-значения в этой колонке. Это важное различие при анализе данных.

SELECT SUM(salary), AVG(age), MIN(created_at), MAX(created_at)
FROM employees;

Математические функции: SUM и AVG игнорируют NULL (считают только непустые значения). MIN/MAX работают с любыми типами данных (даты, строки, числа) и тоже игнорируют NULL.

Агрегаты со строками

SELECT STRING_AGG(name, ', ' ORDER BY name) FROM users;
SELECT ARRAY_AGG(name ORDER BY created_at) FROM users;

STRING_AGG конкатенирует строки с разделителем. Параметр ORDER BY внутри функции сортирует элементы перед конкатенацией. ARRAY_AGG возвращает массив вместо строки — полезно для вложенных структур JSON.

GROUP BY и HAVING

SELECT country, COUNT(*) as user_count
FROM users
GROUP BY country;

GROUP BY группирует строки по значениям колонки. В SELECT можно выбирать только: (1) колонки из GROUP BY, (2) агрегаты. Попытка выбрать другую колонку приведёт к ошибке.

Важно: порядок обработки — WHERE фильтрует строки, потом GROUP BY группирует, потом HAVING фильтрует группы. Не используйте агрегаты в WHERE (это ошибка).

SELECT country, AVG(salary) as avg_salary
FROM employees
GROUP BY country
HAVING AVG(salary) > 50000;

HAVING фильтрует по агрегатам. В HAVING можно использовать агрегатные функции, в WHERE — нет. Иногда дешевле добавить подзапрос в WHERE вместо HAVING для производительности.

ROLLUP и CUBE: многоуровневая агрегация

SELECT region, country, SUM(sales)
FROM orders
GROUP BY ROLLUP(region, country);

ROLLUP создаёт иерархические подитоги:

  • Строки с (region, country) — детальные данные
  • Строки с (region, NULL) — подитоги по регионам
  • Строка с (NULL, NULL) — общий итог

Полезно для отчётов. ROLLUP(a, b, c) эквивалентен UNION результатов GROUP BY всех комбинаций.

SELECT country, product, SUM(amount)
FROM sales
GROUP BY CUBE(country, product);

CUBE похож на ROLLUP, но генерирует подитоги по ВСЕм комбинациям столбцов (2^n строк). Мощнее, но дороже ROLLUP.

3. JOIN операции

INNER JOIN

SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;

INNER JOIN возвращает только строки, где оба условия ON совпадают. Если пользователь не имеет заказов, его нет в результате. INNER явно, но часто пишут просто JOIN (по умолчанию INNER).

LEFT JOIN

SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

LEFT JOIN возвращает ВСЕ строки из левой таблицы (users), даже если нет совпадений. Для пользователя без заказов o.id будет NULL, COUNT(o.id) вернёт 0. Помните: после LEFT JOIN фильтрация должна быть в WHERE осторожно — добавление условия типа WHERE o.status = 'completed' превратит это в INNER JOIN.

RIGHT JOIN и FULL OUTER JOIN

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

RIGHT JOIN — противоположность LEFT (все строки из правой таблицы).

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

FULL OUTER JOIN возвращает все строки из обеих таблиц. NULL-значения будут в колонках, где совпадение отсутствует. Полезно для поиска расхождений между таблицами.

CROSS JOIN

SELECT p.name, c.name
FROM products p
CROSS JOIN categories c;

CROSS JOIN (декартово произведение) — каждая строка левой таблицы комбинируется с КАЖДОЙ строкой правой. Результат содержит n×m строк. Используется редко, обычно случайно (забыли ON). Может быть дорогостоящей операцией на больших таблицах.

SELF JOIN

SELECT e1.name, e2.name as manager_name
FROM employees e1
LEFT JOIN employees e2 ON e1.manager_id = e2.id;

SELF JOIN — таблица присоединяется к самой себе. Нужны разные алиасы (e1, e2). Используется для иерархических данных (сотруднику — менеджеры, товарам — связанные товары).

Множественные JOIN

SELECT u.name, o.total, p.name as product_name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;

Можно чейнить несколько JOIN. Порядок обычно не влияет на результат (оптимизатор переставляет), но может влиять на производительность. Каждый дополнительный JOIN — потенциально дорогостоящее слияние. Мониторьте EXPLAIN для больших запросов.

4. Подзапросы

Подзапросы в WHERE

SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000);

IN проверяет принадлежность подмножеству. Результирующий набор подзапроса должен быть одной колонкой. На больших наборах IN может быть медленнее JOIN (зависит от оптимизатора). Часто лучше переписать как INNER JOIN.

Скалярные подзапросы

SELECT name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count
FROM users u;

Скалярный подзапрос возвращает одно значение на каждую строку. Выполняется для КАЖДОЙ строки основного запроса (correlated subquery) — это может быть очень медленно на больших таблицах (O(n²)). LATERAL JOIN или GROUP BY обычно быстрее.

EXISTS

SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

EXISTS проверяет, существует ли хотя бы одна строка в подзапросе. Останавливает поиск при первом совпадении. Часто быстрее IN для больших наборов. Возвращаемое значение (1, 0, *) не важно — важно только наличие строк.

NOT EXISTS

SELECT * FROM products p
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.product_id = p.id);

NOT EXISTS находит строки, которых НЕТ в подзапросе. Стандартный способ найти неиспользуемые товары или "левые" записи.

Подзапросы в FROM (производные таблицы)

SELECT avg_salary_by_dept.department, avg_salary_by_dept.avg_salary
FROM (
    SELECT department, AVG(salary) as avg_salary
    FROM employees
    GROUP BY department
) avg_salary_by_dept
WHERE avg_salary > 50000;

Подзапрос в FROM создаёт временную таблицу (производную таблицу). Полезно для сложной логики группировки. Подзапрос ДОЛЖЕН иметь алиас. Альтернатива — CTE (WITH), обычно более читаема.

5. Window функции

Window функции выполняют вычисления "над окном" строк без схлопывания результатов (в отличие от GROUP BY).

ROW_NUMBER

SELECT name, salary,
       ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) as row_num
FROM employees;

ROW_NUMBER нумерует строки в порядке. PARTITION BY разделяет на группы (окна), ORDER BY определяет порядок нумерации. Результат: нумерация внутри каждого отдела независимо.

RANK и DENSE_RANK

SELECT name, score,
       RANK() OVER (ORDER BY score DESC) as rank,
       DENSE_RANK() OVER (ORDER BY score DESC) as dense_rank
FROM students;

RANK присваивает одинаковые номера одинаковым значениям, пропускает номера (1, 1, 3, ...). DENSE_RANK не пропускает номера (1, 1, 2, ...). Выбор зависит от требований отчёта. RANK используется чаще для рангирования.

LAG и LEAD

SELECT date, amount,
       LAG(amount, 1) OVER (ORDER BY date) as prev_amount,
       LEAD(amount, 1) OVER (ORDER BY date) as next_amount
FROM sales;

LAG достаёт значение из предыдущей строки, LEAD — из следующей. Второй параметр — смещение (1 = соседняя строка, 2 = через одну). Полезно для анализа временных рядов, вычисления дельт, трендов.

Агрегатные window функции

SELECT name, salary, department,
       AVG(salary) OVER (PARTITION BY department) as dept_avg,
       SUM(salary) OVER (ORDER BY hire_date) as running_total
FROM employees;

Window версии агрегатов (AVG, SUM, COUNT, MIN, MAX) не схлопывают результаты. Первый пример: каждой строке добавляется средняя зарплата в отделе. Второй: running_total — накопительная сумма отсортированных по дате найма зарплат (важен ORDER BY).

FIRST_VALUE и LAST_VALUE

SELECT name, salary,
       FIRST_VALUE(salary) OVER (PARTITION BY department ORDER BY salary DESC) as highest_salary,
       LAST_VALUE(salary) OVER (PARTITION BY department ORDER BY salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as lowest_salary
FROM employees;

FIRST_VALUE/LAST_VALUE достают первое/последнее значение в окне. Важно: по умолчанию окно ("frame") начинается в первой строке и заканчивается ТЕКУЩЕЙ строкой (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Для LAST_VALUE нужно явно указать UNBOUNDED FOLLOWING, иначе результат неправильный.

6. Common Table Expressions (CTE / WITH)

CTE (WITH) — именованные подзапросы, которые выполняются один раз и могут переиспользоваться несколько раз.

Простой CTE

WITH high_earners AS (
    SELECT * FROM employees WHERE salary > 100000
)
SELECT department, COUNT(*) as count
FROM high_earners
GROUP BY department;

CTE определяется перед основным запросом. Улучшает читаемость сложных запросов. Производительность не сильно отличается от подзапроса в FROM.

Множественные CTE

WITH 
    dept_stats AS (
        SELECT department, AVG(salary) as avg_salary
        FROM employees GROUP BY department
    ),
    high_salary_depts AS (
        SELECT department FROM dept_stats WHERE avg_salary > 70000
    )
SELECT e.name, e.salary, e.department
FROM employees e
JOIN high_salary_depts hsd ON e.department = hsd.department;

Несколько CTE разделяются запятыми. Каждый CTE может ссылаться на предыдущие. Это мощнее и понятнее, чем вложенные подзапросы.

Рекурсивный CTE

WITH RECURSIVE employee_hierarchy AS (
    SELECT id, name, manager_id, 1 as level
    FROM employees WHERE manager_id IS NULL
    
    UNION ALL
    
    SELECT e.id, e.name, e.manager_id, eh.level + 1
    FROM employees e
    JOIN employee_hierarchy eh ON e.manager_id = eh.id
)
SELECT * FROM employee_hierarchy ORDER BY level, name;

Рекурсивный CTE имеет две части: якорь (базовый case) и рекурсивный step (UNION ALL). Якорь инициализирует результат (в примере — корневые сотрудники без менеджеров). Рекурсивный step ссылается на сам CTE для расширения иерархии. Выполняется итеративно пока находятся новые строки. Используется для иерархий, графов, деревьев.

7. UNION, INTERSECT, EXCEPT

Операции над набором результатов.

SELECT name FROM customers
UNION
SELECT name FROM suppliers;

UNION объединяет результаты двух запросов, убирая дубликаты. Требует одинакового числа колонок и совместимых типов. Это дорогостоящая операция (требует сортировки или хеширования).

SELECT product_id FROM order_items_2023
UNION ALL
SELECT product_id FROM order_items_2024;

UNION ALL оставляет дубликаты, поэтому быстрее UNION. Используйте, если гарантированно нет дубликатов или они допустимы.

SELECT customer_id FROM orders_2023
INTERSECT
SELECT customer_id FROM orders_2024;

INTERSECT возвращает строки, присутствующие в ОБОИХ запросах (пересечение). Результат: клиенты, совершившие заказы и в 2023, и в 2024.

SELECT product_id FROM inventory
EXCEPT
SELECT product_id FROM sold_products;

EXCEPT возвращает строки, присутствующие в первом запросе, но НЕ во втором (разность). Результат: товары, которые в инвентаре, но никогда не продавались.

8. Условная логика

CASE выражение

SELECT name, age,
       CASE
           WHEN age < 18 THEN 'Несовершеннолетний'
           WHEN age BETWEEN 18 AND 65 THEN 'Взрослый'
           ELSE 'Пенсионер'
       END as age_group
FROM users;

CASE вычисляет условия по порядку, возвращает значение первого true-условия. ELSE опционален (если не совпадает ничего — NULL). CASE можно использовать в SELECT, WHERE, ORDER BY, везде.

COALESCE

SELECT name, COALESCE(phone, email, 'No contact') as contact
FROM users;

COALESCE возвращает первый НЕ-NULL аргумент. Полезно для обработки пропущенных данных — выбирает альтернативу, если основное значение NULL.

NULLIF

SELECT name, NULLIF(phone, '') as phone
FROM users;

NULLIF возвращает NULL, если два аргумента равны, иначе первый аргумент. Полезно превращать пустые строки в NULL для консистентности.

GREATEST и LEAST

SELECT GREATEST(10, 20, 30) as max_value;
SELECT LEAST(10, 20, 30) as min_value;

GREATEST возвращает максимум из аргументов, LEAST — минимум. Работают с любыми типами (числа, даты, строки). Если хоть один аргумент NULL — результат NULL.

9. Работа с датами

Текущие дата и время

SELECT NOW(), CURRENT_DATE, CURRENT_TIME, CURRENT_TIMESTAMP;

NOW() и CURRENT_TIMESTAMP возвращают текущие дату и время с часовым поясом. CURRENT_DATE — только дата. CURRENT_TIME — только время. В транзакции они одинаковы (freeze) для консистентности.

Извлечение компонентов

SELECT EXTRACT(YEAR FROM created_at) as year,
       EXTRACT(MONTH FROM created_at) as month,
       EXTRACT(DAY FROM created_at) as day
FROM orders;

EXTRACT разбирает дату на компоненты (YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, WEEK, QUARTER). Возвращает число.

DATE_TRUNC

SELECT DATE_TRUNC('month', created_at) as month,
       COUNT(*) as orders_count
FROM orders
GROUP BY 1;

DATE_TRUNC обнуляет части даты (например, 'month' оставляет год-месяц, обнуляет день, час, минуту). Полезно для группировки по периодам (день, час, неделя).

Арифметика с датами

SELECT created_at + INTERVAL '1 day' as tomorrow,
       created_at - INTERVAL '1 month' as month_ago
FROM orders;

Можно добавлять/вычитать интервалы (INTERVAL) из дат. INTERVAL — типи PostgreSQL для длительности. '1 day' эквивалентно 1 дню, '2 weeks' — 2 неделям.

AGE — функция для вычисления возраста

SELECT name, AGE(NOW(), birth_date) as age
FROM users;

AGE вычисляет разницу между двумя датами в виде интервала (годы-месяцы-дни). Результат можно использовать в вычислениях или конвертировать в годы (EXTRACT(YEAR FROM age(...))).

10. Строковые функции

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

SELECT first_name || ' ' || last_name as full_name FROM users;
SELECT CONCAT(first_name, ' ', last_name) as full_name FROM users;

Оператор || конкатенирует строки. CONCAT — функция для той же цели (более переносима на другие БД). Если любой аргумент NULL — результат NULL.

Изменение регистра

SELECT UPPER(name), LOWER(email), INITCAP(city) FROM users;

UPPER — все в верхний регистр, LOWER — в нижний, INITCAP — первая буква заглавная (остальные строчные). Полезно для нормализации пользовательских данных.

Обрезание пробелов

SELECT TRIM('  hello  ') as trimmed;  -- 'hello'

TRIM удаляет пробелы слева и справа. LTRIM только слева, RTRIM только справа. Параметр по умолчанию — пробел, но можно указать другой символ: TRIM('"' FROM '"hello"').

Длина и позиция

SELECT LENGTH(name), POSITION('@' IN email), SUBSTRING(name FROM 1 FOR 5)
FROM users;

LENGTH возвращает количество символов. POSITION найди позицию подстроки (1-based, 0 если не найдено). SUBSTRING извлекает часть строки FROM позиции FOR количество символов.

REPLACE и регулярные выражения

SELECT REPLACE(phone, '-', '') as phone_clean,
       REGEXP_REPLACE(email, '@.*', '') as username
FROM users;

REPLACE заменяет все вхождения подстроки. REGEXP_REPLACE использует регулярные выражения (мощнее, медленнее). В REGEXP_REPLACE можно использовать группы захвата: REGEXP_REPLACE(email, '(.*)@.*', '\1') — часть перед @.

SPLIT_PART

SELECT SPLIT_PART(email, '@', 1) as username,
       SPLIT_PART(email, '@', 2) as domain
FROM users;

SPLIT_PART разбивает строку по разделителю и возвращает N-ый элемент (1-based). Полезно разбирать структурированные строки типа CSV или email.

11. JSON операции

PostgreSQL имеет встроенную поддержку JSON с двумя типами: json (текст) и jsonb (бинарный, проиндексирован, проверен).

Извлечение данных

SELECT metadata->>'name' as name,
       metadata->'age' as age,
       metadata->'address'->>'city' as city
FROM users;

Оператор -> возвращает JSON объект/массив (результат JSON). Оператор ->> возвращает текстовое значение (результат text). Для глубокого доступа чейнить: obj->'a'->'b'.

Операторы проверки

SELECT * FROM products WHERE attributes @> '{\"color\": \"red\"}';
SELECT * FROM products WHERE attributes ? 'warranty';
SELECT * FROM products WHERE attributes ?| ARRAY['size', 'color'];

@> проверяет, содержит ли JSON левый аргумент правый (подобъект/подмассив). ? проверяет наличие ключа. ?| проверяет наличие любого из ключей массива. Полезны с индексами (GIN) для быстрого поиска.

JSON функции

SELECT JSON_BUILD_OBJECT('name', name, 'email', email) as user_json
FROM users;

SELECT JSON_AGG(JSON_BUILD_OBJECT('id', id, 'name', name)) as users
FROM users;

JSON_BUILD_OBJECT конструирует JSON объект из пар ключ-значение. JSON_AGG агрегирует результаты в JSON массив. Мощно для API-ответов прямо из БД.

12. Массивы

PostgreSQL поддерживает типи массивов.

Создание массивов

SELECT ARRAY[1, 2, 3, 4, 5];
SELECT ARRAY_AGG(name) FROM users;

ARRAY[...] литерал. ARRAY_AGG агрегирует колонку в массив (похоже на STRING_AGG, но результат массив, не строка).

Операторы массивов

SELECT * FROM articles WHERE tags @> ARRAY['postgresql'];
SELECT * FROM articles WHERE ARRAY['sql'] <@ tags;
SELECT * FROM articles WHERE tags && ARRAY['database', 'sql'];

@> — левый массив содержит правый. <@ — левый подмножество правого. && — пересечение (есть общие элементы). Используйте для фильтрации по тегам/категориям. С GIN индексами очень быстро.

Функции массивов

SELECT ARRAY_LENGTH(tags, 1) as tags_count FROM articles;
SELECT UNNEST(ARRAY[1, 2, 3]);

ARRAY_LENGTH возвращает размер массива (второй параметр — измерение, 1 для одномерного). UNNEST расворачивает массив в строки (каждый элемент в отдельную строку). Обратная операция ARRAY_AGG.

13. Транзакции

Транзакция — набор операций, выполняющихся атомарно (все или ничего).

Базовые транзакции

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

BEGIN начинает транзакцию. COMMIT подтверждает все изменения. Если ошибка — автоматически ROLLBACK (откат). Важно для операций, где консистентность критична (переводы денег, обновления связанных таблиц).

ROLLBACK для явного отката

BEGIN;
    DELETE FROM orders WHERE id = 123;
    ROLLBACK;  -- Отменяет изменения

ROLLBACK отменяет ВСЕ изменения в транзакции. Используйте в обработчиках ошибок.

SAVEPOINT для частичного отката

BEGIN;
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
    SAVEPOINT sp1;
    UPDATE accounts SET balance = balance + 100 WHERE id = 2;
    ROLLBACK TO sp1;  -- Откатывает только второе обновление
    -- Первое обновление остаётся
COMMIT;

SAVEPOINT создаёт промежуточную точку отката. ROLLBACK TO sp1 откатывает операции ДО sp1, но сохраняет предыдущие изменения. Полезно для обработки ошибок в приложениях.

14. UPSERT (INSERT ... ON CONFLICT)

PostgreSQL поддерживает "upsert" — вставить или обновить, если уже существует.

INSERT INTO users (id, email, name)
VALUES (1, 'john@example.com', 'John')
ON CONFLICT (id) DO UPDATE
SET email = EXCLUDED.email, name = EXCLUDED.name;

ON CONFLICT (id) срабатывает, если нарушено уникальное ограничение на id. DO UPDATE меняет значения (EXCLUDED — новые значения). Эффективнее, чем проверка наличия + условное INSERT/UPDATE.

INSERT INTO users (email, name)
VALUES ('john@example.com', 'John')
ON CONFLICT (email) DO NOTHING;

DO NOTHING игнорирует конфликт — не делает ничего. Полезно для идемпотентных операций.

INSERT INTO products (id, name, price)
VALUES (1, 'Product', 100)
ON CONFLICT (id) DO UPDATE
SET price = EXCLUDED.price
WHERE products.price < EXCLUDED.price;

Можно добавить WHERE в ON CONFLICT для условного обновления. Здесь цена обновляется только если новая выше.

15. LATERAL JOIN

LATERAL позволяет подзапросу в FROM ссылаться на предыдущие таблицы.

SELECT u.name, recent_orders.order_date, recent_orders.total
FROM users u
CROSS JOIN LATERAL (
    SELECT order_date, total
    FROM orders o
    WHERE o.user_id = u.id
    ORDER BY order_date DESC
    LIMIT 5
) recent_orders;

Для каждого пользователя u подзапрос выполняется и возвращает 5 последних заказов. Результат: пользователь + его 5 последних заказов. Эквивалент скалярного подзапроса, но мощнее (может вернуть несколько строк). Часто быстрее correlated subquery.

16. RETURNING

RETURNING возвращает данные из изменённых строк (INSERT/UPDATE/DELETE).

INSERT INTO users (name, email)
VALUES ('John', 'john@example.com')
RETURNING id, name, created_at;

После вставки возвращает id (обычно auto-generated), name, created_at. Полезно в приложениях, чтобы получить сгенерированные id без дополнительного запроса.

UPDATE users
SET last_login = NOW()
WHERE id = 1
RETURNING id, name, last_login;

RETURNING показывает обновленные значения после UPDATE.

DELETE FROM users
WHERE inactive = true
RETURNING id, name, email;

DELETE с RETURNING показывает удаленные строки. Пригодится для аудита или восстановления.

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

Проблема, которую решают блокировки

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

Пример проблемы без блокировок

-- Транзакция 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- Видит 1000
-- Пока идёт обработка...

-- Транзакция 2 (параллельно)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- Видит те же 1000
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;

-- Вернёмся к Транзакции 1
UPDATE accounts SET balance = 1500 WHERE id = 1;  -- Потерянное обновление!
COMMIT;

Финальный баланс 1500, хотя должен быть либо 500, либо 1000 (в зависимости от порядка операций). Это потеря данных — Lost Update Anomaly.

Решение: пессимистичная блокировка

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

-- Правильный вариант
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- Блокируем строку
-- Теперь никто другой не может изменять эту строку
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

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


1. Блокировки строк (Row-Level Locks)

Row-level блокировки — самый распространённый тип. Они гарантируют изоляцию при работе с конкретными строками, не блокируя остальную таблицу.

FOR UPDATE — Эксклюзивная блокировка строки

Это самый сильный тип row-level блокировки. Блокирует строку для любых изменений (UPDATE/DELETE) и для других FOR UPDATE/FOR NO KEY UPDATE.

Когда использовать:

  • Когда вам нужно прочитать текущее значение и затем его изменить
  • В финансовых операциях (переводы, снятия)
  • Когда вы хотите гарантировать эксклюзивный доступ
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Другие транзакции могут читать, но не могут:

--   - UPDATE эту строку
--   - DELETE эту строку
--   - SELECT FOR UPDATE / FOR NO KEY UPDATE / FOR SHARE / FOR KEY SHARE
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

Практический пример: резервирование товара

CREATE OR REPLACE FUNCTION reserve_product(p_product_id INT, p_qty INT)
RETURNS BOOLEAN AS $$
DECLARE
    v_current_qty INT;
BEGIN
    -- Блокируем на чтение
    SELECT quantity INTO v_current_qty
    FROM products WHERE id = p_product_id FOR UPDATE;
    
    -- Проверяем наличие
    IF v_current_qty < p_qty THEN
        RETURN FALSE;  -- Недостаточно товара
    END IF;
    
    -- Резервируем
    UPDATE products SET quantity = quantity - p_qty WHERE id = p_product_id;
    INSERT INTO reservations (product_id, qty, reserved_at)
    VALUES (p_product_id, p_qty, NOW());
    
    RETURN TRUE;
END;
$$ LANGUAGE plpgsql;

Без FOR UPDATE другой процесс мог бы прочитать количество, а мы бы продали товар дважды (Race Condition). С блокировкой все гарантировано.


FOR NO KEY UPDATE — Блокировка без ключа

Менее ограничивающая, чем FOR UPDATE. Разрешает другим транзакциям создавать внешние ключи (Foreign Keys) на эту строку, но запрещает её изменение.

Когда использовать:

  • Когда у вас сложные JOIN-ы с FK ссылками
  • Когда другие процессы создают данные, ссылающиеся на вашу строку
  • В системах с интенсивным insert-ом, ссылающимся на существующие данные
BEGIN;
SELECT * FROM users WHERE id = 1 FOR NO KEY UPDATE;
-- Другие транзакции:

--   ✓ Могут читать
--   ✓ Могут INSERT с FK на эту строку
--   ✗ Не могут UPDATE/DELETE
--   ✗ Не могут FOR UPDATE / FOR NO KEY UPDATE на эту строку
UPDATE users SET name = 'New Name' WHERE id = 1;
COMMIT;

Различие в сценарии:

-- Сценарий 1: Обновляем пользователя
BEGIN;
SELECT * FROM users WHERE id = 1 FOR NO KEY UPDATE;
UPDATE users SET email = 'new@example.com' WHERE id = 1;

-- Параллельно другой процесс МОЖЕТ выполнить:
INSERT INTO user_profiles (user_id, bio)
VALUES (1, 'Some bio');  -- Создаёт FK на users.id = 1
-- Это пройдёт, потому что мы использовали FOR NO KEY UPDATE

COMMIT;

FOR SHARE — Разделяемая блокировка

Разрешает другим FOR SHARE блокировкам на той же строке, но запрещает изменения (UPDATE/DELETE). Используется, когда несколько процессов нужно читать одни данные без риска.

Когда использовать:

  • Когда несколько процессов должны работать с одними данными параллельно
  • В расчётах, где данные не должны меняться во время расчёта
  • В отчётах, требующих консистентности
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- Другие транзакции:

--   ✓ Могут читать
--   ✓ Могут SELECT FOR SHARE на эту строку
--   ✗ Не могут UPDATE/DELETE
--   ✗ Не могут FOR UPDATE / FOR NO KEY UPDATE / FOR KEY SHARE
COMMIT;

Практический пример: расчёт процентов

-- Процесс 1 и Процесс 2 одновременно
BEGIN;
SELECT balance INTO v_balance FROM accounts WHERE id = 1 FOR SHARE;

-- Выполняют сложные расчёты
v_interest := v_balance * calculate_interest_rate();

-- Другой процесс может делать то же самое параллельно
-- Но никто не может UPDATE эту строку

INSERT INTO interest_log (account_id, amount, calc_date)
VALUES (1, v_interest, NOW());

COMMIT;

Ключевое отличие от FOR UPDATE: несколько FOR SHARE блокировок могут быть активны одновременно на одной строке. Это позволяет параллелизировать read-heavy операции.


FOR KEY SHARE — Самая слабая блокировка

Блокирует только DELETE и изменения первичного ключа (важно для Foreign Keys). Разрешает UPDATE неключевых столбцов.

Когда использовать:

  • Редко (как правило, не нужна)
  • Когда вы работаете только с неключевыми столбцами и хотите минимизировать конфликты
  • В очень специфичных сценариях с множественными FK
BEGIN;
SELECT * FROM users WHERE id = 1 FOR KEY SHARE;
-- Другие транзакции:

--   ✓ Могут читать
--   ✓ Могут SELECT FOR SHARE / FOR KEY SHARE
--   ✓ Могут UPDATE неключевые поля
--   ✗ Не могут DELETE
--   ✗ Не могут UPDATE первичный ключ
--   ✗ Не могут FOR UPDATE / FOR NO KEY UPDATE
COMMIT;

2. Модификаторы блокировок

NOWAIT — Нетерпеливое захватывание

Вместо ожидания, если блокировка недоступна, сразу возвращает ошибку lock_not_available.

Когда использовать:

  • В асинхронных системах, где ждать нельзя
  • В веб-приложениях (не ждите в HTTP request)
  • В очередях задач (попробуйте другую задачу)
  • Когда нужна fast-fail стратегия
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- Если строка уже заблокирована другой транзакцией:

-- → ошибка "lock_not_available" — ОШИБКА: could not obtain lock on row
-- Не ждём бесконечно, не блокируем приложение
COMMIT;

Практический пример: функция с обработкой ошибки

CREATE OR REPLACE FUNCTION try_reserve_product(p_product_id INT, p_qty INT)
RETURNS TABLE(success BOOLEAN, message TEXT) AS $$
DECLARE
    v_current_qty INT;
BEGIN
    -- Пытаемся захватить блокировку без ожидания
    SELECT quantity INTO v_current_qty
    FROM products WHERE id = p_product_id FOR UPDATE NOWAIT;
    
    IF v_current_qty >= p_qty THEN
        UPDATE products SET quantity = quantity - p_qty WHERE id = p_product_id;
        RETURN QUERY SELECT TRUE, 'Reserved'::TEXT;
    ELSE
        RETURN QUERY SELECT FALSE, 'Insufficient quantity'::TEXT;
    END IF;
    
EXCEPTION WHEN lock_not_available THEN
    -- Товар уже зарезервирован кем-то, не ждём
    RETURN QUERY SELECT FALSE, 'Product is being processed by another request'::TEXT;
END;
$$ LANGUAGE plpgsql;

В приложении (например, Java/Kotlin):

try {
    val success = db.queryOne("SELECT try_reserve_product(?, ?)", productId, qty)
    if (success) {
        // Резервирование успешно
    } else {
        // Попробуем другой товар или вернём ошибку клиенту
    }
} catch (e: LockNotAvailableException) {
    // Товар в данный момент заблокирован, предложим другой товар
    logger.info("Product locked, trying alternative")
}

SKIP LOCKED — Пропустить заблокированное

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

Когда использовать:

  • Очереди задач для worker pool
  • Batch processing, где порядок не критичен
  • Когда вы хотите обрабатывать то, что доступно
-- Простой пример
SELECT * FROM tasks
WHERE status = 'pending'
ORDER BY priority DESC
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- Получим первую незаблокированную задачу

Практический пример: очередь задач для воркеров

CREATE OR REPLACE FUNCTION get_next_task_for_worker()
RETURNS TABLE(task_id INT, task_data JSONB, priority INT) AS $$
BEGIN
    RETURN QUERY
    UPDATE tasks
    SET 
        status = 'processing',
        worker_pid = pg_backend_pid(),
        started_at = NOW()
    WHERE id = (
        SELECT id FROM tasks
        WHERE status = 'pending'
        ORDER BY priority DESC, created_at ASC
        FOR UPDATE SKIP LOCKED  -- Пропустим уже взятые задачи
        LIMIT 1
    )
    RETURNING id, data, priority;
END;
$$ LANGUAGE plpgsql;

Поведение в многопроцессной среде:

-- Если 5 воркеров одновременно вызывают get_next_task_for_worker():

-- Поток 1: Видит tasks id=1,2,3,4,5 - берёт 1 (priority=100, чтение FOR UPDATE)
-- Поток 2: Видит 2,3,4,5 (1 пропущена SKIP LOCKED) - берёт 2
-- Поток 3: Видит 3,4,5 - берёт 3
-- и т.д.

-- Без SKIP LOCKED:

-- Поток 1: Берёт 1, получает блокировку
-- Поток 2: Пытается для 1, ждёт... ждёт... (блокирован!)
-- Это замораживает очередь

Сценарий без SKIP LOCKED (ПРОБЛЕМА):

-- Все потоки пытаются взять одну задачу
SELECT id FROM tasks WHERE status = 'pending' ORDER BY priority DESC LIMIT 1
FOR UPDATE;  -- Все ждут получить одну строку, конфликт!

OF table_name — Частичная блокировка в JOIN

Когда JOIN-ите несколько таблиц, можно заблокировать только нужные. Другие останутся доступны.

Когда использовать:

  • JOIN-ы нескольких таблиц
  • Когда вам нужно заблокировать только основную таблицу
  • Когда другие процессы работают со связанными таблицами
SELECT u.id, u.name, a.balance
FROM users u
JOIN accounts a ON u.id = a.user_id
WHERE u.id = 1
FOR UPDATE OF u;  -- Только users.id=1 заблокирована
-- accounts строка НЕ заблокирована!

Практический сценарий:

-- Операция: переводим деньги между счётами
BEGIN;
SELECT a1.id, a1.balance, a2.id, a2.balance
FROM accounts a1
JOIN accounts a2 ON a1.user_id = a2.user_id
WHERE a1.id = 100 AND a2.id = 200
FOR UPDATE OF a1;  -- Блокируем только счёт отправителя

-- Вторая половина операции может идти параллельно для других счётов!
UPDATE accounts SET balance = balance - 100 WHERE id = 100;
COMMIT;

-- Параллельно:
BEGIN;
SELECT * FROM accounts WHERE id = 200 FOR UPDATE;  -- Это может выполниться!
COMMIT;

Без OF обе строки были бы заблокированы.


3. Блокировки таблиц (Table-Level Locks)

Table-level блокировки нужны редко, но при неправильном использовании создают серьёзные проблемы с перформансом.

Когда PostgreSQL автоматически ставит table-level блокировки

INSERT INTO table VALUES (...);            -- ROW EXCLUSIVE
UPDATE table SET ...;                      -- ROW EXCLUSIVE
DELETE FROM table WHERE ...;               -- ROW EXCLUSIVE
SELECT * FROM table;                       -- ACCESS SHARE
SELECT * FROM table FOR UPDATE;            -- ROW SHARE (не FOR UPDATE!)
DROP TABLE table;                          -- ACCESS EXCLUSIVE
ALTER TABLE table ADD COLUMN ...;          -- ACCESS EXCLUSIVE
TRUNCATE TABLE table;                      -- ACCESS EXCLUSIVE
CREATE INDEX ON table (...);               -- SHARE
VACUUM;                                    -- SHARE UPDATE EXCLUSIVE

Явная table-level блокировка (использовать редко!)

BEGIN;
LOCK TABLE accounts IN ACCESS EXCLUSIVE MODE;
-- Никто не может ничего с этой таблицей делать
-- Даже SELECT!
COMMIT;

Матрица совместимости table-level блокировок

Понимание матрицы критично — неправильная блокировка замораживает приложение.

Режим ACCESS SHARE ROW SHARE ROW EXCL SHARE UPD SHARE SHARE ROW EXCLUSIVE ACCL EXCL
ACCESS SHARE
ROW SHARE
ROW EXCL
SHARE UPDATE
SHARE
SHARE ROW
EXCLUSIVE
ACCESS EXCL

Как читать: Row1 конфликтует с Column2 если в ячейке ✗.

Ключевые принципы:

  • ACCESS SHARE (SELECT) не конфликтует ни с чем кроме ACCESS EXCLUSIVE
  • ROW EXCLUSIVE (INSERT/UPDATE/DELETE) конфликтует со всеми режимами выше (SHARE и выше)
  • ACCESS EXCLUSIVE конфликтует со всем

Проблемный сценарий:

-- Сессия 1
BEGIN;
ALTER TABLE products ADD COLUMN new_col INT;  -- ACCESS EXCLUSIVE
-- Таблица полностью заморозена!

-- Сессия 2 (параллельно)
SELECT * FROM products;  -- ЖДЁТ! ACCESS SHARE конфликтует с ACCESS EXCLUSIVE
-- INSERT, UPDATE, DELETE - всё ждёт!

-- Пока Сессия 1 не COMMIT, вся таблица недоступна

4. Advisory Locks — Приложение-уровневые блокировки

Advisory locks — это явные, приложение-уровневые блокировки, не связанные с данными. Используются для синхронизации бизнес-логики, не физических строк.

Сессионные advisory locks

Действуют всю сессию, пока не освободите явно или соединение закроется.

-- Захватить блокировку
SELECT pg_advisory_lock(12345);

-- Попытаться захватить (неблокирующее)
SELECT pg_try_advisory_lock(12345);  -- true если получилась, false если заблокирована

-- Освободить
SELECT pg_advisory_unlock(12345);

-- Освободить все
SELECT pg_advisory_unlock_all();

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

-- Сессия 1
SELECT pg_advisory_lock(100);  -- Получаем блокировку
-- Ждём...

-- Сессия 2
SELECT pg_advisory_lock(100);  -- Ждёт пока Сессия 1 не освободит!
-- (Это блокирует сессию 2, как FOR UPDATE)

Транзакционные advisory locks

Действуют только в рамках транзакции, автоматически освобождаются при COMMIT/ROLLBACK.

BEGIN;
SELECT pg_advisory_xact_lock(12345);  -- Автоматически освободится
-- Работаем...
COMMIT;  -- Блокировка автоматически освобождена

-- Неблокирующая версия
BEGIN;
SELECT pg_try_advisory_xact_lock(12345);
COMMIT;

Практический пример: синглтон процесс

Ensure, что только одна инстанция процесса работает одновременно.

CREATE OR REPLACE FUNCTION ensure_singleton_process(p_process_name TEXT)
RETURNS BOOLEAN AS $$
DECLARE
    v_lock_id BIGINT;
BEGIN
    v_lock_id := ('x' || md5(p_process_name))::bit(64)::bigint;
    
    -- Пытаемся получить блокировку без ожидания
    IF pg_try_advisory_lock(v_lock_id) THEN
        RAISE LOG 'Process % started', p_process_name;
        RETURN TRUE;
    ELSE
        RAISE LOG 'Process % already running', p_process_name;
        RETURN FALSE;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Использование в приложении (например, Kotlin coroutine)

Java/Kotlin реализация:

fun processBackgroundJob(jobName: String) {
    val lockId = jobName.hashCode().toLong()
    
    try {
        db.queryOne("SELECT pg_try_advisory_lock(?)", lockId)?.let { (locked) ->
            if (locked as Boolean) {
                try {
                    // Выполняем долгую операцию
                    performHeavyWork()
                } finally {
                    // Если другой process уже стартовал за время нашей работы,
                    // он начнёт работать сам
                    db.query("SELECT pg_advisory_unlock(?)", lockId)
                }
            } else {
                logger.info("Process $jobName is already running")
            }
        }
    } catch (e: Exception) {
        logger.error("Background job failed", e)
    }
}

Другой пример: distributed cron job

CREATE OR REPLACE FUNCTION distribute_paychecks()
RETURNS TABLE(success BOOLEAN, processed_count INT) AS $$
DECLARE
    v_lock_id BIGINT := 1001;  -- Фиксированный ID для этой операции
    v_count INT;
BEGIN
    -- Только один процесс в кластере должен распределять зарплату
    IF NOT pg_try_advisory_xact_lock(v_lock_id) THEN
        RETURN QUERY SELECT FALSE, 0;
        RETURN;
    END IF;
    
    -- Мы получили блокировку - выполняем
    INSERT INTO payroll_transactions (employee_id, amount, date)
    SELECT employee_id, salary, NOW()
    FROM employees
    WHERE status = 'active';
    
    GET DIAGNOSTICS v_count = ROW_COUNT;
    
    RETURN QUERY SELECT TRUE, v_count;
    -- Блокировка автоматически освобождается при COMMIT
END;
$$ LANGUAGE plpgsql;

5. Мониторинг и отладка блокировок

Все активные блокировки

SELECT
    l.locktype,                    -- Тип: relation (table), extend, etc
    l.relation::regclass AS table, -- Имя таблицы
    l.mode,                        -- Режим: AccessShare, ExclusiveLock, etc
    l.granted,                     -- true если получена, false если ждёт
    a.pid,                         -- ID процесса PostgreSQL
    a.usename,                     -- Username
    a.state,                       -- idle, active, fastpath function call
    a.query,                       -- Текущий SQL запрос
    a.xact_start,                  -- Когда началась транзакция
    NOW() - a.xact_start AS xact_duration
FROM pg_locks l
LEFT JOIN pg_stat_activity a ON l.pid = a.pid
ORDER BY l.granted, l.pid;

Что искать:

  • granted = false — процесс ждёт получить блокировку
  • state = 'idle in transaction' — долгая транзакция, занимает блокировку без дела
  • Долгие xact_duration — транзакция чем-то зависла

Найти deadlock-ы и блокирующие запросы

SELECT
    blocked.pid AS blocked_pid,
    blocked.usename AS blocked_user,
    blocked.query AS blocked_query,
    blocked.query_start AS blocked_since,
    blocking.pid AS blocking_pid,
    blocking.usename AS blocking_user,
    blocking.query AS blocking_query,
    NOW() - blocked.query_start AS wait_time
FROM pg_stat_activity blocked
JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON (
    blocked_locks.locktype = blocking_locks.locktype
    AND blocked_locks.pid != blocking_locks.pid
)
JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid
WHERE NOT blocked_locks.granted
ORDER BY blocked.query_start;

Как читать результат:

  • blocked_pid — кто ждёт
  • blocking_pid — кто держит блокировку
  • Если blocked_pid и blocking_pid взаимно ждут друг друга — deadlock

Убить зависший процесс

-- Мягко: позволить завершить текущий запрос, потом отключить
SELECT pg_cancel_backend(12345);  -- Отменить текущий запрос

-- Жёстко: сразу отключить
SELECT pg_terminate_backend(12345);  -- Убить соединение

Типичные ошибки и как их избежать

1. Долгие транзакции с блокировками

-- ❌ ПЛОХО: блокировка удерживается долго
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
PERFORM some_heavy_computation();  -- 30 секунд!
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- ✓ ХОРОШО: минимизируем время блокировки
v_data := (SELECT * FROM accounts WHERE id = 1);
PERFORM some_heavy_computation();  -- Без блокировки
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

2. Блокировка в неправильном порядке (Deadlock)

-- Сессия 1
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
PERFORM pg_sleep(1);
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;

-- Сессия 2 (параллельно)
BEGIN;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
PERFORM pg_sleep(1);
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;  -- Deadlock!
-- ✓ Решение: всегда блокируем в одном порядке
-- Обе сессии блокируют сначала id=1, потом id=2
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;

3. FOR UPDATE в SELECT без UPDATE/DELETE

-- ❌ Ненужное усложнение: блокируем, но не изменяем
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
PERFORM complex_calculation();
COMMIT;  -- Ничего не изменили!

Используйте просто SELECT или FOR SHARE если нужна изоляция от изменений.

4. NOWAIT без обработки ошибки

-- ❌ Приложение упадёт с ошибкой
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- ✓ Правильно: обработайте ошибку
BEGIN;
    SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
EXCEPTION WHEN lock_not_available THEN
    -- Пробуйте позже или вернётё ошибку клиенту
    RAISE EXCEPTION 'Resource is busy, try again later';
END;

Резюме: когда использовать что

Сценарий Используйте
Финансовые операции (деньги не должны теряться) FOR UPDATE
Резервирование товара FOR UPDATE
Несколько процессов читают одни данные FOR SHARE
Быстрый веб-запрос, не ждать блокировку FOR UPDATE NOWAIT
Очередь задач для воркеров FOR UPDATE SKIP LOCKED
JOIN нескольких таблиц, блокировать одну FOR UPDATE OF table
Синхронизация бизнес-логики Advisory locks
Распределённый cron job pg_advisory_xact_lock

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

Оптимистичная блокировка — механизм обеспечения консистентности данных, который проверяет конфликты только при записи, не блокируя строки при чтении. Эффективна в системах с низким уровнем конфликтов конкурентного доступа.

Фундаментальное различие: оптимистичные vs пессимистичные блокировки

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

-- Пессимистичная: заблокировать немедленно
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

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

-- Оптимистичная: прочитать, затем проверить при обновлении
SELECT id, balance, version FROM accounts WHERE id = 1;  -- version = 5
UPDATE accounts SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;  -- Проверяем, что версия не изменилась

Оптимистичная блокировка лучше масштабируется когда: чтения значительно превышают записи, конфликты редки (обычно < 5% операций), система распределена на несколько серверов/БД. Пессимистичная предпочтительна когда: частые конфликты, небольшое число потребителей, критична 100% гарантия атомарности без retry логики.

Механизм версионирования: как отследить изменения

Версионирование через числовое поле — самый прямолинейный подход. Каждая строка имеет поле version, которое инкрементируется с каждым обновлением. При записи мы передаем ожидаемую версию и обновляем только если она совпадает.

CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL,
    balance DECIMAL(10,2) NOT NULL DEFAULT 0,
    version INT NOT NULL DEFAULT 1,
    updated_at TIMESTAMP DEFAULT NOW()
);

Процесс выглядит так:

  1. Приложение читает запись и её текущую версию: SELECT id, balance, version FROM accounts WHERE id = 1; (получает version = 5)
  2. Приложение совершает логику, изменяя balance
  3. При обновлении мы передаем ожидаемую версию: UPDATE accounts SET balance = balance - 100.00, version = version + 1 WHERE id = 1 AND version = 5;
  4. Если никто не обновил запись, условие version = 5 истинно, обновление происходит успешно
  5. Если другой процесс уже обновил строку, version уже не равна 5, условие ложно, UPDATE затрагивает 0 строк — конфликт!
-- Проверка: сколько строк было обновлено?
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows = 0 THEN
    RAISE EXCEPTION 'Optimistic lock failure: data was modified';
END IF;

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

Конкретный пример: транспортировка денег с retry логикой

Рассмотрим сложный сценарий — перевод денег между счетами. Нужно обновить два счета атомарно, обработав конфликты retry механизмом:

CREATE OR REPLACE FUNCTION transfer_money_optimistic(
    from_account_id INT,
    to_account_id INT,
    amount DECIMAL(10,2),
    max_retries INT DEFAULT 3
) RETURNS BOOLEAN AS $$
DECLARE
    retry_count INT := 0;
    affected_rows INT;
BEGIN
    WHILE retry_count < max_retries LOOP
        BEGIN
            -- Пытаемся обновить оба счета атомарно
            -- Если версии не совпадают, ни одно обновление не произойдет
            UPDATE accounts 
            SET balance = balance - amount, version = version + 1
            WHERE id = from_account_id 
              AND balance >= amount;
            
            -- Проверяем, обновилась ли строка
            GET DIAGNOSTICS affected_rows = ROW_COUNT;
            IF affected_rows = 0 THEN
                -- Либо версия конфликт, либо недостаточно средств
                RAISE EXCEPTION 'Insufficient funds or lock conflict on source account';
            END IF;

            -- Теперь обновляем счет получателя
            UPDATE accounts 
            SET balance = balance + amount, version = version + 1
            WHERE id = to_account_id;
            
            GET DIAGNOSTICS affected_rows = ROW_COUNT;
            IF affected_rows = 0 THEN
                -- Конфликт версий на счете получателя
                RAISE EXCEPTION 'Lock conflict on target account';
            END IF;

            -- Если мы здесь, оба обновления успешны
            RETURN TRUE;

        EXCEPTION WHEN OTHERS THEN
            -- Конфликт или другая ошибка — повторяем
            retry_count := retry_count + 1;
            IF retry_count >= max_retries THEN
                -- Исчерпли количество попыток
                RAISE;
            END IF;
            -- Экспоненциальная задержка перед повтором: 10ms, 20ms, 30ms
            PERFORM pg_sleep(0.01 * retry_count);
        END;
    END LOOP;
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

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

Альтернативный механизм: версионирование через timestamp

Вместо числового счетчика можно использовать TIMESTAMP последнего обновления. Концепция та же: если timestamp, который мы передали при обновлении, не совпадает с текущим значением в БД, значит данные изменились.

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    quantity INT NOT NULL DEFAULT 0,
    last_modified TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Приложение читает текущий timestamp
SELECT id, price, quantity, last_modified FROM products WHERE id = 1;
-- Результат: last_modified = '2024-01-15 10:30:45.123456'

-- Обновление с проверкой timestamp
UPDATE products
SET price = 99.99, quantity = quantity - 1, last_modified = NOW()
WHERE id = 1 AND last_modified = '2024-01-15 10:30:45.123456';

-- Если кто-то изменил этот товар, last_modified уже другой, UPDATE затронет 0 строк

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

Встроенный механизм PostgreSQL: xmin

PostgreSQL имеет встроенную системную колонку xmin — идентификатор транзакции, которая создала видимую версию строки. Когда строка обновляется, создается новая версия (MVCC), и старая версия помечается как невидимая, но её xmin остается.

-- Явно выбираем xmin
SELECT id, balance, xmin FROM accounts WHERE id = 1;
-- xmin = 12345 (например)

-- Обновляем с проверкой xmin
UPDATE accounts
SET balance = balance - 100.00
WHERE id = 1 AND xmin = 12345::xid;

Подвох: xmin может переполниться (4 млрд транзакций), требуя периодического VACUUM. Кроме того, это низкоуровневая деталь PostgreSQL, и полагаться на неё напрямую менее переносимо, чем на явное поле version. Используется редко в приложениях, чаще встречается в специализированных инструментах репликации.

Реализация в Java с чистым JDBC

При работе с JDBC нужна ручная обработка версий и проверка количества затронутых строк.

Entity класс

public class Account {
    private Long id;
    private BigDecimal balance;
    private Integer version;

    public Account() {}
    
    public Account(Long id, BigDecimal balance, Integer version) {
        this.id = id;
        this.balance = balance;
        this.version = version;
    }

    // Геттеры, сеттеры опущены для краткости
    public Long getId() { return id; }
    public BigDecimal getBalance() { return balance; }
    public Integer getVersion() { return version; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
    public void setVersion(Integer version) { this.version = version; }
}

DAO с оптимистичными блокировками

public class AccountDAO {
    private final DataSource dataSource;

    public AccountDAO(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Account findById(Long id) throws SQLException {
        String sql = "SELECT id, balance, version FROM accounts WHERE id = ?";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setLong(1, id);

            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return new Account(
                        rs.getLong("id"),
                        rs.getBigDecimal("balance"),
                        rs.getInt("version")
                    );
                }
                return null;
            }
        }
    }

    // Критический метод: обновляем только если версия совпадает
    public boolean updateBalance(Long id, BigDecimal newBalance, Integer expectedVersion)
            throws SQLException {
        String sql = "UPDATE accounts SET balance = ?, version = version + 1 " +
                    "WHERE id = ? AND version = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            stmt.setBigDecimal(1, newBalance);
            stmt.setLong(2, id);
            stmt.setInt(3, expectedVersion);

            // executeUpdate() возвращает количество затронутых строк
            int affected = stmt.executeUpdate();
            return affected > 0;  // true = успешно, false = конфликт версий
        }
    }
}

Сервис с retry логикой

public class AccountService {
    private final AccountDAO accountDAO;
    private static final int MAX_RETRIES = 3;
    private static final int RETRY_DELAY_MS = 50;

    public AccountService(AccountDAO accountDAO) {
        this.accountDAO = accountDAO;
    }

    public void transferMoney(Long fromId, Long toId, BigDecimal amount) 
            throws SQLException, OptimisticLockException {
        
        for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
            try {
                // Прочитаем оба счета с их текущими версиями
                Account fromAccount = accountDAO.findById(fromId);
                Account toAccount = accountDAO.findById(toId);

                if (fromAccount == null || toAccount == null) {
                    throw new IllegalArgumentException("Account not found");
                }

                // Проверка бизнес-логики
                if (fromAccount.getBalance().compareTo(amount) < 0) {
                    throw new IllegalArgumentException("Insufficient funds");
                }

                // Вычисляем новые балансы
                BigDecimal newFromBalance = fromAccount.getBalance().subtract(amount);
                BigDecimal newToBalance = toAccount.getBalance().add(amount);

                // Пытаемся обновить ОБОИХ счетов
                // Если хотя бы один конфликтует, оба должны откатиться
                boolean fromSuccess = accountDAO.updateBalance(fromId, newFromBalance, 
                                                              fromAccount.getVersion());
                boolean toSuccess = accountDAO.updateBalance(toId, newToBalance, 
                                                            toAccount.getVersion());

                if (!fromSuccess || !toSuccess) {
                    // Конфликт: данные изменились между SELECT и UPDATE
                    if (attempt < MAX_RETRIES) {
                        // Экспоненциальная задержка перед повтором
                        Thread.sleep(RETRY_DELAY_MS * attempt);
                        continue;  // Повторяем попытку
                    } else {
                        throw new OptimisticLockException(
                            "Transfer failed after " + MAX_RETRIES + " attempts");
                    }
                }

                // Успех!
                return;

            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new SQLException("Transfer interrupted", e);
            }
        }
    }
}

public class OptimisticLockException extends Exception {
    public OptimisticLockException(String message) {
        super(message);
    }
}

Ключевой момент: если обновление одного счета прошло, а второго нет, нам нужно откатить транзакцию полностью. В обычных операциях это сложнее — нужна вложенность или координация. JDBC с autocommit=true усложняет ситуацию. На практике используют Connection.setAutoCommit(false) и manual commit/rollback, или полагаются на фреймворки.

Использование JPA/Hibernate: аннотация @Version

Hibernate предоставляет встроенную поддержку оптимистичных блокировок через аннотацию @Version. Фреймворк автоматически управляет версионированием и проверкой конфликтов.

Entity с @Version

@Entity
@Table(name = "accounts")
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(precision = 10, scale = 2, nullable = false)
    private BigDecimal balance = BigDecimal.ZERO;

    @Version  // Hibernate автоматически управляет этим полем
    private Integer version;

    @UpdateTimestamp
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    protected Account() {}
    
    public Account(BigDecimal balance) {
        this.balance = balance;
    }

    // Бизнес-методы изменяют состояние
    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new IllegalArgumentException("Insufficient funds");
        }
        balance = balance.subtract(amount);
        // Заметьте: version НЕ меняем явно, Hibernate сделает это
    }

    public void deposit(BigDecimal amount) {
        if (amount.signum() <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        balance = balance.add(amount);
    }

    public Long getId() { return id; }
    public BigDecimal getBalance() { return balance; }
    public Integer getVersion() { return version; }
}

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {}

Сервис с Hibernate

@Service
@Transactional
public class AccountService {
    private final AccountRepository accountRepository;

    public AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // @Retryable автоматически перепробует метод при OptimisticLockingFailureException
    @Retryable(
        value = {OptimisticLockingFailureException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 50, multiplier = 2)  // 50ms, 100ms, 200ms
    )
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
        Account fromAccount = accountRepository.findById(fromId)
            .orElseThrow(() -> new EntityNotFoundException("Source account not found"));
        
        Account toAccount = accountRepository.findById(toId)
            .orElseThrow(() -> new EntityNotFoundException("Target account not found"));

        // Hibernate перехватит эти вызовы и создаст SQL UPDATE с проверкой версии
        fromAccount.withdraw(amount);
        toAccount.deposit(amount);

        // Сохраняем изменения. При flush транзакции Hibernate проверит версии
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
        
        // Если версия не совпадает, выбросится OptimisticLockingFailureException
        // и @Retryable повторит весь метод заново
    }

    // Fallback метод, если retry исчерпаны
    @Recover
    public void recoverFromOptimisticLock(OptimisticLockingFailureException ex,
                                         Long fromId, Long toId, BigDecimal amount) {
        // Логирование ошибки, отправка алерта, или graceful degradation
        throw new BusinessException(
            "Transfer failed due to too many concurrent modifications. Try again.",
            ex
        );
    }
}

Как это работает:

  1. Когда transactional метод начинается, Hibernate открывает сессию
  2. При чтении Account версия загружается в память
  3. При вызове withdraw/deposit объект изменяется, но SQL не выполняется
  4. При выходе из метода (commit), Hibernate выполняет UPDATE с WHERE clause проверкой версии
  5. Если версия не совпадает, БД вернет 0 затронутых строк, Hibernate выбросит OptimisticLockingFailureException
  6. Spring @Retryable перехватит исключение и повторит весь метод

Преимущество: Нам не нужно писать ручную retry логику или проверки ROW_COUNT. Фреймворк делает это за нас.

Продвинутые техники

Условные обновления (optimistic locking + бизнес-правила)

Можно комбинировать версионирование с дополнительными проверками бизнес-логики в WHERE clause:

UPDATE products
SET quantity = quantity - 1, version = version + 1
WHERE id = 1
  AND version = 5                    -- Оптимистичная блокировка
  AND quantity > 0                   -- Товар в наличии
  AND status = 'available'           -- Доступен для продажи
  AND price < 1000;                  -- Цена в разумных пределах

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

Batch операции с проверкой версий

Обновление нескольких строк за один запрос:

public void updateMultipleAccounts(List<AccountUpdate> updates) throws SQLException {
    String sql = "UPDATE accounts SET balance = ?, version = version + 1 " +
                "WHERE id = ? AND version = ?";

    try (Connection conn = dataSource.getConnection();
         PreparedStatement stmt = conn.prepareStatement(sql)) {
        
        conn.setAutoCommit(false);

        // Добавляем в batch
        for (AccountUpdate update : updates) {
            stmt.setBigDecimal(1, update.getNewBalance());
            stmt.setLong(2, update.getAccountId());
            stmt.setInt(3, update.getExpectedVersion());
            stmt.addBatch();
        }

        // Выполняем batch и получаем количество затронутых строк для каждого
        int[] results = stmt.executeBatch();

        // Проверяем: все ли обновления прошли?
        boolean allSuccess = true;
        for (int i = 0; i < results.length; i++) {
            if (results[i] == 0) {
                // Конфликт версии на i-й записи
                allSuccess = false;
                System.err.println("Conflict at index " + i);
            }
        }

        if (!allSuccess) {
            conn.rollback();
            throw new OptimisticLockException("Some records conflicted");
        }

        conn.commit();
    }
}

Тонкость: batch операции возвращают массив результатов. Если даже одна запись конфликтует, обычная стратегия — откатить всё и повторить. Но можно реализовать partial retry: перечитать конфликтующие записи и повторить только их.

Compare-and-Swap (CAS) паттерн

Вместо числового версионирования можно использовать проверку старого значения при обновлении:

public boolean compareAndSwap(Long recordId, String oldValue, String newValue)
        throws SQLException {
    String sql = "UPDATE records SET value = ?, version = version + 1 " +
                "WHERE id = ? AND value = ?";

    try (PreparedStatement stmt = connection.prepareStatement(sql)) {
        stmt.setString(1, newValue);
        stmt.setLong(2, recordId);
        stmt.setString(3, oldValue);

        return stmt.executeUpdate() > 0;  // true если успешно, false если oldValue не совпадал
    }
}

// Использование
String current = readValue(recordId);
while (!compareAndSwap(recordId, current, newValue)) {
    // Конфликт: значение изменилось
    current = readValue(recordId);  // Перечитываем
    newValue = computeNewValue(current);  // Пересчитываем
    // Пытаемся снова
}

Этот паттерн полезен когда значение атомарно и полностью видимо приложению. Например, при обновлении JSON полей, когда приложение отправляет новый JSON и проверяет, что старый совпадает.

Стратегии обработки конфликтов

Fail-Fast (без retry)

@Service
public class FailFastAccountService {
    public void transferMoney(Long fromId, Long toId, BigDecimal amount) 
            throws BusinessException {
        try {
            performTransfer(fromId, toId, amount);
        } catch (OptimisticLockingFailureException e) {
            // Сразу сообщаем клиенту: попробуй позже
            throw new BusinessException("Transaction failed due to concurrent access. " +
                                      "Please retry your request.", e);
        }
    }
}

Используется когда retry вероятно не поможет (например, конфликт между двумя юзерами, редактирующими один документ) или когда нужна явная обратная связь пользователю.

Автоматический retry с exponential backoff

@Service
public class RetryAccountService {
    private static final int MAX_RETRIES = 3;
    private static final long INITIAL_DELAY = 50;  // ms

    public void transferMoney(Long fromId, Long toId, BigDecimal amount)
            throws BusinessException {
        OptimisticLockingFailureException lastException = null;

        for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
            try {
                performTransfer(fromId, toId, amount);
                return;  // Успех!
            } catch (OptimisticLockingFailureException e) {
                lastException = e;
                if (attempt < MAX_RETRIES) {
                    long delay = INITIAL_DELAY * (long) Math.pow(2, attempt - 1);
                    try {
                        Thread.sleep(delay);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new BusinessException("Transfer interrupted", ie);
                    }
                }
            }
        }

        throw new BusinessException(
            "Transfer failed after " + MAX_RETRIES + " attempts",
            lastException
        );
    }
}

Exponential backoff (50ms, 100ms, 200ms) снижает вероятность повторного конфликта, так как другие транзакции успеют завершиться. Обычно 3 retry достаточно.

Merge стратегия

Если конфликт содержит конкурирующие изменения, которые можно скомбинировать, используется merge:

@Service
public class MergeAccountService {
    public void updateAccountProfile(Long accountId, AccountUpdateRequest request) {
        int maxAttempts = 3;
        for (int attempt = 0; attempt < maxAttempts; attempt++) {
            try {
                Account current = accountRepository.findById(accountId)
                    .orElseThrow(() -> new EntityNotFoundException("Account not found"));

                // Кто-то мог изменить поля между нашим SELECT и обновлением
                // Мы пытаемся "мержить" изменения: применить запрос к текущему состоянию
                Account merged = new Account(current.getId());
                merged.setVersion(current.getVersion());
                merged.setEmail(request.getEmail() != null ? 
                               request.getEmail() : current.getEmail());
                merged.setPhone(request.getPhone() != null ? 
                               request.getPhone() : current.getPhone());
                merged.setName(request.getName() != null ? 
                              request.getName() : current.getName());

                accountRepository.save(merged);
                return;  // Успех

            } catch (OptimisticLockingFailureException e) {
                if (attempt >= maxAttempts - 1) {
                    throw new BusinessException("Failed to update account after " +
                                              maxAttempts + " attempts", e);
                }
                // Повторяем с новым текущим состоянием
                try {
                    Thread.sleep(50 * (attempt + 1));
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new BusinessException("Update interrupted", ie);
                }
            }
        }
    }
}

Merge полезна когда изменения ортогональны (например, один юзер меняет email, другой меняет phone). Вместо отката обоих, мы комбинируем обновления.

Когда использовать оптимистичные блокировки

Выбирайте оптимистичные блокировки если:

  • Конфликты редки (< 5% операций)
  • Большинство операций — это чтение
  • Система распределена, мультисерверная, микросервисы
  • Нужна высокая пропускная способность
  • Данные могут изменяться между SELECT и UPDATE (хороший признак редких конфликтов)

Выбирайте пессимистичные (FOR UPDATE) если:

  • Конфликты частые (> 50% операций)
  • Критично избежать retry логики
  • Небольшое число конкурирующих процессов
  • Гарантия атомарности важнее производительности

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

Уровни изоляции транзакций

Изоляция транзакций — свойство ACID, определяющее видимость изменений между параллельными транзакциями. PostgreSQL поддерживает 4 уровня изоляции по стандарту SQL, но реально реализует только 3 (READ UNCOMMITTED работает как READ COMMITTED).

1. Проблемы параллельных транзакций

Dirty Read (Грязное чтение)

Суть проблемы: транзакция читает данные, которые были изменены другой транзакцией, но еще не зафиксированы (uncommitted). Если изменяющая транзакция откатится, первая транзакция получит данные, которых никогда не существовало в реальности.

-- Транзакция 1                    -- Транзакция 2
BEGIN;
UPDATE accounts SET balance = 1000 WHERE id = 1;
                                   BEGIN;
                                   SELECT balance FROM accounts WHERE id = 1;
                                   -- Видит 1000 (uncommitted)
ROLLBACK;                          -- Но транзакция 1 откатывается!

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

В PostgreSQL: полностью предотвращено на всех уровнях изоляции, так как используется MVCC.

Non-Repeatable Read (Неповторяемое чтение)

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

-- Транзакция 1                    -- Транзакция 2
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- Читает 500
                                   BEGIN;
                                   UPDATE accounts SET balance = 1000 WHERE id = 1;
                                   COMMIT;
SELECT balance FROM accounts WHERE id = 1;  -- Читает 1000!
COMMIT;

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

Механизм в PostgreSQL: на уровне READ COMMITTED каждый новый statement получает fresh snapshot БД (новый снимок состояния), который включает все недавно закоммиченные изменения. Поэтому повторное чтение видит новые данные.

Phantom Read (Фантомное чтение)

Суть проблемы: повторный запрос с условием WHERE возвращает разное количество строк, потому что другая транзакция добавила/удалила строки, удовлетворяющие условию.

-- Транзакция 1                    -- Транзакция 2
BEGIN;
SELECT COUNT(*) FROM accounts WHERE balance > 500;  -- 5
                                   INSERT INTO accounts (balance) VALUES (1000);
                                   COMMIT;
SELECT COUNT(*) FROM accounts WHERE balance > 500;  -- 6!
COMMIT;

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

Важное отличие от Non-Repeatable Read: здесь изменяется не значение существующей строки, а состав выборки (появляются/исчезают строки).

Serialization Anomaly (Аномалия сериализации)

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

Классический пример — Write Skew:

-- Начальное состояние: Alice = 500$, Bob = 500$
-- Правило: сумма всех счетов >= 0

-- Транзакция 1 (Alice снимает 600$)
BEGIN;
SELECT SUM(balance) FROM accounts;  -- 1000$ (всё ок для снятия 600$)
UPDATE accounts SET balance = balance - 600 WHERE owner = 'Alice';
-- Alice = -100$, Bob = 500$, сумма = 400$
COMMIT;

-- Транзакция 2 (Bob снимает 600$)
BEGIN;
SELECT SUM(balance) FROM accounts;  -- 1000$ (всё ок для снятия 600$)
UPDATE accounts SET balance = balance - 600 WHERE owner = 'Bob';
-- Alice = -100$, Bob = -100$, сумма = -200$ ← нарушено правило!
COMMIT;

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

Механизм возникновения: каждая транзакция читает данные (read set) и на их основе принимает решение, затем модифицирует другие данные (write set). При параллельном выполнении read set одной транзакции пересекается с write set другой, но это обнаруживается слишком поздно.

2. Уровни изоляции

Матрица проблем

READ UNCOMMITTED (в PostgreSQL = READ COMMITTED):

  • ✓ Dirty Read предотвращен
  • ✗ Non-Repeatable Read возможен
  • ✗ Phantom Read возможен
  • ✗ Serialization Anomaly возможна

READ COMMITTED (по умолчанию):

  • ✓ Dirty Read предотвращен
  • ✗ Non-Repeatable Read возможен
  • ✗ Phantom Read возможен
  • ✗ Serialization Anomaly возможна

REPEATABLE READ:

  • ✓ Dirty Read предотвращен
  • ✓ Non-Repeatable Read предотвращен
  • ✓ Phantom Read предотвращен (это сильнее, чем требует SQL стандарт)
  • ✗ Serialization Anomaly возможна

SERIALIZABLE:

  • ✓ Все проблемы предотвращены

Установка уровня изоляции

-- Для текущей транзакции
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Для сессии
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- Глобально в postgresql.conf
default_transaction_isolation = 'repeatable read'

-- Проверка текущего уровня
SHOW transaction_isolation;

3. READ COMMITTED (по умолчанию)

Механизм работы

Ключевая особенность: новый snapshot (снимок состояния БД) создается для каждого statement в транзакции.

Что такое snapshot:

  • Это структура данных, содержащая информацию о состоянии всех транзакций на момент его создания
  • Включает: xmin (минимальный ID активной транзакции), xmax (следующий ID транзакции, который будет выдан), xip (список активных транзакций)
  • На основе snapshot решается, видна ли конкретная версия строки (tuple) текущей транзакции

Видимость данных:

  • Видны только committed данные на момент начала statement
  • Каждый новый SELECT/UPDATE/DELETE видит все изменения, закоммиченные к моменту его выполнения
  • Высокая производительность из-за минимальных блокировок

Пример: изменения видны между statements

-- Сессия 1 (READ COMMITTED)
BEGIN;
SELECT * FROM accounts WHERE id = 1;  -- alice | 1000.00

-- Сессия 2
UPDATE accounts SET balance = 1500.00 WHERE id = 1;
COMMIT;

-- Сессия 1 (новый snapshot для этого SELECT!)
SELECT * FROM accounts WHERE id = 1;  -- alice | 1500.00 (видит изменения!)
COMMIT;

Почему так: между двумя SELECT в транзакции 1 создается новый snapshot, который включает уже закоммиченные изменения из сессии 2.

Блокировка при UPDATE и механизм retry

-- Сессия 1
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
-- Установлена row-level блокировка на строку id=1

-- Сессия 2 (READ COMMITTED)
BEGIN;
UPDATE accounts SET balance = balance + 200 WHERE id = 1;  
-- Ждет освобождения блокировки от сессии 1

-- Сессия 1
COMMIT;  -- balance теперь 1100, блокировка снята

-- Сессия 2 АВТОМАТИЧЕСКИ:

-- 1. Получает блокировку
-- 2. Перечитывает СВЕЖУЮ версию строки (1100)
-- 3. Применяет операцию: 1100 + 200 = 1300
COMMIT;

Критически важный момент: в READ COMMITTED при конфликте UPDATE не завершается ошибкой, а ждет. После освобождения блокировки PostgreSQL:

  1. Перечитывает строку заново (получает свежую версию с учетом закоммиченных изменений)
  2. Пересчитывает WHERE условие на свежих данных
  3. Если строка больше не удовлетворяет WHERE — пропускает её
  4. Если удовлетворяет — применяет операцию к свежим данным

Проблема Lost Update здесь предотвращена благодаря автоматическому retry на свежих данных. Но Write Skew возможна, если между SELECT (для принятия решения) и UPDATE данные изменились.

Применение

  • OLTP приложения с высокой нагрузкой
  • Веб-приложения с короткими транзакциями
  • Когда критична производительность, а не абсолютная консистентность
  • Когда транзакции не опираются на бизнес-логику типа "прочитать → принять решение → изменить"

4. REPEATABLE READ

Механизм работы

Ключевая особенность: snapshot создается один раз при выполнении первого statement в транзакции и используется до конца транзакции.

Реализация через MVCC:

  • Каждая версия строки (tuple) имеет метаданные: xmin (ID транзакции, создавшей версию) и xmax (ID транзакции, удалившей версию)
  • Snapshot содержит информацию, какие транзакции были active/committed на момент его создания
  • Для каждой tuple проверяется видимость на основе snapshot: видна ли версия с данными xmin/xmax

Правила видимости tuple:

  1. Создавшая транзакция (xmin) должна быть закоммичена до создания snapshot
  2. Удаляющая транзакция (xmax) не должна быть закоммичена до создания snapshot
  3. Транзакция не видит изменения, закоммиченные после создания snapshot

Пример: изменения НЕ видны

-- Сессия 1 (REPEATABLE READ)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts WHERE id = 1;  -- alice | 1000.00
-- Snapshot зафиксирован

-- Сессия 2
UPDATE accounts SET balance = 1500.00 WHERE id = 1;
COMMIT;

-- Сессия 1 (использует ТОТ ЖЕ snapshot!)
SELECT * FROM accounts WHERE id = 1;  -- alice | 1000.00 (старые данные!)
COMMIT;

Почему так: snapshot был создан до коммита в сессии 2. По правилам видимости, транзакция 1 не может видеть изменения с xmin больше, чем xmax её snapshot.

Предотвращение Phantom Read

-- Сессия 1 (REPEATABLE READ)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM accounts WHERE balance > 600;  -- 2

-- Сессия 2
INSERT INTO accounts (balance) VALUES (800.00);
COMMIT;

-- Сессия 1 (тот же snapshot → новая строка не видна)
SELECT COUNT(*) FROM accounts WHERE balance > 600;  -- Все еще 2
COMMIT;

Почему так: новая строка имеет xmin, равный ID транзакции из сессии 2. Этот xmin больше xmax snapshot транзакции 1, поэтому строка для неё невидима.

Это сильнее стандарта SQL: SQL стандарт допускает Phantom Read на уровне REPEATABLE READ, но PostgreSQL предотвращает их благодаря snapshot isolation.

Serialization failure и механизм first-updater-wins

-- Сессия 1 (REPEATABLE READ)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- 1000.00

-- Сессия 2
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = 1200.00 WHERE id = 1;
COMMIT;  -- Успешно

-- Сессия 1
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
ROLLBACK;

Почему ошибка:

  1. Сессия 1 прочитала строку в своем snapshot (version с xmin от старой транзакции)

  2. Сессия 2 успешно изменила и закоммитила строку (создала новую version с новым xmin)

  3. Сессия 1 пытается изменить строку, но PostgreSQL обнаруживает, что:

    • Есть более новая version строки (с xmin больше, чем xmax snapshot сессии 1)
    • Эта version создана закоммиченной транзакцией
    • Сессия 1 работает в REPEATABLE READ и не может "перепрыгнуть" на новую версию
  4. PostgreSQL прерывает транзакцию с serialization_failure

Правило first-updater-wins: первая транзакция, которая успела закоммитить изменение строки, побеждает. Все остальные получают ошибку.

Обработка serialization failures с retry

CREATE OR REPLACE FUNCTION transfer_with_retry(
    from_id INT, to_id INT, amount DECIMAL, max_retries INT DEFAULT 5
) RETURNS BOOLEAN AS $$
DECLARE
    retry_count INT := 0;
BEGIN
    WHILE retry_count < max_retries LOOP
        BEGIN
            -- Начинаем новую транзакцию с REPEATABLE READ
            BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

            -- Проверка баланса
            IF (SELECT balance FROM accounts WHERE id = from_id) < amount THEN
                ROLLBACK;
                RETURN FALSE;
            END IF;

            -- Выполняем перевод
            UPDATE accounts SET balance = balance - amount WHERE id = from_id;
            UPDATE accounts SET balance = balance + amount WHERE id = to_id;

            COMMIT;
            RETURN TRUE;

        EXCEPTION
            WHEN serialization_failure THEN
                ROLLBACK;
                retry_count := retry_count + 1;
                IF retry_count >= max_retries THEN
                    RAISE;  -- Пробрасываем ошибку выше
                END IF;
                -- Exponential backoff
                PERFORM pg_sleep(0.01 * retry_count);
        END;
    END LOOP;
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

Важно:

  • Retry обязателен на уровне приложения
  • Exponential backoff снижает нагрузку при высокой конкуренции
  • Нужен лимит попыток, чтобы не зациклиться

Применение

  • Отчеты и аналитика (консистентное чтение данных)
  • Batch обработка больших объемов
  • Финансовые операции, требующие repeatable reads
  • Когда нужна консистентность в рамках транзакции, но можно обработать ошибки retry

5. SERIALIZABLE

Механизм работы: Serializable Snapshot Isolation (SSI)

Ключевая идея: эмулировать последовательное (serial) выполнение транзакций, хотя физически они выполняются параллельно.

Как это работает:

  1. Predicate Locks (Предикатные блокировки):

    • Это не настоящие блокировки — они не блокируют выполнение
    • Это маркеры (SIRead locks), отслеживающие, какие данные транзакция прочитала
    • Могут быть на уровне tuple, page или целой таблицы (в зависимости от типа scan)
  2. Dependency Graph (Граф зависимостей):

    • PostgreSQL строит граф read-write зависимостей между транзакциями
    • Если находится цикл в графе → обнаружена serialization anomaly
    • Одна из транзакций в цикле откатывается с ошибкой
  3. Read set и Write set:

    • Read set: данные, которые транзакция прочитала
    • Write set: данные, которые транзакция изменила
    • Конфликт: read set транзакции A пересекается с write set транзакции B

Пример: предотвращение Write Skew

-- Начальное состояние: checking = 1000, savings = 1000
-- Правило: SUM(balance) >= 2000

-- Сессия 1 (SERIALIZABLE)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM balances;  -- 2000.00
-- SIRead lock на все прочитанные строки
UPDATE balances SET balance = balance - 100 WHERE account_type = 'checking';
-- checking = 900

-- Сессия 2 (SERIALIZABLE)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM balances;  -- 2000.00
-- SIRead lock на все прочитанные строки
UPDATE balances SET balance = balance - 100 WHERE account_type = 'savings';
-- savings = 900

-- Сессия 1
COMMIT;  -- Успешно

-- Сессия 2
COMMIT;  -- ERROR: could not serialize access due to read/write dependencies

Почему ошибка:

  1. Обе транзакции прочитали строки (установили SIRead locks)
  2. Транзакция 1 изменила checking (write set пересекается с read set транзакции 2)
  3. Транзакция 2 изменила savings (write set пересекается с read set транзакции 1)
  4. Образовался цикл зависимостей: T1 → T2 → T1
  5. PostgreSQL откатывает последнюю коммитящуюся транзакцию

Если бы они выполнялись последовательно:

  • T1 → T2: SUM=2000, T1 списывает 100, SUM=1900, T2 видит 1900 и не спишет
  • T2 → T1: SUM=2000, T2 списывает 100, SUM=1900, T1 видит 1900 и не спишет

Параллельное выполнение дало бы SUM=1800, что невозможно при serial execution.

Read-only транзакции

-- Read-only SERIALIZABLE никогда не получают serialization failure
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY;
SELECT * FROM accounts;
SELECT SUM(balance) FROM accounts;
COMMIT;  -- Всегда успешно

Почему безопасно: read-only транзакции не создают write set, поэтому не могут образовывать циклы зависимостей. Они всегда получают консистентный snapshot и не могут быть источником аномалий.

DEFERRABLE транзакции

-- Может подождать безопасного snapshot
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT * FROM accounts;
COMMIT;

Суть: транзакция может подождать момента, когда можно будет взять snapshot без риска конфликтов. Полезно для длительных read-only операций (например, backup).

Predicate Locks: почему False Positives

Проблема: при малых таблицах или отсутствии подходящих индексов PostgreSQL может использовать SeqScan вместо Index Scan.

-- Сессия 1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM devices WHERE customer_id = 1;  -- SeqScan!
-- SIRead lock на ВСЮ таблицу, а не только на строки customer_id=1

-- Сессия 2
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM devices WHERE customer_id = 2;  -- SeqScan!
-- SIRead lock на ВСЮ таблицу
INSERT INTO devices (customer_id) VALUES (2);

-- Конфликт, хотя строки разные!
COMMIT;  -- ERROR: could not serialize access

Почему: predicate locks ставятся на данные, которые сканируются, а не только на те, которые возвращаются. SeqScan сканирует всю таблицу → lock на всю таблицу.

Решение:

  • Добавить индексы для Index Scan
  • Использовать SET enable_seqscan = off; (для тестирования)
  • Понимать, что false positives — это trade-off между производительностью и точностью

Настройки

-- postgresql.conf
max_pred_locks_per_transaction = 64      -- Predicate locks на транзакцию
max_pred_locks_per_relation = -2         -- На таблицу (-2 = auto)
max_pred_locks_per_page = 2              -- На страницу

-- Мониторинг predicate locks
SELECT locktype, relation::regclass, mode, granted
FROM pg_locks 
WHERE mode = 'SIReadLock';

Применение

  • Критически важные финансовые операции
  • Высокие требования к консистентности (абсолютная защита от аномалий)
  • Сложные business rules с read-then-write паттернами
  • Низкая нагрузка (высокая вероятность serialization failures при конкуренции)

6. Практические примеры

Проблема: Lost Update в READ COMMITTED

-- Начальное состояние: balance = 1000

-- Сессия 1
BEGIN;  -- READ COMMITTED
SELECT balance FROM accounts WHERE id = 1;  -- 1000
-- ... бизнес-логика на клиенте: new_balance = 1000 + 100 = 1100

-- Сессия 2
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 1000
-- ... бизнес-логика: new_balance = 1000 + 200 = 1200
UPDATE accounts SET balance = 1200 WHERE id = 1;
COMMIT;

-- Сессия 1
UPDATE accounts SET balance = 1100 WHERE id = 1;  -- Перезаписало 1200!
COMMIT;
-- Потерян UPDATE сессии 2

Проблема: вычисление происходит на клиенте на основе прочитанных данных. Между SELECT и UPDATE данные изменились, но клиент об этом не знает.

Решение 1: Операция в одном statement (READ COMMITTED)

CREATE OR REPLACE FUNCTION transfer_read_committed(
    from_id INT, to_id INT, amount DECIMAL
) RETURNS BOOLEAN AS $$
BEGIN
    -- Всё в одной транзакции, операции атомарны
    BEGIN;
    
    -- Проверка и списание в одном месте
    IF (SELECT balance FROM accounts WHERE id = from_id) < amount THEN
        ROLLBACK;
        RETURN FALSE;
    END IF;

    -- UPDATE с relative операцией (не абсолютное значение!)
    UPDATE accounts SET balance = balance - amount WHERE id = from_id;
    UPDATE accounts SET balance = balance + amount WHERE id = to_id;

    COMMIT;
    RETURN TRUE;
END;
$$ LANGUAGE plpgsql;

Ключевой момент: balance = balance - amount — операция relative. PostgreSQL выполнит её атомарно на актуальных данных в момент UPDATE.

Решение 2: SELECT FOR UPDATE (Pessimistic Locking)

BEGIN;
-- Блокируем строку для чтения и изменения
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Другие транзакции будут ждать

-- Вычисляем на клиенте (или в приложении)
-- ... new_balance = balance + 100

UPDATE accounts SET balance = new_balance WHERE id = 1;
COMMIT;

Плюсы: явная блокировка, понятное поведение Минусы: может привести к deadlocks, снижает параллелизм

Решение 3: REPEATABLE READ с retry

CREATE OR REPLACE FUNCTION transfer_repeatable_read(
    from_id INT, to_id INT, amount DECIMAL
) RETURNS BOOLEAN AS $$
DECLARE
    retry_count INT := 0;
BEGIN
    WHILE retry_count < 5 LOOP
        BEGIN
            BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

            IF (SELECT balance FROM accounts WHERE id = from_id) < amount THEN
                ROLLBACK;
                RETURN FALSE;
            END IF;

            UPDATE accounts SET balance = balance - amount WHERE id = from_id;
            UPDATE accounts SET balance = balance + amount WHERE id = to_id;

            COMMIT;
            RETURN TRUE;

        EXCEPTION
            WHEN serialization_failure THEN
                ROLLBACK;
                retry_count := retry_count + 1;
                PERFORM pg_sleep(0.01 * retry_count);
        END;
    END LOOP;
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

Решение 4: SERIALIZABLE с retry

CREATE OR REPLACE FUNCTION transfer_serializable(
    from_id INT, to_id INT, amount DECIMAL
) RETURNS BOOLEAN AS $$
DECLARE
    retry_count INT := 0;
BEGIN
    WHILE retry_count < 10 LOOP  -- Больше попыток из-за SSI
        BEGIN
            BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

            IF (SELECT balance FROM accounts WHERE id = from_id) < amount THEN
                ROLLBACK;
                RETURN FALSE;
            END IF;

            UPDATE accounts SET balance = balance - amount WHERE id = from_id;
            UPDATE accounts SET balance = balance + amount WHERE id = to_id;

            COMMIT;
            RETURN TRUE;

        EXCEPTION
            WHEN serialization_failure THEN
                ROLLBACK;
                retry_count := retry_count + 1;
                PERFORM pg_sleep(0.01 * power(2, retry_count));  -- Exponential backoff
        END;
    END LOOP;
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

Важно: SERIALIZABLE требует больше retry попыток из-за predicate locks и false positives.

7. Мониторинг

Статистика конфликтов и deadlocks

-- Общая статистика по базе
SELECT 
    datname,
    xact_commit AS commits,
    xact_rollback AS rollbacks,
    ROUND(100.0 * xact_rollback / NULLIF(xact_commit + xact_rollback, 0), 2) AS rollback_ratio,
    conflicts,
    deadlocks
FROM pg_stat_database
WHERE datname = current_database();

-- Текущие активные транзакции и их длительность
SELECT 
    pid,
    usename,
    state,
    query_start,
    NOW() - query_start AS duration,
    wait_event_type,
    wait_event,
    LEFT(query, 100) AS query_preview
FROM pg_stat_activity
WHERE state IN ('active', 'idle in transaction')
  AND pid <> pg_backend_pid()
ORDER BY query_start;

-- Блокировки и ожидающие транзакции
SELECT 
    blocked.pid AS blocked_pid,
    blocked.usename AS blocked_user,
    blocking.pid AS blocking_pid,
    blocking.usename AS blocking_user,
    blocked.query AS blocked_query,
    blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_locks AS blocked_locks ON blocked.pid = blocked_locks.pid
JOIN pg_locks AS blocking_locks ON blocked_locks.locktype = blocking_locks.locktype
    AND blocked_locks.database IS NOT DISTINCT FROM blocking_locks.database
    AND blocked_locks.relation IS NOT DISTINCT FROM blocking_locks.relation
    AND blocked_locks.tuple IS NOT DISTINCT FROM blocking_locks.tuple
    AND blocked_locks.virtualxid IS NOT DISTINCT FROM blocking_locks.virtualxid
    AND blocked_locks.transactionid IS NOT DISTINCT FROM blocking_locks.transactionid
    AND blocked_locks.classid IS NOT DISTINCT FROM blocking_locks.classid
    AND blocked_locks.objid IS NOT DISTINCT FROM blocking_locks.objid
    AND blocked_locks.objsubid IS NOT DISTINCT FROM blocking_locks.objsubid
    AND blocked_locks.pid <> blocking_locks.pid
JOIN pg_stat_activity AS blocking ON blocking_locks.pid = blocking.pid
WHERE NOT blocked_locks.granted;

Мониторинг Predicate Locks (SERIALIZABLE)

-- Текущие predicate locks
SELECT 
    locktype,
    relation::regclass AS table_name,
    page,
    tuple,
    pid,
    mode
FROM pg_locks 
WHERE mode = 'SIReadLock'
ORDER BY relation, page, tuple;

-- Статистика по granularity predicate locks
SELECT 
    relation::regclass AS table_name,
    COUNT(*) FILTER (WHERE tuple IS NOT NULL) AS tuple_locks,
    COUNT(*) FILTER (WHERE page IS NOT NULL AND tuple IS NULL) AS page_locks,
    COUNT(*) FILTER (WHERE page IS NULL AND tuple IS NULL) AS relation_locks
FROM pg_locks 
WHERE mode = 'SIReadLock'
GROUP BY relation
ORDER BY relation_locks DESC, page_locks DESC;

Логирование

-- postgresql.conf
log_lock_waits = on                    -- Логировать ожидание блокировок
deadlock_timeout = 1s                  -- Через сколько детектить deadlock
log_statement = 'all'                  -- Логировать все statement (осторожно!)
log_duration = on                      -- Логировать длительность запросов
log_min_duration_statement = 1000      -- Логировать только медленные (> 1s)

-- Для отладки serialization failures
log_error_verbosity = verbose          -- Подробные сообщения об ошибках

8. Best Practices

Выбор уровня изоляции

READ COMMITTED:

  • ✓ OLTP приложения с высокой нагрузкой
  • ✓ Веб-приложения с короткими транзакциями
  • ✓ Простые CRUD операции
  • ✗ Бизнес-логика типа "прочитать → решение → изменить"
  • ✗ Агрегатные операции с последующими действиями

REPEATABLE READ:

  • ✓ Отчеты и аналитика (консистентное чтение)
  • ✓ Batch обработка данных
  • ✓ Финансовые расчеты
  • ✓ Когда можно обработать serialization_failure
  • ✗ Высокая конкуренция за одни и те же строки

SERIALIZABLE:

  • ✓ Критически важные операции
  • ✓ Сложные бизнес-правила с read-then-write
  • ✓ Низкая нагрузка или редкие операции
  • ✗ High-throughput системы (много retry)
  • ✗ Малые таблицы без индексов (false positives)

Минимизация serialization failures

-- ПЛОХО: долгая транзакция с вычислениями
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM accounts;
-- ... длительные вычисления на клиенте ...
-- ... HTTP запросы, бизнес-логика ...
UPDATE accounts SET balance = calculated_value WHERE id = 1;
COMMIT;

-- ХОРОШО: вычисления вне транзакции
-- Подготовка данных вне транзакции
calculated_value := complex_calculation();

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = calculated_value WHERE id = 1;
COMMIT;

Правило: держите транзакции максимально короткими. Чем дольше транзакция, тем выше шанс конфликта.

Универсальная retry функция

CREATE OR REPLACE FUNCTION execute_with_retry(
    sql_command TEXT,
    isolation_level TEXT DEFAULT 'READ COMMITTED',
    max_retries INT DEFAULT 5
) RETURNS BOOLEAN AS $$
DECLARE
    retry_count INT := 0;
    backoff_ms INT;
BEGIN
    WHILE retry_count < max_retries LOOP
        BEGIN
            EXECUTE format('BEGIN TRANSACTION ISOLATION LEVEL %s', isolation_level);
            EXECUTE sql_command;
            COMMIT;
            RETURN TRUE;

        EXCEPTION
            WHEN serialization_failure OR deadlock_detected THEN
                ROLLBACK;
                retry_count := retry_count + 1;
                
                IF retry_count >= max_retries THEN
                    RAISE NOTICE 'Max retries exceeded: %', SQLERRM;
                    RAISE;
                END IF;
                
                -- Exponential backoff с jitter
                backoff_ms := (10 * power(2, retry_count))::INT;
                backoff_ms := backoff_ms + (random() * backoff_ms * 0.1)::INT;
                
                RAISE NOTICE 'Retry % after % ms: %', retry_count, backoff_ms, SQLERRM;
                PERFORM pg_sleep(backoff_ms / 1000.0);
        END;
    END LOOP;
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

Read-only оптимизации

-- Для обычных read-only операций
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT * FROM large_report_view;
COMMIT;
-- Преимущества: не может модифицировать данные, немного быстрее

-- Для долгих отчетов без риска конфликтов
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT * FROM complex_analytical_view;
COMMIT;
-- Преимущества: подождет безопасного snapshot, никогда не получит ошибку

-- Для backup
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- pg_dump использует этот паттерн
COMMIT;

Использование индексов для снижения predicate lock escalation

-- ПЛОХО: SeqScan → predicate lock на всю таблицу
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM devices WHERE customer_id = 1;  -- SeqScan если таблица мала
COMMIT;

-- ХОРОШО: создать индекс → Index Scan → row-level predicate locks
CREATE INDEX idx_devices_customer_id ON devices(customer_id);

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM devices WHERE customer_id = 1;  -- Index Scan → row locks
COMMIT;

Application-level retry wrapper (псевдокод)

# Python пример retry логики
def execute_with_retry(transaction_func, max_retries=5):
    for attempt in range(max_retries):
        try:
            return transaction_func()
        except psycopg2.extensions.TransactionRollbackError as e:
            if "could not serialize" in str(e) or "deadlock" in str(e):
                if attempt >= max_retries - 1:
                    raise
                backoff = (0.01 * (2 ** attempt)) + random.uniform(0, 0.01)
                time.sleep(backoff)
                continue
            raise

# Использование
def transfer_money():
    conn.execute("BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE")
    # ... бизнес-логика ...
    conn.execute("COMMIT")

execute_with_retry(transfer_money)

9. Диагностика проблем

Выявление Write Skew

-- Создать таблицу для audit
CREATE TABLE balance_audit (
    id SERIAL PRIMARY KEY,
    check_time TIMESTAMP DEFAULT NOW(),
    total_balance DECIMAL,
    constraint_violated BOOLEAN
);

-- Триггер для проверки бизнес-правила
CREATE OR REPLACE FUNCTION check_balance_constraint()
RETURNS TRIGGER AS $$
DECLARE
    total DECIMAL;
BEGIN
    SELECT SUM(balance) INTO total FROM accounts;
    
    INSERT INTO balance_audit (total_balance, constraint_violated)
    VALUES (total, total < 0);
    
    IF total < 0 THEN
        RAISE NOTICE 'Business rule violated: total balance = %', total;
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER balance_check_trigger
AFTER INSERT OR UPDATE ON accounts
FOR EACH STATEMENT
EXECUTE FUNCTION check_balance_constraint();

Выявление hot rows (частые конфликты)

-- Найти строки, которые часто изменяются
SELECT 
    schemaname,
    relname,
    n_tup_upd + n_tup_del AS modifications,
    n_tup_upd,
    n_tup_del,
    n_live_tup,
    n_dead_tup,
    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE n_tup_upd + n_tup_del > 1000
ORDER BY modifications DESC
LIMIT 20;

-- Если dead_ratio высок → много concurrent updates → вероятны конфликты

Анализ query plans для predicate locks

-- Проверить, использует ли запрос SeqScan
EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM devices WHERE customer_id = 1;

-- Если видим "Seq Scan" → predicate lock на всю таблицу
-- Решение: добавить индекс или использовать FORCE INDEX

-- Принудительно использовать Index Scan (для теста)
SET enable_seqscan = off;
EXPLAIN SELECT * FROM devices WHERE customer_id = 1;
SET enable_seqscan = on;

10. Распространенные антипаттерны

Антипаттерн 1: Read-then-write в READ COMMITTED

-- ПЛОХО
BEGIN;  -- READ COMMITTED
balance := (SELECT balance FROM accounts WHERE id = 1);
IF balance >= 100 THEN
    -- Между SELECT и UPDATE данные могли измениться!
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
END IF;
COMMIT;

-- ХОРОШО: атомарная операция
BEGIN;
UPDATE accounts 
SET balance = balance - 100 
WHERE id = 1 AND balance >= 100;
-- Проверить, была ли изменена строка
IF NOT FOUND THEN
    RAISE EXCEPTION 'Insufficient balance';
END IF;
COMMIT;

Антипаттерн 2: Забывать про retry в REPEATABLE READ / SERIALIZABLE

-- ПЛОХО: без обработки ошибок
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... операции ...
COMMIT;  -- Может упасть с serialization_failure

-- ХОРОШО: с retry
retry_count := 0;
LOOP
    BEGIN
        BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
        -- ... операции ...
        COMMIT;
        EXIT;  -- Успешно
    EXCEPTION
        WHEN serialization_failure THEN
            IF retry_count >= 5 THEN RAISE; END IF;
            retry_count := retry_count + 1;
    END;
END LOOP;

Антипаттерн 3: Длительные транзакции в SERIALIZABLE

-- ПЛОХО: долгая транзакция
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM orders;  -- Snapshot зафиксирован
-- ... 10 секунд обработки на клиенте ...
-- ... HTTP запросы, вычисления ...
UPDATE orders SET status = 'processed';
COMMIT;  -- Высокий шанс serialization_failure

-- ХОРОШО: минимальная транзакция
-- Обработка вне транзакции
processed_ids := process_orders_logic();

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE orders SET status = 'processed' WHERE id = ANY(processed_ids);
COMMIT;

Антипаттерн 4: Игнорирование индексов

-- ПЛОХО: без индекса → SeqScan → table-level predicate lock
CREATE TABLE events (id INT, user_id INT, event_type TEXT);

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM events WHERE user_id = 123;  -- SeqScan
-- ... другие операции ...
COMMIT;  -- Конфликтует с транзакциями других user_id!

-- ХОРОШО: с индексом → Index Scan → row-level predicate locks
CREATE INDEX idx_events_user_id ON events(user_id);

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM events WHERE user_id = 123;  -- Index Scan
COMMIT;  -- Конфликтует только с тем же user_id

Итоговые рекомендации

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

  • Используйте READ COMMITTED (по умолчанию)
  • Пишите операции атомарно: UPDATE ... SET x = x + delta WHERE ...
  • Избегайте read-then-write паттернов

Для финансовых операций:

  • Используйте REPEATABLE READ с retry логикой
  • Или SELECT FOR UPDATE для explicit locking
  • Тестируйте под нагрузкой

Для критически важной консистентности:

  • Используйте SERIALIZABLE с retry
  • Добавляйте индексы для снижения predicate lock escalation
  • Мониторьте serialization failures

Общие правила:

  • Держите транзакции короткими
  • Обрабатывайте serialization_failure и deadlock
  • Используйте exponential backoff в retry
  • Мониторьте блокировки и конфликты
  • Тестируйте параллельную нагрузку

Индексы

1. Основы индексов

Индексы — это отдельные структуры данных, которые создают быстрый путь доступа к строкам таблицы. Без индекса PostgreSQL должен просканировать всю таблицу (Sequential Scan) для нахождения нужных данных. С индексом же база может сразу перейти к нужным строкам через структуру индекса.

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

Физически индекс хранит кортежи из одной или нескольких колонок исходной таблицы в отсортированном виде. Каждая запись индекса содержит:

  • Значение из индексируемой колонки (например, email)
  • TID (Tuple IDentifier) — физический адрес строки в heap (основном хранилище таблицы)

Когда вы ищете данные, PostgreSQL использует индекс для быстрого поиска TID, а затем обращается к heap только для получения всех остальных колонок.

Баланс производительности

Индексы — это компромисс между скоростью чтения и скоростью записи:

Преимущества:

  • ✓ Ускоряют SELECT запросы (иногда в 100+ раз)
  • ✓ Ускоряют ORDER BY и GROUP BY
  • ✓ Ускоряют JOIN операции

Недостатки:

  • ✗ Требуют дополнительное место на диске (обычно 10-30% от размера таблицы)
  • ✗ Замедляют INSERT, UPDATE, DELETE (нужно обновлять индекс)
  • ✗ Требуют регулярного обслуживания (VACUUM, анализ)

Измерение размера индексов

-- Посмотреть размер всех индексов по таблице
SELECT
    schemaname, tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as total_size,
    pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) as table_size,
    pg_size_pretty(pg_indexes_size(schemaname||'.'||tablename)) as indexes_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_indexes_size(schemaname||'.'||tablename) DESC;

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


2. Типы индексов

B-tree (по умолчанию)

B-tree (Balanced Search Tree) — это самый универсальный и часто используемый тип индекса. Его используют PostgreSQL по умолчанию для всех новых индексов, если не указан другой тип.

Внутренняя структура B-tree

                     [корень: 50]
                    /            \
           [20, 30, 40]        [60, 70, 80]
          /    |    |    \     /   |   |   \
    [5]  [25]  [35] [45]  [55] [65] [75] [85]

Индекс содержит три уровня:

  1. Корневая страница (Root) — содержит ключи, которые определяют диапазоны значений в подзапросах. Например, если вы ищете 35, корень скажет "идите вправо, потому что 35 > 30".

  2. Промежуточные страницы (Internal nodes) — работают так же: содержат ключи для навигации по диапазонам.

  3. Листовые страницы (Leaf nodes) — содержат реальные значения и TID. Это именно то, что нам нужно.

Как работает поиск

Когда вы ищете запись, PostgreSQL:

  1. Стартует с корневой страницы
  2. Сравнивает ваше значение с ключами на текущей странице
  3. Выбирает нужный диапазон и спускается вниз
  4. Повторяет, пока не достигнет листовой страницы
  5. Находит TID нужной строки
  6. Обращается к heap'у для получения остальных колонок

Сложность: O(log n) — даже для таблиц с миллионами строк нужно максимум ~20 обращений к диску.

Создание B-tree индексов

-- Простой индекс на одну колонку
CREATE INDEX idx_users_email ON users (email);

-- Составной индекс (несколько колонок)
CREATE INDEX idx_orders_user_date ON orders (user_id, created_at);

-- Индекс в обратном порядке (для ORDER BY DESC)
CREATE INDEX idx_products_price_desc ON products (price DESC);

-- Управление NULL значениями
CREATE INDEX idx_users_last_login ON users (last_login DESC NULLS LAST);
-- NULLS LAST = NULL значения в конце (по умолчанию для DESC)
-- NULLS FIRST = NULL значения в начале (по умолчанию для ASC)

-- Уникальный индекс (также выполняет роль constraint)
CREATE UNIQUE INDEX idx_users_email_unique ON users (email);
-- Гарантирует, что в колонке email не будет дубликатов

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

B-tree поддерживает эти операторы: =, <, <=, >, >=, BETWEEN, IN, IS NULL, IS NOT NULL

-- Все эти запросы используют индекс на age:
SELECT * FROM users WHERE age = 25;                    -- Точный поиск
SELECT * FROM users WHERE age > 18;                   -- Диапазон
SELECT * FROM users WHERE age BETWEEN 18 AND 65;      -- Диапазон
SELECT * FROM users WHERE age IN (25, 30, 35);        -- Несколько значений
SELECT * FROM users WHERE age IS NULL;                -- NULL значения

-- ORDER BY также использует индекс
SELECT * FROM orders ORDER BY created_at DESC;        -- Быстро, если индекс есть
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10; -- Очень быстро!

Важный момент: префиксные поиски и LIKE

-- LIKE с префиксом ИСПОЛЬЗУЕТ индекс
SELECT * FROM users WHERE email LIKE 'john%';
-- PostgreSQL понимает, что это поиск в диапазоне [john, john~)

-- LIKE с суффиксом НЕ использует индекс
SELECT * FROM users WHERE email LIKE '%@gmail.com';
-- Нужно сканировать все значения, потому что паттерн может быть в любом месте

-- Решение: использовать pg_trgm для полнотекстовых поисков
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_email_trgm ON users USING GIN (email gin_trgm_ops);
SELECT * FROM users WHERE email LIKE '%gmail%';  -- Теперь быстро!

Hash индексы

Hash индексы используют хеширование вместо сбалансированного дерева. Они создают хеш-таблицу на основе значений в колонке.

Главное ограничение: Hash индексы поддерживают ТОЛЬКО операцию равенства =

-- Создание Hash индекса
CREATE INDEX idx_users_id_hash ON users USING HASH (id);

-- Эффективно - работает с Hash индексом
SELECT * FROM users WHERE id = 12345;

-- НЕ эффективно - Hash индекс не помогает
SELECT * FROM users WHERE id > 12345;        -- Нужен B-tree
SELECT * FROM users WHERE id BETWEEN 100 AND 200;  -- Нужен B-tree

Когда использовать:

  • Таблицы с ОЧЕНЬ большим количеством строк (миллиарды)
  • Только точные поиски по одной колонке
  • Когда нужно сэкономить место (Hash может быть меньше B-tree)

Реальность: Hash индексы редко используют, потому что B-tree почти всегда лучше или равен по производительности, но более универсален.


GIN (Generalized Inverted Index)

GIN индексы предназначены для поиска внутри сложных структур данных: массивов, JSONB документов, полнотекстовых данных.

Как работает GIN:

  • Разбирает структуру (например, массив на элементы)
  • Создает обратный индекс: для каждого элемента хранит список TID, где он встречается
  • При поиске быстро находит все TID, содержащие нужный элемент

GIN для массивов

-- Таблица со статьями с тегами
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255),
    tags TEXT[]  -- Массив тегов
);

-- Индекс для поиска по тегам
CREATE INDEX idx_articles_tags ON articles USING GIN (tags);

-- Поиск работает быстро
SELECT * FROM articles WHERE tags @> ARRAY['postgresql', 'database'];
-- Оператор @> означает "содержит элементы"

SELECT * FROM articles WHERE 'postgresql' = ANY(tags);
-- Тоже работает быстро с GIN индексом

GIN для JSONB

-- Таблица с JSON данными
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    attributes JSONB  -- {"color": "red", "size": "L", "material": "cotton"}
);

CREATE INDEX idx_products_attributes ON products USING GIN (attributes);

-- Поиск по JSONB быстро
SELECT * FROM products WHERE attributes @> '{"color": "red"}';
-- Находит все продукты, где color = red, независимо от других полей

SELECT * FROM products WHERE attributes ? 'size';
-- Находит продукты, у которых есть ключ size

GIN для полнотекстовых поисков

-- Полнотекстовый поиск
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255),
    content TEXT
);

-- Создаем полнотекстовый индекс
CREATE INDEX idx_articles_content ON articles 
    USING GIN (to_tsvector('english', content));

-- Поиск очень быстро
SELECT * FROM articles 
WHERE to_tsvector('english', content) @@ to_tsquery('postgresql & performance');
-- Ищет статьи, содержащие ОБА слова: postgresql И performance

Важно о GIN:

  • INSERT/UPDATE медленнее, чем B-tree (нужно индексировать каждый элемент)
  • SELECT намного быстрее для операций @> и @@
  • Требует больше места
  • Хорош для "содержит" операции, плохо для сортировки

GiST (Generalized Search Tree)

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

Для геометрических данных

-- Таблица с локациями
CREATE TABLE locations (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    point POINT  -- Координаты (x, y)
);

CREATE INDEX idx_locations_point ON locations USING GiST (point);

-- Поиск близких точек
SELECT * FROM locations 
WHERE point <-> POINT(0,0) < 1000;  -- В радиусе 1000 единиц от начала координат
-- <-> это оператор расстояния, работает очень быстро с GiST

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

-- Система бронирования комнат
CREATE TABLE bookings (
    id SERIAL PRIMARY KEY,
    room_id INT,
    check_in DATE,
    check_out DATE
);

-- Создаем индекс на диапазоне
CREATE INDEX idx_bookings_period ON bookings 
    USING GiST (tsrange(check_in::timestamp, check_out::timestamp));

-- Поиск пересечений
SELECT * FROM bookings 
WHERE tsrange(check_in::timestamp, check_out::timestamp) @> '2024-01-15'::timestamp;
-- Находит все брони, которые включают эту дату

Исключающие constraints (Exclusion constraints)

-- Гарантируем, что одна комната не может быть забронирована на пересекающиеся периоды
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING GiST (
    room_id WITH =,  -- Одна и та же комната
    tsrange(check_in::timestamp, check_out::timestamp) WITH &&  -- Пересекающиеся диапазоны
);

-- Теперь попытка создать пересекающуюся бронь вызовет ошибку
INSERT INTO bookings (room_id, check_in, check_out) 
VALUES (1, '2024-01-15', '2024-01-20');  -- OK

INSERT INTO bookings (room_id, check_in, check_out) 
VALUES (1, '2024-01-18', '2024-01-22');  -- ERROR - пересекается с предыдущей

SP-GiST (Space-Partitioned GiST)

SP-GiST — это оптимизированная версия GiST для пространственного разбиения данных.

-- Для точек на плоскости (quad-tree структура)
CREATE INDEX idx_locations_spgist ON locations USING SPGIST (point);

-- Для IP адресов (иерархическая структура)
CREATE INDEX idx_logs_ip ON access_logs USING SPGIST (client_ip inet_ops);

-- Для префиксных поисков по строкам
CREATE INDEX idx_domains_spgist ON domains USING SPGIST (domain_name text_ops);
-- Например, быстро может найти все домены, начинающиеся с "example"

Когда использовать SP-GiST:

  • Пространственные данные (координаты, IP адреса)
  • Когда нужны операции типа "содержится ли точка в квадранте"
  • Префиксные поиски

BRIN (Block Range Index)

BRIN — это очень маленький индекс для больших таблиц, где данные отсортированы или имеют естественный порядок.

Как работает BRIN:

  • Вместо индексирования каждой строки, BRIN работает с блоками (Block Ranges)
  • Для каждого блока хранит MIN и MAX значение
  • При поиске проверяет, какие блоки могут содержать нужное значение
  • Затем сканирует только эти блоки
-- Логи с временным рядом данных
CREATE TABLE logs (
    id BIGSERIAL PRIMARY KEY,
    timestamp TIMESTAMP NOT NULL,
    level VARCHAR(20),
    message TEXT
);

-- Индекс на timestamp
CREATE INDEX idx_logs_timestamp_brin ON logs USING BRIN (timestamp);
-- Это займет примерно в 1000 раз меньше места, чем B-tree индекс!

-- Поиск по дате работает быстро
SELECT * FROM logs 
WHERE timestamp > '2024-01-15' AND timestamp < '2024-01-16';
-- PostgreSQL используя BRIN быстро находит какие блоки содержат эту дату

Преимущества BRIN:

  • ✓ Оч·ень маленький размер (часто 0.1% от размера таблицы)
  • ✓ Быстрое создание и обслуживание
  • ✓ Отличен для append-only таблиц (логи, события)

Недостатки BRIN:

  • ✗ Медленнее B-tree для точных поисков в больших наборах
  • ✗ Требует, чтобы данные были отсортированы или имели корреляцию
-- Настройка BRIN
CREATE INDEX idx_sales_date_brin ON sales USING BRIN (sale_date)
WITH (pages_per_range = 128);
-- pages_per_range - сколько страниц в одном диапазоне
-- Меньше = точнее, но больше индекс
-- Больше = менее точно, но меньше индекс

3. Составные индексы

Составные индексы (multi-column indexes) индексируют несколько колонок одновременно. Это мощный инструмент, но нужно правильно выбрать порядок колонок.

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

Принцип: Наиболее селективные колонки первыми.

Селективность — это то, насколько колонка разнообразна. Колонка с 1000 уникальными значениями на 10000 строк более селективна, чем колонка с 100 уникальными значениями.

-- Пример: таблица заказов
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT,
    status VARCHAR(20),  -- только 5-10 значений: 'pending', 'completed' и т.д.
    created_at TIMESTAMP
);

-- ПЛОХО: status первым (низкая селективность)
CREATE INDEX idx_orders_bad ON orders (status, user_id, created_at);
-- PostgreSQL найдет тысячи строк со status='pending', потом должен отфильтровать по user_id
-- Это неэффективно

-- ХОРОШО: user_id первым (высокая селективность)
CREATE INDEX idx_orders_good ON orders (user_id, created_at, status);
-- PostgreSQL сразу фильтрует до ~10 заказов конкретного пользователя
-- Потом быстро находит нужные даты и статусы

Измерение селективности

-- Посмотреть статистику колонок
SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders' AND schemaname = 'public'
ORDER BY abs(n_distinct) DESC;

-- n_distinct - примерное число уникальных значений (-1 означает очень много)
-- correlation - насколько значения коррелируют с физическим порядком (от -1 к 1)

Префиксное использование индекса

Важное правило: если у вас есть индекс (user_id, created_at, status), то:

-- ЭТИ ЗАПРОСЫ используют индекс эффективно:

-- Поиск по первой колонке
SELECT * FROM orders WHERE user_id = 123;
-- Используется: (user_id) ← только первая часть индекса

-- Поиск по первым двум колонкам
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2024-01-01';
-- Используется: (user_id, created_at) ← первые две части

-- Поиск по всем трем
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2024-01-01' AND status = 'pending';
-- Используется: (user_id, created_at, status) ← весь индекс

-- ЭТИ ЗАПРОСЫ ПЛОХО используют индекс:

-- Пропущена первая колонка
SELECT * FROM orders WHERE created_at > '2024-01-01';
-- НЕ использует индекс! PostgreSQL не может использовать индекс, если пропущена первая колонка

-- Пропущена вторая колонка
SELECT * FROM orders WHERE user_id = 123 AND status = 'pending';
-- Используется (user_id) часть, но status НЕ эффективен

Почему? B-tree строит иерархию: сначала ищет по первой колонке, потом внутри результата по второй. Если пропустить первую, нельзя использовать структуру индекса.

Покрывающие индексы (INCLUDE)

Покрывающие индексы позволяют включить дополнительные колонки в индекс, чтобы PostgreSQL мог ответить на запрос, не обращаясь к основной таблице (Index Only Scan).

-- Обычный индекс
CREATE INDEX idx_users_email ON users (email);

-- Если запрос выбирает другие колонки, PostgreSQL должен обращаться к таблице
SELECT name, phone, created_at FROM users WHERE email = 'john@example.com';
-- План: Index Scan → потом Heap Fetch

-- Покрывающий индекс
CREATE INDEX idx_users_email_covering ON users (email) INCLUDE (name, phone, created_at);

-- Теперь запрос полностью отвечается индексом!
SELECT name, phone, created_at FROM users WHERE email = 'john@example.com';
-- План: Index Only Scan ← никакого обращения к таблице!

Важные моменты про INCLUDE:

  • Колонки в INCLUDE не участвуют в поиске (WHERE)
  • Колонки в INCLUDE не используются для сортировки
  • Колонки в INCLUDE находятся только в листовых страницах индекса
  • Это увеличивает размер индекса, но сильно ускоряет селективные запросы

4. Специальные типы индексов

Частичные индексы (Partial Indexes)

Частичные индексы индексируют только часть таблицы — строки, соответствующие WHERE условию. Это экономит место и ускоряет обновления.

-- Обычный индекс (индексирует все 10 млн строк пользователей)
CREATE INDEX idx_users_email ON users (email);

-- Частичный индекс (индексирует только 1 млн активных пользователей)
CREATE INDEX idx_users_active_email ON users (email) WHERE status = 'active';

-- Если в запросе есть условие WHERE, PostgreSQL может использовать частичный индекс
SELECT * FROM users WHERE email = 'john@example.com' AND status = 'active';
-- Использует idx_users_active_email (меньше, быстрее)

-- Но если статус не указан, индекс не поможет
SELECT * FROM users WHERE email = 'john@example.com';
-- Не может использовать частичный индекс (нужен обычный)

Практические примеры:

-- Только недавние заказы (последний месяц)
CREATE INDEX idx_orders_recent ON orders (created_at, total)
WHERE created_at > NOW() - INTERVAL '30 days';

-- Исключая NULL значения
CREATE INDEX idx_users_phone ON users (phone) WHERE phone IS NOT NULL;
-- Сэкономим место, если у 50% пользователей нет phone

-- Сложные условия
CREATE INDEX idx_orders_important ON orders (priority, created_at)
WHERE status IN ('pending', 'processing') AND total > 1000;
-- Индексирует только важные, активные заказы с большой суммой

Функциональные индексы (Expression Indexes)

Функциональные индексы индексируют результат функции или выражения, а не саму колонку.

-- Без индекса - каждый раз медленно
SELECT * FROM users WHERE lower(email) = 'john@example.com';
-- PostgreSQL должен вычислить lower(email) для КАЖДОЙ строки

-- С функциональным индексом
CREATE INDEX idx_users_lower_email ON users (lower(email));

-- Теперь поиск быстро (индекс предвычислил lower для всех строк)
SELECT * FROM users WHERE lower(email) = 'john@example.com';

Важное правило: функция должна быть IMMUTABLE

-- ✓ IMMUTABLE функции - результат зависит только от аргументов
CREATE OR REPLACE FUNCTION normalize_phone(phone TEXT)
RETURNS TEXT AS $$
BEGIN
    RETURN regexp_replace(phone, '[^0-9]', '', 'g');
END;
$$ LANGUAGE plpgsql IMMUTABLE;

CREATE INDEX idx_users_phone_normalized ON users (normalize_phone(phone));

-- ✗ VOLATILE функции - результат может меняться
-- PostgreSQL НЕ позволит использовать такие функции в индексах
CREATE OR REPLACE FUNCTION get_discount(user_id INT)
RETURNS DECIMAL AS $$
BEGIN
    RETURN (SELECT discount FROM user_discounts WHERE user_id = $1);
END;
$$ LANGUAGE plpgsql VOLATILE;

-- Попытка создать индекс вызовет ошибку
CREATE INDEX idx_orders_discount ON orders ((get_discount(user_id)));
-- ERROR: index expression cannot reference variables

Примеры функциональных индексов:

-- Индекс по году и месяцу (для группировки)
CREATE INDEX idx_orders_year_month ON orders 
    (EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at));

-- Индекс по длине строки
CREATE INDEX idx_long_descriptions ON products (length(description))
WHERE length(description) > 1000;

-- Индекс по вычисленному полю (profit margin)
CREATE INDEX idx_products_profit ON products ((price - cost) / price)
WHERE price > cost;

Условные уникальные индексы

Это комбинация UNIQUE индекса и частичного индекса.

-- Гарантируем уникальность email только для активных пользователей
CREATE UNIQUE INDEX idx_users_email_active_unique ON users (email)
WHERE status = 'active';

-- Теперь:
INSERT INTO users (email, status) VALUES ('test@example.com', 'active');   -- OK
INSERT INTO users (email, status) VALUES ('test@example.com', 'inactive'); -- OK (другой статус)
INSERT INTO users (email, status) VALUES ('test@example.com', 'active');   -- ERROR (дубликат)

-- Это полезно когда:

-- - Удаленные пользователи могут иметь дублирующийся email
-- - Нужна уникальность только для активных записей
-- - Экономим место на индексе (индексируем только активных)

5. Операции с индексами

Создание и удаление

-- Синхронное создание (блокирует таблицу для записи)
CREATE INDEX idx_users_email ON users (email);
-- Все INSERT/UPDATE/DELETE ждут, пока индекс создастся
-- Плохо для production!

-- Конкурентное создание (не блокирует таблицу)
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- INSERT/UPDATE/DELETE работают нормально
-- Создание индекса медленнее, но таблица доступна
-- Используйте ЭТО для production

-- Проверка перед созданием
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
-- Если индекс уже существует, ошибки не будет

-- Удаление
DROP INDEX CONCURRENTLY idx_users_email;
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;

Переиндексация

Переиндексация пересоздает индекс, удаляя фрагментацию.

-- Переиндексировать один индекс
REINDEX INDEX idx_users_email;
-- ← это блокирует таблицу во время работы

-- Конкурентная переиндексация (PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_users_email;

-- Переиндексировать все индексы таблицы
REINDEX TABLE users;

-- Переиндексировать всю базу
REINDEX DATABASE mydb;

6. Мониторинг индексов

Статистика использования

-- Посмотреть какие индексы как часто используются
SELECT
    schemaname, tablename, indexname,
    idx_scan,           -- Сколько раз индекс был использован в поиске
    idx_tup_read,       -- Сколько записей индекса было прочитано
    idx_tup_fetch       -- Сколько записей было получено из таблицы через индекс
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC;

-- Интерпретация:

-- idx_scan = 0 → индекс не используется (можно удалить)
-- idx_tup_read >> idx_tup_fetch → индекс возвращает много "ложных срабатываний"

Неиспользуемые индексы

-- Найти индексы, которые не используются
SELECT schemaname, tablename, indexname,
       pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

-- Это хорошие кандидаты на удаление (сначала проверьте!)

Эффективность кэша

-- Проверить, насколько часто индекс попадает в кэш
SELECT schemaname, tablename, indexname,
       idx_blks_read,  -- Обращения к диску
       idx_blks_hit,   -- Попадания в кэш
       ROUND(100.0 * idx_blks_hit / NULLIF(idx_blks_hit + idx_blks_read, 0), 2) as cache_hit_ratio
FROM pg_statio_user_indexes
WHERE schemaname = 'public' AND (idx_blks_read + idx_blks_hit) > 0
ORDER BY cache_hit_ratio;

-- cache_hit_ratio близко к 100% → индекс хорошо используется
-- cache_hit_ratio < 90% → может быть недостаточно памяти или плохая локальность доступа

Дублирующиеся индексы

-- Найти индексы, которые дублируют друг друга
WITH index_columns AS (
    SELECT schemaname, tablename, indexname,
           string_agg(attname, ',' ORDER BY attnum) as columns
    FROM pg_stat_user_indexes si
    JOIN pg_index pi ON si.indexrelid = pi.indexrelid
    JOIN pg_attribute pa ON pa.attrelid = pi.indrelid AND pa.attnum = ANY(pi.indkey)
    WHERE schemaname = 'public'
    GROUP BY schemaname, tablename, indexname
)
SELECT i1.schemaname, i1.tablename,
       i1.indexname as index1, i2.indexname as index2,
       i1.columns
FROM index_columns i1
JOIN index_columns i2 ON i1.schemaname = i2.schemaname
    AND i1.tablename = i2.tablename
    AND i1.columns = i2.columns
    AND i1.indexname > i2.indexname;

-- Если два индекса на одни и те же колонки - это избыточно

7. Оптимизация индексов

Анализ планов выполнения

-- Посмотреть как PostgreSQL выполняет запрос
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM users WHERE email = 'john@example.com';

-- Типы сканирования:

-- Seq Scan - сканирует всю таблицу (плохо)
-- Index Scan - использует индекс + обращается к таблице (хорошо)
-- Bitmap Index Scan - сканирует индекс, потом комбинирует несколько индексов (хорошо для OR)
-- Index Only Scan - только индекс, БЕЗ обращения к таблице (отлично!)

Давайте разберемся что означают цифры:

EXPLAIN (ANALYZE)
SELECT * FROM orders WHERE user_id = 123 AND status = 'completed' ORDER BY created_at DESC LIMIT 10;

-- Output:

-- Limit  (cost=0.29..9.28 rows=10 width=32) (actual time=0.034..0.041 rows=10 loops=1)
--   -> Index Scan Backward using idx_orders_user_status_date on orders
--       (cost=0.29..9821.29 rows=1000 width=32) (actual time=0.033..0.039 rows=10 loops=1)
--       Index Cond: (user_id = 123) AND (status = 'completed')

-- cost=0.29..9.28 - плановые затраты (первая строка к последней строке)
-- actual time=0.034..0.041 - реальное время
-- rows=10 - сколько строк вернулось
-- index cond - условие использованное для индекса

Настройка планировщика

PostgreSQL использует стоимостную модель для выбора плана. Иногда эта модель не совпадает с реальностью (особенно на SSD).

-- Стандартные параметры (для HDD)
-- random_page_cost = 4.0 (стоимость случайного обращения к странице)
-- seq_page_cost = 1.0 (стоимость последовательного обращения к странице)

-- Для SSD диски должны быть меньше разницы
SET random_page_cost = 1.1;      -- SSD почти как последовательный доступ
SET seq_page_cost = 1.0;
SET cpu_index_tuple_cost = 0.005;

-- Проверить текущие значения
SHOW random_page_cost;

-- Для постоянного изменения добавьте в postgresql.conf
random_page_cost = 1.1

Стратегии индексирования для разных систем

OLTP системы (Online Transaction Processing - операционные системы):

  • Много коротких запросов на чтение одной-нескольких строк
  • Много INSERT/UPDATE/DELETE
  • Стратегия: индексы на иностранные ключи и WHERE условия
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL REFERENCES users(id),
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Индексы для OLTP
CREATE INDEX idx_orders_user_id ON orders (user_id);           -- FK
CREATE INDEX idx_orders_status ON orders (status);             -- WHERE фильтр
CREATE INDEX idx_orders_created_at ON orders (created_at DESC); -- ORDER BY

OLAP системы (Online Analytical Processing - аналитика):

  • Сложные запросы с агрегациями
  • Много JOIN'ов
  • Редкие INSERT/UPDATE/DELETE (batch операции)
  • Стратегия: составные индексы, частичные индексы, BRIN
CREATE TABLE sales (
    id BIGSERIAL PRIMARY KEY,
    region VARCHAR(50),
    product_category VARCHAR(50),
    sale_date DATE,
    amount DECIMAL(10,2)
);

-- Индексы для OLAP
CREATE INDEX idx_sales_analysis ON sales (region, product_category, sale_date);
CREATE INDEX idx_sales_date_brin ON sales USING BRIN (sale_date) 
    WITH (pages_per_range = 128);
-- BRIN очень хорош для временных рядов в аналитике

8. Обслуживание индексов

VACUUM

VACUUM удаляет "мертвые" версии строк (удаленные или обновленные). Это важно, потому что PostgreSQL использует MVCC (Multi-Version Concurrency Control) - каждая версия строки остается на диске.

-- Очистить таблицу от мертвых версий
VACUUM users;

-- Очистить + обновить статистику
VACUUM ANALYZE users;

-- Полная очистка (пересоздает таблицу и индексы, освобождает место)
VACUUM FULL users;  -- ← блокирует таблицу!

Мониторинг фрагментации (Bloat)

Фрагментация происходит, когда много мертвых строк оставляет "дыры" в индексе.

-- Установка расширения
CREATE EXTENSION pgstattuple;

-- Проверить фрагментацию индекса
SELECT * FROM pgstatindex('idx_users_email');

-- Ключевые метрики:

-- avg_leaf_density > 90% - хорошо
-- leaf_fragmentation < 10% - хорошо
-- version = 4 - это B-tree индекс

-- Если фрагментация высокая, переиндексируйте
REINDEX INDEX CONCURRENTLY idx_users_email;

Автоматическое обслуживание

PostgreSQL имеет встроенный daemon (autovacuum), который автоматически очищает таблицы.

-- Настроить для конкретной таблицы
ALTER TABLE users SET (
    autovacuum_vacuum_scale_factor = 0.1,   -- 10% от строк
    autovacuum_analyze_scale_factor = 0.05, -- 5% от строк
    autovacuum_vacuum_cost_delay = 10       -- мс задержки (не перегружать диск)
);

-- Посмотреть когда последний раз была очистка
SELECT schemaname, tablename,
       last_vacuum, last_autovacuum,
       last_analyze, last_autoanalyze,
       vacuum_count, autovacuum_count
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY last_autovacuum DESC NULLS LAST;

9. Практические примеры

E-commerce каталог

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    category_id INT NOT NULL REFERENCES categories(id),
    price DECIMAL(10,2) NOT NULL,
    brand VARCHAR(100),
    attributes JSONB,  -- {"size": "L", "color": "red", "material": "cotton"}
    created_at TIMESTAMP DEFAULT NOW()
);

-- Индексы оптимизированные для e-commerce
CREATE INDEX idx_products_category ON products (category_id);
-- FK индекс для JOIN'ов и фильтрации по категориям

CREATE INDEX idx_products_brand ON products (brand) 
WHERE brand IS NOT NULL;
-- Частичный индекс (экономим место, т.к. может быть много NULL)

CREATE INDEX idx_products_category_price ON products (category_id, price DESC);
-- Составной: сначала фильтруем по категории, потом сортируем по цене

CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
-- GIN для поиска по JSON (быстро находит все товары определенного цвета/размера)

CREATE INDEX idx_products_search ON products 
    USING GIN (to_tsvector('russian', name));
-- Полнотекстовый поиск по названию товара

CREATE INDEX idx_products_catalog ON products (category_id, price DESC)
INCLUDE (name, brand) WHERE price > 0;
-- Покрывающий индекс для каталога: (катего+цена) + (название+бренд)
-- Может полностью ответить на запрос "Товары в категории, отсортированные по цене"

Система логирования

CREATE TABLE access_logs (
    id BIGSERIAL PRIMARY KEY,
    timestamp TIMESTAMP NOT NULL,
    ip_address INET NOT NULL,
    user_id INT,
    url TEXT NOT NULL,
    response_code INT NOT NULL,
    response_time_ms INT NOT NULL
);

-- Loggerные таблицы обычно очень большие, поэтому стратегия другая

CREATE INDEX idx_logs_timestamp_brin ON access_logs 
    USING BRIN (timestamp);
-- BRIN очень маленький для временных рядов (логи обычно append-only)

CREATE INDEX idx_logs_user_recent ON access_logs (user_id, timestamp DESC)
WHERE timestamp > NOW() - INTERVAL '30 days';
-- Частичный индекс только для недавних логов (обычно это интересует)

CREATE INDEX idx_logs_errors ON access_logs (timestamp DESC, response_code)
WHERE response_code >= 400;
-- Поиск ошибок

CREATE INDEX idx_logs_ip ON access_logs USING SPGIST (ip_address);
-- SP-GiST для IP адресов (поиск подсетей, диапазонов)

CREATE INDEX idx_logs_slow ON access_logs (timestamp, response_time_ms)
WHERE response_time_ms > 1000;
-- Поиск медленных запросов

10. Best Practices и чек-лист

Хорошие практики

  • Анализируйте реальные запросы перед созданием индекса. Не индексируйте "на всякий случай".
  • Индексируйте иностранные ключи - они часто используются в JOIN'ах.
  • Используйте составные индексы для сложных WHERE условий с несколькими колонками.
  • Применяйте частичные индексы для оптимизации места.
  • Мониторьте использование индексов - удаляйте неиспользуемые.
  • Используйте CONCURRENTLY для создания/удаления индексов в production.
  • Правильный порядок колонок в составных индексах (селективные первыми).

Что избегать

  • Индексы на каждую колонку - это замедляет INSERT/UPDATE.
  • Дублирующиеся индексы - регулярно проверяйте.
  • Индексы на маленьких таблицах (<1000 строк).
  • Слишком много индексов на часто изменяемых таблицах - замедляет запись.
  • Функциональные индексы без IMMUTABLE - PostgreSQL не позволит создать.

Чек-лист перед созданием индекса

□ Есть ли запросы, которые этот индекс ускорит?
□ Проверил ли я EXPLAIN план текущих запросов?
□ Нет ли уже существующего индекса, который можно использовать?
□ Оправдывает ли выигрыш в SELECT стоимость INSERT/UPDATE?
□ Есть ли WHERE условие, которое можно использовать для частичного индекса?
□ Правильный ли порядок колонок (наиболее селективные первыми)?
□ Смогу ли я использовать INCLUDE для меньшего размера?
□ Есть ли мониторинг, который подскажет, используется ли индекс?

Если ответ "да" на большинство - создавайте индекс.
Если ответ "не уверен" - используйте EXPLAIN ANALYZE и тестируйте.

Пример создания и мониторинга индекса в production

-- 1. Анализируем текущий запрос
EXPLAIN ANALYZE
SELECT user_id, COUNT(*) as order_count, SUM(total) as total_sum
FROM orders
WHERE created_at > '2024-01-01'
GROUP BY user_id
HAVING COUNT(*) > 10;
-- → Seq Scan - плохо, нужен индекс

-- 2. Создаем индекс CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_orders_recent_user 
ON orders (user_id, created_at DESC)
WHERE created_at > '2024-01-01';

-- 3. Проверяем результат
EXPLAIN ANALYZE
SELECT user_id, COUNT(*) as order_count, SUM(total) as total_sum
FROM orders
WHERE created_at > '2024-01-01'
GROUP BY user_id
HAVING COUNT(*) > 10;
-- → Index Scan - хорошо!

-- 4. Мониторим использование
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE indexname = 'idx_orders_recent_user';

-- 5. Через неделю проверяем, что индекс работает
-- Если idx_scan = 0, удаляем лишний индекс
DROP INDEX CONCURRENTLY idx_orders_recent_user;

Шардирование, Репликация, Масштабирование

1. Виды масштабирования

Вертикальное масштабирование (Scale Up)

Увеличение мощности одного сервера путём добавления ресурсов (CPU, RAM, диск).

-- Оптимальная конфигурация для 32GB сервера с OLTP нагрузкой
shared_buffers = 8GB                  -- 25% от RAM: кэш PostgreSQL
effective_cache_size = 24GB           -- 75% от RAM: для планировщика запросов
work_mem = 256MB                      -- Per operation memory (sort, hash join)
maintenance_work_mem = 2GB            -- Для VACUUM, CREATE INDEX, ALTER TABLE
max_connections = 200
max_parallel_workers = 16
random_page_cost = 1.1                -- SSD: меньше чем HDD (4.0)

Когда использовать:

  • Стартап с растущей нагрузкой (до 100K RPS)
  • Простая архитектура без сложного шардирования
  • ACID гарантии критичны
  • Все данные умещаются на одном сервере

Почему shared_buffers = 25%: PostgreSQL держит собственный кэш + ОС имеет page cache. Если выставить shared_buffers слишком высоко (>40%), ОС не сможет использовать free memory. Оптимум 25-30% - баланс между PostgreSQL кэшем и OS page cache.

Ограничения:

  • Физический потолок: максимум 512GB RAM на одном сервере
  • Стоимость растёт экспоненциально (4GB→8GB дешевле чем 128GB→256GB)
  • Single point of failure: один сбой = весь кластер недоступен
  • Масштабирование по CPU ограничено - максимум 128 ядер на практике
  • WAL операции становятся узким местом при очень высокой нагрузке

Горизонтальное масштабирование (Scale Out)

Распределение нагрузки между несколькими серверами.

-- Стратегии распределения
-- Read Replicas: масштабирование чтения (80% операций)
-- Sharding: разделение данных по серверам (каждый сервер отвечает за подмножество)
-- Connection Pooling: переиспользование соединений

Когда использовать:

  • Нагрузка на чтение > 100K RPS
  • Данные слишком объёмны для одного сервера (>1TB)
  • Требуется высокая доступность
  • Географически распределённые пользователи

Trade-offs:

  • +: Масштабируется линейно по серверам
  • -: Сложность: управление консистентностью, распределённые транзакции
  • -: Задержки между репликами (eventual consistency)
  • -: Операционная сложность

2. Репликация в PostgreSQL

Репликация - механизм создания копии базы данных на другом сервере для отказоустойчивости и масштабирования чтения.

Streaming Replication (физическая репликация)

WAL (Write-Ahead Log) записи передаются с Master на Standby в реальном времени. Standby воспроизводит все операции.

Архитектура:

Master (5432)
  ↓ WAL stream
Standby (in recovery mode)
  ↓ WAL stream
Standby2 (cascading)

Master сервер:

-- postgresql.conf
wal_level = replica                   -- Обязателен для репликации
max_wal_senders = 10                  -- Макс одновременных реплик
wal_keep_size = 1GB                   -- Сохранять WAL на 1GB больше
max_replication_slots = 10            -- Слоты предотвращают удаление нужного WAL
hot_standby = on                      -- Разрешить запросы на Standby
archive_mode = on                     -- Архивирование WAL для disaster recovery
archive_command = 'cp %p /archive/%f' -- Команда архивирования

WAL содержит все изменения БД. При достижении wal_keep_size, старые логи удаляются. Replication slots предотвращают удаление нужного WAL - слот запоминает позицию реплики.

Standby сервер:

# Создание базовой копии - снимок всех файлов БД с Master
# -R: автоматически создать recovery.conf (PostgreSQL 12+: standby.signal)
pg_basebackup -h master_host -D /var/lib/postgresql/data \
    -U replica -W -v -P -R

# WAL streaming начинается автоматически

pg_hba.conf на Master:

# Разрешить репликацию от Standby
host    replication     replica     192.168.1.0/24     md5

Создание пользователя репликации:

CREATE USER replica REPLICATION LOGIN ENCRYPTED PASSWORD 'secure_password';

Мониторинг на Master:

-- Проверить статус реплик
SELECT client_addr, 
       state,                         -- 'streaming', 'catchup', 'backup'
       sent_lsn,                      -- До какого LSN отправлены WAL
       write_lsn,                     -- Принято, но не синхронизировано
       flush_lsn,                     -- Записано на диск
       replay_lsn,                    -- Применено (Standby прошёл)
       write_lag,                     -- Задержка write
       flush_lag,                     -- Задержка flush
       replay_lag                     -- Полная задержка репликации
FROM pg_stat_replication;

Мониторинг на Standby:

-- Проверить статус восстановления
SELECT pg_is_in_recovery();                    -- true = в режиме Standby
SELECT pg_last_wal_receive_lsn();              -- Последний полученный WAL
SELECT pg_last_wal_replay_lsn();               -- Последний применённый WAL
SELECT pg_last_xact_replay_timestamp();        -- Когда был выполнен последний XID

Вычисление lag в байтах:

SELECT client_addr,
       pg_wal_lsn_diff(sent_lsn, write_lsn) AS write_lag_bytes,
       pg_wal_lsn_diff(write_lsn, flush_lsn) AS flush_lag_bytes,
       pg_wal_lsn_diff(flush_lsn, replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;

Разница между LSN позициями показывает объём неприменённых изменений. Большой lag означает сильное отставание Standby.

Synchronous Replication

По умолчанию репликация асинхронная: Master не ждёт подтверждения Standby. Sync реплика гарантирует, что данные достигли Standby перед возвратом COMMIT.

-- postgresql.conf на Master
synchronous_commit = on               -- COMMIT ждёт: sent+write+flush+replay на реплике

Типы синхронизации:

-- off: полностью асинхронно (по умолчанию)
synchronous_commit = off

-- local: данные записаны локально на Master в WAL
synchronous_commit = local

-- remote_write: Standby получил и записал в OS buffer (на диск ещё может не быть)
synchronous_commit = remote_write

-- on (он же remote_flush): Standby синхронизировал на диск
synchronous_commit = on

-- remote_apply: Standby полностью применил изменение (медленнее, самое безопасное)
synchronous_commit = remote_apply

Выбор Standby для синхронизации:

-- Синхронизировать с первым ответившим (быстро)
synchronous_standby_names = 'FIRST 1 (standby1, standby2, standby3)'

-- Ждать подтверждения от любых 2 из 3 реплик (отказоустойчиво)
synchronous_standby_names = 'ANY 2 (standby1, standby2, standby3)'

-- Все standby (консервативно, медленно)
synchronous_standby_names = 'standby1,standby2,standby3'

Компромисс:

  • synchronous_commit = on гарантирует RPO=0 (ноль потери данных), но замедляет запись на 10-50%
  • synchronous_commit = off быстро, но рискует потерять несекоммитченные транзакции при сбое Master

Когда использовать:

  • Финансовые системы: sync с remote_apply
  • E-commerce: sync с on (remote_write)
  • High-traffic read-heavy: асинхронно (sync off)

Logical Replication

Репликация на логическом уровне: не WAL логи, а изменения на уровне таблиц (INSERT/UPDATE/DELETE). Позволяет реплицировать только конкретные таблицы и фильтровать данные.

-- Publisher (отправляет изменения)
-- postgresql.conf
wal_level = logical                   -- Обязателен для logical replication
max_replication_slots = 10
max_wal_senders = 10

-- Создать publication (набор таблиц для репликации)
CREATE PUBLICATION my_publication FOR ALL TABLES;

-- Или только конкретные таблицы
CREATE PUBLICATION users_publication FOR TABLE users, orders;

-- С фильтром (только активные пользователи)
CREATE PUBLICATION active_users FOR TABLE users WHERE (status = 'active');

-- Subscriber (получает изменения и применяет)
-- Может быть на другой версии PostgreSQL!
CREATE SUBSCRIPTION my_subscription
    CONNECTION 'host=publisher_host dbname=mydb user=replica password=password'
    PUBLICATION my_publication;

-- Опции: copy_data=true (копировать существующие данные)
-- synchronous_commit=off (асинхронно)

Мониторинг:

-- На Publisher
SELECT slot_name, slot_type, active, restart_lsn 
FROM pg_replication_slots;

-- На Subscriber
SELECT subname, received_lsn, latest_end_lsn, latest_end_time,
       pg_size_pretty(pg_wal_lsn_diff(latest_end_lsn, received_lsn)) as lag
FROM pg_stat_subscription;

Когда использовать Logical вместо Streaming:

  • Нужно реплицировать только часть таблиц
  • Publisher и Subscriber на разных версиях PostgreSQL
  • Нужны фильтры на уровне данных
  • Subscriber может быть non-PostgreSQL (например, через логический decoder)
  • Двусторонняя репликация (multi-master)

Ограничение: Logical replication медленнее streaming репликации (10-20% overhead).

Cascading Replication

Standby может быть Publisher для других Standby. Экономит пропускную способность Master.

Master
  ↓ WAL
Standby1 (промежуточный, может быть Publisher)
  ↓ WAL
Standby2, Standby3 (питаются от Standby1)

Конфигурация промежуточного Standby:

-- postgresql.conf на Standby1
hot_standby = on                      -- Разрешить запросы
max_wal_senders = 5                   -- Может отправлять WAL дальше
wal_level = replica

-- Standby2 подключится к Standby1
primary_conninfo = 'host=standby1_host port=5432 user=replica password=password'

Эффект: Нагрузка на Master распределяется, но увеличивается total lag.


3. Шардирование (Partitioning & Distribution)

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

Range Partitioning

Данные разделяются по диапазонам одного столбца. Типично по времени.

CREATE TABLE orders (
    id BIGSERIAL,
    user_id INT NOT NULL,
    total DECIMAL(10,2) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    status VARCHAR(20) NOT NULL
) PARTITION BY RANGE (created_at);

-- Каждый месяц - отдельная партиция
CREATE TABLE orders_2024_01 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

Почему Range по времени:

  • Query на последний месяц автоматически использует только orders_2024_11
  • Старые партиции можно архивировать / удалять
  • Это скользящее окно - добавляем новую партицию каждый месяц

Автоматическое управление с pg_partman:

CREATE EXTENSION pg_partman;

-- Создать родительскую таблицу с автоматическим добавлением партиций
SELECT partman.create_parent(
    p_parent_table => 'public.orders',
    p_control => 'created_at',
    p_type => 'range',
    p_interval => 'monthly',              -- Новая партиция каждый месяц
    p_premake => 2                        -- Создать 2 партиции заранее
);

-- Запланировать обслуживание
SELECT cron.schedule('maintain_orders_partitions', 
    '0 1 * * *',                          -- Каждую ночь в 01:00
    'SELECT partman.maintain_partition_trigger_value(''public.orders'')');

Преимущества:

  • Fast partition elimination: queries on 2024_11 don't scan 2024_01
  • Efficient archive: можно drop old partitions
  • Vacuum & ANALYZE быстрее (параллельно по партициям)
  • Можно разместить разные партиции на разных дисках

Когда использовать:

  • Time-series данные (logs, events, transactions)
  • Большие таблицы с natural time boundaries
  • Нужна архивация старых данных

Hash Partitioning

Равномерное распределение по шардам используя хеш функцию от столбца.

CREATE TABLE users (
    id BIGSERIAL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL
) PARTITION BY HASH (id);

-- 4 партиции (для 4 ядер или 4 серверов)
CREATE TABLE users_part_0 PARTITION OF users 
    FOR VALUES WITH (modulus 4, remainder 0);
CREATE TABLE users_part_1 PARTITION OF users 
    FOR VALUES WITH (modulus 4, remainder 1);
CREATE TABLE users_part_2 PARTITION OF users 
    FOR VALUES WITH (modulus 4, remainder 2);
CREATE TABLE users_part_3 PARTITION OF users 
    FOR VALUES WITH (modulus 4, remainder 3);

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

  • id % 4 = 0 → users_part_0
  • id % 4 = 1 → users_part_1
  • и т.д.

Преимущества:

  • Идеальное распределение (если id равномерно распределён)
  • Просто добавить/удалить партицию (но нужно перехешировать данные)

Проблемы:

  • Горячие ключи: если id распределён неравномерно (много операций с id=1), одна партиция станет bottleneck
  • Cross-partition queries медленнее: SELECT * FROM users WHERE email='x' сканирует все 4 партиции

Когда использовать:

  • Равномерно распределённые ID
  • Вертикально-ориентированные операции (get user by id)
  • Нужна простота и автоматическое распределение

List Partitioning

Явное распределение по категориям.

CREATE TABLE sales (
    id BIGSERIAL,
    region VARCHAR(50) NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    sale_date DATE NOT NULL
) PARTITION BY LIST (region);

CREATE TABLE sales_us PARTITION OF sales 
    FOR VALUES IN ('US', 'USA', 'United States');
CREATE TABLE sales_eu PARTITION OF sales 
    FOR VALUES IN ('UK', 'DE', 'FR', 'IT', 'ES');
CREATE TABLE sales_asia PARTITION OF sales 
    FOR VALUES IN ('JP', 'CN', 'IN', 'KR');
CREATE TABLE sales_default PARTITION OF sales DEFAULT;

Когда использовать:

  • Данные естественно разбиваются на категории (region, country, dept)
  • Разные регионы имеют разную нагрузку (можно масштабировать по-отдельности)
  • Query предсказуемо использует WHERE region='US'

Ограничение: Нужно знать все возможные значения заранее.

Citus Extension (True Distributed PostgreSQL)

Расширение PostgreSQL для управления распределённым шардированием.

CREATE EXTENSION citus;

-- Добавить worker nodes
SELECT citus_add_node('worker1_host', 5432);
SELECT citus_add_node('worker2_host', 5432);

-- Создать распределённую таблицу
CREATE TABLE events (
    id BIGSERIAL,
    user_id INT NOT NULL,
    event_type VARCHAR(50),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Сделать её распределённой (шардированной) по user_id
SELECT create_distributed_table('events', 'user_id');

-- Reference таблица - реплицируется на все worker nodes
CREATE TABLE users (id SERIAL PRIMARY KEY, email VARCHAR(255), name VARCHAR(255));
SELECT create_reference_table('users');

Архитектура:

Coordinator (принимает запросы)
  ↓
Worker1 (events shard: user_id % 3 = 0)
Worker2 (events shard: user_id % 3 = 1)
Worker3 (events shard: user_id % 3 = 2)

Как работает запрос:

SELECT * FROM events WHERE user_id = 123;
-- Coordinator вычислит: 123 % 3 = 0 → routing на Worker1
-- Worker1 вернёт результат

Преимущества Citus:

  • Автоматический routing запросов
  • Поддержка distributed joins
  • Собственный backup/restore механизм
  • Сжатие данных (columnar storage)

Ограничения:

  • Требует отдельного процесса Coordinator
  • Дороже в production (коммерческая поддержка)
  • Не поддерживает все SQL (например, FOREIGN KEY между разными шардами)

Когда использовать:

  • Огромные объёмы данных (100GB+)
  • Нужна прозрачная шардизация (не хочется писать routing логику)
  • Организация может позволить себе Citus Enterprise

FDW (Foreign Data Wrappers) - Самодельное Sharding

Использовать postgres_fdw для связи с удалёнными PostgreSQL серверами.

CREATE EXTENSION postgres_fdw;

-- Создать connection к worker узлам
CREATE SERVER shard1_server
    FOREIGN DATA WRAPPER postgres_fdw
    OPTIONS (host 'shard1.example.com', port '5432', dbname 'shard1_db');

CREATE USER MAPPING FOR postgres
    SERVER shard1_server
    OPTIONS (user 'postgres', password 'secret');

-- Создать foreign table, указывающую на удалённую таблицу
CREATE FOREIGN TABLE users_shard1 (
    id BIGINT,
    email VARCHAR(255),
    name VARCHAR(255)
) SERVER shard1_server 
  OPTIONS (schema_name 'public', table_name 'users');

Объединение шардов через partitioning:

-- Создать локальную распределённую таблицу
CREATE TABLE users (
    id BIGINT NOT NULL,
    email VARCHAR(255),
    name VARCHAR(255)
) PARTITION BY RANGE (id);

-- Подключить foreign tables как партиции
-- Shard 1: ids 1-1M
ALTER TABLE users ATTACH PARTITION users_shard1
    FOR VALUES FROM (1) TO (1000001);

-- Shard 2: ids 1M-2M
ALTER TABLE users_shard2 ATTACH PARTITION ...
    FOR VALUES FROM (1000001) TO (2000001);

-- Теперь SELECT * FROM users автоматически кверирует нужные шарды!

Преимущества:

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

Минусы:

  • Нужно писать routing логику вручную
  • Производительность ниже чем native partitioning (network overhead)
  • Distributed транзакции сложные (нет native 2PC)

Когда использовать:

  • Уже есть несколько отдельных PostgreSQL кластеров
  • Нужна федерация между организациями
  • Хочется избежать vendor lock-in (Citus)

4. Connection Pooling

Переиспользование соединений для эффективного использования ресурсов.

Проблема: Каждое соединение требует ~5MB RAM. При 10K соединений это 50GB RAM.

┌─ Клиент 1
├─ Клиент 2  ──→ PgBouncer (pool) ──→ 10 реальных соединений с PostgreSQL
├─ Клиент 3
└─ Клиент 10K

PgBouncer

# /etc/pgbouncer/pgbouncer.ini

[databases]
mydb = host=localhost port=5432 dbname=mydb max_db_connections=50

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# КРИТИЧНЫЙ параметр: режим переиспользования соединений
pool_mode = transaction              # ← Обычно используется

# Пулинг параметры
max_client_conn = 1000               # Макс клиентов к PgBouncer
default_pool_size = 25               # Соединений от PgBouncer к PostgreSQL
reserve_pool_size = 5                # Резервные соединения при перегрузке
reserve_pool_timeout = 3             # Таймаут для резервных

# Очистка соединений
server_reset_query = DISCARD ALL     # Очистить сессию между использованиями
server_check_query = SELECT 1        # Проверка живого соединения
server_lifetime = 3600               # Переподключение каждый час
server_idle_timeout = 600            # Закрыть неиспользуемое соединение

Режимы пулинга (pool_mode):

1. session - соединение привязано к клиенту на всю сессию
   + Полная сессионная изоляция (SET переменные сохраняются)
   - Скейлится только до ~100 одновременных клиентов
   Когда: Приложения с долгоживущими соединениями

2. transaction - соединение возвращается в пул после COMMIT/ROLLBACK
   + Скейлится до 1000+ клиентов на одном PgBouncer
   + Эффективное использование ресурсов
   - Нельзя использовать мультистейтментные транзакции
   - SET session_var не сохраняется между операциями
   Когда: Микросервисы, API серверы

3. statement - соединение возвращается после каждого statement (агрессивно)
   + Максимальное переиспользование
   - Большинство приложений не совместимы
   Когда: Очень редко, специальные сценарии

Выбор режима для разных сценариев:

  • Django ORM: transaction (автокоммит)
  • Spring Boot JdbcTemplate: transaction
  • Node.js с пулом соединений: session
  • Микросервисы с k6s: transaction

Мониторинг PgBouncer

# Подключиться к админ интерфейсу
psql -h localhost -p 6432 -U pgbouncer pgbouncer

# Команды
SHOW POOLS;      # Статус каждого пула
SHOW CLIENTS;    # Активные клиенты и их статусы
SHOW SERVERS;    # Серверные соединения
SHOW STATS;      # Статистика (запросы, bytesio)
SHOW CONFIG;     # Текущая конфигурация

Интерпретация SHOW POOLS:

database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | sv_tested | sv_login | maxwait
---------+------+-----------+------------+-----------+---------+---------+-----------+---------+-------
mydb     | app  | 5         | 0          | 10        | 15      | 0       | 0         | 0       | 0
  • cl_active=5: 5 клиентов получили соединение
  • cl_waiting=0: 0 клиентов ждут соединения (хорошо!)
  • sv_active=10: 10 серверных соединений используются
  • sv_idle=15: 15 готовых соединений в пуле
  • maxwait: максимальное время ожидания клиента (в миллисекундах)

Если cl_waiting > 0 и sv_idle=0 - нужно увеличить default_pool_size.

Odyssey (альтернатива PgBouncer)

Более высокопроизводительный пулер для очень высоких нагрузок.

# odyssey.conf
storage "postgres_server" {
    type "remote"
    host "localhost"
    port 5432
}

database "mydb" {
    user "postgres" {
        authentication "md5"
        password "password"

        storage "postgres_server"
        pool "transaction"
        pool_size 50
        pool_timeout 4000      # ms
        client_max 1000
    }
}

listen {
    host "*"
    port 6432
}

Odyssey vs PgBouncer:

  • Odyssey: нативно написан на C, faster (~2x), асинхронный I/O
  • PgBouncer: более стабильный и протестированный в production

5. Load Balancing для PostgreSQL

Распределение запросов между Master (write) и Replicas (read).

HAProxy + Health Checks

# /etc/haproxy/haproxy.cfg
global
    maxconn 4096
    tune.ssl.default-dh-param 2048

defaults
    mode tcp
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms
    option tcplog

# Master для записи (все WRITE операции + transactions)
frontend postgres_write
    bind *:5000
    default_backend postgres_master

backend postgres_master
    balance roundrobin
    option tcp-check
    tcp-check connect port 5432
    server master1 master1.example.com:5432 check inter 5000 fall 3
    server master2 master2.example.com:5432 check inter 5000 fall 3 backup

# Replicas для чтения (READ-only запросы)
frontend postgres_read
    bind *:5001
    default_backend postgres_replicas

backend postgres_replicas
    balance roundrobin
    option tcp-check
    tcp-check connect port 5432
    server replica1 replica1.example.com:5432 check inter 5000 fall 3
    server replica2 replica2.example.com:5432 check inter 5000 fall 3
    server replica3 replica3.example.com:5432 check inter 5000 fall 3

TCP check работает, но примитивно: просто проверяет, открыт ли порт. Не проверяет, применён ли WAL на Standby.

Health Checks с Python

#!/usr/bin/env python3
import psycopg2
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

class HealthCheckHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        response_code = 503  # Service Unavailable
        response_data = {"status": "unhealthy"}
        
        if self.path == '/master':
            is_healthy = self.check_master()
            response_code = 200 if is_healthy else 503
            response_data = {"status": "healthy" if is_healthy else "unhealthy"}
            
        elif self.path == '/replica':
            is_healthy, lag = self.check_replica()
            response_code = 200 if is_healthy else 503
            response_data = {
                "status": "healthy" if is_healthy else "unhealthy",
                "replication_lag_seconds": lag
            }
        
        self.send_response(response_code)
        self.send_header('Content-Type', 'application/json')
        self.end_headers()
        self.wfile.write(json.dumps(response_data).encode())

    def check_master(self):
        """Проверить, что узел - Master и пригоден для записи"""
        try:
            conn = psycopg2.connect(
                host='localhost', port=5432, 
                database='postgres', 
                user='healthcheck', password='password',
                connect_timeout=2
            )
            cur = conn.cursor()
            # NOT pg_is_in_recovery() = Master (не в режиме восстановления)
            cur.execute("SELECT NOT pg_is_in_recovery()")
            is_master = cur.fetchone()[0]
            conn.close()
            return is_master
        except:
            return False

    def check_replica(self):
        """Проверить Standby и его lag от Master"""
        try:
            conn = psycopg2.connect(
                host='localhost', port=5432,
                database='postgres',
                user='healthcheck', password='password',
                connect_timeout=2
            )
            cur = conn.cursor()
            
            # Проверить, в режиме восстановления ли
            cur.execute("SELECT pg_is_in_recovery()")
            is_standby = cur.fetchone()[0]
            
            # Посчитать lag в секундах
            cur.execute("""
                SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) 
                as replay_lag_sec
            """)
            lag = cur.fetchone()[0] or 0
            
            conn.close()
            
            # Здоров, если: Standby + lag < 30 сек
            is_healthy = is_standby and lag < 30
            return is_healthy, lag
        except Exception as e:
            return False, -1

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 8008), HealthCheckHandler)
    print("Health check server started on port 8008")
    server.serve_forever()

HAProxy может использовать HTTP health checks:

# Улучшенная конфигурация HAProxy с HTTP checks
backend postgres_replicas
    balance roundrobin
    option httpchk GET /replica
    http-check expect status 200
    server replica1 replica1.example.com:5432 check port 8008 inter 5000 fall 3
    server replica2 replica2.example.com:5432 check port 8008 inter 5000 fall 3

Теперь HAProxy будет вызывать GET http://replica1:8008/replica и проверять ответ.


6. Failover и High Availability

Patroni - автоматический failover

Инструмент для управления HA кластером с автоматическим выбором Master при сбое.

# /etc/patroni/patroni.yml
scope: postgres-cluster                 # Имя кластера в etcd
name: node1                             # Уникальное имя узла

restapi:
  listen: 0.0.0.0:8008
  connect_address: node1.example.com:8008

etcd:
  hosts: 

    - etcd1.example.com:2379
    - etcd2.example.com:2379
    - etcd3.example.com:2379

bootstrap:
  dcs:
    ttl: 30                             # TTL лидера (Master)
    loop_wait: 10                       # Проверка каждые 10 сек
    maximum_lag_on_failover: 1048576    # Max 1MB lag на реплике для failover
    
    postgresql:
      use_pg_rewind: true               # Быстрое переключение при failover
      use_slots: true                   # Использовать replication slots
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        max_replication_slots: 10

postgresql:
  listen: 0.0.0.0:5432
  data_dir: /var/lib/postgresql/data
  
  authentication:
    replication:
      username: replicator
      password: replicator_password
    superuser:
      username: postgres
      password: postgres_password

Как работает failover:

  1. Master падает → Patroni на Master не может обновить TTL в etcd
  2. TTL истекает → Standby берут лидерство через etcd consensus
  3. Один Standby (обычно с наименьшим lag) выбирается новым Master
  4. Old Master на восстановление переводится в Standby (pg_rewind)

Мониторинг Patroni:

# Проверить статус кластера
curl http://node1:8008/cluster
# Ответ:
# {
#   "members": [
#     {"name": "node1", "role": "leader", ...},
#     {"name": "node2", "role": "replica", "lag": 123456, ...},
#     {"name": "node3", "role": "replica", "lag": 654321, ...}
#   ]
# }

Manual Failover (если нет Patroni)

# На Standby: повысить до Master
pg_ctl promote -D /var/lib/postgresql/data

# Или создать trigger файл (если standby.signal используется)
touch /tmp/promote_standby

# После этого Standby перестанет быть в recovery mode

Проверки после failover:

-- На новом Master
SELECT pg_is_in_recovery();           -- Должно быть FALSE
SELECT pg_last_wal_replay_lsn();      -- Последний LSN
SELECT * FROM pg_stat_replication;   -- Проверить новых реплик

7. Практические архитектуры

Read/Write Splitting с Django

# settings.py
DATABASES = {
    'master': {
        'ENGINE': 'django.db.backends.postgresql',
        'HOST': 'pg-master.example.com',
        'NAME': 'production',
        'USER': 'app',
        'PASSWORD': 'password'
    },
    'replica': {
        'ENGINE': 'django.db.backends.postgresql',
        'HOST': 'pg-replica.example.com',  # или через HAProxy port 5001
        'NAME': 'production',
        'USER': 'app',
        'PASSWORD': 'password'
    }
}

# router.py - автоматический выбор БД
class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):
        """Все чтение идёт на реплику"""
        return 'replica'

    def db_for_write(self, model, **hints):
        """Вся запись идёт на Master"""
        return 'master'

    def allow_relation(self, obj1, obj2, **hints):
        """Разрешить relations"""
        return True

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        """Миграции только на Master"""
        return db == 'master'

# Использование
from django.apps import apps
User = apps.get_model('myapp', 'User')

# Это автоматически использует replica DB
users = User.objects.all()

# Это использует master DB
new_user = User.objects.create(name='John')

# Принудить Master (например, после write нужна актуальная информация)
user = User.objects.using('master').get(id=1)

Pitfall: Репликация асинхронна. После INSERT на master, replica может ещё не иметь это значение. Решение:

# После create, прочитать из master
new_user = User.objects.create(name='John')
fresh_user = User.objects.using('master').get(id=new_user.id)

Multi-Region Setup

# Архитектура для глобального масштабирования
Регион US-East:

  - PostgreSQL Master (5 ядер, 32GB)
  - 2 Read Replicas (синхронные)
  - PgBouncer на каждой машине
  - HAProxy для routing read/write

Регион EU-West:

  - Read Replica от US-East Master (асинхронная, ~500ms lag)
  - PgBouncer
  - HAProxy для балансировки между US-East Master (write) и локальной Replica

Регион Asia-Pacific:

  - Read Replica от US-East Master (асинхронная, ~1s lag)
  - Локальный Redis cache
  - PgBouncer
  - HAProxy

Стратегия маршрутизации:

- Write (PUT/POST): всегда US-East Master (может быть медленно из других регионов)
- Read (GET): локальная Replica
- Для критичного чтения (нужны fresh данные): Master

Реализация в приложении:

# Выбор БД по типу операции и локации
def get_db_for_operation(operation_type, user_region):
    if operation_type == 'write':
        return 'master_us_east'  # Всегда в master регионе
    else:  # read
        region_to_db = {
            'us-east': 'replica_us_east',
            'eu-west': 'replica_eu_west',
            'asia': 'replica_asia'
        }
        return region_to_db.get(user_region, 'replica_us_east')

8. Best Practices для Interview

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

Сценарий Нагрузка Решение Почему
Стартап <1K RPS Вертикальное Простота, ACID, нет необходимости
Растущий сервис 1K-10K RPS Vertical + Read Replicas Чтение масштабируется, запись на одном Master
Высоконагруженный сервис 10K-100K RPS Sharding (Range или Hash) Распределить запись, каждый шард может быть ~20K RPS
Гигантское приложение 100K+ RPS Citus / Multi-Master Полностью распределённое, но сложное в операции
Time-series данные Любая Range partitioning по времени Архивация, partition elimination, fast cleanup
Разные регионы Любая Logical replication / multi-region setup Локальный read, глобальный write или eventual consistency

Типичные вопросы на интервью и ответы

Q: В чём разница между Streaming и Logical Replication?

A: Streaming репликует на уровне WAL логов (физические блоки), Logical - на уровне SQL операций (INSERT/UPDATE/DELETE). Streaming быстрее, но жёстче привязана к версии PostgreSQL. Logical гибче, можно фильтровать, но медленнее.

Q: Почему synchronous_commit = on замедляет запись?

A: Master дожидается подтверждения от Standby перед возвратом COMMIT клиенту. Это добавляет network round-trip (~1-10ms в зависимости от latency).

Q: Как выбрать между Range и Hash партиционированием?

A: Range если есть природный порядок (время, ID диапазон) и нужна архивация. Hash если нужно равномерное распределение и нет естественного порядка.

Q: Можно ли без downtime перейти с одного Master на Citus?

A: Сложно. Нужно: 1) создать Citus кластер 2) запустить миграцию данных 3) синхронизировать 4) переключить приложения. Обычно требует maintenance window.

Q: Что такое replication lag и когда он критичен?

A: Delay между Master коммитом и применением на Standby. Критичен для: финансовых транзакций (sync replication), высокочастотных reads сразу после write (use master). Некритичен для analytics (eventual consistency OK).

Вопросы для self-check

  • Как развернуть PostgreSQL с failover за 30 минут? (Patroni + etcd)
  • На каком этапе нагрузки переходить с вертикального на горизонтальное? (~5K-10K RPS)
  • Почему shared_buffers не должен быть >40% RAM? (OS page cache нужен)
  • Как минимизировать replication lag? (async commit, оптимизировать WAL generation)
  • Какой pool_mode выбрать для микросервиса? (transaction)

Процедуры и функции

1. Functions vs Procedures: критические различия

Functions - возвращают значение, используются непосредственно в SQL-выражениях (SELECT, WHERE, JOIN), по умолчанию READ операции без возможности COMMIT/ROLLBACK в основной транзакции.

Procedures (PostgreSQL 11+) - не возвращают значение (результаты получаются через OUT параметры), вызываются через CALL, поддерживают явные COMMIT/ROLLBACK, что позволяет разбивать работу на несколько транзакций.

Практический выбор:

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

2. Создание функций

Базовые функции: скалярный возврат

CREATE OR REPLACE FUNCTION calculate_discount(
    order_amount DECIMAL,
    customer_type VARCHAR(20)
) RETURNS DECIMAL AS $$
BEGIN
    RETURN CASE
        WHEN customer_type = 'VIP' AND order_amount > 1000 THEN order_amount * 0.15
        WHEN customer_type = 'VIP' THEN order_amount * 0.10
        WHEN order_amount > 500 THEN order_amount * 0.05
        ELSE 0
    END;
END;
$$ LANGUAGE plpgsql;

Когда это нужно: Вычисление значения на основе входных параметров. Результат можно использовать в SELECT, условиях, JOIN. Функция переиспользуется в разных запросах без повторения логики.

SELECT order_id, total_amount,
       calculate_discount(total_amount, customer_type) AS discount,
       total_amount - calculate_discount(total_amount, customer_type) AS final_price
FROM orders
WHERE calculate_discount(total_amount, customer_type) > 100;

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

Возврат табличного результата (TABLE)

CREATE OR REPLACE FUNCTION get_user_orders(user_id_param BIGINT)
RETURNS TABLE(
    order_id BIGINT,
    order_date TIMESTAMP,
    total_amount DECIMAL
) AS $$
BEGIN
    RETURN QUERY
    SELECT o.id, o.created_at, o.total_amount
    FROM orders o
    WHERE o.user_id = user_id_param
    ORDER BY o.created_at DESC;
END;
$$ LANGUAGE plpgsql;

SELECT * FROM get_user_orders(123);

Когда это нужно: Возврат набора строк с предопределённыхструктурой (типизированные колонки). Удобнее чем SETOF, так как явно объявляются колонки результата.

Механика: RETURN QUERY выполняет SELECT и добавляет результаты в наружный набор функции. Функция собирает все строки и возвращает их целиком.

Возврат множества строк (SETOF)

CREATE OR REPLACE FUNCTION get_top_products(limit_count INT)
RETURNS SETOF products AS $$
BEGIN
    RETURN QUERY
    SELECT * FROM products
    ORDER BY sales_count DESC
    LIMIT limit_count;
END;
$$ LANGUAGE plpgsql;

SELECT * FROM get_top_products(10);

Различие от TABLE: SETOF returns типы существующих таблиц/типов (в примере - весь row type таблицы products). TABLE даёт контроль над названиями и типами выходных колонок.

Когда это нужно: Когда структура результата совпадает с существующей таблицей/типом. Удобнее воспроизводить весь row, чем перечислять колонки.

OUT параметры: множественный возврат

CREATE OR REPLACE FUNCTION calculate_order_totals(
    order_id_param BIGINT,
    OUT subtotal DECIMAL,
    OUT tax DECIMAL,
    OUT total DECIMAL
) AS $$
BEGIN
    SELECT SUM(price * quantity) INTO subtotal
    FROM order_items WHERE order_id = order_id_param;
    
    tax := subtotal * 0.1;
    total := subtotal + tax;
END;
$$ LANGUAGE plpgsql;

SELECT * FROM calculate_order_totals(123);
SELECT subtotal, total FROM calculate_order_totals(123);

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

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

3. Процедуры

Базовые процедуры: многошаговая логика

CREATE OR REPLACE PROCEDURE process_order(
    order_id_param BIGINT,
    OUT success BOOLEAN,
    OUT message TEXT
) AS $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM products p
        JOIN order_items oi ON p.id = oi.product_id
        WHERE oi.order_id = order_id_param
        AND p.stock >= oi.quantity
    ) THEN
        success := FALSE;
        message := 'Insufficient stock';
        RETURN;
    END IF;
    
    UPDATE orders SET status = 'processing' WHERE id = order_id_param;
    
    success := TRUE;
    message := 'Order processed successfully';
    COMMIT;
END;
$$ LANGUAGE plpgsql;

CALL process_order(123, NULL, NULL);

Ключевой момент: Процедура может вызывать COMMIT, завершая транзакцию. Это позволяет блокировать отдельные части логики в отдельных транзакциях — если что-то пойдёт не так, предыдущие операции останутся.

OUT параметры: Используются вместо RETURN TABLE, результат получается через параметры.

Процедуры с явным управлением транзакциями

CREATE OR REPLACE PROCEDURE transfer_money(
    from_account_id BIGINT,
    to_account_id BIGINT,
    amount DECIMAL
) AS $$
DECLARE
    current_balance DECIMAL;
BEGIN
    SELECT balance INTO current_balance
    FROM accounts WHERE id = from_account_id FOR UPDATE;
    
    IF current_balance < amount THEN
        RAISE EXCEPTION 'Insufficient funds';
    END IF;
    
    UPDATE accounts SET balance = balance - amount
    WHERE id = from_account_id;
    
    UPDATE accounts SET balance = balance + amount
    WHERE id = to_account_id;
    
    COMMIT;
EXCEPTION
    WHEN OTHERS THEN
        ROLLBACK;
        RAISE;
END;
$$ LANGUAGE plpgsql;

Почему FOR UPDATE важен: Блокирует строку для других транзакций до конца текущей. Критично для денег/инвентаря — иначе две процедуры могут одновременно проверить баланс, оба найдут достаточно, и баланс станет отрицательным.

EXCEPTION блок: Гарантирует откат при любой ошибке. RAISE без аргументов пробрасывает исходное исключение дальше (соблюдает stack trace).

4. Переменные и управление потоком

Переменные: декларация и инициализация

CREATE OR REPLACE FUNCTION complex_calculation()
RETURNS INT AS $$
DECLARE
    counter INT := 0;
    max_value INT;
    result INT;
BEGIN
    SELECT MAX(value) INTO max_value FROM table_name;
    
    WHILE counter < 10 LOOP
        counter := counter + 1;
        result := counter * max_value;
    END LOOP;
    
    RETURN result;
END;
$$ LANGUAGE plpgsql;

INTO: Присваивает результат SELECT переменной. Если результатов несколько, используется FIRST, остальные игнорируются.

Инициализация значения := выполняется один раз при входе в функцию.

Условная логика: IF/ELSIF

CREATE OR REPLACE FUNCTION check_age_category(age INT)
RETURNS VARCHAR AS $$
BEGIN
    IF age < 18 THEN
        RETURN 'Minor';
    ELSIF age BETWEEN 18 AND 65 THEN
        RETURN 'Adult';
    ELSE
        RETURN 'Senior';
    END IF;
END;
$$ LANGUAGE plpgsql;

ELSIF (не ELSEIF): Специфика PL/pgSQL. Допускается несколько ELSIF ветвей.

Циклы: FOR, WHILE, цикл по результатам

-- Числовой FOR цикл (от start до end включительно)
CREATE OR REPLACE FUNCTION sum_range(start_num INT, end_num INT)
RETURNS INT AS $$
DECLARE
    total INT := 0;
    i INT;
BEGIN
    FOR i IN start_num..end_num LOOP
        total := total + i;
    END LOOP;
    RETURN total;
END;
$$ LANGUAGE plpgsql;

Диапазон .. включает оба конца. Цикл выполняется (end - start + 1) раз.

-- Цикл по результатам запроса
CREATE OR REPLACE FUNCTION process_all_orders()
RETURNS VOID AS $$
DECLARE
    order_record RECORD;
BEGIN
    FOR order_record IN SELECT * FROM orders WHERE status = 'pending' LOOP
        UPDATE orders SET status = 'processing' WHERE id = order_record.id;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

RECORD: Не типизированная переменная, принимает любую строку. Колонки доступны через order_record.column_name. Удобно для динамичных запросов, но теряется IDE подсказка типов.

Когда это нужно вместо UPDATE ... WHERE: Когда для каждой строки нужна отдельная логика, не просто UPDATE.

-- WHILE цикл
CREATE OR REPLACE FUNCTION calculate_fibonacci(n INT)
RETURNS INT AS $$
DECLARE
    a INT := 0;
    b INT := 1;
    temp INT;
    counter INT := 0;
BEGIN
    WHILE counter < n LOOP
        temp := a + b;
        a := b;
        b := temp;
        counter := counter + 1;
    END LOOP;
    RETURN a;
END;
$$ LANGUAGE plpgsql;

Когда WHILE: Количество итераций неизвестно заранее, зависит от условия.

5. Обработка ошибок: EXCEPTION

CREATE OR REPLACE FUNCTION safe_division(a DECIMAL, b DECIMAL)
RETURNS DECIMAL AS $$
BEGIN
    RETURN a / b;
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'Division by zero';
        RETURN NULL;
    WHEN OTHERS THEN
        RAISE NOTICE 'Error: %', SQLERRM;
        RETURN NULL;
END;
$$ LANGUAGE plpgsql;

SQLERRM: Текст ошибки, возвращённый последним исключением.

WHEN OTHERS: Ловит все исключения, которые не перехвачены раньше. Используйте в конце.

RAISE NOTICE vs WARNING vs EXCEPTION:

  • NOTICE: Информационное сообщение, выполнение продолжается
  • WARNING: Предупреждение, выполнение продолжается
  • EXCEPTION: Прерывает выполнение функции, выполняется EXCEPTION блок

Retry логика для обработки deadlock

CREATE OR REPLACE FUNCTION update_with_retry(record_id INT, max_retries INT DEFAULT 3)
RETURNS BOOLEAN AS $$
DECLARE
    retry_count INT := 0;
BEGIN
    WHILE retry_count < max_retries LOOP
        BEGIN
            UPDATE my_table SET value = value + 1 WHERE id = record_id;
            RETURN TRUE;
        EXCEPTION
            WHEN serialization_failure OR deadlock_detected THEN
                retry_count := retry_count + 1;
                IF retry_count >= max_retries THEN
                    RAISE;
                END IF;
                PERFORM pg_sleep(0.1 * retry_count);
        END;
    END LOOP;
    RETURN FALSE;
END;
$$ LANGUAGE plpgsql;

Зачем retry: При высокой конкурентности UPDATE может конфликтовать с другой транзакцией. Retry с задержкой часто решает проблему.

pg_sleep: Приостанавливает выполнение на N секунд. Увеличивающаяся задержка (0.1, 0.2, 0.3) — exponential backoff — избегает перегрузки при массовых конфликтах.

6. Триггеры: автоматизация данных

BEFORE триггер: валидация и трансформация

CREATE OR REPLACE FUNCTION check_salary_before_insert()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.salary < 0 THEN
        RAISE EXCEPTION 'Salary cannot be negative';
    END IF;
    
    IF NEW.salary > 1000000 THEN
        RAISE NOTICE 'High salary: %', NEW.salary;
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER salary_check_trigger
    BEFORE INSERT OR UPDATE ON employees
    FOR EACH ROW
    EXECUTE FUNCTION check_salary_before_insert();

BEFORE: Выполняется до INSERT/UPDATE/DELETE. Может модифицировать NEW (для INSERT/UPDATE) или отклонить операцию через RAISE.

NEW и OLD: Специальные переменные триггера. NEW содержит новые значения, OLD содержит старые (не доступно для INSERT).

FOR EACH ROW: Триггер срабатывает для каждой строки. Альтернатива FOR EACH STATEMENT (один раз для всей операции, но NEW/OLD недоступны).

AFTER триггер: логирование и побочные эффекты

CREATE OR REPLACE FUNCTION log_user_changes()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'DELETE' THEN
        INSERT INTO users_audit (user_id, operation, changed_at)
        VALUES (OLD.id, TG_OP, NOW());
        RETURN OLD;
    ELSE
        INSERT INTO users_audit (user_id, operation, changed_at)
        VALUES (NEW.id, TG_OP, NOW());
        RETURN NEW;
    END IF;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER users_audit_trigger
    AFTER INSERT OR UPDATE OR DELETE ON users
    FOR EACH ROW
    EXECUTE FUNCTION log_user_changes();

AFTER: Выполняется после операции. Не может модифицировать строку (её уже нет в памяти для изменения), но может вставлять в другие таблицы, обновлять счётчики и т.д.

TG_OP: Текстовое значение операции: 'INSERT', 'UPDATE' или 'DELETE'.

RETURN NEW/OLD: Для AFTER триггера это формально, результат игнорируется. Для BEFORE результат используется для модификации (или отклонения).

INSTEAD OF триггер: обновление представлений

CREATE OR REPLACE FUNCTION update_user_view()
RETURNS TRIGGER AS $$
BEGIN
    UPDATE users SET name = NEW.name WHERE id = NEW.id;
    UPDATE user_profiles SET bio = NEW.bio WHERE user_id = NEW.id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_user_view_trigger
    INSTEAD OF UPDATE ON users_view
    FOR EACH ROW
    EXECUTE FUNCTION update_user_view();

INSTEAD OF: Заменяет операцию (INSERT/UPDATE/DELETE) целиком. Только для представлений (VIEW). Используется, когда представление объединяет данные из нескольких таблиц, а обновление требует логики.

Механика: Когда кто-то UPDATE users_view, вместо ошибки выполняется триггер, обновляющий обе таблицы.

7. Динамический SQL: EXECUTE и format()

CREATE OR REPLACE FUNCTION dynamic_query(
    table_name TEXT,
    column_name TEXT,
    filter_value TEXT
) RETURNS TABLE(result JSON) AS $$
BEGIN
    RETURN QUERY EXECUTE format(
        'SELECT row_to_json(t) FROM %I t WHERE %I = $1',
        table_name, column_name
    ) USING filter_value;
END;
$$ LANGUAGE plpgsql;

format(): Подставляет аргументы в строку:

  • %I — идентификатор (имя таблицы, колонки) с кавычками при необходимости
  • %L — литерал (строка, число) с кавычками и экранированием
  • $1, $2... — заполнители для USING параметров (защита от SQL injection)

USING: Передаёт параметры в динамический запрос. Значения экранируются, SQL injection невозможен.

Безопасность: валидация таблицы

CREATE OR REPLACE FUNCTION safe_dynamic_query(
    table_name TEXT,
    id_value INT
) RETURNS SETOF RECORD AS $$
BEGIN
    IF table_name NOT IN ('users', 'orders', 'products') THEN
        RAISE EXCEPTION 'Invalid table name';
    END IF;
    
    RETURN QUERY EXECUTE
        format('SELECT * FROM %I WHERE id = $1', table_name)
    USING id_value;
END;
$$ LANGUAGE plpgsql;

Когда это нужно: Белый список имён таблиц. Даже %I не защищает от попыток обхода (например, table_name = 'users"; DROP TABLE' — не сработает, но могут быть другие инъекции).

8. Агрегатные функции: custom aggregates

CREATE OR REPLACE FUNCTION median_accumulate(state NUMERIC[], value NUMERIC)
RETURNS NUMERIC[] AS $$
BEGIN
    RETURN array_append(state, value);
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION median_final(state NUMERIC[])
RETURNS NUMERIC AS $$
DECLARE
    sorted_state NUMERIC[];
    count INT;
BEGIN
    sorted_state := ARRAY(SELECT unnest(state) ORDER BY 1);
    count := array_length(sorted_state, 1);
    
    IF count % 2 = 0 THEN
        RETURN (sorted_state[count/2] + sorted_state[count/2 + 1]) / 2;
    ELSE
        RETURN sorted_state[(count + 1) / 2];
    END IF;
END;
$$ LANGUAGE plpgsql;

CREATE AGGREGATE median(NUMERIC) (
    SFUNC = median_accumulate,
    STYPE = NUMERIC[],
    FINALFUNC = median_final,
    INITCOND = '{}'
);

SELECT department, median(salary) FROM employees GROUP BY department;

Механика агрегатной функции:

  1. SFUNC (State Function) — вызывается для каждой строки группы. Принимает текущее состояние и новое значение, возвращает обновленное состояние.
  2. STYPE — тип состояния (накопитель).
  3. INITCOND — начальное состояние пустой группы.
  4. FINALFUNC — вызывается один раз после обработки всех строк. Преобразует состояние в финальный результат.

Пример выполнения для MEDIAN(1, 3, 2):

  • INITCOND: {}
  • После 1: median_accumulate('{}', 1) = '{1}'
  • После 3: median_accumulate('{1}', 3) = '{1, 3}'
  • После 2: median_accumulate('{1, 3}', 2) = '{1, 3, 2}'
  • Финал: median_final('{1, 3, 2}') = 2

9. Безопасность: SECURITY DEFINER vs INVOKER

CREATE OR REPLACE FUNCTION get_sensitive_data()
RETURNS TABLE(id INT, data TEXT)
SECURITY DEFINER AS $$
BEGIN
    RETURN QUERY SELECT id, sensitive_column FROM secret_table;
END;
$$ LANGUAGE plpgsql;

GRANT EXECUTE ON FUNCTION get_sensitive_data() TO app_user;
REVOKE EXECUTE ON FUNCTION get_sensitive_data() FROM PUBLIC;

SECURITY DEFINER: Функция выполняется с правами создателя (обычно superuser). Приложение может выдать пользователю доступ к функции, даже если у пользователя нет доступа к таблице напрямую.

Использование: Контролируемый доступ к данным. Функция реализует бизнес-логику, кто какие данные видит.

CREATE OR REPLACE FUNCTION public_data()
RETURNS TABLE(id INT, name TEXT)
SECURITY INVOKER AS $$
BEGIN
    RETURN QUERY SELECT id, name FROM public_table;
END;
$$ LANGUAGE plpgsql;

SECURITY INVOKER (по умолчанию): Функция выполняется с правами вызывающего. Если у пользователя нет доступа к таблице, функция вернёт ошибку.

10. Оптимизация: IMMUTABLE, STABLE, VOLATILE

CREATE OR REPLACE FUNCTION calculate_tax(amount DECIMAL)
RETURNS DECIMAL
IMMUTABLE AS $$
BEGIN
    RETURN amount * 0.1;
END;
$$ LANGUAGE plpgsql;

IMMUTABLE: Результат зависит только от входных параметров, никогда не меняется. PostgreSQL может вычислить такую функцию один раз и кешировать результат.

Когда: Чистые математические функции без побочных эффектов.

CREATE OR REPLACE FUNCTION get_current_exchange_rate()
RETURNS DECIMAL
STABLE AS $$
BEGIN
    RETURN (SELECT rate FROM exchange_rates WHERE currency = 'USD' AND date = CURRENT_DATE);
END;
$$ LANGUAGE plpgsql;

STABLE: Результат не меняется в рамках одной транзакции. Может меняться между транзакциями (CURRENT_DATE разный для разных дней). PostgreSQL может оптимизировать, вычислив один раз за транзакцию.

CREATE OR REPLACE FUNCTION log_access()
RETURNS VOID
VOLATILE AS $$
BEGIN
    INSERT INTO access_log (timestamp) VALUES (NOW());
END;
$$ LANGUAGE plpgsql;

VOLATILE (по умолчанию): Результат может меняться между вызовами в одной транзакции. PostgreSQL не кеширует, вызывает каждый раз. Использует для функций с побочными эффектами (INSERT, UPDATE) или зависящих от NOW(), случайных чисел.

Производительность: IMMUTABLE > STABLE > VOLATILE. Если функция в WHERE или JOIN, правильно обозначить — может ускорить запрос в 10+ раз.

11. Управление функциями и процедурами

-- Просмотр всех функций и процедур схемы
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_schema = 'public'
ORDER BY routine_name;

-- Просмотр кода функции (psql команда)
\sf calculate_discount

-- Просмотр параметров
SELECT parameter_name, parameter_mode, data_type
FROM information_schema.parameters
WHERE specific_name = 'calculate_discount'
ORDER BY ordinal_position;
-- Удаление (IF EXISTS предотвращает ошибку)
DROP FUNCTION IF EXISTS calculate_discount(DECIMAL, VARCHAR);
DROP PROCEDURE IF EXISTS process_order(BIGINT);

-- Если функция перегружена (несколько версий с разными параметрами)
DROP FUNCTION IF EXISTS my_func(INT, TEXT);

-- Переименование
ALTER FUNCTION calculate_discount RENAME TO calc_discount;

-- Изменение владельца
ALTER FUNCTION calculate_discount OWNER TO new_user;

-- Изменение схемы
ALTER FUNCTION calculate_discount SET SCHEMA new_schema;

12. Отладка и логирование

CREATE OR REPLACE FUNCTION debug_function(value INT)
RETURNS VOID AS $$
BEGIN
    RAISE NOTICE 'Processing value: %', value;
    RAISE WARNING 'This is a warning';
    RAISE INFO 'Informational message';
    
    IF value < 0 THEN
        RAISE EXCEPTION 'Value must be positive';
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Вывод можно видеть в логах PostgreSQL или в результатах CALL
CALL debug_function(-5);

RAISE уровни:

  • DEBUG: самый подробный, видно при SET client_min_messages = DEBUG
  • NOTICE: видно по умолчанию
  • WARNING: всегда видно
  • INFO: видно при SET client_min_messages = INFO или ниже
  • EXCEPTION: прерывает выполнение

Отладка переменных:

RAISE NOTICE 'Current value: %, next value: %', var1, var2;

Логирование на диск:

SET log_min_messages = DEBUG;
SET log_statement = 'all';
-- Перенаправляет RAISE в log файл PostgreSQL (/var/log/postgresql/)

Вывод конкретного параметра:

SELECT * FROM calculate_order_totals(123) \g
-- \g выполняет последний запрос и показывает результат

13. Типичные ошибки и подводные камни

Ошибка 1: Вызов SELECT без INTO

-- ❌ Неправильно
BEGIN
    SELECT MAX(salary) FROM employees;  -- SELECT просто выполнится и забудется
END;

-- ✅ Правильно
BEGIN
    SELECT MAX(salary) INTO max_salary FROM employees;
END;

Ошибка 2: Забывают RETURN после условия

-- ❌ Неправильно
IF condition THEN
    SET some_var = value;
    -- нет RETURN, выполнение продолжится дальше
END IF;

-- ✅ Правильно
IF condition THEN
    RETURN value;
END IF;

Ошибка 3: Рекурсия без условия выхода

-- ❌ Бесконечная рекурсия
CREATE FUNCTION bad_recursion(n INT) RETURNS INT AS $$
BEGIN
    RETURN bad_recursion(n + 1);
END;

-- ✅ Условие выхода
CREATE FUNCTION good_recursion(n INT) RETURNS INT AS $$
BEGIN
    IF n <= 1 THEN RETURN 1; END IF;
    RETURN n * good_recursion(n - 1);
END;

Ошибка 4: Забывают FOR UPDATE при конкурентных операциях

-- ❌ Race condition
BEGIN
    SELECT balance INTO bal FROM accounts WHERE id = 1;
    UPDATE accounts SET balance = bal - 100 WHERE id = 1;
END;

-- ✅ Блокировка
BEGIN
    SELECT balance INTO bal FROM accounts WHERE id = 1 FOR UPDATE;
    UPDATE accounts SET balance = bal - 100 WHERE id = 1;
END;

Ошибка 5: OUT параметры как входные

-- ❌ Неправильно, OUT параметр передан как обычное значение
CALL my_proc(123, 'result');

-- ✅ Правильно, передаём NULL для OUT параметра
CALL my_proc(123, NULL);

ACID

Atomicity (Атомарность)

Транзакция — это неделимая единица работы. Либо все операции внутри транзакции выполняются и сохраняются, либо при любой ошибке все откатываются. Нет состояния «половина выполнено».

-- Денежный перевод между счетами
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;  -- Обе UPDATE выполнены или ни одна

Если между UPDATE'ами произойдет ошибка (например, нарушение constraint или отключение), обе операции откатятся автоматически. Это предотвращает потерю денег — они не могут просто исчезнуть из базы.

-- Явный откат
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK;  -- Ничего не произойдет, данные вернулись в исходное состояние

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

SAVEPOINT для подтранзакций:

Можно создать точку сохранения внутри транзакции и откатить только часть операций:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT sp1;  -- Сохраняем точку
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
ROLLBACK TO sp1;  -- Откатываем только второй UPDATE
COMMIT;  -- Первый UPDATE будет применен, второй отменен

Это полезно при обработке batch-операций, когда нужно откатить ошибочные записи, но сохранить успешные.


Consistency (Непротиворечивость)

Данные переходят из одного согласованного состояния в другое. База гарантирует, что ВСЕ constraints (ограничения) соблюдаются до, во время и после каждой транзакции. Если операция нарушает constraint, она отклоняется.

Primary Key constraint — уникальность идентификаторов:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

INSERT INTO users (id, name) VALUES (1, 'Alice');
INSERT INTO users (id, name) VALUES (1, 'Bob');  -- ERROR: duplicate key value violates unique constraint

База не позволит вставить два пользователя с одинаковым ID. Это гарантирует, что каждый пользователь уникален.

Foreign Key constraint — ссылочная целостность:

CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id),  -- account может ссылаться только на существующего user
    balance DECIMAL
);

INSERT INTO accounts (user_id, balance) VALUES (999, 1000);  -- ERROR: insert or update on table "accounts" violates foreign key constraint

Попытка создать счет для несуществующего пользователя будет отклонена. Это предотвращает "зависающие" ссылки в базе.

CHECK constraint — произвольная валидация данных:

CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    balance DECIMAL CHECK (balance >= 0)  -- баланс не может быть отрицательным
);

UPDATE accounts SET balance = -100 WHERE id = 1;  -- ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"

CHECK гарантирует, что данные соответствуют бизнес-правилам. В примере выше — счет не может иметь отрицательный баланс.

UNIQUE constraint — уникальность любого поля:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(100) UNIQUE NOT NULL
);

INSERT INTO users (email) VALUES ('test@example.com');
INSERT INTO users (email) VALUES ('test@example.com');  -- ERROR: duplicate key value violates unique constraint

Гарантирует, что у каждого пользователя уникальный email.

Вся суть Consistency: база не позволяет перейти в "невозможное" состояние. Если вы определили constraint, база его защитит. Это работает на уровне БД, а не приложения — даже если ваш код багован, база защитит себя.


Isolation (Изоляция)

Параллельные транзакции не мешают друг другу. Каждая видит консистентный снимок данных. PostgreSQL предоставляет 4 уровня изоляции (стандарт SQL + extension):

READ UNCOMMITTED (редко используется)

Видит даже незавершенные (uncommitted) данные других транзакций. На практике в PostgreSQL работает как READ COMMITTED (стандарт SQL не требует настоящей READ UNCOMMITTED).

-- Транзакция 1 (READ UNCOMMITTED)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- Вернет 1000

-- Транзакция 2 (параллельная)
-- BEGIN;
-- UPDATE accounts SET balance = 500 WHERE id = 1;
-- -- Еще не COMMIT

-- Транзакция 1 снова читает
SELECT balance FROM accounts WHERE id = 1;  -- Может вернуть 500 (dirty read - видит незавершенные данные)
-- Но если Транзакция 2 откатится, то информация была неверной

Проблема: Dirty Read — читаем данные, которые потом откатятся. Применяется в системах, где немного некорректность приемлема (очень редко).

READ COMMITTED (по умолчанию)

Видит только закоммиченные данные. Но если между двумя SELECT'ами в одной транзакции другая транзакция изменила данные, увидим новое значение.

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- Вернет 1000

-- Другая транзакция обновит и закоммитит
-- UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;

SELECT balance FROM accounts WHERE id = 1;  -- Вернет 500 (non-repeatable read)
COMMIT;

Проблема: Non-Repeatable Read — один и тот же SELECT в одной транзакции вернет разные результаты. Может привести к ошибкам в бизнес-логике.

Используется для: отчетов, чтения, где небольшие несогласованности допустимы.

REPEATABLE READ

Каждая транзакция получает снимок (snapshot) данных на момент BEGIN. Все SELECT'ы в этой транзакции видят этот снимок, независимо от изменений других транзакций.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- Snapshot на момент BEGIN, вернет 1000

-- Другая транзакция обновит и закоммитит
-- UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;

SELECT balance FROM accounts WHERE id = 1;  -- ВСЕГДА вернет 1000 (консистентный snapshot)
COMMIT;

Гарантия: один и тот же SELECT вернет одинаковый результат в рамках одной транзакции.

Но есть одна тонкость — Phantom Read (появление новых строк):

BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 5 заказов
-- Другая транзакция добавит новый ORDER
-- INSERT INTO orders (status) VALUES ('pending'); COMMIT;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- Может вернуть 6 (phantom read!)

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

SERIALIZABLE

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

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT SUM(balance) FROM accounts;  -- Snapshot

-- Другая транзакция добавит новый счет
-- INSERT INTO accounts (balance) VALUES (100); COMMIT;

SELECT SUM(balance) FROM accounts;  -- Новый счет НЕ появится в сумме (старый snapshot)
COMMIT;

Как это работает: PostgreSQL использует Serializable Snapshot Isolation (SSI). Отслеживает конфликты между транзакциями и откатывает одну, если они нарушают serializable порядок. Это медленнее, но дает максимальную консистентность.

-- Если конфликт обнаружен:

-- ERROR: could not serialize access due to concurrent update
-- Нужно повторить транзакцию

Используется для: критичного функционала (транспортировки денег, резервирования мест), где ошибка стоит дорого.

Выбор уровня:

  • READ COMMITTED — аналитика, отчеты, когда небольшие противоречия ОК
  • REPEATABLE READ — базовый выбор для большинства операций
  • SERIALIZABLE — финансы, бизнес-критичные операции

Durability (Надежность)

Завершенная и закоммиченная транзакция сохраняется навечно на диск. Даже если сервер упадет через микросекунду после COMMIT, отключится питание, произойдет землетрясение — данные восстановятся.

Механизм: WAL (Write-Ahead Logging)

Перед изменением данных PostgreSQL сначала пишет логи в WAL (журнал упреждающего логирования):

Приложение
    ↓
WRITE to WAL Log (на диск)  ← Безопасная точка
    ↓
APPLY to buffers (в памяти)  ← Быстро
    ↓
COMMIT успешен

Если crash случится после записи в WAL, но до коммита, PostgreSQL при рестарте прочитает WAL логи и восстановит транзакцию. Данные будут консистентны.

Управление Durability:

-- postgresql.conf

-- fsync = on (по умолчанию) — гарантирует запись на диск
-- Каждый COMMIT записывается на диск (безопасно, медленнее)
fsync = on

-- synchronous_commit = on (по умолчанию)
-- Приложение ждет подтверждения записи WAL на диск перед возвратом COMMIT
synchronous_commit = on

-- Для высоконагруженных систем (есть риск потери последних коммитов):
synchronous_commit = local  -- Ждет записи на локальный диск, но не на реплику
synchronous_commit = off  -- Не ждет записи (быстро, но опасно!)

Trade-off:

  • synchronous_commit = on — медленнее, но максимально безопасно
  • synchronous_commit = off — быстрее, но при crash можно потерять последние транзакции

Проверка что данные сохранены:

BEGIN;
INSERT INTO accounts VALUES (100, 'Test', 1000);
COMMIT;  -- После этого данные гарантированно на диске (если fsync=on)

-- Даже если сервер упадет прямо сейчас,
-- при рестарте эта транзакция восстановится из WAL

Практический пример: Денежный перевод

Сценарий без транзакции (плохо):

// Приложение
money = 100;
accountFrom.balance -= money;  // Обновлено в памяти (но еще не в БД!)
// CRASH! Сервер упал, соединение закрыто

accountTo.balance += money;    // Никогда не выполнится
// Деньги исчезли: снялись с одного счета, но не поступили на другой!

Решение с ACID транзакцией:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- Alice отдает
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- Bob получает
COMMIT;  -- Обе операции атомарны

-- Сценарий 1: crash до COMMIT
-- → Оба UPDATE откатываются (ROLLBACK автоматически)
-- → Балансы Alice и Bob не изменились

-- Сценарий 2: crash после COMMIT
-- → WAL гарантирует, что обе операции восстановятся из логов
-- → Деньги благополучно перечислены

Обработка ошибок:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- Alice: OK
UPDATE accounts SET balance = balance + 100 WHERE id = 999;  -- Bob не существует: ERROR!
-- Автоматический ROLLBACK (весь блок отменяется)

-- В базе:

-- Alice: баланс не изменился
-- Ошибка была обработана атомарно

Обработка ошибок с SAVEPOINT:

BEGIN;
-- Пакетная обработка 100 переводов
FOR i = 1 TO 100:
    SAVEPOINT sp_i;
    TRY:
        UPDATE accounts SET balance = balance - amount[i] WHERE id = from_id[i];
        UPDATE accounts SET balance = balance + amount[i] WHERE id = to_id[i];
    CATCH:
        ROLLBACK TO sp_i;  -- Откатываем только этот перевод
        LOG error

COMMIT;  -- Все успешные переводы сохранены, ошибочные откачены

Примеры нарушения Consistency

CHECK constraint violation — Atomicity спасает:

ALTER TABLE accounts ADD CONSTRAINT positive_balance CHECK (balance >= 0);

BEGIN;
UPDATE accounts SET balance = -50 WHERE id = 1;  -- Нарушает CHECK
COMMIT;  -- ERROR: violates check constraint "positive_balance"

-- Результат: UPDATE не применен, баланс остался прежним (Atomicity + Consistency)

Foreign Key violation — Consistency защищает:

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id) ON DELETE RESTRICT,  -- на удаление — запрет
    amount DECIMAL
);

BEGIN;
DELETE FROM users WHERE id = 1;  -- У этого user есть orders!
COMMIT;  -- ERROR: update or delete on table "users" violates foreign key constraint

-- Foreign key защитил consistency — не дал создать "зависающую" ссылку

Каскадное удаление (если нужно):

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id) ON DELETE CASCADE,  -- на удаление — удалить тоже
    amount DECIMAL
);

DELETE FROM users WHERE id = 1;  -- Все orders этого user тоже удалятся автоматически
-- Consistency сохранена: нет зависающих ссылок

Правильный выбор Isolation Level для разных случаев

Сценарий Уровень Причина
Чтение отчета/аналитика READ COMMITTED Легкие противоречия допустимы, максимальная производительность
Финансовые операции (перевод денег) REPEATABLE READ Нужна точность значений, защита от non-repeatable read
Критичные операции (резервирование, трейдинг) SERIALIZABLE Максимальная консистентность, ошибка стоит дорого
Пакетное обновление большого объема READ COMMITTED + SAVEPOINT Откат ошибочных строк без потери успешных

Тонкости и подводные камни

Phantom Read при REPEATABLE READ:

-- Транзакция 1 (REPEATABLE READ)
BEGIN;
SELECT COUNT(*) FROM accounts WHERE balance > 1000;  -- 5 счетов

-- Транзакция 2: добавляет новый счет с balance > 1000 и коммитит

-- Транзакция 1
SELECT COUNT(*) FROM accounts WHERE balance > 1000;  -- Может вернуть 6!

Решение: если нужна абсолютная защита от phantom read, используйте SERIALIZABLE.

Deadlock при SERIALIZABLE:

-- Транзакция 1
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- ждет Транзакцию 2

-- Транзакция 2
BEGIN;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;  -- Deadlock!
-- ERROR: deadlock detected

Производительность vs Консистентность:

  • READ COMMITTED — быстро, но есть погрешности
  • REPEATABLE READ — немного медленнее, но консистентно
  • SERIALIZABLE — медленнее всего (из-за обнаружения конфликтов), но идеально консистентно

Не выбирайте SERIALIZABLE "на всякий случай". Используйте правильный уровень для задачи.


WAL и восстановление после сбоя

Как восстанавливаются данные:

Файловая система
    ↓
WAL логи (pg_wal/)  ← Точные записи всех операций
    ↓
Data files (base/)  ← Могут быть "грязные" (не все коммиты применены)

При рестарте:
1. PostgreSQL читает WAL логи
2. Применяет все коммиченные транзакции к data files
3. Откатывает все некоммиченные транзакции
4. База готова к работе (консистентна)

На что влияют параметры:

fsync = on              -- гарантирует записи на физический диск
synchronous_commit = on -- приложение ждет подтверждения записи

-- Если fsync = off:

-- PostgreSQL не гарантирует запись на диск → риск потери данных при crash'е OS
-- (но это очень редко, современные файловые системы буферизируют)

В production: ВСЕГДА используйте fsync = on и synchronous_commit = on (или хотя бы local).

Transactions & Deadlocks

Жизненный цикл транзакции

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

Явные транзакции:

BEGIN;              -- Начало транзакции
SELECT ...;         -- Операция 1
UPDATE ...;         -- Операция 2
INSERT ...;         -- Операция 3
COMMIT;             -- Фиксация (успех) или ROLLBACK (откат)

Autocommit режим (по умолчанию):

INSERT INTO users VALUES (1, 'Alice');  -- Автоматически COMMIT после каждой команды
UPDATE users SET name = 'Bob' WHERE id = 1;  -- Еще один автоматический COMMIT

Каждая команда в режиме autocommit обрабатывается как отдельная транзакция. Если команда успешна — автоматический COMMIT, если ошибка — откат той команды. Это полезно для интерактивных сессий, но опасно для сложных операций, которые должны быть атомарными.

Отключить autocommit:

-- В psql:
\set AUTOCOMMIT off

-- Теперь каждая команда требует явного COMMIT или ROLLBACK
INSERT INTO users VALUES (2, 'Charlie');
COMMIT;

После отключения autocommit все команды накапливаются в одной транзакции, пока вы не выполните COMMIT или ROLLBACK.

Транзакция с ошибкой:

BEGIN;
INSERT INTO users VALUES (3, 'Diana');     -- OK, никаких проблем
INSERT INTO users VALUES (3, 'Edward');    -- ERROR: duplicate key
-- После ошибки транзакция переходит в состояние "failed" (отмены)
-- В этом состоянии все последующие команды игнорируются
-- Нужно явно ROLLBACK перед новыми командами
ROLLBACK;
-- После ROLLBACK все изменения в этой транзакции отменяются

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


SAVEPOINT (подтранзакции)

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

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT transfer1;
-- На этой точке состояние сохранено. Баланс счета 1 уменьшился на 100.

UPDATE accounts SET balance = balance + 100 WHERE id = 2;
SAVEPOINT transfer2;
-- На этой точке оба счета обновлены.

UPDATE accounts SET balance = balance + 50 WHERE id = 3;  -- ERROR: id=3 не существует
-- Ошибка! Эта операция не выполнена.

ROLLBACK TO transfer2;  
-- Откатываемся к transfer2. Операция с id=3 отменяется.
-- Но операции до transfer2 (обновления счетов 1 и 2) остаются в транзакции.

COMMIT;  
-- Коммитятся обновления счетов 1 и 2 (первые два UPDATE).
-- UPDATE для счета 3 не был выполнен благодаря ROLLBACK TO.

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


Уровни изоляции транзакций

PostgreSQL поддерживает три уровня изоляции (SQL стандарт требует четырех, но PostgreSQL реализует три):

READ UNCOMMITTED → в PostgreSQL работает как READ COMMITTED (это особенность реализации)

READ COMMITTED (по умолчанию):

  • Транзакция видит только данные, закоммиченные до её начала.
  • Каждый SELECT в рамках транзакции видит текущее состояние БД (после других коммитов, произошедших между SELECTами внутри этой транзакции).
  • Может привести к "phantom read" — когда между двумя SELECT'ами в одной транзакции появляются новые строки из других транзакций.
-- Транзакция A
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT COUNT(*) FROM orders;  -- Результат: 5
-- (в этот момент Транзакция B вставляет новый order и коммитит)
SELECT COUNT(*) FROM orders;  -- Результат: 6 (phantom read!)
COMMIT;

REPEATABLE READ:

  • Транзакция видит "снимок" данных с момента её начала.
  • Все SELECT'ы внутри одной транзакции возвращают одинаковый результат (даже если другие транзакции изменили данные и закоммитили).
  • Избегает phantom read'ов внутри одной транзакции.
-- Транзакция A
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM orders;  -- Результат: 5
-- (в этот момент Транзакция B вставляет новый order и коммитит)
SELECT COUNT(*) FROM orders;  -- Результат: 5 (тот же! защита от phantom read)
COMMIT;

SERIALIZABLE:

  • Самый строгий уровень. PostgreSQL гарантирует, что результат выглядит так, как если бы транзакции выполнялись последовательно (одна за другой).
  • Может привести к конфликтам сериализации и откатам транзакций даже без deadlock'ов.

Что такое Deadlock и почему он возникает

Deadlock — циклическая ситуация, когда две или более транзакции взаимно ждут друг друга и никогда не смогут завершиться.

Транзакция A                       Транзакция B
├─ Блокирует строку 1             ├─ Блокирует строку 2
├─ Ждет блокировку строки 2   ←→  ├─ Ждет блокировку строки 1
└─ Вечное ожидание                └─ Вечное ожидание

Deadlock возникает потому, что каждая транзакция держит ресурс (блокировку), который нужен другой транзакции, и при этом ждет ресурс, который держит другая транзакция.

Классический пример (перевод денег):

-- Транзакция A (в сессии 1)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Блокирует строку 1 (держит эксклюзивную блокировку на запись)

-- [ждем несколько секунд, не коммитим]

-- Транзакция B (в сессии 2) 
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- Блокирует строку 2 (держит эксклюзивную блокировку на запись)

UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Блокирует строку 2 еще раз (уже держит блокировку, поэтому OK)

-- [В сессии 1 (Транзакция A) продолжаем]
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Ждет эксклюзивной блокировки на строку 2 (которую держит Транзакция B)
-- Транзакция A заморозилась здесь.

-- [В сессии 2 (Транзакция B) пытаемся]
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- Ждет эксклюзивной блокировки на строку 1 (которую держит Транзакция A)
-- Транзакция B заморозилась здесь.

-- DEADLOCK! Обе транзакции зависли в цикле взаимного ожидания.
-- PostgreSQL обнаруживает циклическую зависимость через ~1 секунду
-- и убивает одну из них (обычно более молодую):

-- ERROR: deadlock detected

Более сложный пример (цепочка из трех транзакций):

Транзакция A ждет блокировку X (держит Транзакция B)
    ↓
Транзакция B ждет блокировку Y (держит Транзакция C)
    ↓
Транзакция C ждет блокировку X (держит Транзакция A)
    ↓
Циклическая зависимость → DEADLOCK!

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


Как предотвратить Deadlock'и

1. Упорядочение блокировок (Order by ID)

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

Плохо (может быть deadlock):

-- Процесс 1: обновляет в порядке id=1, затем id=2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Процесс 2: обновляет в порядке id=2, затем id=1 (ОБРАТНЫЙ порядок!)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;  
-- Блокирует строку 2

UPDATE accounts SET balance = balance + 50 WHERE id = 1;  
-- Ждет блокировку строки 1 (которую держит Процесс 1)
-- Процесс 2 заморозилась.

-- Тем временем Процесс 1:

-- Блокирует строку 1, ждет строку 2 (которую держит Процесс 2)
-- Процесс 1 заморозилась.

-- DEADLOCK!
COMMIT;

Хорошо (без deadlock):

-- Оба процесса обновляют в ОДИНАКОВОМ порядке: всегда меньший ID сначала, потом больший

-- Процесс 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Процесс 2
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;  
-- Пытается получить блокировку строки 1
-- Но Процесс 1 уже держит её → Процесс 2 ждет

UPDATE accounts SET balance = balance + 50 WHERE id = 2;
-- Эта строка не выполнится, пока Процесс 2 ждет строку 1

-- Процесс 1 успешно обновляет обе строки и коммитит → освобождает блокировки
-- Процесс 2 получает блокировку 1, затем обновляет 1 и 2 → успешно коммитит

-- Никакого deadlock'а благодаря одинаковому порядку
COMMIT;

Правильная реализация:

CREATE OR REPLACE FUNCTION transfer_money(
    from_id INT, 
    to_id INT, 
    amount DECIMAL
) RETURNS VOID AS $$
BEGIN
    -- Всегда обновляем в порядке: меньший id → больший id
    IF from_id < to_id THEN
        UPDATE accounts SET balance = balance - amount WHERE id = from_id;
        UPDATE accounts SET balance = balance + amount WHERE id = to_id;
    ELSE
        UPDATE accounts SET balance = balance + amount WHERE id = to_id;
        UPDATE accounts SET balance = balance - amount WHERE id = from_id;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Используем эту функцию
SELECT transfer_money(1, 2, 100);  -- Безопасно от deadlock'ов
SELECT transfer_money(2, 1, 100);  -- Тоже безопасно (функция переставит в правильный порядок)

2. Shorter transactions (минимизировать длину транзакции)

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

Плохо: долгая транзакция

BEGIN;
SELECT * FROM orders WHERE id = 123;  
-- Получаем данные order'а из БД
-- Блокировка на чтение (если используется FOR UPDATE)

-- Теперь долгая бизнес-логика: 5 минут
-- — Валидация данных
-- — API вызовы
-- — Вычисления
-- — Логирование
-- ВСЕ это время транзакция активна и держит блокировку!

UPDATE orders SET status = 'processed' WHERE id = 123;
COMMIT;

-- Проблема: другие транзакции не могут работать с этим order'ом 5 минут!

Хорошо: минимальная транзакция

-- ШАГ 1: Читаем данные БЕЗ транзакции (копируем в память)
SELECT * FROM orders WHERE id = 123;  
-- Результат: {id=123, total=500, ...}

-- ШАГ 2: Долгая бизнес-логика (вне транзакции!) — 5 минут
-- Никаких блокировок! Другие транзакции могут спокойно работать.
expensive_calculation_result = process_order({id=123, total=500, ...});

-- ШАГ 3: Коротенькая транзакция только для записи результата
BEGIN;
UPDATE orders SET status = 'processed', result = expensive_calculation_result WHERE id = 123;
-- Эта строка выполняется за миллисекунды
COMMIT;

-- Транзакция существовала минимально необходимое время

Это изменение кардинально снижает вероятность deadlock'ов и конфликтов.

3. Избегать FOR UPDATE если можно (оптимистичная блокировка)

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

Плохо: пессимистичная блокировка

BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;  
-- Получаем строку И блокируем её для других транзакций
-- Другие транзакции не смогут даже прочитать эту строку

-- Долгая бизнес-логика, 5 минут
-- Строка заблокирована всё это время

COMMIT;

-- Проблема: конкурренция. Другие транзакции вынуждены ждать.

Хорошо: оптимистичная блокировка

-- ШАГ 1: Читаем данные БЕЗ блокировки
SELECT id, balance, version FROM accounts WHERE id = 1;  
-- Результат: {id=1, balance=1000, version=3}

-- ШАГ 2: Бизнес-логика (вне транзакции)
new_balance = 1000 - 100;

-- ШАГ 3: Обновляем с проверкой версии
BEGIN;
UPDATE accounts 
SET balance = 900, version = version + 1 
WHERE id = 1 AND version = 3;
-- Если version все еще 3 → обновляем и инкрементируем версию
-- Если version != 3 (кто-то другой изменил строку) → UPDATE не произойдет (0 rows)

-- Проверяем, что UPDATE прошел успешно
IF ROW_COUNT = 0 THEN
    -- Версия не совпала → конфликт, нужен retry с новой версией
    ROLLBACK;
    -- Повторяем с новыми данными
ELSE
    -- Успех
    COMMIT;
END IF;

-- Преимущества:

-- 1. Обе транзакции могут читать одну и ту же строку одновременно
-- 2. Конфликт решается через retry, а не через взаимную блокировку
-- 3. Никакого deadlock'а

Оптимистичная блокировка работает хорошо, когда конфликты редки. Если конфликты частые, overhead от retry'ев может быть больше, чем от блокировок.

4. Снизить timeout ожидания блокировки

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

-- postgresql.conf или во время сессии
SET lock_timeout = '1s';  
-- Максимум 1 секунда ждать блокировку
-- Если блокировка держится дольше → ERROR: canceling statement due to lock timeout

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  
-- Если эта строка не может получить блокировку 2 за 1 секунду → ERROR
COMMIT;

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


Как разрешить Deadlock

PostgreSQL автоматически обнаруживает deadlock'и и убивает одну из конфликтующих транзакций:

ОШИБКА: deadlock detected

Приложение должно обработать эту ошибку:

  1. Перехватить исключение с текстом "deadlock detected"
  2. Откатить транзакцию (ROLLBACK)
  3. Повторить попытку (retry)

Обычно deadlock'и разрешаются с первого или второго retry'я, потому что исходная причина (конфликт порядка) вряд ли повторится случайно.

Простой retry:

int maxRetries = 3;
int retryCount = 0;

while (retryCount < maxRetries) {
    try {
        connection.setAutoCommit(false);
        
        // Операции, которые могут привести к deadlock'у
        statement.executeUpdate("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
        statement.executeUpdate("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
        
        connection.commit();
        break;  // Успех, выходим из цикла
    } catch (SQLException e) {
        if (e.getMessage().contains("deadlock detected")) {
            connection.rollback();
            retryCount++;
            if (retryCount >= maxRetries) throw e;  // Максимум попыток исчерпан
            
            // Ждем немного перед повтором (чтобы не перегружать систему)
            Thread.sleep(100 * retryCount);
        } else {
            throw e;  // Не deadlock, а другая ошибка → пробросим
        }
    }
}

Exponential backoff (интеллектуальнее):

Exponential backoff означает: с каждой неудачной попыткой ждем всё дольше. Это снижает конкуренцию и повышает вероятность успеха.

int maxRetries = 5;
long initialWait = 10;  // миллисекунды

for (int attempt = 0; attempt < maxRetries; attempt++) {
    try {
        // Бизнес-логика в транзакции
        executeTransfer();
        break;  // Успех
    } catch (DeadlockException e) {
        if (attempt == maxRetries - 1) throw e;  // Последняя попытка, сдаемся
        
        // Экспоненциальное ожидание: 10ms, 20ms, 40ms, 80ms, 160ms
        long waitTime = initialWait * (long) Math.pow(2, attempt);
        Thread.sleep(waitTime);
    }
}

Kotlin / Spring Data JPA с retry:

@Transactional(noRollbackFor = [DeadlockException::class])
fun transferMoney(fromId: Long, toId: Long, amount: BigDecimal) {
    try {
        // код транзакции
    } catch (e: DeadlockException) {
        Thread.sleep(100)
        transferMoney(fromId, toId, amount)  // Retry
    }
}

Мониторинг Deadlock'ов

Включить логирование deadlock'ов:

-- postgresql.conf
log_lock_waits = on
deadlock_timeout = 1000  -- миллисекунды (по умолчанию 1000)

-- После изменения конфига перезагрузить PostgreSQL:

-- systemctl restart postgresql

После этого все deadlock'и будут залогированы в pg_log или журнал событий.

Просмотр активных блокировок:

-- Какие транзакции ждут блокировку
SELECT 
    pid, 
    usename, 
    pg_blocking_pids(pid) as blocked_by,
    query
FROM pg_stat_activity
WHERE pg_blocking_pids(pid)::text != '{}';
-- Если результат не пустой → есть активное ожидание

-- Информация о блокировке
SELECT 
    l.locktype,
    l.relation::regclass,
    l.mode,
    l.granted,
    a.usename,
    a.query,
    a.query_start
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted
ORDER BY a.query_start;
-- Показывает, какие блокировки не предоставлены (held или waiting)

Тестирование deadlock'а (для обучения):

-- Сессия 1 (терминал 1)
BEGIN;
UPDATE accounts SET balance = 100 WHERE id = 1;
-- Блокируем строку 1, но не коммитим

-- [ждем 10 секунд, не закрывая сеанс]
-- Сессия 2 (терминал 2)
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 2;
UPDATE accounts SET balance = 300 WHERE id = 1;  
-- Ждет блокировку 1 (которую держит Сессия 1)
-- Сессия 2 заморозилась
-- Вернемся в Сессию 1 (терминал 1)
UPDATE accounts SET balance = 150 WHERE id = 2;  
-- Ждет блокировку 2 (которую держит Сессия 2)

-- DEADLOCK обнаружен! Одна из сессий получит ERROR.

Практические примеры

Правильный перевод денег (функция с упорядочением):

CREATE OR REPLACE FUNCTION transfer_money(
    from_id INT, 
    to_id INT, 
    amount DECIMAL
) RETURNS BOOLEAN AS $$
DECLARE
    first_id INT;
    second_id INT;
BEGIN
    -- Упорядочиваем: меньший ID обновляем первым
    IF from_id < to_id THEN
        first_id := from_id;
        second_id := to_id;
    ELSE
        first_id := to_id;
        second_id := from_id;
    END IF;
    
    -- Обновляем в гарантированном порядке
    UPDATE accounts SET balance = balance - amount WHERE id = first_id AND id = from_id;
    UPDATE accounts SET balance = balance + amount WHERE id = second_id AND id = to_id;
    
    -- Проверяем, что оба счета существуют и имели достаточный баланс
    RETURN FOUND;
END;
$$ LANGUAGE plpgsql;

-- Используем функцию
SELECT transfer_money(1, 2, 100);  -- Безопасно
SELECT transfer_money(2, 1, 100);  -- Тоже безопасно
SELECT transfer_money(1, 1, 100);  -- Ошибка (один счет)

Оптимистичная блокировка с версионированием:

-- Таблица с версией (для оптимистичной блокировки)
CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance DECIMAL NOT NULL,
    version INT NOT NULL DEFAULT 1
);

-- Приложение читает данные
-- SELECT balance, version FROM accounts WHERE id = 1;  -> {balance=1000, version=3}

-- Бизнес-логика применяется (без БД)
-- new_balance = 1000 - 100 = 900;

-- Приложение обновляет, проверяя версию
BEGIN;
UPDATE accounts 
SET balance = 900, version = version + 1 
WHERE id = 1 AND version = 3;

-- Если версия совпадает → 1 row updated
-- Если версия не совпадает (другой процесс изменил запись) → 0 rows updated

-- Приложение проверяет количество обновленных строк
-- Если 0 → конфликт, retry с новыми данными
-- Если 1 → успех
COMMIT;

Race condition с конкурирующими обновлениями:

-- Проблема: два процесса читают одно значение, оба его изменяют, оба пишут
-- Результат: изменение одного процесса теряется (lost update)

-- Процесс 1
SELECT balance FROM accounts WHERE id = 1;  -- Читает: 1000
-- ... вычисляет ...
UPDATE accounts SET balance = 900 WHERE id = 1;  -- Пишет: 900 (успешно)

-- Процесс 2 (параллельно)
SELECT balance FROM accounts WHERE id = 1;  -- Читает: 1000 (не видит изменение Процесса 1!)
-- ... вычисляет ...
UPDATE accounts SET balance = 950 WHERE id = 1;  -- Пишет: 950
-- Результат: изменение Процесса 1 потеряно! Баланс 900, но должен быть 850 или 950.

-- Решение: оптимистичная блокировка с версией
SELECT balance, version FROM accounts WHERE id = 1;  -- Читает: {balance=1000, version=1}

-- [оба процесса выполняют свои вычисления]

-- Процесс 1
UPDATE accounts SET balance = 900, version = 2 WHERE id = 1 AND version = 1;
-- Успешно (version совпадает)

-- Процесс 2
UPDATE accounts SET balance = 950, version = 2 WHERE id = 1 AND version = 1;
-- FAIL! (version уже 2, не 1) → нужен retry
-- Retry: заново читает {balance=900, version=2}, пересчитывает, пишет с новой версией

-- Потери данных избежана!

Query Planner & Execution

Как работает Query Planner

PostgreSQL использует трёхэтапный процесс обработки любого SQL-запроса:

1. Parser (синтаксический анализ)

На этом этапе проверяется корректность синтаксиса и создаётся parse tree (дерево разбора):

SELECT u.name, COUNT(o.id) 
FROM users u 
JOIN orders o ON u.id = o.user_id 
GROUP BY u.id 
HAVING COUNT(o.id) > 5
    ↓
Проверяет синтаксис (все ли скобки, запятые, ключевые слова на месте)
    ↓
Создает parse tree (дерево с узлами для SELECT, FROM, JOIN, GROUP BY, HAVING)
    ↓
Если синтаксис неправильный → возвращает ошибку, процесс останавливается

Заметьте: на этом этапе не проверяется существование таблиц, типы данных, валидность логики. Проверяется только синтаксис.

2. Planner (оптимизация)

Это самая сложная часть. Плаер анализирует parse tree и выбирает оптимальный способ выполнения:

Parse tree 
    ↓
Определяет все возможные пути выполнения запроса:

  - Какой порядок таблиц в JOIN?
  - Использовать индекс или полное сканирование?
  - Какая стратегия JOIN (Nested Loop, Hash, Merge)?
    ↓
Вычисляет cost (стоимость) для каждого варианта
    ↓
Выбирает вариант с минимальной стоимостью
    ↓
Создает execution plan (подробный план выполнения с операциями и порядком)

3. Executor (исполнение)

Выполняет пошагово созданный на этапе 2 план:

Execution plan
    ↓
Выполняет операции в порядке плана:

  - Sequential Scan: прочитать все строки таблицы
  - Index Scan: прочитать через индекс
  - Hash Join: соединить две таблицы с помощью хэш-таблицы
  - Sort: отсортировать результаты
  - GroupAggregate: сгруппировать и агрегировать
    ↓
Возвращает результаты пользователю

Как посмотреть план выполнения

EXPLAIN - показать план БЕЗ выполнения:

EXPLAIN SELECT * FROM users WHERE id = 1;

--     QUERY PLAN
-- Seq Scan on users  (cost=0.00..35.00 rows=1 width=100)
--   Filter: (id = 1)

Что означает:

  • Seq Scan on users: полное сканирование таблицы users
  • cost=0.00..35.00: стоимость от 0 до 35 условных единиц (начальная и финальная стоимость)
  • rows=1: плаер ожидает получить 1 строку
  • width=100: примерно 100 байт на одну строку

EXPLAIN ANALYZE - выполнить И показать реальные числа:

EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;

--     QUERY PLAN
-- Seq Scan on users  (cost=0.00..35.00 rows=1 width=100) (actual time=0.023..0.025 rows=1 loops=1)
--   Filter: (id = 1)

Дополнительная информация:

  • actual time=0.023..0.025: реальное время выполнения (0.023 до первой строки, 0.025 всего)
  • rows=1: реально вернула 1 строку (совпадает с прогнозом плаера)
  • loops=1: этот узел выполнен 1 раз

Важно: EXPLAIN ANALYZE выполняет запрос, поэтому его нельзя использовать для запросов с INSERT/UPDATE/DELETE (если не в транзакции с ROLLBACK). Для SELECT безопасно.


Index Selection (когда использовать индекс)

PostgreSQL часто выбирает между двумя стратегиями: полное сканирование таблицы (Sequential Scan) или использование индекса (Index Scan). Выбор зависит от стоимости.

Sequential Scan (полное сканирование)

PostgreSQL выбирает Sequential Scan, когда нужно прочитать большую часть таблицы или когда таблица малая:

EXPLAIN SELECT * FROM users WHERE id = 1;
-- Seq Scan on users (cost=0.00..35.00 rows=1)

Почему Sequential Scan?

  • Таблица маленькая (стоимость только 35 условных единиц)
  • Нет индекса на колонке id, или индекс создан недавно и статистика устарела
  • Для маленьких таблиц Sequential Scan часто быстрее (данные в одном куске памяти)

Index Scan (использование индекса)

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

CREATE INDEX idx_users_id ON users(id);

EXPLAIN SELECT * FROM users WHERE id = 1;
-- Index Scan using idx_users_id on users (cost=0.28..8.29 rows=1)

Теперь:

  • cost=0.28..8.29: стоимость упала с 35 до 8.29 (почти в 4 раза дешевле)
  • Плаер выбрал Index Scan, потому что 8.29 < 35

Когда Index Scan победит Sequential Scan

Представьте таблицу с 1 млн строк:

Нужна 1 строка с id=123

Sequential Scan:

  - Читает ВСЕ 1,000,000 строк
  - 1,000,000 × 100 байт = 100 МБ
  - Нужно обратиться к диску (slow disk IO)

Index Scan:

  - B-tree индекс находит нужное значение за 3-4 обращения к диску
  - Затем обращается к таблице за данными (ещё 1 обращение)
  - Всего ~4-5 обращений к диску

Разница в производительности: 100,000x раз быстрее!

Когда Sequential Scan лучше Index Scan

EXPLAIN SELECT * FROM users;  -- Без WHERE, нужны ВСЕ строки
-- Seq Scan on users (cost=0.00..1000.00 rows=10000)

Почему не использован индекс?

  • Нужны ВСЕ 10,000 строк
  • Index Scan должна была бы обратиться к диску 10,000 раз
  • Sequential Scan сканирует последовательно: одна блокировка памяти за другой (кэш работает эффективнее)
  • Результат: Sequential Scan в 10-100 раз быстрее!

Правило: Индекс имеет смысл только когда нужна малая часть строк (обычно < 10-20% таблицы).


JOIN Strategies (стратегии соединения)

Когда нужно объединить две таблицы, PostgreSQL выбирает одну из трёх стратегий.

Nested Loop Join (для маленьких таблиц и хороших индексов)

Алгоритм:

FOR EACH row IN outer_table:
    FOR EACH row IN inner_table:
        IF join_condition matches:
            output row

Это вложенный цикл: для каждой строки внешней таблицы ищет соответствующие строки во внутренней таблице.

Пример:

EXPLAIN SELECT * FROM users u 
JOIN orders o ON u.id = o.user_id 
WHERE u.country = 'USA';

-- Nested Loop  (cost=0.29..1000.00)
--   -> Seq Scan on users u (cost=0.00..35.00)
--        Filter: (country = 'USA')
--   -> Index Scan on orders o (cost=0.29..20.00)
--        Index Cond: (user_id = u.id)

Как это работает:

  1. Фильтрует таблицу users по условию country = 'USA' → 50 пользователей
  2. Для каждого пользователя (50 итераций) ищет его заказы через индекс на orders.user_id
  3. Индекс находит заказы быстро (не нужно сканировать все 100,000 заказов)

Когда использовать: Маленькая outer таблица (после фильтра), есть хороший индекс на join key во внутренней таблице.

Сложность: O(R × log S), где R - размер outer таблицы, S - размер inner таблицы.

Hash Join (для больших таблиц без индекса)

Алгоритм в две фазы:

Фаза 1: Читает внутреннюю таблицу целиком и строит хэш-таблицу в памяти
Фаза 2: Для каждой строки внешней таблицы ищет совпадения в хэш-таблице

Пример:

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

-- Hash Join (cost=100.00..5000.00 rows=50000)
--   Hash Cond: (o.user_id = u.id)
--   -> Seq Scan on orders o (cost=0.00..3000.00 rows=100000)
--   -> Hash (cost=50.00..50.00 rows=10000)
--        -> Seq Scan on users u (cost=0.00..35.00 rows=10000)

Пошагово:

  1. Читает ALL 10,000 пользователей в память, строит хэш-таблицу (быстро, O(n))
  2. Читает ALL 100,000 заказов
  3. Для каждого заказа ищет пользователя в хэш-таблице (O(1) за поиск)
  4. Итого: O(R + S)

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

Сложность: O(R + S), где R и S - размеры таблиц. Самая быстрая для больших таблиц, если memory хватает.

Внимание: Если хэш-таблица не влезает в work_mem, PostgreSQL перейдёт на диск (очень медленно). Увеличьте work_mem для больших JOIN.

Merge Join (для отсортированных данных)

Алгоритм синхронизированного прохода:

Обе таблицы отсортированы по join key
    ↓
Проходит по обеим таблицам одновременно, как застежка-молния
    ↓
Когда находит совпадение, выдает результат

Пример:

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

-- Merge Join (cost=5000.00..10000.00 rows=50000)
--   Merge Cond: (u.id = o.user_id)
--   -> Index Scan using idx_users_id on users u (cost=0.29..50.00)
--   -> Index Scan using idx_orders_user_id on orders o (cost=0.29..3000.00)

Как это работает:

  • Обе таблицы читаются через B-tree индекс, что даёт отсортированные данные
  • Алгоритм проходит по ним синхронно, комбинируя совпадающие строки
  • Сложность O(R + S), но только если обе таблицы отсортированы

Когда использовать: Обе таблицы уже отсортированы (например, через B-tree индекс), обе таблицы большие, нужна deterministic производительность.

Сравнение стратегий

Стратегия Сложность Когда использовать Требования
Nested Loop O(R × log S) Маленькая outer таблица Индекс на join key
Hash Join O(R + S) Большие таблицы Хватает memory
Merge Join O(R + S) Нужна deterministic производительность Отсортированные данные (индексы)

Cost Estimation (как плаер считает стоимость)

PostgreSQL оценивает стоимость каждой операции в условных единицах и выбирает план с минимальной стоимостью. Это главное - выбор основан на стоимости, а не на логике.

Формула стоимости

cost = seq_page_cost × pages + random_page_cost × random_accesses + cpu_cost

Пример:
Таблица 1000 страниц, нужно сканировать 800 страниц
cost = 1.0 × 800 (sequential) + 0.01 × 100000 (обработка строк)
     = 800 + 1000
     = 1800

Параметры в postgresql.conf

seq_page_cost = 1.0          # Стоимость чтения одной страницы последовательно
random_page_cost = 4.0       # Стоимость случайного обращения к странице (диск)
                              # На SSD: 1.1 (почти нет разницы, пока нет кэша)
cpu_tuple_cost = 0.01        # Стоимость обработки одной строки (фильтр, агрегация)
cpu_operator_cost = 0.0025   # Стоимость одной операции (+, >, LIKE и т.д.)

Почему random_page_cost = 4.0? Потому что случайное обращение к диску в 4 раза медленнее, чем последовательное (диск нужно физически переместить голову, на SSD этого нет).

Как плаер выбирает между вариантами

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

Вариант 1 (Sequential Scan всех 10,000 строк):
  cost = 1.0 × 100 pages = 100

Вариант 2 (Index Scan):
  cost = random_page_cost × random_accesses + cpu_cost
       = 4.0 × 4 (4 обращения через индекс) + 0.01 × 1 (обработка 1 строки)
       = 16 + 0.01
       = 16.01

Вывод: 16.01 < 100 → выбирает Index Scan

Когда估计 идёт неправильно

Если статистика устарела, плаер может выбрать неправильный план:

INSERT INTO users SELECT ... FROM generate_series(1, 1000000);
-- Таблица теперь 1,000,000 строк, но статистика старая

EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- Seq Scan (cost=100)  ← неправильный выбор!
-- Плаер думает, что таблица маленькая (только 10,000 строк)

ANALYZE users;  -- Обновляем статистику

EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- Index Scan (cost=16.01)  ← правильный выбор!

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

1. Добавить индекс на колонку с WHERE условием

-- Медленный запрос
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- Seq Scan on users (actual time=0.023..50.5 rows=1)
-- Полное сканирование 10,000 строк, всего 50ms

-- Добавляем индекс
CREATE INDEX idx_users_email ON users(email);

-- Проверяем новый план
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- Index Scan using idx_users_email (actual time=0.0023..0.005 rows=1)
-- Поиск через индекс, всего 0.005ms
-- Улучшение в 10,000 раз!

2. Индекс на JOIN колонку

Если часто соединяете таблицы по определённой колонке, добавьте индекс:

-- Медленный JOIN
CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR);
CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, amount DECIMAL);

EXPLAIN SELECT c.name, COUNT(o.id) 
FROM customers c 
JOIN orders o ON c.id = o.customer_id 
GROUP BY c.id;
-- Hash Join (cost=5000..10000)  ← дорого, нет индекса

-- Добавляем индекс на customer_id
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- Теперь плаер может использовать Nested Loop
EXPLAIN SELECT ...;
-- Nested Loop (cost=100..500)  ← дешевле!

3. Переписать запрос, чтобы использовать индекс

Проблема: функция в WHERE блокирует индекс

-- Медленно: индекс на name не используется
EXPLAIN SELECT * FROM users WHERE LOWER(name) = 'john';
-- Seq Scan (cost=0.00..1000.00)  ← не может использовать индекс
-- Плаер не знает, как использовать индекс для LOWER(name)

-- Решение 1: переписать без функции (если возможно)
EXPLAIN SELECT * FROM users WHERE name = 'John';
-- Index Scan on idx_users_name (cost=0.28..8.3)  ← использует индекс!

-- Решение 2: создать индекс на выражение
CREATE INDEX idx_users_name_lower ON users(LOWER(name));
EXPLAIN SELECT * FROM users WHERE LOWER(name) = 'john';
-- Index Scan on idx_users_name_lower (cost=0.28..8.3)  ✓

Проблема: OR в WHERE может блокировать индекс

-- Медленно: индекс может не использоваться
EXPLAIN SELECT * FROM users 
WHERE first_name = 'John' OR last_name = 'Smith';
-- Seq Scan (cost=0.00..1000.00)
-- PostgreSQL может не уметь комбинировать индексы

-- Решение: использовать UNION
EXPLAIN SELECT * FROM users WHERE first_name = 'John'
UNION
SELECT * FROM users WHERE last_name = 'Smith';
-- Index Scan (cost=0.28..8.3)  + Index Scan (cost=0.28..8.3)
-- Может быть быстрее, если есть индексы

4. Увеличить work_mem для сложных операций

-- Default work_mem = 4MB (очень мало)
-- Для больших JOIN, GROUP BY, ORDER BY может быть недостаточно

-- Плохо: Hash Join не влезает в memory
EXPLAIN ANALYZE SELECT ... FROM big_table1 
JOIN big_table2 ON ... 
GROUP BY ... ORDER BY ...;
-- Hash Join ... (actual time=5000ms due to disk spilling)

-- Улучшить:
SET work_mem = '256MB';

-- Теперь:
EXPLAIN ANALYZE SELECT ...;
-- Hash Join ... (actual time=50ms)  ← в 100 раз быстрее!
-- Данные теперь в памяти, нет обращений к диску

Внимание: work_mem - это PER OPERATION, не глобально! Если в запросе 5 операций (5 Hash Join), вся память = 5 × 256MB = 1280MB.

5. Обновить статистику (ANALYZE)

После больших операций статистика может устаречь:

-- Массовая вставка
INSERT INTO users SELECT ... FROM generate_series(1, 1000000);

-- Статистика старая, плаер выбирает неправильный план
EXPLAIN SELECT ... WHERE ...;
-- Seq Scan (cost=100)  ← думает, что таблица маленькая

-- Обновляем статистику
ANALYZE users;  -- Для конкретной таблицы
-- Или
ANALYZE;  -- Для всех таблиц в БД

-- Теперь плаер видит правду
EXPLAIN SELECT ... WHERE ...;
-- Index Scan (cost=16)  ← правильный выбор

Когда нужен ANALYZE:

  • После INSERT/DELETE большого количества строк
  • После CREATE INDEX (новый индекс могут не использовать до ANALYZE)
  • После ALTER TABLE изменения типа колонки
  • Регулярно (раз в день для больших БД) автоматически (autovacuum)

6. Избегать N+1 проблемы (особенно актуально для приложений)

-- Плохо: много отдельных запросов
SELECT * FROM users WHERE id = 1;  -- 1 запрос
SELECT * FROM orders WHERE user_id = 1;  -- 2-й запрос
SELECT * FROM products WHERE id IN (SELECT product_id FROM order_items);  -- 3-й запрос
-- Всего: 100+ запросов для 100 пользователей!

-- Хорошо: один JOIN
SELECT u.*, o.*, oi.*, p.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN products p ON oi.product_id = p.id
WHERE u.id = 1;
-- Один запрос, один plan

Практические примеры

Пример 1: Медленный JOIN с неправильным индексом

Проблема:

CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR);
CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, amount DECIMAL);

-- Плохой индекс (на неправильное поле, не используется в JOIN)
CREATE INDEX idx_orders_id ON orders(id);

EXPLAIN SELECT c.name, SUM(o.amount) 
FROM customers c 
JOIN orders o ON c.id = o.customer_id 
GROUP BY c.id, c.name;

-- Hash Join (cost=1000..5000)  ← дорого!
-- Плаер не может использовать индекс, потому что индекс на id, а нужен на customer_id

Решение:

-- Добавляем правильный индекс
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

EXPLAIN SELECT c.name, SUM(o.amount) 
FROM customers c 
JOIN orders o ON c.id = o.customer_id 
GROUP BY c.id, c.name;

-- Nested Loop (cost=100..500)  ← намного дешевле!
-- Теперь плаер может использовать индекс на customer_id

Пример 2: Субзапрос vs JOIN

Вариант 1: Субзапрос (медленнее)

SELECT * FROM users 
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);

-- EXPLAIN показывает:

-- Subquery Scan on orders_subquery (cost=5000..10000)
-- Seq Scan on users (cost=0.00..1000.00)
--   Filter: (id IN (...))
-- Плаер вынужден выполнить субзапрос, потом для каждого результата искать в users

Вариант 2: JOIN (быстрее)

SELECT DISTINCT u.* 
FROM users u 
JOIN orders o ON u.id = o.user_id 
WHERE o.amount > 1000;

-- EXPLAIN показывает:

-- Hash Join (cost=100..500)
-- Seq Scan on orders (filter: amount > 1000)
-- Seq Scan on users
-- Плаер выполняет JOIN, результат получается быстрее

Почему быстрее?

  • Субзапрос может выполняться для каждой строки outer таблицы (N раз)
  • JOIN выполняется один раз, плаер может оптимизировать порядок таблиц

Пример 3: Агрегация на большой таблице

Проблема:

SELECT user_id, COUNT(*), SUM(amount)
FROM orders
GROUP BY user_id;

EXPLAIN ANALYZE SELECT ...;
-- GroupAggregate (actual time=5000ms)
-- Sort (actual time=3000ms)  ← долго сортирует!
--   Seq Scan on orders (actual time=1000ms)  ← читает все данные
-- Данные не влезают в work_mem, идёт сортировка на диске

Решение: Добавить индекс на GROUP BY колонку

CREATE INDEX idx_orders_user_id ON orders(user_id);

EXPLAIN ANALYZE SELECT ...;
-- GroupAggregate (actual time=50ms)  ← в 100 раз быстрее!
--   Index Scan using idx_orders_user_id (actual time=30ms)
-- Данные уже отсортированы по user_id (потому что индекс B-tree), не нужна сортировка

Пример 4: Отсутствие индекса на SELECT DISTINCT

Медленный запрос:

SELECT DISTINCT email 
FROM users;

EXPLAIN ANALYZE SELECT ...;
-- HashAggregate (actual time=5000ms, memory=100mb)
-- Seq Scan on users (10,000,000 rows)
-- Плаер вынужден прочитать ВСЕ строки и хэшировать для DISTINCT

Оптимизация (если нужно только уникальные значения):

-- Если нужны просто уникальные emails
CREATE INDEX idx_users_email ON users(email);

SELECT DISTINCT email 
FROM users;

-- Не всегда помогает Index, но можно использовать
-- SELECT email FROM users GROUP BY email;
-- с индексом на email плаер может использовать Index Scan для групп

Отладка плохой производительности - чеклист

  1. Посмотрите план: EXPLAIN ANALYZE SELECT ...;
  2. Ищите Seq Scan на большие таблицы: Нужен индекс?
  3. Ищите высокую стоимость (cost): Может быть неправильный порядок JOIN?
  4. Проверьте loops > 1: Операция повторяется много раз?
  5. Обновите статистику: ANALYZE users;
  6. Увеличьте work_mem: Может Hash Join/Sort идёт на диск?
  7. Проверьте индексы: SELECT * FROM pg_stat_user_indexes; - используются ли они?
  8. Переписать запрос: Может быть лучше переформулировать логику?

WAL & Recovery

Write-Ahead Logging (WAL)

WAL - механизм, обеспечивающий Durability (А из ACID). Основной принцип: все изменения сначала пишутся в лог на диск, только потом применяются к данным в памяти. Это гарантирует, что даже при неожиданном отключении сервера данные не будут потеряны.

Почему WAL необходим?

Без WAL процесс выглядел бы так:

  • Приложение отправляет INSERT/UPDATE
  • PostgreSQL применяет изменение в буферы памяти (shared_buffers)
  • Приложение получает подтверждение коммита
  • Если в этот момент произойдет crash - данные в памяти потеряны

С WAL:

  • Приложение отправляет INSERT/UPDATE
  • PostgreSQL записывает в WAL лог на диск - это физическая гарантия
  • PostgreSQL применяет изменение в буферы памяти
  • Приложение получает подтверждение коммита
  • Если crash происходит после записи в WAL - данные восстановятся при перезагрузке
  • Если crash происходит до записи в WAL - приложение не получит подтверждения и повторит операцию

Структура WAL файлов

PostgreSQL/pg_wal/
├── 000000010000000000000001  (16 MB)
├── 000000010000000000000002  (16 MB)
├── 000000010000000000000003  (16 MB)
└── ...

Каждый WAL файл имеет фиксированный размер 16 MB и содержит записи всех выполненных операций: INSERT, UPDATE, DELETE, CREATE TABLE и т.д. Запись включает:

  • LSN (Log Sequence Number) - уникальный идентификатор позиции в логе
  • XID (Transaction ID) - какой транзакции принадлежит операция
  • Тип операции - INSERT, UPDATE, DELETE и т.д.
  • Данные изменения - фактические значения или ссылки на них

Новый файл создается автоматически, когда текущий заполняется. Имя файла - это временная шкала (timeline) + порядковый номер. Это позволяет восстанавливать данные не только после краша, но и восстанавливаться на конкретный момент времени.

Параметры управления WAL и Durability

-- postgresql.conf

-- fsync = on | off
-- Гарантирует, что данные из WAL буфера действительно записались на физический диск
-- (не останавливаются в буферах ОС)
-- ВАЖНО: fsync = off - ОПАСНО для production, может привести к потере данных при power failure!
fsync = on

-- synchronous_commit = on | local | off | remote_apply | remote_write
-- Контролирует ждет ли PostgreSQL физической записи WAL на диск перед COMMIT

-- synchronous_commit = on (default)
-- Ждет подтверждения записи WAL на диск локально перед ответом приложению
-- Самый безопасный вариант, но медленнее (накладные расходы на fsync)
synchronous_commit = on

-- synchronous_commit = local
-- Ждет записи на локальный диск но НЕ ждет репликации на standby
-- Быстрее чем on, но при crash'е мастера потеряются некоммитанные изменения на master
-- что уже покинули master и достигли приложения (RPO = 0, но потеря возможна)
synchronous_commit = local

-- synchronous_commit = off
-- Не ждет даже локальной записи перед COMMIT
-- Приложение получает подтверждение, но данные еще в буфере памяти
-- ОПАСНО! Используется только для non-critical данных или тестирования
-- При power failure потеряются последние изменения, но на очень короткий интервал
synchronous_commit = off

-- synchronous_commit = remote_write
-- Ждет, пока standby запишет WAL в свой буфер (но не применит к своим данным)
-- Быстрее чем remote_apply, но standby может потерять данные при его crash'е
synchronous_commit = remote_write

-- synchronous_commit = remote_apply
-- Ждет пока standby применит WAL к своим данным
-- Максимальная безопасность, но максимальная задержка
synchronous_commit = remote_apply

-- wal_level = minimal | replica | logical
-- Определяет сколько информации писать в WAL
-- minimal - минимум, нужен только crash recovery (нельзя делать архивирование и репликацию!)
-- replica - для репликации и PITR (обычно используется)
-- logical - для logical replication и logical decoding
wal_level = replica

Как именно работает fsync

Приложение: INSERT INTO table VALUES (...)
         ↓
PostgreSQL copilot: Скопировать в WAL buffer (в памяти ОС)
         ↓
synchronous_commit = on?
  - Да: Вызвать fsync() - блокирующий вызов ОС, ждет пока диск физически запишет
  - Нет: Продолжить сразу
         ↓
Приложение получит OK

Без fsync = on приложение будет получать OK, но данные будут находиться в буфере ОС, который может быть потерян при power failure. С fsync = on - гарантия на уровне аппаратуры.


Checkpoint - Снимок согласованного состояния

Checkpoint - это процесс, при котором PostgreSQL:

  1. Записывает ВСЕ грязные страницы из shared_buffers на диск (кэш данных)
  2. Записывает метаданные о том, что checkpoint произошел
  3. Удаляет или архивирует старые WAL файлы (те, которые уже применены к данным на диске)

Почему checkpoints нужны?

Без checkpoints:

  • Все изменения в shared_buffers (памяти)
  • WAL лог содержит всю историю с начала времени
  • При crash сервер должен воспроизводить ВСЕ WAL логи с начала - восстановление может занять часы для больших БД

С checkpoints:

  • Периодически все грязные буферы писались на диск
  • WAL логи до последнего checkpoint больше не нужны (можно удалить)
  • При crash нужно воспроизводить только WAL логи после последнего checkpoint - восстановление займет минуты

Как запускаются checkpoints?

Автоматические checkpoints:

1. По времени
   checkpoint_timeout = 5min  (default)
   Каждые 5 минут PostgreSQL проверяет: пора ли сделать checkpoint
   
2. По размеру WAL
   max_wal_size = 100MB  (default при установке)
   Если WAL логи выросли больше 100 MB - принудительный checkpoint
   Это гарантирует что логи не займут весь диск
   
3. Явный CHECKPOINT
   Приложение может вызвать: CHECKPOINT;
   Полезно перед обслуживанием

Параметры checkpoint'ов

-- postgresql.conf

-- Как часто делать checkpoints
-- Слишком частые checkpoints = много дисковых операций = медленно
-- Слишком редкие = большой WAL, долгое восстановление при crash'е
checkpoint_timeout = 5min      # По умолчанию

-- Максимальный размер WAL логов
-- При превышении - принудительный checkpoint
-- Слишком маленький = частые checkpoints
-- Слишком большой = требуется больше места на диске для pg_wal/
max_wal_size = 100MB           # Зависит от нагрузки и места на диске

-- Как долго растягивать checkpoint процесс
-- checkpoint_completion_target = 0.9
-- Значит: checkpoint'ы должны завершиться за 90% времени между checkpoints
-- Позволяет распределить нагрузку писания на диск равномерно
-- Без этого параметра checkpoint может создать пик нагрузки на диск
checkpoint_completion_target = 0.9  # 50% - 99%

-- Минимальное расстояние в WAL между checkpoints
-- После checkpoint'а PostgreSQL будет ждать этот интервал перед следующим
min_wal_size = 80MB             # Не делать checkpoints слишком часто

Цепочка действий checkpoint'а

Начало checkpoint
    ↓
PostgreSQL создает CHECKPOINT RECORD в WAL
    ↓
Собирает все грязные буферы (shared_buffers с флагом dirty=true)
    ↓
Распределяет писание на диск в течение checkpoint_completion_target времени
    ↓
Синхронизирует основные данные (fsync для всех изменённых блоков)
    ↓
Обновляет pg_control файл (контрольная точка восстановления)
    ↓
Удаляет или архивирует WAL файлы перед этой checkpoint
    ↓
Checkpoint завершен

Мониторинг checkpoints

-- Информация о контрольных точках
SELECT * FROM pg_control_checkpoint();
-- checkpoints: количество сделанных checkpoints
-- time: время последнего checkpoint'а

-- Если checkpoints слишком частые (каждые 30 сек):

-- 1. Увеличить max_wal_size (много нагрузки на запись)
-- 2. Увеличить shared_buffers (вмещать больше грязных страниц)
-- 3. Увеличить checkpoint_timeout

-- Если checkpoint'ы долгие (дольше 1 минуты):

-- 1. Снизить checkpoint_completion_target (распределять писание дольше)
-- 2. Увеличить shared_buffers (меньше грязных страниц на checkpoint)
-- 3. Проверить performance диска (может быть медленный SSD/HDD)

PITR - Point-In-Time Recovery

PITR - восстановление базы на конкретный момент времени в прошлом.

Сценарий

11:00 - Администратор запускает вредоносный запрос: DELETE FROM users;
        Все пользователи удалены, никто не заметил
11:05 - Обнаружена проблема, БД содержит 0 пользователей
11:10 - Нужна база на состояние 11:03 (за 2 минуты до ошибки)

Требования для PITR

PITR требует двух компонентов:

  1. Base backup - полный снимок базы в конкретный момент времени T0

    • Размер: весь размер БД (50 GB БД = 50 GB backup)
    • Частота: один раз в день/неделю (зависит от RPO требований)
    • Хранение: долгосрочное хранилище (S3, NAS, тейп)
  2. WAL архив - непрерывный лог всех изменений после T0

    • Размер: ~1-100 MB/час (зависит от нагрузки)
    • Частота: постоянно, каждый заполненный WAL файл архивируется
    • Хранение: горячее хранилище с быстрым доступом

Настройка WAL архивирования

Архивирование означает: копировать заполненные WAL файлы в отдельное место.

-- postgresql.conf

-- Уровень логирования (должен быть replica или logical для архивирования)
wal_level = replica

-- Включить архивирование
archive_mode = on

-- Команда для архивирования каждого заполненного WAL файла
-- %p = путь к WAL файлу в pg_wal/
-- %f = только имя файла
-- Команда выполняется как shell command
archive_command = 'test ! -f /mnt/backup/wal_archive/%f && cp %p /mnt/backup/wal_archive/%f'
# Это означает: если файла еще нет в архиве, скопировать его туда
# test ! -f = проверка что файла нет (защита от двойного копирования)

-- Опционально: где хранить WAL если репликация не работает
-- При потере стендбая WAL нужно где-то держать
wal_keep_size = 1GB  # Хранить 1 GB WAL локально (для повторного подключения репликанта)

Проверка что архивирование работает

# 1. Посмотреть архив на файловой системе
ls -lh /mnt/backup/wal_archive/ | head -20
# Должны быть файлы вроде: 000000010000000000000001, 000000010000000000000002

# 2. В PostgreSQL проверить статистику архивирования
SELECT * FROM pg_stat_archiver;

Ключевые столбцы:

  • archived_count - количество успешно заархивированных WAL файлов (должно расти)
  • failed_count - количество ошибок архивирования (должно быть 0)
  • last_archived_wal - имя последнего заархивированного файла
  • last_archived_time - когда он был заархивирован
-- Если архивирование отстает (old_count > 100):

-- 1. Проверить доступность `/mnt/backup/wal_archive/` 
-- 2. Проверить место на диске
-- 3. Проверить права доступа на архивную директорию
-- Пока архивирование отстает, БД не может удалить старые WAL файлы
-- и pg_wal/ будет расти (может заполнить диск!)

Создание базового backup'а

# Способ 1: pg_basebackup (встроенная утилита)
# -h host, -U user, -D destination, -Ft (tar format), -z (gzip compress), -P (progress)
pg_basebackup -h localhost -U backup_user \
  -D /backup/base_backup_2025_11_13 \
  -Ft -z -P

# Результат: /backup/base_backup_2025_11_13/base.tar.gz (~размер БД)
# Запомнить: backup начинается с какой LSN? 
# Это нужно при восстановлении

# Способ 2: pg_basebackup с заметкой
pg_basebackup ... -l "backup_2025_11_13_critical"

# Способ 3: На более новых версиях можно использовать SQL:
SELECT * FROM pg_backup_start('backup_2025_11_13');
-- Сделать что-то в это время (скопировать pg_data вручную)
SELECT * FROM pg_backup_stop();

Восстановление на момент времени

Процесс восстановления:

# 1. Подготовить чистую директорию для pg_data
rm -rf /var/lib/postgresql/new_data
mkdir -p /var/lib/postgresql/new_data

# 2. Распаковать base backup
cd /var/lib/postgresql/new_data
tar -xzf /backup/base_backup_2025_11_13/base.tar.gz

# 3. Создать recovery.signal (PostgreSQL 12+)
# Это файл сигнализирует серверу "ты в режиме восстановления"
touch /var/lib/postgresql/new_data/recovery.signal

# 4. Создать postgresql.auto.conf с параметрами восстановления
cat > /var/lib/postgresql/new_data/postgresql.auto.conf << EOF
# Восстанавливаться до этого момента времени
recovery_target_timeline = 'latest'
recovery_target_time = '2025-11-13 11:03:00'
recovery_target_inclusive = false  # До этого времени, не включая

# Где искать архивированные WAL файлы
restore_command = 'cp /mnt/backup/wal_archive/%f "%p"'
# %f = имя файла, %p = путь куда копировать
EOF

# 5. Запустить PostgreSQL
# Он прочитает recovery.signal и запустится в режиме восстановления
systemctl start postgresql
# или
pg_ctl -D /var/lib/postgresql/new_data start

# 6. PostgreSQL будет:
#    а) Применять base_backup
#    б) Вызывать restore_command для каждого WAL файла
#    в) Воспроизводить операции из WAL до recovery_target_time
#    г) Остановиться при достижении целевого времени
#    д) Удалить recovery.signal
#    е) БД готова!

Варианты recovery_target_*

-- Восстановиться к конкретному времени
recovery_target_time = '2025-11-13 11:03:00'

-- Восстановиться к конкретной LSN (Log Sequence Number)
recovery_target_lsn = '0/3000000'

-- Восстановиться к конкретной транзакции
recovery_target_xid = 12345

-- Восстановиться на самый последний момент (default)
recovery_target = 'immediate'

-- Включить ли целевое время/LSN в восстановление
recovery_target_inclusive = true   # До и включая
recovery_target_inclusive = false  # До, но не включая

Crash Recovery

Crash recovery - процесс автоматического восстановления после неожиданного отключения.

Сценарий: сервер упал (power failure, kill -9, crash БД)
    ↓
Администратор запускает сервер заново
    ↓
PostgreSQL видит "unclean shutdown" в pg_control файле
    ↓
Запускается CRASH RECOVERY
    ↓
PostgreSQL читает последний checkpoint из pg_control
    ↓
PostgreSQL загружает WAL файлы с момента checkpoint'а
    ↓
Воспроизводит все операции из WAL (redo phase)
    ↓
Откатывает все незаконченные транзакции (undo phase)
    ↓
Если были открытые транзакции - они ОТКАТЫВАЮТСЯ
    ↓
БД переводится в режим готовности
    ↓
Новые клиенты могут подключаться

Время восстановления зависит от:

  • Размера WAL логов после последнего checkpoint
  • Скорости диска
  • Количества сложных операций

Пример: Если max_wal_size = 1GB и нагрузка 100 MB/мин, то при crash может потребоваться воспроизвести ~10 минут WAL логов = 1-2 минуты восстановления (в зависимости от операций).

Откат незаконченных транзакций

-- Во время работы БД
BEGIN;
UPDATE users SET active = true WHERE id = 1;
-- Сервер упал ЗДЕСЬ (не было COMMIT)

-- При восстановлении:

-- PostgreSQL видит что транзакция 12345 не имеет COMMIT записи в WAL
-- Откатывает все её операции
-- UPDATE отменяется
-- users.active остается как было до UPDATE

Это безопасно потому что:

  • Приложение никогда не получило подтверждение COMMIT
  • Приложение не знает был ли COMMIT выполнен
  • Приложение повторит операцию (или откажет)

Структура восстановления (Archival Recovery)

Archival Recovery - восстановление из backup'а после потери данных.

Сценарий: диск с pg_data полностью повреждён (отказал, удалена, скомпрометирована)
    ↓
1. Удаляем поврежденный pg_data (или переименовываем на .old)
2. Распаковываем base_backup в новый pg_data
   └─> pg_data теперь содержит состояние БД от момента backup'а (например 23:00)
3. Копируем все WAL архивные файлы в pg_wal/
   └─> pg_wal теперь содержит лог всех операций с 23:00 до "сейчас"
4. Создаем recovery.signal (сигнал режиму восстановления)
5. Создаем postgresql.auto.conf с:
   └─> recovery_target_time (если хотим на конкретный момент)
   └─> restore_command (где искать архивные WAL файлы)
6. Запускаем PostgreSQL
    ↓
PostgreSQL видит recovery.signal:
  а) Включает режим архивного восстановления (archival recovery mode)
  б) Вызывает restore_command для каждого WAL файла по порядку
  в) Воспроизводит операции из WAL
  г) Если recovery_target_time указано - останавливается при его достижении
  д) Удаляет recovery.signal
  е) Становится новым primary (или standby, если настроено)

Порядок WAL файлов

WAL файлы должны восстанавливаться в правильном порядке:

000000010000000000000001  ← содержит операции 0-16MB
000000010000000000000002  ← содержит операции 16-32MB
000000010000000000000003  ← содержит операции 32-48MB

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


WAL архивирование vs Base Backup

Параметр Base Backup WAL Archive
Что содержит Снимок всех данных БД в момент времени T0 Лог операций после T0
Размер ~100% размера БД ~1-100 MB/час (зависит от нагрузки)
Период хранения 1-30 дней (зависит от требований) Непрерывно
Способ создания pg_basebackup (однократно) Автоматическое архивирование WAL
Один backup спасает? Да, может восстановить целую БД (но на момент backup'а) Нет, нужен base_backup для начальной точки
Recovery time Быстро (просто распаковать) Долго (распаковать + воспроизвести ВСЕ WAL)
Использование Для быстрого восстановления на фиксированную дату Для восстановления на конкретное время в прошлом

Практическая настройка для Production

-- postgresql.conf для critical production БД

-- === DURABILITY ===
fsync = on                          # ОБЯЗАТЕЛЬНО! Без этого потеря данных при power failure
synchronous_commit = on             # Ждать физической записи перед COMMIT

-- === WAL ARCHIVING ===
wal_level = replica                 # Нужно для archiving и replication
archive_mode = on
archive_command = 'test ! -f /backup/wal_archive/%f && cp %p /backup/wal_archive/%f'
archive_timeout = 300               # Архивировать每300 sec даже если WAL не заполнен

-- === CHECKPOINTS ===
checkpoint_timeout = 10min          # Не слишком часто, не слишком редко
max_wal_size = 1GB                  # На основе available disk space
min_wal_size = 500MB                # Не делать checkpoints слишком часто
checkpoint_completion_target = 0.9  # Распределить нагрузку

-- === REPLICATION ===
max_wal_senders = 3                 # Максимум 3 стендбая
wal_keep_size = 1GB                 # Хранить WAL локально для репликантов

Бэкап стратегия

# 1. Ежедневный base backup (например в 2:00 ночи)
crontab: 0 2 * * * pg_basebackup -D /backup/daily_backup_$(date +%Y%m%d) -Ft -z -P

# 2. WAL архивирование (автоматическое через archive_command)
# Проверять что работает:
* * * * * psql -c "SELECT archived_count FROM pg_stat_archiver" 
# Если archived_count не растет - проблема!

# 3. Хранение:
# - Base backups: 7-30 дней на NAS/S3 (зависит от RTO/RPO)
# - WAL archives: 7-14 дней на диске, потом на долгосрочное хранилище

# 4. Тестирование восстановления (ежемесячно!)
# На тестовой машине восстановить на конкретный момент
# Проверить что данные корректны

Диагностика и мониторинг

Проблема: Диск быстро заполняется

-- Возможная причина: архивирование не работает
SELECT * FROM pg_stat_archiver;
-- Если failed_count > 0 или archived_count не растет

-- Проверить что archive_command работает
-- Вручную выполнить команду:
bash> test ! -f /backup/wal_archive/000000010000000000000001 && \
      cp /var/lib/postgresql/pg_wal/000000010000000000000001 \
      /backup/wal_archive/000000010000000000000001

-- Если ошибка - проблема с доступом к архивной директории
-- Проверить: существует ли директория, права доступа, место на диске

Проблема: Восстановление после crash долгое (>10 мин)

-- Возможные причины:

-- 1. max_wal_size слишком большой (много WAL логов воспроизводить)
-- Решение: уменьшить max_wal_size (но будет больше checkpoints)

-- 2. checkpoint_timeout слишком большой (долго между checkpoints)
-- Решение: уменьшить checkpoint_timeout

-- 3. shared_buffers слишком большой (много грязных страниц на checkpoint)
-- Решение: может быть оптимизировать нагрузку

-- Проверить текущий размер pg_wal/
ls -lh /var/lib/postgresql/pg_wal/ | tail -20
# Сколько WAL файлов? Если 100+ файлов - это может быть проблемой

Проблема: PITR не работает (не может найти WAL файлы)

# Проверить что файлы на месте:
ls /backup/wal_archive/ | grep "^000000010000000000000001$"

# Проверить что restore_command работает:
bash> cp /backup/wal_archive/000000010000000000000001 /tmp/test_wal
# Если ошибка - проверить права доступа

# Проверить postgresql.auto.conf параметры:
grep "restore_command\|recovery_target" /var/lib/postgresql/pg_data/postgresql.auto.conf

Тестирование PITR (на тестовой машине!)

#!/bin/bash
# Тестовый скрипт восстановления на момент времени

# 1. Создать тестовую БД
createdb test_pitr

# 2. Создать таблицу и добавить данные
psql test_pitr << SQL
CREATE TABLE products (id INT PRIMARY KEY, name TEXT, price DECIMAL);
INSERT INTO products VALUES (1, 'Apple', 0.5), (2, 'Orange', 0.3);
SQL

# 3. Запомнить текущее время (нужно для восстановления)
TARGET_TIME=$(date '+%Y-%m-%d %H:%M:%S')
echo "Target recovery time: $TARGET_TIME"
sleep 5

# 4. Добавить еще данных
psql test_pitr << SQL
INSERT INTO products VALUES (3, 'Banana', 0.2);
INSERT INTO products VALUES (4, 'Grape', 1.0);
SQL

# 5. Создать base backup
pg_basebackup -D /tmp/test_backup -Ft -z

# 6. Восстановить на старую дату
rm -rf /tmp/test_restored
mkdir -p /tmp/test_restored
cd /tmp/test_restored
tar -xzf /tmp/test_backup/base.tar.gz

# 7. Создать recovery.signal
touch /tmp/test_restored/recovery.signal

# 8. Создать postgresql.auto.conf с параметрами восстановления
cat > /tmp/test_restored/postgresql.auto.conf << EOF
recovery_target_time = '$TARGET_TIME'
recovery_target_inclusive = false
restore_command = 'cp /var/lib/postgresql/pg_wal/%f "%p"'
EOF

# 9. Запустить сервер на другом порту
pg_ctl -D /tmp/test_restored -o "-p 5433" start

# 10. Проверить что восстановилось на нужный момент
sleep 5
psql -p 5433 -d test_pitr -c "SELECT * FROM products ORDER BY id;"
# Должно быть 2 записи (до вставки Apple и Orange)

# 11. Очистить
pg_ctl -D /tmp/test_restored stop

Ключевые параметры - напоминание

-- Для надежности (production):
fsync = on
synchronous_commit = on
wal_level = replica
archive_mode = on

-- Для производительности восстановления:
checkpoint_timeout = 10min
max_wal_size = 1GB
checkpoint_completion_target = 0.9

-- Для PITR:
archive_command = 'test ! -f /backup/wal_archive/%f && cp %p /backup/wal_archive/%f'
restore_command = 'cp /backup/wal_archive/%f "%p"'

Помните: WAL обеспечивает Durability, но требует дополнительного места на диске и мониторинга архивирования!

VACUUM & Maintenance

Почему нужен VACUUM

MVCC и создание мертвых версий

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

Транзакция 1 (xid=100): UPDATE users SET name = 'Bob' WHERE id = 1;
    ↓
Строка с (id=1, name='Alice') xmin=95 xmax=100  → помечена как "удалена для 100+"
Строка с (id=1, name='Bob')   xmin=100 xmax=∞   → новая версия
    ↓
Обе версии занимают место на диске одновременно!

Позже, когда все транзакции завершены и максимальный xmin превышает 100:
Строка (id=1, name='Alice') → МЕРТВАЯ (ни одна активная транзакция не нуждается в ней)
    ↓
VACUUM должен удалить мертвые версии и освободить место

Проблема без VACUUM: раздутие таблицы

Сценарий:

UPDATE users SET status = 'active' 100 раз;

Если в таблице 1 млн строк:

- После 1-го UPDATE: 2 млн версий (1 млн живых + 1 млн мертвых)
- После 100-го UPDATE: 101 млн версий (1 млн живых + 100 млн мертвых)

Размер диска: 1 GB → 101 GB (рост в 100 раз!)

Почему это критично:

  • Индексы растут: индекс хранит ссылки на все версии строк. Раздутая таблица = раздутые индексы
  • Медленные запросы: Sequential scan читает больше данных. Index scan вынужден посещать больше блоков из-за мертвых версий
  • Меньше кэша: эффективнее используется кэш (buffer pool), когда живые строки компактны
  • Фрагментация диска: мертвые версии разбросаны по таблице, не образуют сплошной блок

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

Фаза 1: Определение мертвых версий (Mark Dead Tuples)

VACUUM users;

PostgreSQL выполняет следующее:

  1. Определяет horizon (горизонт видимости)
  • Ищет наименьший активный xid среди всех транзакций
  • Все версии с xmax < horizon считаются мертвыми (никому не видны)
  1. Сканирует таблицу
  • Проходит по каждой странице (page) и каждой версии строки
  • Проверяет xmin/xmax каждой версии против horizon
  1. Запоминает мертвые версии
  • Записывает TID (tuple identifier = page_id + offset) в dead_tuples список
  • Этот список хранится в памяти (может быть большой для больших таблиц)

Важно: На этом этапе данные еще НЕ удалены с диска!

Фаза 2: Удаление мертвых версий (Cleanup)

1. Проходит по записанным TID'ам
2. Удаляет строку с диска
3. Отмечает это место как свободное в FSM (Free Space Map)

Free Space Map (FSM) — вспомогательная структура, которая отслеживает количество свободного места на каждой странице. Это позволяет:

  • INSERT/UPDATE'ам быстро найти страницу с достаточным свободным местом
  • Избежать добавления новых страниц, когда старые имеют дырки

Но:

  • Место остается зарезервировано за таблицей (диск не отдается ОС)
  • На диске остаются фрагменты неиспользуемого пространства

VACUUM FULL — полная реорганизация

VACUUM FULL users;

Это дорогая операция, но дает максимальный результат:

1. Переписывает всю таблицу в новый файл
   - Копирует ТОЛЬКО живые строки в правильном порядке
   - Убирает все дырки (фрагментацию)
   
2. Отдает старый файл ОС (место действительно освобождается на диске)

3. Перестраивает индексы
   - Старые индексы указывали на старые TID'ы
   - Новые версии имеют новые TID'ы → нужна переиндексация

4. Полностью блокирует таблицу на весь процесс

Когда это нужно:

  • Таблица была удалена на 80-90% и никогда больше не будет такой большой
  • После крупной миграции или очистки данных
  • Очень редко, потому что мешает работе системы

Примеры:

-- Базовый VACUUM (рекомендуемый)
VACUUM users;
-- Выполняется БЕЗ эксклюзивной блокировки
-- Может работать параллельно с SELECT, INSERT, UPDATE, DELETE

-- VACUUM + обновление статистики
VACUUM ANALYZE users;
-- После очистки обновляет pg_statistic для плана запросов

-- VACUUM FULL (ОПАСНО!)
VACUUM FULL users;
-- Полная блокировка таблицы, очень медленно для больших таблиц

-- VACUUM конкретных колонок (редко используется)
VACUUM (ANALYZE, VERBOSE) users (id, name);
-- ANALYZE соберет статистику только по id и name

AUTOVACUUM — автоматическое очищение

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

PostgreSQL периодически запускает фоновые воркеры, которые:

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

Параметры управления

-- postgresql.conf

# Включить autovacuum (по умолчанию ON)
autovacuum = on

# Количество фоновых процессов autovacuum одновременно
autovacuum_max_workers = 3  # default

# Как часто проверять таблицы (в секундах)
autovacuum_naptime = 1min  # default

# Главный параметр: запустить VACUUM когда мертвые версии составляют % от таблицы
autovacuum_vacuum_scale_factor = 0.1  # 10% мертвых версий (default)

# Минимальное абсолютное количество мертвых версий для VACUUM
autovacuum_vacuum_threshold = 50  # default

# Аналогично для ANALYZE (обновления статистики)
autovacuum_analyze_scale_factor = 0.05  # 5% (default)
autovacuum_analyze_threshold = 50  # default

Когда запускается VACUUM

Таблица: 10,000 строк
autovacuum_vacuum_scale_factor = 0.1 (10%)
autovacuum_vacuum_threshold = 50

VACUUM запустится когда:
dead_tuples >= max(10,000 * 0.1, 50) = max(1,000, 50) = 1,000

Логика:

  • Для малых таблиц (< 500 строк): используется threshold (абсолютное значение)
  • Для больших таблиц (> 500 строк): используется scale_factor * таблица_размер

Специальные параметры для конкретных таблиц

Можно переопределить поведение для отдельных таблиц:

-- Высоконагруженная таблица (много UPDATE'ов)
ALTER TABLE accounts SET (
    autovacuum_vacuum_scale_factor = 0.05,  # 5% (вместо 10%, чище чаще)
    autovacuum_vacuum_threshold = 100,
    autovacuum_analyze_scale_factor = 0.02, # 2% (чаще обновляем статистику)
    autovacuum_analyze_threshold = 50
);

-- Таблица, которую редко обновляют (mostly read-only)
ALTER TABLE metadata SET (
    autovacuum_vacuum_scale_factor = 0.2,   # 20% (чистим реже)
    autovacuum_vacuum_threshold = 200,
    autovacuum_enabled = off  # Можем вообще отключить autovacuum
);

Проблемы с AUTOVACUUM и балансировка

Если AUTOVACUUM запускается слишком часто:

Симптомы:

- Высокая нагрузка на CPU (особенно для больших таблиц)
- Другие операции замедляются (I/O конкуренция)
- vacuum_cost_limit ограничивает скорость VACUUM

Решение:

- Увеличить autovacuum_vacuum_scale_factor (0.1 → 0.2)
- Увеличить autovacuum_naptime (проверять реже)
- Увеличить vacuum_cost_limit

Если AUTOVACUUM запускается слишком редко:

Симптомы:

- Таблица растет на диске (мертвые версии скапливаются)
- Index bloat (индексы тоже раздуваются)
- Замедление запросов со временем
- Риск hit transaction ID (xid) wraparound (редко, но критично)

Решение:

- Уменьшить autovacuum_vacuum_scale_factor (0.1 → 0.05)
- Уменьшить autovacuum_naptime (проверять чаще)
- Увеличить autovacuum_max_workers (больше параллельных процессов)

Рекомендуемые настройки:

-- Для большинства приложений (balanced)
autovacuum_vacuum_scale_factor = 0.1
autovacuum_vacuum_threshold = 50
autovacuum_analyze_scale_factor = 0.05
autovacuum_analyze_threshold = 50

-- Для high-churn таблиц (много INSERT/UPDATE/DELETE)
autovacuum_vacuum_scale_factor = 0.05
autovacuum_vacuum_threshold = 100
autovacuum_analyze_scale_factor = 0.02
autovacuum_analyze_threshold = 50

-- Для read-mostly таблиц (стабильные данные)
autovacuum_vacuum_scale_factor = 0.2
autovacuum_vacuum_threshold = 100
autovacuum_analyze_scale_factor = 0.1
autovacuum_analyze_threshold = 100

ANALYZE — обновление статистики

Зачем нужна статистика

Query Planner (оптимизатор) использует статистику для выбора оптимального плана выполнения запроса:

Вопросы, на которые отвечает статистика:
1. Сколько всего строк в таблице?
2. Распределение значений в колонке (min, max, avg)?
3. Сколько NULL значений?
4. Есть ли корреляция между колонками?
5. Какой процент строк содержит значение = 'active'?

Без статистики:

SELECT * FROM users WHERE status = 'active';

Planner гадает:

- Может вернуть 1% строк (нужен index)
- Может вернуть 99% строк (нужен seq scan)

Неправильное предположение → неправильный план → медленный запрос

Со статистикой:

Planner знает точно:

- 'active' составляет 80% строк
- Index не поможет (слишком много строк)
- Выбирает sequential scan

Правильное решение → быстрый запрос

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

ANALYZE users;

Процесс:

  1. Сканирует таблицу

    • Не читает ВСЕ строки (даже для больших таблиц это медленно)
    • Берет random sample (обычно несколько тысяч строк)
  2. Собирает статистику

    • min/max значения в колонке
    • среднее значение (для numeric типов)
    • количество NULL значений
    • частотность значений (histogram)
    • distinct count (примерное количество уникальных значений)
  3. Обновляет pg_statistic

    • Хранит статистику в специальной системной таблице
    • Query Planner читает эту таблицу при оптимизации

Важно: ANALYZE только собирает информацию, НЕ удаляет данные. Можно запускать в production без проблем.

Примеры использования

-- Обновить статистику всей таблицы
ANALYZE users;

-- Обновить статистику конкретных колонок (редко нужно)
ANALYZE users (id, email);

-- Детальная статистика (VERBOSE выводит информацию)
ANALYZE (VERBOSE) users;
-- Вывод: количество живых/мертвых строк, время выполнения

-- ANALYZE без VACUUM (отдельно)
ANALYZE users;

-- Чаще используется вместе с VACUUM
VACUUM ANALYZE users;

Когда нужен ручной ANALYZE

Обычно AUTOVACUUM запускает ANALYZE автоматически после VACUUM, но бывают исключения:

1. После массовой вставки
   INSERT INTO users SELECT * FROM import_table;
   ANALYZE users;  -- Planner теперь знает размер таблицы

2. После массового удаления
   DELETE FROM users WHERE archived = true;
   ANALYZE users;  -- Плана запросов могут измениться

3. После восстановления из backup'а
   -- Статистика не восстанавливается, нужна переиндексация
   REINDEX TABLE users;
   ANALYZE users;

4. После изменения distribution параметров
   ALTER TABLE users ALTER COLUMN status SET STATISTICS 100;
   ANALYZE users;

Мониторинг VACUUM

Найти раздутые таблицы

-- Таблицы с большим процентом мертвых версий
SELECT 
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as total_size,
    pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) as table_size,
    round(100.0 * (total_relation_size - pg_relation_size) / total_relation_size) as wasted_percent
FROM (
    SELECT 
        schemaname,
        tablename,
        pg_total_relation_size(schemaname||'.'||tablename) as total_relation_size,
        pg_relation_size(schemaname||'.'||tablename) as pg_relation_size
    FROM pg_tables
    WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
) t
WHERE total_relation_size > 1000000  -- Только таблицы > 1MB
ORDER BY wasted_percent DESC
LIMIT 20;

-- Интерпретация:

-- wasted_percent > 30% → нужна чистка
-- wasted_percent > 50% → срочная чистка
-- wasted_percent > 70% → может быть VACUUM FULL (если таблица стабилизируется)

Что здесь происходит:

  • pg_total_relation_size — общий размер (таблица + индексы + FSM + VM)
  • pg_relation_size — размер только таблицы (без индексов)
  • Разница = размер индексов (и их дополнительных структур)
  • wasted_percent = (размер индексов / общий размер) * 100

Это дает понимание, насколько раздуты индексы из-за мертвых версий.

История VACUUM по таблицам

-- Когда был последний VACUUM/AUTOVACUUM для каждой таблицы
SELECT 
    schemaname,
    tablename,
    last_vacuum,
    last_autovacuum,
    vacuum_count,
    autovacuum_count,
    n_live_tup,
    n_dead_tup,
    round(100.0 * n_dead_tup / (n_live_tup + n_dead_tup)) as dead_percent
FROM pg_stat_user_tables
ORDER BY last_autovacuum DESC NULLS LAST;

-- Что важно смотреть:

-- 1. last_autovacuum = NULL → AUTOVACUUM никогда не запускался (проблема!)
-- 2. dead_percent > 20% → много мертвых версий, нужна чистка
-- 3. vacuum_count = 0 → таблица никогда не вакуумилась (проблема!)

Логирование AUTOVACUUM операций

-- postgresql.conf
log_autovacuum_min_duration = 0
# log_autovacuum_min_duration = 1000  # Только операции > 1 секунды

-- Потом в логах можно видеть:

-- LOG: autovacuum: VACUUM users (pages: 0 removed, 1520 remain)
-- LOG: analyze: ANALYZE users (total time: 125.524 ms)

Можно отслеживать:

  • Как часто запускается VACUUM
  • Сколько страниц удаляется
  • Время выполнения

Практические примеры

Высоконагруженная таблица (много UPDATE'ов)

-- Таблица accounts постоянно обновляется (баланс, статус платежа и т.д.)

ALTER TABLE accounts SET (
    autovacuum_vacuum_scale_factor = 0.05,     -- Чистим при 5% мертвых (вместо 10%)
    autovacuum_vacuum_threshold = 100,         -- Минимум 100 мертвых версий
    autovacuum_analyze_scale_factor = 0.02,    -- Обновляем статистику при 2%
    autovacuum_analyze_threshold = 50,
    autovacuum_vacuum_cost_delay = 1,          -- 1ms задержка (более агрессивно)
    autovacuum_vacuum_cost_limit = 500         -- Больше операций за раз
);

-- Результат: очистка происходит чаще, но не перегружает систему

После массовой вставки

-- Вставили миллион новых пользователей из external источника
INSERT INTO users (name, email, created_at) 
SELECT name, email, NOW() FROM external_import;

-- На этот момент:

-- 1. Таблица выросла на 1 млн новых строк
-- 2. Статистика старая (Query Planner не знает о новых строках)
-- 3. ANALYZE не запустился (может быть задержка)

-- Принудительно обновляем статистику
ANALYZE users;

-- Теперь:

-- EXPLAIN SELECT * FROM users WHERE ...
-- покажет правильное количество строк
-- и правильный план выполнения

Миграция: слишком много UPDATE'ов

-- Старый паттерн: UPDATE всей таблицы в цикле
BEGIN;
FOR i IN 1..100 LOOP
  UPDATE products SET price = price * 1.1 WHERE category_id = 1;
  COMMIT;
  BEGIN;
END LOOP;

-- Результат: 100% мертвых версий в таблице
-- Таблица выросла в 100+ раз!

-- Лучший подход:
DELETE FROM products WHERE category_id = 1;
INSERT INTO products SELECT * FROM archive.products WHERE category_id = 1;
-- Потом VACUUM и ANALYZE

-- Или даже лучше: CREATE TABLE AS SELECT и переименовать
CREATE TABLE products_new AS SELECT * FROM products WHERE category_id != 1;
ALTER TABLE products RENAME TO products_old;
ALTER TABLE products_new RENAME TO products;
DROP TABLE products_old;
-- Это дает полностью чистую таблицу

Параметры для production системы (high-performance)

-- postgresql.conf для системы с высокой нагрузкой

# AUTOVACUUM основные параметры
autovacuum = on
autovacuum_max_workers = 4              # 4 процесса параллельно
autovacuum_naptime = 10s                # Проверять каждые 10 сек (вместо 1 мин)

# Параметры VACUUM
autovacuum_vacuum_scale_factor = 0.05   # 5% (агрессивнее чистим)
autovacuum_vacuum_threshold = 50        # Минимум 50 мертвых
autovacuum_analyze_scale_factor = 0.02  # 2% для ANALYZE
autovacuum_analyze_threshold = 30

# Стоимость VACUUM (не перегружать систему)
vacuum_cost_limit = 200                 # Максимум 200 ms на операцию
vacuum_cost_delay = 2                   # Ждать 2ms между операциями
# Формула: один UPDATE = 20 cost units
# 200 ms на 10 cost units = 200 / 10 = 20 UPDATES в секунду (контролируемо)

# Логирование
log_autovacuum_min_duration = 1000      # Логировать VACUUM > 1 сек
log_min_duration_statement = 5000       # Логировать запросы > 5 сек

# Результат:
# - AUTOVACUUM проверяет таблицы чаще (каждые 10 сек)
# - Очищает агрессивнее (5% vs 10%)
# - Не перегружает систему (cost_limit контролирует нагрузку)
# - Дает достаточно времени на очистку между пиками трафика

Отладка медленного VACUUM

-- Если VACUUM работает медленно, смотрим:

1. Размер таблицы и количество мертвых версий:
SELECT 
    pg_size_pretty(pg_total_relation_size('public.large_table')),
    (SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'large_table');

-- Если > 1M мертвых версий и таблица > 10GB → очень долгий VACUUM

2. Параметры VACUUM:
SHOW vacuum_cost_limit;      -- Если < 200, VACUUM может быть очень медленным
SHOW vacuum_cost_delay;
-- Если cost_delay > 10ms, это еще больше замедляет

3. Попытка ускорить (осторожно!):
SET vacuum_cost_limit = 1000;       -- Уменьшить ограничение на стоимость
SET vacuum_cost_delay = 0;          -- Убрать задержку между операциями
VACUUM users;                        -- Теперь быстрее, но нагружает систему

4. Если очень срочно и таблица малоиспользуемая:

-- Можно вообще отключить cost limit
SET vacuum_cost_limit = 0;
VACUUM ANALYZE users;
-- Но делать в off-peak время!