Что такое Repeatable Read?
На уровне Repeatable Read транзакция работает со "снимком" данных, сделанным в момент первого запроса.
🟢 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 IDxmax: next transaction ID to be assignedxip_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_tupfor bloat - Set
statement_timeoutto prevent runaway transactions - Use
pg_stat_activityto 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]]