Питання 5 · Розділ 11

Що таке Repeatable Read?

На рівні Repeatable Read транзакція працює зі "знімком" даних, зробленим в момент першого запиту.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Repeatable Read — це рівень ізоляції, який гарантує: якщо транзакція прочитала дані, то всі наступні читання тих самих рядків повернуть той самий результат.

Основні властивості

  • Брудне читання: Заборонено
  • Неповторюване читання: Заборонено — дані не зміняться під час транзакції
  • Фантомне читання: Залежить від СУБД (у MySQL заборонено, у стандарті можливе)

Простий приклад

Транзакція А: SELECT price FROM products WHERE id = 1; -- Повернув 100
-- Інша транзакція змінює ціну і робить COMMIT
Транзакція А: SELECT price FROM products WHERE id = 1; -- Все одно 100!

На рівні Repeatable Read транзакція працює зі “знімком” даних, зробленим в момент першого запиту.

Відмінність від Read Committed

  • Read Committed: Кожен SELECT бачить останні закомічені дані
  • Repeatable Read: Усі SELECTи бачать дані на момент початку транзакції

🟡 Middle Level

Механізм реалізації (MVCC)

На рівні Repeatable Read знімок (Snapshot) даних створюється один раз — в момент першого запиту в транзакції. Усі наступні запити бачать базу в тому стані, в якому вона була на момент знімка.

Відмінності між MySQL та PostgreSQL

Хоча назва однакова, реалізація фундаментально різниться:

MySQL (InnoDB)

  • Це рівень ізоляції за замовчуванням
  • Запобігає фантомному читанню через Next-Key Locking (Record Lock + Gap Lock):

Gap = проміжок між двома індексними записами. Gap Lock блокує вставку нових рядків у цей проміжок. Наприклад, якщо в індексі є id=5 та id=10, gap lock на (5,10) не дасть вставити id=7.

  • Блокуються не лише рядки, а й проміжки між ними
  • Поводиться майже як Serializable

PostgreSQL

  • Не використовує блокування проміжків (Gap Locks)
  • Якщо транзакція намагається змінити рядок, який закомітила інша транзакція після початку вашої — отримаєте помилку: ERROR: could not serialize access due to concurrent update
  • Додаток має повторити транзакцію повністю

Сценарій використання

  • Звіти: Формування звіту, де дані не повинні “плисти” в процесі
  • Багатокрокові обчислення: Коли проміжні результати не повинні змінюватися
  • Міграції даних: Копіювання даних у консистентному стані

Не використовуйте RR для: висококонкурентних записів (>100 writers на одні дані), довгих транзакцій (>30 сек) — ризик bloat та serialization failures.

Накладні витрати

  • СУБД зберігає старі версії рядків довше (поки не завершиться найдовша транзакція RR)
  • Може призвести до роздування бази (Table Bloat)
  • Збільшує навантаження на процес VACUUM

Проблема Lost Update

Repeatable Read у PostgreSQL захищає від “втраченого оновлення”. Якщо два потоки одночасно змінюють один запис, другий отримає помилку серіалізації при commit-і.


🔴 Senior Level

MVCC Snapshot Lifecycle

PostgreSQL Snapshot Behavior

  • Snapshot created at first read in transaction, not at BEGIN
  • Snapshot includes:
    • xmin: oldest active transaction ID
    • xmax: next transaction ID to be assigned
    • xip_array: snapshot of all in-progress transactions
  • All subsequent reads use this same snapshot
  • Writes see their own changes immediately (own-XID visibility rule)

Tuple Visibility Under RR

// Simplified PostgreSQL visibility check
bool HeapTupleSatisfiesMVCC(HeapTuple tuple, Snapshot snapshot) {
    if (tuple->xmin >= snapshot->xmax) return false;
    if (tuple->xmax < snapshot->xmin) return false;
    if (TransactionIdIsInProgress(tuple->xmin)) return false;
    if (!TransactionIdDidCommit(tuple->xmin)) return false;
    if (TransactionIdIsInProgress(tuple->xmax)) return true;
    if (TransactionIdDidCommit(tuple->xmax)) return false;
    return true;
}

MySQL Next-Key Locking — Internal Details

Next-Key Lock = Record Lock + Gap Lock

-- Index: id = 1, 5, 10, 15
SELECT * FROM t WHERE id = 10 FOR UPDATE;

This locks:

  • The record with id=10 (record lock)
  • The gap (5, 10) — prevents INSERT of id=6,7,8,9
  • The gap (10, 15) — prevents INSERT of id=11,12,13,14

Why? Prevents phantom reads by blocking inserts that would match the query predicate.

Side effect: Higher deadlock probability, especially with range queries.

Serialization Failure Handling (PostgreSQL)

@Retryable(
    value = {CannotSerializeTransactionException.class},
    backoff = @Backoff(delay = 100, multiplier = 2, maxDelay = 1000),
    maxAttempts = 3
)
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void updateBalance(Long accountId, BigDecimal amount) {
    Account account = repo.findById(accountId);
    account.setBalance(account.getBalance().add(amount));
    repo.save(account);
}

Lost Update Prevention Deep Dive

Scenario under RR in PostgreSQL:

T1: BEGIN;                              T2: BEGIN;
T1: SELECT balance FROM acc WHERE id=1; -- бачить 100
                                        T2: SELECT balance FROM acc WHERE id=1; -- бачить 100
T1: UPDATE acc SET balance = 90 WHERE id=1;
T1: COMMIT;
                                        T2: UPDATE acc SET balance = 80 WHERE id=1;
                                        -- T2 BLOCKS, waiting for T1
                                        -- T1 committed, T2 detects conflict
                                        -- ERROR: could not serialize access

PostgreSQL detects that T2 read a row that T1 modified after T2’s snapshot. T2 must retry.

Performance Impact Analysis

Метрика Read Committed Repeatable Read
Створення snapshot На оператор На транзакцію
Зберігання старих версій Короткочасне До завершення найдовшої tx
Потенціал bloat Низький Середньо-Високий
Тиск на VACUUM Низький Високий
Помилки конфліктів Відсутні Можливі при записі
Ризик deadlock Низький Середній (MySQL)

Production Recommendations

When to use Repeatable Read:

  • Complex reports requiring self-consistency
  • Multi-step business logic with invariants
  • Batch processing where source data must not change
  • When you need protection against lost updates without explicit locking

When to avoid:

  • High-concurrency write-heavy workloads
  • Long-running transactions (bloat risk)
  • When retry logic is too complex for your use case

Tuning Tips:

  • Keep RR transactions as short as possible
  • Monitor pg_stat_user_tables.n_dead_tup for bloat
  • Set statement_timeout to prevent runaway transactions
  • Use pg_stat_activity to detect long-running RR transactions

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • Repeatable Read гарантує: повторний SELECT тих самих рядків поверне той самий результат
  • Забороняє dirty read та non-repeatable read; фантоми — залежить від СУБД
  • PostgreSQL: snapshot створюється при першому запиті, один на всю транзакцію
  • MySQL: дефолтний рівень, запобігає фантомам через Next-Key Locking (Gap Locks)
  • PostgreSQL при конфлікті оновлень кидає помилку серіалізації — потрібен retry
  • RR захищає від lost update у PostgreSQL — другий потік отримає serialization failure

Часті уточнюючі питання:

  • Чому MySQL та PG по-різному обробляють фантоми на RR? — MySQL: Gap Locks, PG: MVCC snapshot
  • Коли використовувати RR? — Звіти, багатокрокові обчислення, міграції даних
  • Що таке Gap Lock? — Блокування проміжку між індексними записами, запобігає вставці
  • Чим RR відрізняється від Serializable? — RR не запобігає write skew, Serializable — запобігає

Червоні прапорці (НЕ говорити):

  • “RR в усіх СУБД однаковий” — MySQL та PG фундаментально різняться
  • “RR = Serializable” — RR не запобігає всім аномаліям (write skew)
  • “RR не впливає на продуктивність” — bloat від старих версій рядків, VACUUM pressure

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

  • [[2. Які рівні ізоляції транзакцій існують]]
  • [[6. Що таке Serializable]]
  • [[10. Що таке втрачене оновлення (Lost Update)]]
  • [[12. Який рівень ізоляції за замовчуванням в MySQL]]