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

Что такое Read Committed

На этом уровне транзакция гарантированно видит только те данные, которые были официально зафиксированы (COMMIT) другими транзакциями к моменту чтения. Любые промежуточные, незав...


🟢 Junior Level

Read Committed (Чтение зафиксированных данных) — это самый распространенный уровень изоляции транзакций в современных реляционных базах данных.

На этом уровне транзакция гарантированно видит только те данные, которые были официально зафиксированы (COMMIT) другими транзакциями к моменту чтения. Любые промежуточные, незавершенные изменения параллельных транзакций остаются невидимыми.

Основные правила:

  1. Защита от грязного чтения (Dirty Read): Вы никогда не прочитаете данные, которые впоследствии могут быть отменены командой ROLLBACK.
  2. Допуск неповторяющегося чтения (Non-Repeatable Read): Если выполнить один и тот же SELECT дважды внутри одной транзакции, результаты могут различаться, если между запросами параллельная транзакция изменила строки и выполнила COMMIT.
  3. Допуск фантомного чтения (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 рублей!
}

Способы предотвращения:

  1. Атомарный SQL-запрос: UPDATE accounts SET balance = balance + :bonus WHERE id = :id.
  2. Пессимистическая блокировка: SELECT ... FOR UPDATE (в Spring Data JPA: @Lock(LockModeType.PESSIMISTIC_WRITE)).
  3. Оптимистическая блокировка: Версионирование сущности через @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;
  1. Транзакция $T_2$ видит строку по своему снапшоту, пытается взять эксклюзивную блокировку строки (X-lock) и засыпает в ожидании коммита $T_1$.
  2. Транзакция $T_1$ выполняет COMMIT.
  3. Транзакция $T_2$ просыпается. Новую версию строки она изначально не видела в своем снимке!
  4. В этот момент в 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):

  1. Запрос игнорирует старые версии строк из текущего снимка (Snapshot) и запрашивает самую последнюю зафиксированную версию строки.
  2. На прочитанные строки накладывается монопольная блокировка на запись (X-lock / FOR UPDATE), которая удерживается до момента выполнения COMMIT или ROLLBACK.
  3. Все остальные транзакции, пытающиеся прочитать эти же строки через 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-апдейты.

Связанные темы