Питання 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. Високонавантажені системи — мінімальний overhead, максимальний 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 системи — overhead 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

Overhead:

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

Архітектурні Trade-offs

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

  • ✅ Плюси: Мінімальний overhead, проста семантика, немає 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 на транзакцію)
  • Serializable у 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 в транзакції повернуть однаковий результат” — per-statement snapshot
  • “Serializable = просто постав анотацію” — потрібен retry logic та monitoring abort rate

Пов’язані теми:

  • [[4. Що таке Read Committed]]
  • [[5. Що таке Repeatable Read]]
  • [[12. Який рівень ізоляції за замовчуванням в MySQL]]
  • [[6. Що таке Serializable]]