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

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

В PostgreSQL уровнем изоляции транзакций по умолчанию является Read Committed.


🟢 Junior Level

В PostgreSQL уровнем изоляции транзакций по умолчанию является Read Committed.

Суть в 30 секундах

На уровне Read Committed транзакция гарантированно защищена от «грязного чтения» (Dirty Read): любой запрос видит только те изменения, которые были успешно зафиксированы (COMMITTED) другими транзакциями до начала выполнения текущего оператора SQL.

Главная архитектурная особенность PostgreSQL на этом уровне:

Каждый отдельный SQL-запрос (SELECT, UPDATE, DELETE) внутри одной и той же транзакции получает свой собственный, свежий снимок данных (Statement-level Snapshot)!

Если внутри одной открытой транзакции выполнить SELECT balance FROM accounts WHERE id = 1, подождать пару секунд и повторить точно такой же SELECT, то второй запрос увидит изменения, закоммиченные параллельными транзакциями за это время.

Транзакция A (Read Committed)          Параллельная транзакция B
----------------------------          -------------------------
BEGIN;
SELECT count(*) FROM orders; -> 100
                                      BEGIN;
                                      INSERT INTO orders ...;
                                      COMMIT; -- Закоммичено!
SELECT count(*) FROM orders; -> 101   -- Увидели новую строку!
COMMIT;

Какие аномалии возможны на дефолтном уровне в PostgreSQL

Аномалия по стандарту ANSI SQL Статус в PostgreSQL (Read Committed) Почему так происходит
Грязное чтение (Dirty Read) ❌ Невозможно PostgreSQL проверяет биты коммита в заголовке строки; незакоммиченные кортежи не видны
Неповторяющееся чтение (Non-Repeatable Read) ⚠️ Возможно Повторный SELECT получает новый снимок данных и видит свежие изменения строк
Фантомное чтение (Phantom Read) ⚠️ Возможно Новый снимок данных видит добавленные или удаленные строки в диапазоне
Потерянное обновление (Lost Update) ⚠️ Возможно При шаблоне «прочитал в память Java -> изменил -> записал» (Read-Modify-Write)

Практический пример на Java и Spring Boot

По умолчанию Spring-аннотация @Transactional использует уровень изоляции нижележащей СУБД (Isolation.DEFAULT транслируется в Read Committed для PostgreSQL):

package com.example.service;

import com.example.entity.Account;
import com.example.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;

    @Transactional // По умолчанию: Read Committed в PostgreSQL
    public void printBalanceTwice(Long accountId) throws InterruptedException {
        // Первый SELECT: создает Snapshot #1
        Account firstRead = accountRepository.findById(accountId).orElseThrow();
        System.out.println("Первое чтение баланса: " + firstRead.getBalance());

        // Имитация длительной бизнес-логики (2 секунды)
        // В этот момент параллельный поток выполняет UPDATE accounts SET balance = 5000 WHERE id = 1
        Thread.sleep(2000);

        // Второй SELECT (в той же транзакции!): создает Snapshot #2
        // ВНИМАНИЕ: EntityManager кэширует сущность в Persistence Context (1st level cache).
        // Чтобы увидеть физический запрос к БД, делаем refresh или нативный запрос:
        Account secondRead = accountRepository.findByIdFresh(accountId);
        System.out.println("Второе чтение баланса: " + secondRead.getBalance());
        // secondRead.getBalance() может отличаться от firstRead.getBalance()!
    }
}

⚠️ Важное замечание про JPA/Hibernate: Первичный кэш Hibernate (EntityManager) по умолчанию возвращает уже загруженную в сессию сущность. Поэтому в чистом Spring Data JPA эффект неповторяющегося чтения может быть замаскирован кэшем L1. Однако при нативных запросах (@Query(nativeQuery = true)), вызове entityManager.refresh(...) или прямом чтении через JdbcTemplate каждый SELECT обращается к новому снимку PostgreSQL.


🟡 Middle Level

Архитектура MVCC в PostgreSQL: почему именно Read Committed

PostgreSQL реализует изоляцию транзакций через MVCC (Multi-Version Concurrency Control). В отличие от СУБД с блокировочником (где читатели блокируют писателей, а писатели — читателей), в PostgreSQL:

Читатели никогда не блокируют писателей, а писатели никогда не блокируют читателей.

Каждое изменение строки (UPDATE) или удаление (DELETE) физически не перезаписывает данные на диске «по месту», а создает новую версию строки (tuple):

  • INSERT: создает кортеж с идентификатором текущей транзакции в системном поле xmin и xmax = 0.
  • DELETE: не стирает кортеж, а записывает ID удаляющей транзакции в системное поле xmax.
  • UPDATE: представляет собой атомарную связку DELETE старой версии (установка xmax) + INSERT новой версии (новый кортеж с новым xmin и связкой через указатель ctid).
Страница таблицы (Heap Page):
+---------------------------------------------------------------------------------+
| Версия 1: [xmin: 100, xmax: 105, balance: 1000] -> (Мертвый кортеж после коммита 105)
| Версия 2: [xmin: 105, xmax: 0,   balance: 1500] -> (Живой кортеж для снимка >= 105)
+---------------------------------------------------------------------------------+

Жизненный цикл снимка данных (Snapshot) в Read Committed

В ядре PostgreSQL снимок описывается структурой SnapshotData:

  • xmin: наименьший XID (Transaction ID), который был активен на момент построения снимка. Все транзакции с id < xmin гарантированно завершены.
  • xmax: первый еще не присвоенный XID. Все транзакции с id >= xmax начались позже и невидимы.
  • xip[]: массив активных (in-progress) транзакций в интервале между xmin и xmax.
Разница между уровнями изоляции в PostgreSQL:

Read Committed:
  Запрос 1 (SELECT) -----> GetTransactionSnapshot() -> Snapshot #1 создается
  Запрос 2 (SELECT) -----> GetTransactionSnapshot() -> Snapshot #2 создается заново!

Repeatable Read:
  Запрос 1 (SELECT) -----> GetTransactionSnapshot() -> Snapshot фиксируется на всю транзакцию
  Запрос 2 (SELECT) -----> Переиспользует ранее созданный Snapshot!

В PostgreSQL на уровне Read Committed функция GetTransactionSnapshot() вызывается перед каждым отдельным SQL-выражением. Это обеспечивает минимальное удержание старых снимков и максимальную производительность, но открывает окно для аномалий Non-Repeatable Read и Phantom Read.

Механизм EvalPlanQual (EPQ) при конкурентных UPDATE

Что происходит на уровне Read Committed, когда две транзакции одновременно пытаются обновить одну и ту же строку?

-- Таблица accounts: id = 1, balance = 1000, is_active = true

-- Транзакция T1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND is_active = true;
-- T1 захватывает эксклюзивную блокировку строки (RowExclusiveLock / XMAX)

-- Транзакция T2 (параллельно):
BEGIN;
UPDATE accounts SET is_active = false WHERE id = 1 AND is_active = true;
-- T2 обнаруживает, что строка заблокирована T1, и ПРИОСТАНАВЛИВАЕТСЯ (ждет завершения T1)

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

  1. T2 разблокируется.
  2. Поскольку T2 работает на уровне Read Committed, она не падает с ошибкой сериализации!
  3. Включается механизм EvalPlanQual (EPQ): T2 считывает самую свежую закоммиченную версию строки (созданную T1) и повторно вычисляет предикат WHERE.
  4. В нашем примере: для T2 условие is_active = true всё еще истинно (T1 изменил только баланс). Поэтому T2 успешно применяет свой UPDATE, установив is_active = false.
  5. Если бы T1 изменил is_active = false, то после повторной проверки предикат для T2 вернул бы false, и строка была бы пропущена без обновления!

🔴 Senior Level

Глубокий анализ ядра PostgreSQL: Commit Log и Hint Bits

Каждый раз при проверке видимости кортежа (HeapTupleSatisfiesVisibility) проверять статус транзакции через центральный менеджер транзакций (ProcArrayLock) было бы катастрофически дорого по производительности.

PostgreSQL решает это тремя слоями оптимизации:

  1. CLOG (pg_xact): Специальный кольцевой буфер и файлы на диске, где на каждую транзакцию выделяется ровно 2 бита:
    • 00 — TRANSACTION_STATUS_IN_PROGRESS
    • 01 — TRANSACTION_STATUS_COMMITTED
    • 10 — TRANSACTION_STATUS_ABORTED
    • 11 — TRANSACTION_STATUS_SUB_COMMITTED
  2. Hint Bits в заголовке кортежа (t_infomask):
    • При первой проверке кортежа транзакция смотрит в pg_xact, определяет статус создателя (xmin), а затем напрямую мутирует заголовок кортежа на странице памяти, выставляя биты:
      • HEAP_XMIN_COMMITTED
      • HEAP_XMIN_INVALID (транзакция откатилась)
      • HEAP_XMAX_COMMITTED
    • Все последующие чтения другими транзакциями просто проверяют эти биты без обращения к pg_xact и без захвата спинлоков!
Структура заголовка HeapTuple (HeapTupleHeaderData):
+-------------------------------------------------------------------------------+
| t_xmin (32 bit) | t_xmax (32 bit) | t_cid / t_xvac (32 bit) | t_ctid (6 bytes) |
| t_infomask2     | t_infomask (16 bit: Hint Bits!)          | t_hoff (offset)   |
+-------------------------------------------------------------------------------+

Влияние Read Committed на VACUUM, Bloat и Wraparound

Хотя на уровне Read Committed каждый запрос получает свой снимок, открытая и бездействующая транзакция (idle in transaction) или долгий SELECT может нанести критический ущерб здоровью кластера:

  1. Удержание OldestXmin: Пока выполняется длинный аналитический запрос, его снимок удерживает горизонт чистки OldestXmin.
  2. Блокировка VACUUM: Процесс autovacuum не имеет права удалять мертвые кортежи (dead tuples), чей xmax >= OldestXmin.
  3. Table & Index Bloat: Все параллельные UPDATE и DELETE накапливают мертвые версии строк на диске. Таблица размером 5 ГБ за пару часов может раздуться до 50 ГБ.
  4. Transaction ID Wraparound: Если горизонт транзакций приблизится к $2^{31}$ транзакций из-за зависшей сессии, PostgreSQL перейдет в аварийный режим read-only для предотвращения потери данных.
-- Мониторинг опасных долгих транзакций, блокирующих autovacuum:
SELECT pid, 
       usename, 
       state, 
       now() - xact_start AS xact_duration, 
       now() - state_change AS state_duration, 
       query 
FROM pg_stat_activity 
WHERE state IN ('active', 'idle in transaction') 
  AND xact_start < now() - INTERVAL '5 minutes'
ORDER BY xact_start ASC;

Защита от Lost Update на уровне Read Committed в Highload

Поскольку Read Committed не защищает от потери обновлений при чтении-модификации-записи в коде приложения, Senior Engineer выбирает одну из трех стратегий:

package com.example.service;

import com.example.entity.Wallet;
import com.example.repository.WalletRepository;
import jakarta.persistence.LockModeType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
public class HighloadWalletService {

    private final WalletRepository walletRepository;

    // Стратегия 1: Атомарный UPDATE в БД (Zero Application Contention)
    // Самый масштабируемый подход: ложится на механизм блокировки строк в самом PG
    @Transactional
    public void creditBalanceAtomic(Long walletId, BigDecimal amount) {
        int updatedRows = walletRepository.incrementBalanceAtomic(walletId, amount);
        if (updatedRows == 0) {
            throw new IllegalStateException("Кошелек не найден или заблокирован");
        }
    }

    // Стратегия 2: Пессимистическая блокировка SELECT ... FOR UPDATE
    // Надежно для сложных бизнес-инвариантов, но создает очереди при высоком rps
    @Transactional
    public void debitBalancePessimistic(Long walletId, BigDecimal amount) {
        // Выполняет: SELECT * FROM wallets WHERE id = ? FOR UPDATE
        Wallet wallet = walletRepository.findByIdForUpdate(walletId)
                .orElseThrow(() -> new IllegalArgumentException("Wallet not found"));

        if (wallet.getBalance().compareTo(amount) < 0) {
            throw new IllegalStateException("Недостаточно средств");
        }
        wallet.setBalance(wallet.getBalance().subtract(amount));
        // При коммите генерируется UPDATE, лок освобождается
    }
}

Репозиторий с аннотациями Spring Data:

package com.example.repository;

import com.example.entity.Wallet;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;
import java.util.Optional;

public interface WalletRepository extends JpaRepository<Wallet, Long> {

    @Modifying
    @Query("UPDATE Wallet w SET w.balance = w.balance + :amount WHERE w.id = :id")
    int incrementBalanceAtomic(@Param("id") Long id, @Param("amount") BigDecimal amount);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT w FROM Wallet w WHERE w.id = :id")
    Optional<Wallet> findByIdForUpdate(@Param("id") Long id);
}

4 Tricky Questions

1. Что произойдет, если в PostgreSQL выполнить команду SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED?

Ответ: Запрос выполнится успешно без синтаксических ошибок, однако уровень изоляции будет автоматически и неявно повышен до Read Committed! В архитектуре ядра PostgreSQL механизм чтения «грязных» (незакоммиченных) данных физически не реализован:

  • Алгоритмы видимости кортежей (HeapTupleSatisfiesVisibility) проверяют статус xmin через статус транзакции. Если транзакция не завершена (TRANSACTION_STATUS_IN_PROGRESS), кортеж считается невидимым для всех остальных транзакций.
  • Чтобы сделать Dirty Read возможным, разработчикам пришлось бы намеренно ослаблять проверку коммита, что разрушило бы гарантии целостности страниц, индексов и TOAST-хранилища. Поэтому PostgreSQL поддерживает синтаксис READ UNCOMMITTED исключительно ради совместимости со стандартом ANSI SQL, маппя его 1-в-1 на READ COMMITTED.

2. Как работает механизм EvalPlanQual (EPQ) при конкурентном UPDATE ... WHERE, и может ли строка «исчезнуть» из обновления?

Ответ: Да, строка может быть пропущена без обновления. Когда транзакция T2 пытается обновить строку, заблокированную транзакцией T1:

  1. T2 засыпает в ожидании освобождения блокировки строки (RowExclusiveLock в T1).
  2. После коммита T1 транзакция T2 просыпается и получает самую свежую версию строки по указателю t_ctid.
  3. Включается механизм ядра EvalPlanQual: T2 подставляет поля обновленной строки в исходное выражение WHERE.
  4. Если условие WHERE всё еще истинно — T2 применяет свой UPDATE поверх новой версии.
  5. Если условия WHERE стали ложными (например, T1 изменил статус строки WHERE status = 'PENDING' на status = 'PROCESSING'), T2 видит, что кортеж больше не соответствует фильтру, и пропускает его. Количество обновленных строк executeUpdate() будет равно 0.

3. Почему в PostgreSQL на уровне Read Committed запрос SELECT ... FOR UPDATE может вернуть строку, не соответствующую исходному WHERE-условию в сложных JOIN?

Ответ: Это известная тонкость реализации EvalPlanQual при соединениях (JOIN). Если запрос соединяет таблицы:

SELECT * FROM parent p JOIN child c ON p.id = c.parent_id 
WHERE c.status = 'ACTIVE' FOR UPDATE OF p;
  1. Оптимизатор формирует снимок на момент начала выполнения оператора.
  2. Строка таблицы child на момент снимка была 'ACTIVE', и PostgreSQL находит подходящую пару строк.
  3. При попытке заблокировать строку таблицы parent выясняется, что параллельная транзакция только что обновила parent и уже закоммитилась.
  4. Механизм EPQ перезапрашивает свежую версию parent. Но в старых версиях PostgreSQL перепроверка соединения для всего плана запроса могла вернуть кортеж родителя, даже если параллельная транзакция параллельно деактивировала дочернюю запись child. Начиная с версии PostgreSQL 12+, алгоритм EvalPlanQual был существенно доработан, однако в распределенных или сложных составных планах разработчикам настоятельно рекомендуется блокировать строки обеих таблиц (FOR UPDATE OF p, c).

4. В чем разница между Read Committed в PostgreSQL и Read Committed в Oracle или MS SQL Server?

Ответ:

  • PostgreSQL vs Oracle: Архитектурно очень близки (обе СУБД используют MVCC без блокировок чтения). Однако Oracle хранит старые версии строк не в табличном пространстве (heap pages), а в специализированных сегментах отката (Undo Tablespace). В Postgres старые версии лежат прямо в страницах таблицы, создавая необходимость процесса VACUUM для очистки dead tuples. В Oracle вместо table bloat возникает ошибка ORA-01555: snapshot too old, если сегменты Undo переполнились.
  • PostgreSQL vs MS SQL Server: В классическом MS SQL Server уровень Read Committed основан на кратковременных разделяемых блокировках чтения (Shared S-locks): читатели кратковременно блокируют писателей, а пишущая транзакция удерживает X-блокировку до конца транзакции, наглухо блокируя читателей. Чтобы получить поведение, аналогичное PostgreSQL, в MS SQL Server необходимо явно включать режим READ_COMMITTED_SNAPSHOT (RCSI), использующий базу данных tempdb для версионирования строк.

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

30-секундный ответ

«В PostgreSQL уровнем изоляции по умолчанию является Read Committed. Он реализован через механизм MVCC: читатели не блокируют писателей, а писатели не блокируют читателей. Главная особенность: каждый отдельный SQL-запрос получает новый снимок данных (per-statement snapshot). Он защищает от Dirty Read, но допускает Non-Repeatable Read и Phantom Read. Конкурентные обновления одной строки разрешаются через механизм EvalPlanQual (EPQ): транзакция ждет коммита конкурента, после чего повторно проверяет условие WHERE на свежей версии строки».

Чек-лист ключевых понятий

  1. Дефолт: Read Committed.
  2. Снимок (Snapshot): Создается заново для каждого оператора (GetTransactionSnapshot()).
  3. Строение кортежа: xmin (транзакция-создатель), xmax (транзакция-удалитель), t_infomask (Hint Bits: COMMITTED/INVALID).
  4. EvalPlanQual (EPQ): Механизм повторной проверки предиката WHERE после ожидания блокировки строки.
  5. READ UNCOMMITTED: В PostgreSQL синтаксически принимается, но неявно повышается до READ COMMITTED.
  6. Опасность: Зависшие транзакции (idle in transaction) удерживают OldestXmin, блокируя работу autovacuum и приводя к распуханию таблиц (table bloat).

Красные флаги (чего ни в коем случае нельзя говорить)

  • ❌ «В PostgreSQL дефолтный уровень Repeatable Read» (Это дефолт в MySQL InnoDB, в PostgreSQL дефолт — Read Committed).
  • ❌ «PostgreSQL поддерживает Read Uncommitted и позволяет читать грязные данные» (Грязное чтение в Postgres невозможно в принципе, уровень повышается до Read Committed).
  • ❌ «Если поставить @Transactional, то два одинаковых SELECT гарантированно вернут одно и то же» (На Read Committed каждый SELECT видит изменения, закоммиченные между запросами, если только результат не взят из L1-кэша Hibernate).
  • ❌ «Read Committed защищает от потери обновлений (Lost Update)» (Не защищает при логике Read-Modify-Write на уровне сервиса, требуется атомарный SQL или блокировки).

Ссылки на связанные темы