Какой уровень изоляции по умолчанию в 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:
- T2 разблокируется.
- Поскольку T2 работает на уровне Read Committed, она не падает с ошибкой сериализации!
- Включается механизм EvalPlanQual (EPQ): T2 считывает самую свежую закоммиченную версию строки (созданную T1) и повторно вычисляет предикат
WHERE. - В нашем примере: для T2 условие
is_active = trueвсё еще истинно (T1 изменил только баланс). Поэтому T2 успешно применяет свойUPDATE, установивis_active = false. - Если бы T1 изменил
is_active = false, то после повторной проверки предикат для T2 вернул быfalse, и строка была бы пропущена без обновления!
🔴 Senior Level
Глубокий анализ ядра PostgreSQL: Commit Log и Hint Bits
Каждый раз при проверке видимости кортежа (HeapTupleSatisfiesVisibility) проверять статус транзакции через центральный менеджер транзакций (ProcArrayLock) было бы катастрофически дорого по производительности.
PostgreSQL решает это тремя слоями оптимизации:
- CLOG (pg_xact): Специальный кольцевой буфер и файлы на диске, где на каждую транзакцию выделяется ровно 2 бита:
00—TRANSACTION_STATUS_IN_PROGRESS01—TRANSACTION_STATUS_COMMITTED10—TRANSACTION_STATUS_ABORTED11—TRANSACTION_STATUS_SUB_COMMITTED
- Hint Bits в заголовке кортежа (
t_infomask):- При первой проверке кортежа транзакция смотрит в
pg_xact, определяет статус создателя (xmin), а затем напрямую мутирует заголовок кортежа на странице памяти, выставляя биты:HEAP_XMIN_COMMITTEDHEAP_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 может нанести критический ущерб здоровью кластера:
- Удержание
OldestXmin: Пока выполняется длинный аналитический запрос, его снимок удерживает горизонт чисткиOldestXmin. - Блокировка
VACUUM: Процессautovacuumне имеет права удалять мертвые кортежи (dead tuples), чейxmax >= OldestXmin. - Table & Index Bloat: Все параллельные
UPDATEиDELETEнакапливают мертвые версии строк на диске. Таблица размером 5 ГБ за пару часов может раздуться до 50 ГБ. - 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:
- T2 засыпает в ожидании освобождения блокировки строки (
RowExclusiveLockв T1). - После коммита T1 транзакция T2 просыпается и получает самую свежую версию строки по указателю
t_ctid. - Включается механизм ядра EvalPlanQual: T2 подставляет поля обновленной строки в исходное выражение
WHERE. - Если условие
WHEREвсё еще истинно — T2 применяет свойUPDATEповерх новой версии. - Если условия
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;
- Оптимизатор формирует снимок на момент начала выполнения оператора.
- Строка таблицы
childна момент снимка была'ACTIVE', и PostgreSQL находит подходящую пару строк. - При попытке заблокировать строку таблицы
parentвыясняется, что параллельная транзакция только что обновилаparentи уже закоммитилась. - Механизм 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 на свежей версии строки».
Чек-лист ключевых понятий
- Дефолт:
Read Committed. - Снимок (Snapshot): Создается заново для каждого оператора (
GetTransactionSnapshot()). - Строение кортежа:
xmin(транзакция-создатель),xmax(транзакция-удалитель),t_infomask(Hint Bits: COMMITTED/INVALID). - EvalPlanQual (EPQ): Механизм повторной проверки предиката
WHEREпосле ожидания блокировки строки. - READ UNCOMMITTED: В PostgreSQL синтаксически принимается, но неявно повышается до
READ COMMITTED. - Опасность: Зависшие транзакции (
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 или блокировки).