Блокировки, сериализуемость и восстановление

Как СУБД на самом деле обеспечивает изоляцию и долговечность? Разберём блокировки, идею сериализуемости и журналирование.

Сериализуемое расписание — это план параллельного выполнения транзакций, результат которого эквивалентен какому-то последовательному выполнению этих же транзакций. Сериализуемость — это «золотой стандарт» корректности параллелизма.

Зачем нужен механизм под изоляцией

Уровни изоляции из прошлого урока — это контракт «что гарантируем». Но как СУБД его выполняет технически? Двумя классами механизмов: блокировками (не дать транзакциям мешать) и журналированием (пережить сбой). Понимая их, вы перестаёте воспринимать транзакции как магию и можете осознанно бороться, например, с взаимными блокировками.

Связь с прошлым уроком

Этот урок отвечает на вопрос, который мы оставили открытым: уровни изоляции говорят, что система гарантирует, но как она это делает? Оказывается, за красивыми названиями уровней стоят вполне конкретные механизмы. Serializable — это, по сути, обещание пропускать только сериализуемые расписания, и реализуется оно либо строгими блокировками (двухфазный протокол), либо проверками в MVCC. Read Committed на практике часто означает «блокировку записи держим до конца, а блокировку чтения отпускаем сразу» — отсюда и возможность неповторяемого чтения. Таким образом, выбор уровня изоляции — это, под капотом, выбор того, насколько агрессивно система блокирует или версионирует данные. Понимая механизмы, вы перестаёте воспринимать уровни как магические настройки и начинаете предсказывать их влияние на производительность: более строгий уровень = больше блокировок/проверок = меньше параллелизма.

Расписания и сериализуемость

Расписание (schedule) — это порядок, в котором чередуются операции нескольких параллельных транзакций. Последовательное расписание (одна транзакция целиком, потом другая) заведомо корректно, но убивает параллелизм. Задача — найти чередующееся расписание, которое идёт параллельно, но даёт тот же результат, что какое-то последовательное. Такое расписание называют сериализуемым. Изоляция уровня Serializable как раз и означает «СУБД пропускает только сериализуемые расписания».

Конфликтуют операции, которые работают с одним объектом и хотя бы одна из них — запись (запись/запись, чтение/запись). Именно порядок конфликтующих операций определяет, эквивалентно ли расписание последовательному.

Оптимистичный и пессимистичный подходы

Все механизмы управления параллелизмом делятся на две философии, и полезно видеть их различие. Пессимистичный подход (классические блокировки) исходит из того, что конфликты вероятны, поэтому транзакция заранее захватывает ресурс и не пускает других — «лучше подождать, чем потом откатывать». Оптимистичный подход исходит из обратного: конфликты редки, поэтому транзакция работает без блокировок, а при фиксации проверяет, не вмешался ли кто-то; если вмешался — откатывается и повторяется. Оптимистичный подход выигрывает, когда конфликтов действительно мало (много читателей, мало писателей в одни и те же данные): нет накладных расходов на блокировки. Пессимистичный надёжнее при высокой конкуренции за одни строки: лучше один раз подождать, чем многократно откатываться. MVCC, о котором ниже, во многом ближе к оптимистичной идее для читателей. Выбор подхода — это ставка на то, насколько часты конфликты в вашей нагрузке.

Блокировки

Самый распространённый способ обеспечить изоляцию — блокировки (locks). Перед доступом к объекту транзакция запрашивает блокировку:

  • Разделяемая (shared, S) — для чтения. Несколько транзакций могут одновременно держать S-блокировку на одном объекте: читать друг другу не мешают.
  • Исключительная (exclusive, X) — для записи. Несовместима ни с какой другой: пока транзакция пишет, остальные ждут.

Чтобы блокировки гарантировали сериализуемость, применяют двухфазный протокол блокировок (2PL): у каждой транзакции есть фаза «только захвата» блокировок (растущая) и затем фаза «только освобождения» (сужающаяся) — захватывать снова после первого освобождения нельзя. Доказано, что 2PL обеспечивает сериализуемость расписания.

Взаимные блокировки

У блокировок есть оборотная сторона — deadlock (взаимная блокировка). T1 заблокировала счёт A и ждёт счёт B; T2 заблокировала B и ждёт A. Обе ждут вечно. СУБД обнаруживает такие циклы (по графу ожидания) и разрывает их, принудительно откатывая одну из транзакций — она получит ошибку и должна повториться. Поэтому код, работающий с транзакциями, должен быть готов к повтору при откате по deadlock.

Предотвращение и обнаружение deadlock

С взаимными блокировками борются двумя стратегиями. Обнаружение: система периодически строит граф ожидания (кто кого ждёт) и ищет в нём цикл; найдя цикл, выбирает «жертву» (обычно ту транзакцию, которую дешевле откатить) и принудительно её прерывает, разрывая тупик. Это подход большинства СУБД — он не мешает работе в обычном режиме, а вмешивается только при реальном тупике. Предотвращение: систему строят так, чтобы deadlock в принципе не возникал — например, заставляя все транзакции захватывать ресурсы в едином порядке (если все берут счёт A раньше счёта B, циклического ожидания не выйдет). Предотвращение надёжнее, но ограничивает гибкость кода. На практике чаще полагаются на обнаружение плюс простое правило для разработчика: захватывайте ресурсы в согласованном порядке и держите транзакции короткими — тогда тупики редки, а оставшиеся система разрулит откатом, который ваш код должен уметь повторить.

Альтернатива блокировкам: MVCC

Многие современные СУБД (PostgreSQL, Oracle) для чтения используют не блокировки, а многоверсионность (MVCC): при изменении строки создаётся новая версия, а читающие транзакции продолжают видеть согласованный «снимок» данных на момент своего начала. Главный выигрыш — читатели не блокируют писателей, а писатели не блокируют читателей. Это резко повышает параллелизм; изоляция при этом достигается выбором, какую версию строки показать каждой транзакции.

Гранулярность блокировок

Блокировать можно объекты разного размера, и тут есть важный компромисс. Можно заблокировать одну строку — тогда другие транзакции свободно работают с остальными строками таблицы (высокий параллелизм), но если транзакция трогает миллион строк, ей нужен миллион блокировок (большие накладные расходы на их учёт). Можно заблокировать целую таблицу — одна блокировка, дёшево в учёте, но никто другой не работает с таблицей (параллелизм убит). Между ними — блокировки страниц и диапазонов. СУБД обычно выбирает гранулярность автоматически и умеет эскалировать: если транзакция набрала слишком много строчных блокировок, система заменяет их одной блокировкой таблицы. Этот баланс «много мелких блокировок против одной крупной» — постоянная тема настройки производительности: мелкие дают параллелизм, но дороги в учёте; крупные дёшевы, но сериализуют доступ.

Журналирование и восстановление

Теперь долговечность и атомарность при сбоях. Если просто писать изменения прямо в файлы данных, сбой посреди записи оставит базу в полуразрушенном состоянии. Решение — журнал упреждающей записи (WAL, write-ahead log). Правило железное: сначала записать в журнал намерение изменить данные (на устойчивый носитель), и только потом менять сами данные. Журнал — это последовательный лог записей вида «транзакция T изменила X с a на b».

Что это даёт при восстановлении после сбоя:

  • Redo (повтор). Для транзакций, которые успели сделать COMMIT, но чьи изменения, возможно, не дошли до файлов данных, СУБД повторно применяет записи журнала — так обеспечивается долговечность.
  • Undo (откат). Для транзакций, не успевших зафиксироваться к моменту сбоя, СУБД откатывает их изменения по журналу — так обеспечивается атомарность.

Чтобы не проигрывать весь журнал с начала времён, периодически делают контрольные точки (checkpoint) — фиксируют согласованное состояние, от которого можно начинать восстановление. Журнал — это и есть тот «устойчивый носитель», на который опирается буква D в ACID.

МеханизмОбеспечивает
Блокировки / 2PLИзоляцию (сериализуемость)
MVCCИзоляцию без блокировки читателей
WAL + redo/undoАтомарность и долговечность при сбоях

Типичные ошибки

  • Считают, что блокировки не приводят к deadlock. Любая схема с несколькими блокировками рискует взаимной блокировкой; код должен уметь повторять откаченную транзакцию.
  • Путают сериализуемость с «выполнением по очереди». Сериализуемое расписание идёт параллельно, но эквивалентно какому-то последовательному.
  • Думают, что данные пишутся прямо в файлы. Сначала запись идёт в журнал (WAL), и только это даёт восстановление после сбоя.
  • Полагают, что MVCC отменяет блокировки полностью. Писатели всё равно конфликтуют между собой; MVCC снимает блокировки в основном между читателями и писателями.

Итог

  • Сериализуемое расписание идёт параллельно, но эквивалентно последовательному — это эталон корректности.
  • Блокировки (S/X) и двухфазный протокол (2PL) обеспечивают сериализуемость; риск — deadlock, который СУБД разрывает откатом.
  • MVCC даёт изоляцию без блокировки читателей за счёт версионирования строк.
  • WAL с операциями redo/undo и контрольными точками обеспечивает атомарность и долговечность при сбоях.
Проверьте себя
1. Что такое сериализуемое расписание?
AРасписание, где транзакции идут строго по очереди
BПараллельное расписание, результат которого эквивалентен какому-то последовательному выполнению
CРасписание без блокировок
DРасписание с минимальным числом операций
2. Каково главное правило журнала упреждающей записи (WAL)?
AПисать данные, а потом журнал
BСначала записать изменение в журнал, и только потом менять сами данные
CПисать только при COMMIT
DЖурнал хранится в оперативной памяти
3. В чём преимущество MVCC перед блокировками для чтения?
AПолностью убирает все конфликты
BЧитатели не блокируют писателей, а писатели — читателей
CРаботает без журнала
DУскоряет запись на диск
Поддержать проект