Что такое Read Committed
На этом уровне транзакция гарантированно видит только те данные, которые были официально зафиксированы (COMMIT) другими транзакциями к моменту чтения. Любые промежуточные, незав...
🟢 Junior Level
Read Committed (Чтение зафиксированных данных) — это самый распространенный уровень изоляции транзакций в современных реляционных базах данных.
На этом уровне транзакция гарантированно видит только те данные, которые были официально зафиксированы (COMMIT) другими транзакциями к моменту чтения. Любые промежуточные, незавершенные изменения параллельных транзакций остаются невидимыми.
Основные правила:
- Защита от грязного чтения (Dirty Read): Вы никогда не прочитаете данные, которые впоследствии могут быть отменены командой
ROLLBACK. - Допуск неповторяющегося чтения (Non-Repeatable Read): Если выполнить один и тот же
SELECTдважды внутри одной транзакции, результаты могут различаться, если между запросами параллельная транзакция изменила строки и выполнилаCOMMIT. - Допуск фантомного чтения (Phantom Read): Повторный запрос диапазона (
WHERE age > 18) может вернуть новые добавленные строки.
Пример сценария
Время Транзакция А (Пользователь) Транзакция Б (Списание комиссии)
| --------------------------- --------------------------------
| T1: BEGIN;
| T2: SELECT balance FROM accounts -- Баланс: 1 000 руб.
| WHERE id = 1;
| T3: BEGIN;
| T4: UPDATE accounts SET balance = 900
| WHERE id = 1;
| COMMIT; -- Зафиксировано!
| T5: SELECT balance FROM accounts -- Баланс: 900 руб.!
| WHERE id = 1; -- Данные изменились внутри Т1!
v T6: COMMIT;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserService {
// Read Committed — дефолтный уровень в PostgreSQL, Oracle и MS SQL Server
@Transactional(isolation = Isolation.READ_COMMITTED)
public UserProfile getUserProfile(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
}
}
Аналогия: Read Committed похож на чтение новостной ленты в интернете. Вы видите только официально опубликованные статьи (черновики авторов скрыты), но если обновить страницу через 5 минут, в ленте появятся обновленные тексты и новые публикации.
🟡 Middle Level
Архитектурный механизм: Снимки на уровне каждого оператора (Statement-Level Snapshot)
В СУБД, использующих MVCC (Multi-Version Concurrency Control), таких как PostgreSQL:
- Снимок данных (Snapshot) создается заново перед выполнением КАЖДОГО отдельного SQL-выражения (
SELECT,UPDATE,DELETE). - Если внутри длинной транзакции вы вызываете
SELECT, СУБД фиксирует состояние базы данных ровно на микросекунду старта этогоSELECT. - Следующий
SELECTчерез 10 секунд получит новый снимок, в который войдут все коммиты, завершившиеся за эти 10 секунд.
Транзакция T1 (Read Committed):
[Начало T1]
│
├─► SELECT 1 ──► [Snapshot 1 создается здесь]
│
│ (В этот момент транзакция T2 делает COMMIT)
│
└─► SELECT 2 ──► [Snapshot 2 создается ЗАНОВО здесь! Видит изменения T2]
Сравнение с Repeatable Read
| Критерий | Read Committed | Repeatable Read |
|---|---|---|
| Создание снимка (Snapshot) | Перед каждым запросом (Statement-level) |
Один раз в начале транзакции (Transaction-level) |
| Повторное чтение строки | Может вернуть новое значение | Всегда возвращает исходное значение |
| Риск Serialization Failures | Минимальный (практически 0%) | Средний (ошибки при конкурентных UPDATE) |
| Дефолт в СУБД | PostgreSQL, Oracle, SQL Server | MySQL (InnoDB) |
Ловушка Lost Update (Потерянное обновление)
Read Committed не защищает от классической аномалии потери обновления при параллельных операциях «чтение-модификация-запись» (Read-Modify-Write):
// Опасный код на Read Committed без блокировок:
@Transactional
public void addBonus(Long accountId, BigDecimal bonus) {
Account acc = accountRepository.findById(accountId); // Прочитал balance = 100
// Если параллельный поток в этот момент спишет 50 и сделает COMMIT...
acc.setBalance(acc.getBalance().add(bonus)); // 100 + 10 = 110!
accountRepository.save(acc); // ПЕРЕЗАПИСАЛ и стер списание 50 рублей!
}
Способы предотвращения:
- Атомарный SQL-запрос:
UPDATE accounts SET balance = balance + :bonus WHERE id = :id. - Пессимистическая блокировка:
SELECT ... FOR UPDATE(в Spring Data JPA:@Lock(LockModeType.PESSIMISTIC_WRITE)). - Оптимистическая блокировка: Версионирование сущности через
@Version.
🔴 Senior Level
Механизм EvalPlanQual (EPQ) в PostgreSQL при конкурентных UPDATE
Что происходит, когда две транзакции на уровне Read Committed одновременно пытаются обновить одну и ту же строку?
-- Транзакция 1:
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Захватывает X-lock
-- Транзакция 2 (одновременно):
UPDATE accounts SET balance = balance - 50 WHERE id = 1 AND balance >= 100;
- Транзакция $T_2$ видит строку по своему снапшоту, пытается взять эксклюзивную блокировку строки (
X-lock) и засыпает в ожидании коммита $T_1$. - Транзакция $T_1$ выполняет
COMMIT. - Транзакция $T_2$ просыпается. Новую версию строки она изначально не видела в своем снимке!
- В этот момент в PostgreSQL включается специальный механизм ядра — EvalPlanQual (EPQ):
- $T_2$ считывает самую свежую закоммиченную версию строки (созданную $T_1$).
- $T_2$ заново вычисляет условие
WHERE(balance >= 100) над этой новой версией. - Если условие все еще истинно — обновление $T_2$ применяется к новой версии строки.
- Если условие стало ложным (например, баланс стал 50) — $T_2$ молча пропускает строку, не обновляя ее (
0 rows updated).
Особенности Hibernate / JPA First-Level Cache на уровне Read Committed
Частый вопрос на собеседованиях Senior Java Developer:
«Если база данных работает на уровне Read Committed, гарантирует ли Hibernate неповторяющееся чтение при вызове findById()?»
@Transactional // База на Read Committed
public void demoCache(Long id) {
Account a1 = accountRepo.findById(id).get(); // Баланс = 100
// В этот момент внешняя система в БД изменила баланс на 200 и закоммитила!
Account a2 = accountRepo.findById(id).get(); // ЧТО ВЕРНЕТСЯ?
System.out.println(a1 == a2); // true!
System.out.println(a2.getBalance()); // 100 (НЕ 200!)
}
Объяснение:
Кэш первого уровня Hibernate (Persistence Context) хранит загруженные сущности по их идентификаторам в рамках сессии. При повторном вызове findById(id) Hibernate вообще не отправляет SQL-запрос в БД, а мгновенно возвращает уже существующую ссылку на объект из памяти JVM. Таким образом, на уровне JPA-сущностей кэш первого уровня эмулирует поведение Repeatable Read для конкретных объектов, даже если уровень изоляции СУБД — Read Committed!
Чтобы увидеть свежие данные из базы, необходимо явно вызвать entityManager.refresh(a1) или использовать нативный запрос.
4 Tricky Questions
1. Почему в PostgreSQL и Oracle уровень Read Committed выбран по умолчанию, а в MySQL (InnoDB) — Repeatable Read?
Ответ:
- PostgreSQL и Oracle: Исторически проектировались на базе многоверсионности (MVCC). В MVCC создание снимка на каждый запрос обходится очень дешево, читатели никогда не блокируют писателей, а транзакции на
Read Committedпрактически никогда не сталкиваются с ошибками сериализации (Serialization Failures). Это идеальный компромисс между производительностью и консистентностью. - MySQL (InnoDB): В ранних версиях MySQL репликация между мастером и репликами работала исключительно в режиме Statement-Based Replication (SBR) — на реплику отправлялись исходные SQL-запросы. На уровне
Read Committedпорядок фиксации транзакций и порядок выполнения SQL-запросов на мастере и реплике могли разойтись, приводя к рассинхронизации данных. Поэтому создатели MySQL жестко зафиксировалиRepeatable Readс блокировками диапазонов (Gap Locks) как дефолт.
2. Защищает ли Read Committed от аномалии Lost Update, если используется атомарный запрос UPDATE table SET counter = counter + 1?
Ответ:
Да, защищает.
Если обновление выполняется единым атомарным оператором UPDATE ... SET counter = counter + 1 WHERE id = :id, СУБД накладывает эксклюзивную блокировку (X-lock) на целевую строку на время выполнения оператора.
Параллельные транзакции встают в очередь. Когда очередная транзакция получает блокировку, механизм (например, EvalPlanQual в PostgreSQL или блокирующее чтение в MySQL) считывает уже обновленное предыдущей транзакцией значение и прибавляет к нему единицу. Ни одно обновление не теряется. Потеря обновления возникает только при антипаттерне ручного чтения в память приложения с последующей записью («Read-Modify-Write» в коде).
3. В чем разница между Read Committed в PostgreSQL и стандартным Read Committed в Microsoft SQL Server?
Ответ:
- В PostgreSQL:
Read Committedреализован строго на базе MVCC. Читатели не ставят блокировок и не блокируют пишущие потоки. - В MS SQL Server (по умолчанию):
Read Committedисторически реализован на базе пессимистических блокировок (2PL). Читающий запрос (SELECT) запрашивает разделяемую блокировку (Shared S-Lock) на строку или страницу. Если строка в этот момент обновляется пишущей транзакцией (на ней висит Exclusive X-Lock), читающий запрос блокируется и ждет окончания транзакции-писателя. (Примечание: в MS SQL Server существует опцияREAD_COMMITTED_SNAPSHOT (RCSI), включающая версионирование строк через TempDB, приближая поведение к PostgreSQL).
4. Что произойдет, если в транзакции на уровне Read Committed выполнить SELECT ... FOR UPDATE?
Ответ:
Запрос SELECT ... FOR UPDATE выполняет блокирующее чтение текущего состояния (Locking Current Read):
- Запрос игнорирует старые версии строк из текущего снимка (Snapshot) и запрашивает самую последнюю зафиксированную версию строки.
- На прочитанные строки накладывается монопольная блокировка на запись (
X-lock/FOR UPDATE), которая удерживается до момента выполненияCOMMITилиROLLBACK. - Все остальные транзакции, пытающиеся прочитать эти же строки через
FOR UPDATEили изменить их черезUPDATE/DELETE, блокируются до завершения текущей транзакции. Это гарантирует неповторяемость чтения и отсутствие Lost Update для заблокированных строк.
🎯 Шпаргалка для интервью
- Суть Read Committed: Транзакция видит только закоммиченные изменения.
- Snapshot-механика: Новый снимок данных формируется перед каждым SQL-запросом (
Statement-level). - Аномалии:
- Грязное чтение (Dirty Read) — защищено.
- Неповторяющееся чтение (Non-Repeatable Read) — возможно.
- Фантомы (Phantom Read) — возможны.
- Потерянное обновление (Lost Update) — возможно при ручном Read-Modify-Write.
- EvalPlanQual (PostgreSQL): При конкурентном
UPDATEСУБД ждет коммита первой транзакции, затем перепроверяет условиеWHEREна новой версии строки. - Hibernate L1-Cache ловушка: Метод
findById()кэширует сущность в памяти сессии, поэтому внутри одного потока чтение сущности по ID ведет себя как Repeatable Read. - Защита от аномалий: Используйте
SELECT ... FOR UPDATE, оптимистические блокировки (@Version) или атомарные SQL-апдейты.