Який рівень ізоляції за замовчуванням в PostgreSQL
Рівень ізоляції за замовчуванням у PostgreSQL — Read Committed.
🟢 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 достатньо:
- CRUD-додатки — більшість веб-додатків працюють коректно на Read Committed
- Звіти без суворих вимог до консістентності — допустима невелика похибка
- Високонавантажені системи — мінімальний overhead, максимальний throughput
Коли Read Committed НЕ достатньо:
- Фінансові розрахунки — потрібні консістентні дані протягом всієї транзакції
- Міграції даних — потрібен стабільний snapshot
- Складні бізнес-інваріанти — перевірки, що залежать від кількох таблиць
Реальні сценарії
Сценарій 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
- Залишайтеся на Read Committed за замовчуванням — це оптимальний вибір для 90% workload.
- Використовуйте Repeatable Read точково — лише для звітів та batch jobs, що потребують консістентного snapshot.
- Уникайте Serializable для write-heavy workloads — abort rate робить його непридатним при >50 concurrent writers.
- Atomic SQL замість read-modify-write —
UPDATE SET balance = balance - ?усуває lost update без підвищення рівня ізоляції. - Скорочуйте транзакції — чим коротша транзакція, тим менше dead tuple retention та snapshot overhead.
- Налаштуйте autovacuum агресивно для high-update таблиць:
ALTER TABLE accounts SET ( autovacuum_vacuum_threshold = 50, autovacuum_vacuum_scale_factor = 0.05 ); - Monitor snapshot age — алерт на транзакції старші 5 хвилин.
- 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 viapg_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]]