💳 Раздел 11 · Вопрос #6

Что такое Serializable

Он гарантирует, что результат параллельного выполнения любого количества транзакций будет в точности эквивалентен некоторому строго последовательному их выполнению (одна за друг...


🟢 Junior Level

Serializable (Сериализуемый) — это самый высокий и строгий уровень изоляции транзакций в реляционных базах данных.

Он гарантирует, что результат параллельного выполнения любого количества транзакций будет в точности эквивалентен некоторому строго последовательному их выполнению (одна за другой, без какого-либо перекрытия во времени).

Ключевые свойства:

  1. Полное отсутствие аномалий: Устраняет абсолютно все известные аномалии параллелизма — грязное чтение (Dirty Read), неповторяющееся чтение (Non-Repeatable Read), фантомы (Phantom Read), потерю обновлений (Lost Update) и перекос записи (Write Skew).
  2. Цена надежности: Самый ресурсоемкий уровень. В условиях высокой конкуренции на запись транзакции могут конфликтовать, и СУБД будет принудительно отменять одну из них.
  3. Обязанность приложения: Приложение обязано реализовывать логику повтора (Retry) при получении ошибок сериализации.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TicketBookingService {

    // Гарантирует абсолютную защиту от овербукинга (двойной продажи одного места)
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void bookSeat(Long flightId, String seatNumber, Long passengerId) {
        boolean isTaken = seatRepository.isSeatTaken(flightId, seatNumber);
        if (isTaken) {
            throw new IllegalStateException("Место уже занято!");
        }
        seatRepository.assignSeat(flightId, seatNumber, passengerId);
    }
}

Аналогия: Представьте примерочную кабинку в магазине с узким проходом, куда физически может войти только один человек. Даже если перед магазином собралась толпа из 100 покупателей, они заходят строго по одному, закрывают за собой дверь на ключ, и никто не может помешать или увидеть примерку другого.


🟡 Middle Level

Два фундаментальных подхода к реализации Serializable

Исторически и архитектурно СУБД реализуют уровень Serializable двумя совершенно разными способами:

                  ПОДХОДЫ К РЕАЛИЗАЦИИ SERIALIZABLE
                                │
       ┌────────────────────────┴────────────────────────┐
       ▼                                                 ▼
ПЕССИМИСТИЧЕСКИЙ (2PL)                           ОПТИМИСТИЧЕСКИЙ (SSI)
- Двухфазная блокировка (Two-Phase Locking)      - Serializable Snapshot Isolation
- Читатели блокируют писателей                   - Читатели НЕ блокируют писателей
- Писатели блокируют читателей                   - Построен поверх MVCC
- Блокировки предикатов (Predicate Locks)        - Отслеживание графа зависимостей (rw-antidependencies)
- Пример: MS SQL Server (по умолчанию), MySQL    - Пример: PostgreSQL (начиная с 9.1)

Главный вызов: Ошибки сериализации и необходимость Retry

На уровне Serializable успешное завершение метода не гарантируется базой данных. Если СУБД обнаруживает риск нарушения сериализуемости, она аварийно прерывает транзакцию-жертву со стандартизированным кодом ошибки:

\(\text{SQLSTATE 40001 (serialization\_failure)}\) \(\text{Текст ошибки: "could not serialize access due to read/write dependencies among transactions"}\)

Приложение обязано перехватывать исключение и повторять транзакцию с самого начала:

import org.springframework.dao.CannotSerializeTransactionException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SafeFinancialService {

    @Retryable(
        retryFor = { CannotSerializeTransactionException.class },
        maxAttempts = 5,
        backoff = @Backoff(delay = 50, multiplier = 2.0, random = true)
    )
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void executeCriticalTransfer(Long fromId, Long toId, BigDecimal amount) {
        // Бизнес-логика финансового перевода
    }
}

Когда использовать Serializable:

  1. Финансовые расчеты и балансовые инварианты (где сумма нескольких счетов должна удовлетворять сложному условию).
  2. Бронирование ресурсов (авиабилеты, гостиничные номера, складские остатки), где недопустим овербукинг.
  3. Защита от Write Skew при невозможности использования явных строковых блокировок (SELECT FOR UPDATE).

🔴 Senior Level

Архитектура Serializable Snapshot Isolation (SSI) в PostgreSQL

До версии 9.1 PostgreSQL поддерживал Serializable лишь номинально (как синоним Repeatable Read). Начиная с 9.1, был внедрен революционный алгоритм SSI (Кэхилл, Рём, Фекете, 2008).

1. Неблокирующие SIREAD-замки

Вместо захвата тяжелых блокировок строк и предикатов PostgreSQL использует легковесные структуры в общей оперативной памяти (shared memory) — SIREAD locks:

  • Устанавливаются на кортежи, страницы памяти или целые таблицы при любом SELECT.
  • SIREAD-замки НИКОГДА никого не блокируют! Ни один поток не засыпает в ожидании SIREAD-замка. Они служат лишь метками в памяти: «Транзакция $T$ прочитала этот диапазон данных».

2. Граф зависимостей и поиск опасных структур (Dangerous Structures)

СУБД непрерывно анализирует граф зависимостей между транзакциями. Особое внимание уделяется rw-антизависимостям (rw-antidependencies):

  • Ситуация $T_1 \xrightarrow{rw} T_2$ возникает, когда $T_1$ прочитала версию данных, которую $T_2$ затем обновила или удалила. В гипотетическом последовательном порядке $T_1$ обязана была бы завершиться до $T_2$.
  • Потенциальная аномалия возникает при формировании так называемой опасной структуры (Dangerous Structure / Pivot) — двух последовательных rw-стрелок в графе: \(T_{in} \xrightarrow{rw} T_{pivot} \xrightarrow{rw} T_{out}\) Если в графе образуется цикл или риск цикла, ядро PostgreSQL объявляет центральную транзакцию ($T_{pivot}$) жертвой и принудительно прерывает ее (abort) с ошибкой 40001.

3. Эскалация блокировок предикатов (Lock Coarsening)

Количество SIREAD-замков ограничено размером блока shared memory (параметр max_pred_locks_per_transaction). Если транзакция считывает слишком много строк:

  • Множество кортежных замков схлопываются в один страничный замок (Page Lock).
  • Если и этого мало — в замок на всю таблицу (Relation Lock). Побочный эффект: Огрубление предикатных блокировок резко увеличивает вероятность ложных срабатываний (False Positives) и необоснованных отмен неконфликтующих транзакций.

4 Tricky Questions

1. Что такое SIREAD-блокировки в PostgreSQL и почему они никогда не вызывают ожиданий потоков?

Ответ: SIREAD-блокировки в алгоритме SSI не являются классическими блокировками ресурсов (Lock/Mutex). Они представляют собой информационные метки (флаги в shared memory), регистрирующие факт чтения данных транзакцией. При записи данных пишущая транзакция не ждет освобождения SIREAD-замка (как в 2PL), а мгновенно создает новую версию строки, попутно отмечая в памяти возникновение ребра антизависимости $T_{reader} \xrightarrow{rw} T_{writer}$. SIREAD-замки служат исключительно для математического анализа ацикличности графа сериализации ядром СУБД.


2. Может ли транзакция, выполняющаяся строго в режиме «только для чтения» (READ ONLY), быть отменена на уровне SERIALIZABLE с ошибкой 40001?

Ответ: Да, абсолютно. Это классическая ловушка для архитекторов. В алгоритме SSI даже чистые читатели участвуют в графе зависимостей. Если транзакция READ ONLY считывает промежуточное состояние данных, связанное с двумя другими параллельно пишущими транзакциями, она может замкнуть опасный цикл зависимостей. Чтобы сохранить иллюзию сериализуемости для всей системы, PostgreSQL имеет полное право прервать читающую транзакцию. (Решение в PostgreSQL: объявлять транзакцию как SET TRANSACTION READ ONLY DEFERRABLE; — тогда СУБД задержит старт транзакции до момента, пока не появится гарантированно безопасный глобальный снимок).


3. Откатываются ли значения счетчиков генераторов последовательностей (Sequence.nextval) при возникновении ошибки сериализации?

Ответ: Нет, никогда. Генераторы последовательностей (sequence) в реляционных базах данных (PostgreSQL, Oracle) работают вне транзакционного контекста ради высокой производительности. Функция nextval() атомарно сдвигает счетчик в памяти и на диске без привязки к откату транзакции. Если транзакция получила ID 105, а затем была прервана по serialization_failure, значение 105 навсегда теряется, образуя «дыру» в последовательности идентификаторов. Попытка повтора транзакции (@Retryable) получит уже следующий ID — 106.


4. В чем разница между классическим Serializable в MySQL InnoDB и PostgreSQL?

Ответ:

  • В MySQL InnoDB: При установке SET TRANSACTION ISOLATION LEVEL SERIALIZABLE движок неявно конвертирует все обычные запросы SELECT в блокирующие запросы SELECT ... FOR SHARE (накладывает Shared S-блокировки и Gap Locks на прочитанные диапазоны). Писатели блокируются читателями, а читатели блокируются писателями (пессимистический подход, близкий к 2PL).
  • В PostgreSQL: SELECT остается абсолютно неблокирующим и выполняется по механизму многоверсионности MVCC. Конфликты выявляются оптимистически постфактум через граф SSI.

🎯 Шпаргалка для интервью

  • Суть Serializable: Самый строгий уровень; гарантирует результат, эквивалентный строго последовательному выполнению транзакций.
  • Исключает все аномалии: Dirty Read, Non-Repeatable Read, Phantom Read, Lost Update, Write Skew.
  • Реализация в PostgreSQL (SSI): Не блокирует чтение; строит граф rw-antidependencies через неблокирующие SIREAD locks.
  • Реализация в MySQL: Пессимистическая (неявно превращает все SELECT в SELECT ... FOR SHARE с Gap Locks).
  • Ошибка 40001: Неизбежный спутник Serializable. Приложение обязано иметь механизм @Retryable с экспоненциальным backoff.
  • READ ONLY транзакции: Тоже могут прерываться на уровне Serializable, если не использовать DEFERRABLE.
  • Производительность: На write-heavy нагрузке пропускная способность может проседать в 2–10 раз из-за конфликтов и повторов.

Связанные темы