Вопрос 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 защищает от “потерянного обновления”. Если два потока одновременно меняют одну запись, второй получит ошибку сериализации при коммите.


🔴 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; -- sees 100
                                        T2: SELECT balance FROM acc WHERE id=1; -- sees 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

Metric Read Committed Repeatable Read
Snapshot creation Per statement Per transaction
Old version retention Short-lived Until longest tx ends
Bloat potential Low Medium-High
VACUUM pressure Low High
Conflict errors None Possible on writes
Deadlock risk Low Medium (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]]