💳 Розділ 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-оновлення.

Червоні прапорці (чого уникати)

  • Твердження, що Read Committed захищає від неповторюваного або фантомного читання.
  • Твердження, що Read Committed захищає від Lost Update під час звичайного acc.setBalance(acc.getBalance() + x) у Java.
  • Незнання того, що в PostgreSQL знімок оновлюється перед кожним окремим SQL-запитом, а не тримається всю транзакцію.
  • Твердження, що findById() у Hibernate завжди робить запит до БД і показує свіжі зміни на Read Committed.

Пов’язані теми