💳 Розділ 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 разів через конфлікти та повтори.

Червоні прапорці (чого уникати)

  • Твердження, що Serializable у PostgreSQL блокує всі таблиці важкими блокуваннями (PostgreSQL використовує оптимістичний неблокувальний SSI).
  • Відсутність механізму @Retryable при використанні рівня Serializable.
  • Незнання того, що READ ONLY транзакція теж може впасти з помилкою 40001 без опції DEFERRABLE.
  • Очікування, що nextval() у послідовностях відкотиться назад при помилці серіалізації.

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