Что такое Repeatable Read
В отличие от Read Committed, где снимок видимости данных формируется заново перед каждым SQL-выражением, на уровне Repeatable Read:
🟢 Junior Level
Repeatable Read (Повторяющееся чтение) — это уровень изоляции транзакций, который гарантирует: если транзакция прочитала строку данных, то при любых повторных чтениях этой же строки до окончания транзакции она гарантированно увидит те же самые данные, даже если параллельные транзакции изменили строку и выполнили COMMIT.
Ключевые свойства:
- Защита от грязного чтения (Dirty Read): Незафиксированные данные других транзакций никогда не видны.
- Защита от неповторяющегося чтения (Non-Repeatable Read): Значения однажды прочитанных строк заморожены для текущей транзакции.
- Фантомное чтение (Phantom Read): По стандарту ANSI SQL может допускаться, однако в MySQL (InnoDB) предотвращается благодаря специальным блокировкам диапазонов.
Время Транзакция А (Repeatable Read) Транзакция Б (Внешнее обновление)
| ------------------------------ ---------------------------------
| T1: BEGIN;
| T2: SELECT balance FROM accounts -- Баланс: 1 000 руб.
| WHERE id = 1;
| T3: BEGIN;
| T4: UPDATE accounts SET balance = 500
| WHERE id = 1;
| COMMIT; -- Успешно зафиксировано!
| T5: SELECT balance FROM accounts -- Баланс: 1 000 руб.!
| 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 BillingReportService {
// Гарантирует целостность финансового отчета при длительных расчетах
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public MonthlyReport generateReport(Long companyId) {
// Все последующие SELECT видят снимок базы на момент старта первого запроса
BigDecimal totalDebit = ledgerRepository.sumDebit(companyId);
BigDecimal totalCredit = ledgerRepository.sumCredit(companyId);
return new MonthlyReport(totalDebit, totalCredit);
}
}
Аналогия: Repeatable Read напоминает мгновенный снимок (фотографию) документа на телефон в момент входа в архив. Как бы сотрудники архива ни переписывали оригинал документа на полке, на вашей фотографии текст останется неизменным.
🟡 Middle Level
Механизм реализации через MVCC Snapshot (Снимок транзакции)
В отличие от Read Committed, где снимок видимости данных формируется заново перед каждым SQL-выражением, на уровне Repeatable Read:
- Снимок создается ровно один раз — в момент выполнения первого запроса на чтение в транзакции (Transaction-Level Snapshot).
- Снимок содержит метаданные:
xmin: ID самой старой транзакции, которая еще была активна на момент снимка.xmax: ID первой еще не назначенной транзакции.xip_list: список ID транзакций, выполнявшихся параллельно в момент старта.
- Все последующие
SELECTиспользуют один и тот же снимок, отсекая любые изменения, закоммиченные позже момента фиксации снимка.
Кардинальные различия: MySQL (InnoDB) против PostgreSQL
| Критерий | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| Статус уровня | Дефолтный уровень по умолчанию | Опциональный (дефолт — Read Committed) |
| Фантомные чтения (Phantom Reads) | Защищены для SELECT и SELECT FOR UPDATE |
Защищены для обычных SELECT |
| Механизм защиты от фантомов | Next-Key Locks (Record Lock + Gap Lock) | MVCC Snapshot (без блокировок диапазонов) |
Конфликт параллельных UPDATE |
Блокируется до коммита первой транзакции | Выбрасывает исключение сериализации 40001 |
| Deadlock вероятность | Высокая (из-за Gap Locks) | Низкая |
Защита от Lost Update в PostgreSQL
В PostgreSQL на уровне Repeatable Read база данных аппаратно защищает от аномалии потери обновления (Lost Update):
-- Транзакция T1:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 100
-- Транзакция T2 (параллельно):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = 80 WHERE id = 1;
COMMIT;
-- Транзакция T1 пытается обновить ту же строку:
UPDATE accounts SET balance = 90 WHERE id = 1;
-- ОШИБКА: ERROR: could not serialize access due to concurrent update (SQLSTATE 40001)
Поскольку строка была изменена транзакцией $T_2$ уже после создания снимка $T_1$, PostgreSQL запрещает неконсистентное обновление и требует полного отката и повтора $T_1$.
🔴 Senior Level
Аномалия Write Skew: почему Repeatable Read — это не Serializable
Уровень Repeatable Read защищает от коллизий при модификации одной и той же строки, но бессилен против аномалии Write Skew (Перекос записи), когда инвариант связывает несколько разных строк.
Практический пример:
Правило счета: Суммарный баланс двух кошельков клиента (Основной $A$ и Резервный $B$) не должен быть отрицательным ($A + B \ge 0$). Исходно: $A = 100$, $B = 100$ (Сумма = 200).
Транзакция T1 (Списание со счета A на 150 руб.):
1. Читает A (100) и B (100). Проверяет: 100 + 100 >= 150 (Успешно).
2. UPDATE accounts SET balance = balance - 150 WHERE id = 'A'; (Баланс A = -50)
Транзакция T2 (Параллельное списание со счета B на 150 руб.):
1. Читает A (100) и B (100) по своему снимку. Проверяет: 100 + 100 >= 150 (Успешно).
2. UPDATE accounts SET balance = balance - 150 WHERE id = 'B'; (Баланс B = -50)
Обе транзакции успешно коммитятся (T1 изменила строку A, T2 изменила строку B — физического конфликта строк нет)!
ИТОГ: A = -50, B = -50. Суммарный баланс = -100! Инвариант разрушен!
Решение: переход на уровень SERIALIZABLE или явная блокировка через SELECT ... FOR UPDATE.
Проблема Table Bloat и заморозка VACUUM в PostgreSQL
Если транзакция на уровне Repeatable Read остается открытой долгое время (например, фоновый аналитический отчет выполняется 40 минут):
- Поле
xminэтой транзакции удерживает минимальный горизонт видимости (xmin horizon). - Процесс autovacuum не имеет права очищать или переиспользовать «мертвые» строки (
dead tuples), удаленные любыми другими быстрыми транзакциями, если эти строки были удалены после старта длинной RR-транзакции. - Результат: Таблицы и индексы в СУБД начинают стремительно раздуваться (Table Bloat), переполняя дисковое пространство и замедляя кэш оперативной памяти (
shared_buffers).
4 Tricky Questions
1. Может ли в MySQL InnoDB на уровне Repeatable Read возникнуть фантомное чтение?
Ответ:
Да, существует тонкий краевой сценарий!
В MySQL InnoDB чистый SELECT использует MVCC-снимок и фантомов не видит.
Однако если транзакция обновляет строку, вставленную параллельной транзакцией, возникает фантом:
-- Транзакция T1 (Repeatable Read):
SELECT * FROM users WHERE id = 10; -- Пусто (строки нет)
-- Транзакция T2:
INSERT INTO users (id, name) VALUES (10, 'Phantom');
COMMIT;
-- Транзакция T1:
SELECT * FROM users WHERE id = 10; -- Все еще пусто (снимок защищает)
-- Но теперь T1 выполняет UPDATE по этой строке:
UPDATE users SET name = 'Caught' WHERE id = 10; -- 1 row affected!
-- И повторный SELECT:
SELECT * FROM users WHERE id = 10; -- СТРОКА ПОЯВИЛАСЬ! (Фантомное чтение)
Почему это произошло: Оператор UPDATE в InnoDB работает как Locking Current Read (текущее чтение). Он обнаруживает строку, обновляет ее и проставляет в заголовок строки ID текущей транстанции $T_1$. А по правилам MVCC транзакция всегда видит строки, созданные или обновленные ею самой! В результате последующий SELECT возвращает «магически возникшую» строку.
2. Почему в PostgreSQL при конфликте UPDATE на уровне Repeatable Read выбрасывается исключение, а на Read Committed запрос выполняется штатно?
Ответ:
- На Read Committed: PostgreSQL использует механизм EvalPlanQual (EPQ). При коммите первой транзакции вторая транзакция считывает свежую версию строки, заново вычисляет условие
WHEREи обновляет новую версию. - На Repeatable Read: Снимок данных неизменяем (Immutable Snapshot). Обновление строки, закоммиченной параллельной транзакцией после старта снимка, означало бы нарушение инварианта повторяемости чтения (мы модифицируем то, чего по нашему снимку не существует или выглядит иначе). Чтобы не нарушать целостность снимка, PostgreSQL запрещает применение EPQ и принудительно прерывает операцию с ошибкой
40001 (serialization failure).
3. Почему в MySQL на уровне Repeatable Read значительно чаще возникают взаимные блокировки (Deadlocks), чем на Read Committed?
Ответ:
Причина — использование Next-Key Locks (комбинация Record Lock и Gap Lock).
На уровне Repeatable Read при выполнении запросов с условиями диапазона или поиском по непервичному индексу InnoDB блокирует не только сами строки, но и интервалы (промежутки) между ними (Gap Locks), чтобы предотвратить вставку фантомов другими транзакциями.
Когда две параллельные транзакции вставляют данные в соседние промежутки или перекрестно захватывают пересекающиеся диапазоны, их блокировки зазоров несовместимы, что регулярно приводит к Deadlock found when trying to get lock; try restarting transaction.
4. Как правильно настроить Spring-приложение для работы с методом @Transactional(isolation = Isolation.REPEATABLE_READ)?
Ответ:
Поскольку на уровне Repeatable Read в СУБД (PostgreSQL, CockroachDB) конкурентные конфликты записи штатно приводят к ошибкам сериализации (CannotSerializeTransactionException), метод в Spring обязан быть идемпотентным и сопровождаться механизмом повтора (Retry):
@Retryable(
retryFor = { CannotSerializeTransactionException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2.0)
)
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void safeAccountUpdate(Long accountId, BigDecimal amount) {
Account acc = accountRepository.findById(accountId).orElseThrow();
acc.setBalance(acc.getBalance().add(amount));
accountRepository.save(acc);
}
Без @Retryable любая конкурентная запись на уровне Repeatable Read будет «ронять» HTTP-запросы пользователей с ошибками 500.
🎯 Шпаргалка для интервью
- Суть Repeatable Read: Гарантирует одинаковый результат повторных чтений одних и тех же строк внутри транзакции.
- Snapshot-механика: Снимок видимости данных создается один раз в начале транзакции (
Transaction-level snapshot). - Аномалии:
- Грязное чтение (Dirty Read) — защищено.
- Неповторяющееся чтение (Non-Repeatable Read) — защищено.
- Фантомы (Phantom Read) — по стандарту возможны, но в MySQL блокируются через Next-Key Locks.
- Перекос записи (Write Skew) — не защищен (нужен
Serializable).
- Поведение при конфликте UPDATE (PostgreSQL): Ошибка
could not serialize access due to concurrent update (40001). Требуется обязательный механизм@Retryable. - Опасность долгих транзакций: Блокируют очистку
dead tuplesв PostgreSQL, приводя к раздуванию базы данных (Table Bloat).