Блокировки, сериализуемость и восстановление
Как СУБД на самом деле обеспечивает изоляцию и долговечность? Разберём блокировки, идею сериализуемости и журналирование.
Сериализуемое расписание — это план параллельного выполнения транзакций, результат которого эквивалентен какому-то последовательному выполнению этих же транзакций. Сериализуемость — это «золотой стандарт» корректности параллелизма.
Зачем нужен механизм под изоляцией
Уровни изоляции из прошлого урока — это контракт «что гарантируем». Но как СУБД его выполняет технически? Двумя классами механизмов: блокировками (не дать транзакциям мешать) и журналированием (пережить сбой). Понимая их, вы перестаёте воспринимать транзакции как магию и можете осознанно бороться, например, с взаимными блокировками.
Связь с прошлым уроком
Этот урок отвечает на вопрос, который мы оставили открытым: уровни изоляции говорят, что система гарантирует, но как она это делает? Оказывается, за красивыми названиями уровней стоят вполне конкретные механизмы. 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 и контрольными точками обеспечивает атомарность и долговечность при сбоях.