Вопрос 11 · Раздел 11

Какой уровень изоляции по умолчанию в PostgreSQL

Уровень изоляции по умолчанию в PostgreSQL — Read Committed.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Уровень изоляции по умолчанию в PostgreSQL — Read Committed.

Что это значит простыми словами: Каждая команда SELECT внутри транзакции видит только те данные, которые были уже сохранены (закоммичены) другими транзакциями. Важно: каждый SELECT видит свой snapshot. Поэтому два SELECT в одной транзакции могут вернуть разные данные. Но если вы выполните SELECT дважды в одной транзакции, второй запрос может увидеть новые данные, появившиеся после первого запроса.

Простая аналогия: Вы читаете новостную ленту. Каждый раз, когда вы обновляете страницу (новый SELECT), вы видите свежие новости. Но вы не видите черновики, которые ещё не опубликованы (незакоммиченные данные).

SQL-пример:

-- Начальное состояние: account.balance = 1000

-- Транзакция 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- видит 1000
-- (ждёт...)

-- Транзакция 2 (выполняется параллельно)
BEGIN;
UPDATE accounts SET balance = 1500 WHERE id = 1;
COMMIT;  -- данные сохранены

-- Транзакция 1 продолжает:
SELECT balance FROM accounts WHERE id = 1;  -- видит 1500! (новые данные)
COMMIT;

Что защищено, а что нет на Read Committed:

Аномалия Возможна? Описание
Dirty Read (Грязное чтение) Нет Не видит незакоммиченные данные
Non-repeatable Read Да Повторный SELECT может вернуть другие значения
Phantom Read Да Повторный SELECT может вернуть другие строки

Как изменить уровень изоляции:

-- Для текущей сессии
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- В Spring
@Transactional(isolation = Isolation.REPEATABLE_READ)

🟡 Middle Level

Как это работает внутри

PostgreSQL реализует Read Committed через MVCC (Multi-Version Concurrency Control):

Термины:

  • xmin/xmax — системные поля в каждой строке PG, хранят ID транзакции-автора.
  • Dead tuples — старые версии строк, которые больше не нужны ни одной активной транзакции.
  • TOAST — механизм хранения больших значений (TEXT, JSONB) отдельно от основной строки.
  • Autovacuum — фоновый процесс PG, удаляющий dead tuples.
Каждая строка в PostgreSQL содержит два системных поля:
  - xmin: ID транзакции, которая вставила эту строку
  - xmax: ID транзакции, которая удалила эту строку (0 = строка жива)

При SELECT:
  1. PostgreSQL берёт текущий snapshot
  2. Проверяет: xmin < current_transaction_id и строка "видима"
  3. Каждая команда SELECT получает НОВЫЙ snapshot

Ключевой момент: В отличие от некоторых других СУБД, PostgreSQL на уровне Read Committed создаёт новый snapshot для каждой команды, а не для всей транзакции. Поэтому повторный SELECT может увидеть данные, закоммиченные другими транзакциями после первого SELECT.

Практическое применение

Когда Read Committed достаточно:

  1. CRUD-приложения — большинство веб-приложений работают корректно на Read Committed
  2. Отчёты без строгих требований к консистентности — допустима небольшая погрешность
  3. Высоконагруженные системы — минимальный оверхед, максимальный throughput

Когда Read Committed НЕ достаточно:

  1. Финансовые расчёты — нужны консистентные данные на протяжении всей транзакции
  2. Миграции данных — требуется стабильный snapshot
  3. Сложные бизнес-инварианты — проверки, зависящие от нескольких таблиц

Реальные сценарии

Сценарий 1: Проблема с повторным чтением на Read Committed

@Transactional  // Read Committed по умолчанию
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // Snapshot 1
    Account from = accountRepo.findById(fromId);
    Account to = accountRepo.findById(toId);
    
    // Здесь другая транзакция может изменить баланс 'from'
    
    // Snapshot 2 — может увидеть другие данные!
    BigDecimal fromBalance = from.getBalance();
    BigDecimal toBalance = to.getBalance();
    
    // Если данные изменились, перевод может быть некорректным
    from.setBalance(fromBalance.subtract(amount));
    to.setBalance(toBalance.add(amount));
}

Сценарий 2: Консистентный отчёт на Repeatable Read

@Transactional(isolation = Isolation.REPEATABLE_READ)
public Report generateReport(LocalDate date) {
    // Все SELECT видят один и тот же snapshot
    int count = txRepo.countByDate(date);
    BigDecimal sum = txRepo.sumByDate(date);
    // count и sum гарантированно согласованы
    return new Report(count, sum);
}

Типичные ошибки

Ошибка Последствие Решение
Предположение, что @Transactional даёт консистентный snapshot Повторный SELECT видит разные данные Использовать Repeatable Read для консистентных отчётов
Длинная транзакция на Read Committed Больше window для конфликтов Укоротить транзакцию
Read Committed для финансовых операций Lost Update, incorrect calculations Atomic SQL (SET balance = balance - ?) или Repeatable Read + FOR UPDATE
Игнорирование default_transaction_isolation Разные сессии могут иметь разные уровни Установить уровень явно в коде или конфигурации

Сравнение уровней изоляции в PostgreSQL

Уровень Dirty Read Non-repeatable Read Phantom Read Throughput (relative)
Read Uncommitted Возможна Возможна Возможна 1.0x
Read Committed (default) Нет Да Да 1.0x
Repeatable Read Нет Нет Нет (в PG) 0.95x
Serializable Нет Нет Нет 0.3-0.5x

Цифры relative для read-heavy workload (80% SELECT). Для write-heavy (80% UPDATE) разница будет больше: RC=1.0x, RR=0.8x, Serializable=0.2x.

Важно: В PostgreSQL Repeatable Read защищает и от фантомного чтения тоже (благодаря MVCC), что выходит за рамки стандарта ANSI SQL.

Когда НЕ стоит менять дефолт

  • Стандартные CRUD операции — Read Committed — хороший выбор по умолчанию для стандартных CRUD-операций.
  • Микросервисы с eventual consistency — строгая изоляция избыточна
  • Высоконагруженные read-heavy системы — оверхед Serializability не оправдан

🔴 Senior Level

Internal Implementation: MVCC и Snapshot Management

Transaction ID и Visibility Rules

// Упрощённая логика visibility в PostgreSQL (src/backend/access/common/heaptuple.c)

bool HeapTupleSatisfiesVisibility(HeapTuple htup, Snapshot snapshot) {
    TransactionId xmin = htup->t_data->t_xmin;  // inserting transaction
    TransactionId xmax = htup->t_data->t_xmax;  // deleting transaction
    
    // Read Committed rules:
    // 1. Если xmin ещё не закоммитил → строка невидима
    // 2. Если xmax закоммитил → строка невидима (удалена)
    // 3. snapshot создаётся заново для КАЖДОГО запроса
    
    // Repeatable Read rules:
    // snapshot создаётся ОДИН РАЗ при первом запросе в транзакции
    // и переиспользуется для всех последующих запросов
}

Transaction ID wraparound:

  • PostgreSQL использует 32-bit transaction IDs (~4 billion)
  • После переполнения происходит wraparound
  • Autovacuum обязан “замораживать” старые строки (xmax = FrozenTransactionId)
  • Если autovacuum не справляется → database shutdown (transaction ID wraparound failure)

Snapshot Creation Overhead

Read Committed:
  - Новый snapshot на каждый запрос
  - Snapshot = array of in-progress transaction IDs
  - Размер: O(active_transactions)
  - Аллокация: ~100-500 bytes per snapshot

Repeatable Read / Serializable:
  - Snapshot создаётся один раз при первом запросе
  - Сохраняется до конца транзакции
  - Дополнительно: SSI serializable transaction tracking (~1KB overhead)

MVCC и Dead Tuples

Каждый UPDATE или DELETE не удаляет строку физически, а помечает её как мёртвую:

UPDATE accounts SET balance = 1500 WHERE id = 1;

Старая версия строки:
  xmin = 100, xmax = 200 (новая транзакция), balance = 1000  → DEAD TUPLE
  
Новая версия строки:
  xmin = 200, xmax = 0, balance = 1500  → LIVE TUPLE

Оверхед:

  • Dead tuples занимают место на диске (table bloat: 2-10x роста)
  • Индексы тоже накапливают dead entries
  • AutoVacuum должен периодически очищать (I/O overhead: 10-30% disk throughput)

Архитектурные Trade-offs

Подход A: Read Committed (дефолт)

  • ✅ Плюсы: Минимальный оверхед, простая семантика, нет serialization failures, максимальный throughput
  • ❌ Минусы: Non-repeatable reads, phantom reads, нельзя полагаться на консистентность в рамках длинной транзакции
  • Подходит для: 90% веб-приложений, микросервисов, CRUD

Подход B: Repeatable Read

  • ✅ Плюсы: Консистентный snapshot, защита от phantom (в PG), предсказуемое поведение
  • ❌ Минусы: Snapshot-too-old errors при long-running транзакциях, больше dead tuples
  • Подходит для: отчётов, миграций, batch processing

Подход C: Serializable (SSI)

  • ✅ Плюсы: Полная ACID, гарантия корректности любых параллельных транзакций
  • ❌ Минусы: Serialization failures (10-30% abort rate), predicate lock overhead (5-20MB shared memory), throughput 2-10x ниже
  • Подходит для: финансовых систем, где корректность важнее производительности

Edge Cases и Corner Cases

1. Snapshot Too Old (Repeatable Read / Serializable):

T1: BEGIN ISOLATION LEVEL REPEATABLE READ;
T1: SELECT * FROM accounts;  -- создаёт snapshot

// ... 10 минут проходит ...
// AutoVacuum удаляет dead tuples, включая версии видимые только snapshot T1

T1: SELECT * FROM accounts;
-- ERROR: could not serialize access due to concurrent update
-- или: ERROR: snapshot too old

Решение: Увеличить vacuum_freeze_min_age или укоротить транзакции.

2. Read Committed и UPDATE конкуренция:

-- T1 и T2 одновременно:
T1: UPDATE accounts SET balance = balance - 100 WHERE id = 1;
T2: UPDATE accounts SET balance = balance - 100 WHERE id = 1;

-- PostgreSQL: T2 ждёт T1, потом T2 перезапускает UPDATE с новым snapshot
-- T2 увидит результат T1 и вычтет ещё 100
-- Итог: balance -= 200 (корректно!)

-- НО если логика на уровне приложения (read-modify-write):
-- T1: SELECT balance → 1000
-- T2: SELECT balance → 1000
-- T1: UPDATE balance = 900
-- T2: UPDATE balance = 900  ← Lost Update!

3. DDL внутри транзакции на Read Committed:

BEGIN;
ALTER TABLE accounts ADD COLUMN new_col TEXT;  -- DDL
SELECT * FROM accounts;  -- новый snapshot видит новую колонку
-- Другие сессии ещё не видят new_col до COMMIT
-- Но могут быть блокировки!

4. Read Committed и Foreign Keys:

-- T1: INSERT INTO orders (user_id) VALUES (999);
-- T2: DELETE FROM users WHERE id = 999;

-- FK check в T1 проверяет существование user_id = 999
-- На Read Committed FK check создаёт отдельный snapshot
-- Если T2 уже закоммитил DELETE → FK violation!

5. Read Committed и cursor:

BEGIN;
DECLARE cur CURSOR FOR SELECT * FROM accounts;
FETCH 10 FROM cur;  -- snapshot 1

-- Другая транзакция изменяет данные

FETCH 10 FROM cur;  -- snapshot 2, может увидеть другие данные!
-- Cursor НЕ гарантирует консистентность на Read Committed

Performance Implications

Уровень Latency (p50) Latency (p99) Throughput Memory Overhead
Read Committed 1-3ms 5-15ms 50,000+ TPS Snapshot: ~200 bytes/query
Repeatable Read 1-3ms 5-20ms 48,000 TPS Snapshot: ~200 bytes/tx + hold dead tuples
Serializable 5-20ms 50-200ms 8,000-15,000 TPS Predicate locks: 5-20MB shared memory

Конкретные цифры (PostgreSQL 15, 8 cores, NVMe, pgbench):

Цифры приведены для конкретного стенда. На вашем железе/версии могут отличаться на порядок.

  • Read Committed: ~55,000 SELECT-only TPS
  • Repeatable Read: ~52,000 SELECT-only TPS (5% overhead от snapshot retention)
  • Serializable: ~12,000 TPS при moderate contention (78% снижение)

Write workload (50% reads, 50% writes):

  • Read Committed: ~25,000 TPS
  • Repeatable Read: ~24,000 TPS
  • Serializable: ~6,000 TPS (serialization failures + retries)

Memory Implications

  • Snapshot arrays: ~200 bytes per snapshot. При 1000 queries/sec = 200KB/s allocation.
  • Dead tuple retention (RR/SRL): При long-running транзакции dead tuples не vacuumятся. Таблица 1GB может вырасти до 5-10GB.
  • Predicate locks (Serializable): Shared memory pool (~64MB default). При переполнении → lock coarsening → больше serialization failures.
  • TOAST bloat: Большие поля (TEXT, JSONB) хранятся в TOAST таблицах. Dead TOAST tuples тоже накапливаются.

Concurrency Aspects

Serialization failure patterns (SSI):

T1: reads range A (predicate lock on A)
T2: writes to range A
T1: writes to range B
T2: reads range B (predicate lock on B)

Dependency graph:
  T1 → (read A) → T2 → (write A) = RW-dependency
  T2 → (read B) → T1 → (write B) = RW-dependency
  
Cycle: T1 → T2 → T1 → serialization failure

Abort rate under contention:

  • 10 concurrent writers: ~5% serialization failures
  • 50 concurrent writers: ~15-25% serialization failures
  • 100+ concurrent writers: ~30-50% (непригодно для production)

Real Production Scenario

Ситуация: Платёжная система (2024), PostgreSQL 14, обработка 5000 транзакций/сек.

Проблема: Ежедневная сверка балансов показывала расхождения 0.01-0.05%. При сумме оборота $10M/день это означало $1,000-5,000 “потерянных” долларов.

Root cause: Batch job на Read Committed:

@Transactional  // Read Committed
public BigDecimal calculateDailyTotal(LocalDate date) {
    // Snapshot 1: 10,000 транзакций
    List<Transaction> txs = txRepo.findByDate(date);
    
    // Пока считаем, платежный шлюз подтверждает новые транзакции
    // Snapshot 2: новые закоммиченные строки
    
    BigDecimal total = txs.stream()
        .map(Transaction::getAmount)
        .reduce(BigDecimal.ZERO, BigDecimal::add);
    
    // Другой запрос внутри той же транзакции:
    int count = txRepo.countByDate(date);  // Snapshot 3 — видит больше строк!
    
    // total посчитан по 10,000, count = 10,050
    // Расхождение в отчёте
}

Решение: Переключение на Repeatable Read:

@Transactional(isolation = Isolation.REPEATABLE_READ)
public BigDecimal calculateDailyTotal(LocalDate date) {
    // Все SELECT видят один snapshot
    List<Transaction> txs = txRepo.findByDate(date);
    int count = txRepo.countByDate(date);  // тот же snapshot, count согласован
    // ...
}

Результат:

  • Расхождения: с 0.05% до 0%
  • Throughput: без изменений (read-only workload)
  • Latency: +0.1ms (negligible)

Monitoring и Диагностика

PostgreSQL internal stats:

-- Check isolation level of active transactions
SELECT pid, datname, backend_xid, state, 
       wait_event_type, query
FROM pg_stat_activity
WHERE state = 'active';

-- Serialization failures (PostgreSQL 14+)
SELECT datname, conflicts, deadlocks
FROM pg_stat_database
WHERE datname = 'your_db';

-- MVCC stats: dead tuples
SELECT 
    schemaname, relname, 
    n_dead_tup, n_live_tup,
    ROUND(100.0 * n_dead_tup / NULLIF(n_dead_tup + n_live_tup, 0), 2) AS dead_pct
FROM pg_stat_user_tables
ORDER BY dead_pct DESC;

-- Long-running transactions (snapshot retention risk)
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND xact_start < now() - interval '5 minutes'
ORDER BY xact_start;

Application-level monitoring:

// Micrometer: track isolation level usage
Counter.builder("db.transaction.isolation")
    .tag("level", "read_committed")
    .tag("operation", "daily_report")
    .register(meterRegistry);

// Track serialization failures
Counter.builder("db.transaction.serialization_failure")
    .tag("isolation", "serializable")
    .register(meterRegistry);

Best Practices для Highload

  1. Оставайтесь на Read Committed по умолчанию — это оптимальный выбор для 90% workload.
  2. Используйте Repeatable Read точечно — только для отчётов и batch jobs, требующих консистентного snapshot.
  3. Избегайте Serializable для write-heavy workloads — abort rate делает его непригодным при >50 concurrent writers.
  4. Atomic SQL вместо read-modify-writeUPDATE SET balance = balance - ? устраняет lost update без повышения уровня изоляции.
  5. Укорачивайте транзакции — чем короче транзакция, тем меньше dead tuple retention и snapshot overhead.
  6. Настройте autovacuum агрессивно для high-update таблиц:
    ALTER TABLE accounts SET (
      autovacuum_vacuum_threshold = 50,
      autovacuum_vacuum_scale_factor = 0.05
    );
    
  7. Monitor snapshot age — алерт на транзакции старше 5 минут.
  8. Connection pooling с подготовленными statements — снижает overhead snapshot creation per query.

🎯 Шпаргалка для интервью

Обязательно знать:

  • PostgreSQL дефолт: Read Committed — каждый SELECT видит свой snapshot закоммиченных данных
  • Read Committed запрещает dirty reads, но допускает non-repeatable и phantom reads
  • PostgreSQL MVCC: xmin/xmax на каждую строку, dead tuples → VACUUM cleanup
  • На RC два SELECT в одной транзакции могут вернуть разные данные (per-statement snapshot)
  • Для консистентных отчётов: Repeatable Read (один snapshot на транзакцию)
  • Serializablе в PG через SSI — throughput 2-10x ниже, нужен retry logic

Частые уточняющие вопросы:

  • Чем Read Committed в PG отличается от RC в других СУБД? — Всегда MVCC, нет locking-based RC
  • Когда стоит менять дефолт? — Финансовые расчёты (RR), миграции данных (RR), financial systems (Serializable)
  • Что такое snapshot too old? — При длинных RR транзакциях AutoVacuum удаляет старые версии → ошибка
  • Как мониторить MVCC в PG? — pg_stat_user_tables.n_dead_tup, long-running transactions via pg_stat_activity

Красные флаги (НЕ говорить):

  • “PostgreSQL имеет Read Uncommitted” — автоматически elevation до Read Committed
  • “На RC все SELECT в транзакции вернут одинаковый результат” — перестatement snapshot
  • “Serializable = просто поставь аннотацию” — нужен retry logic и monitoring abort rate

Связанные темы:

  • [[4. Что такое Read Committed]]
  • [[5. Что такое Repeatable Read]]
  • [[12. Какой уровень изоляции по умолчанию в MySQL]]
  • [[6. Что такое Serializable]]