Многие из нас сталкивались в своей работе с СУБД. На текущий момент базы данных в том или ином виде окружают нас повсюду, начиная с мобильных телефонов и заканчивая социальными сетями, в число которых входит и любимый нами хабр. Реляционные СУБД являются наиболее распространенными представителями семейства СУБД, и большинство из них являются транзакционными.
В институте нас заставляли заучивать определение ACID и стоящие за ним свойства, но почему-то стороной обходились подробности реализации этой парадигмы. В данной статье я постараюсь частично заполнить этот пробел, рассказав о MVCC, которая используется в таких СУБД как Oracle, Postgres, MySQL, etc. и является весьма простой и наглядной.
Итак, стоит начать с определения ACID:
- Atomicity — транзакции атомарны, то есть либо все изменения транзакции фиксируются (commit), либо все откатываются (rollback);
- Consistency — транзакции не нарушают согласованность данных, то есть они переводят базу данных из одного корректного состояния в другое. Тут можно упомянуть допустимые значения полей, внешние ключи и более сложные ограничения целостности;
- Isolation — работающие одновременно транзакции не влияют друг на друга, то есть многопоточная обработка транзакций производится таким образом, чтобы результат их параллельного исполнения соответствовал результату их последовательного исполнения;
- Durability — если транзакция была успешно завершена, никакое внешнее событие не должно привести к потере совершенных ей изменений.
Каждое из этих требований выгладит более чем рациональным, особенно если оно затрагивает такие важные сферы, как банковские операции и другие операции с валютой: согласитесь, будет очень неприятно, если с вашего счета деньги спишутся, а на счет магазина они не придут (нарушение «atomicity»), или в результате сбоя АБС будет потеряна информация о том, что вам на счет зачислили зарплату (нарушение «durability»).
Если начать рассуждать о том, как же работают СУБД, поддерживающие ACID транзакции, больше всего вопросов вызовет свойство Isolation: современные СУБД поддерживают сотни и тысячи одновременных транзакций, и все они обращаются к одним и тем же таблицам. Как же сделать так, чтобы они друг другу не мешали? Здесь на помощь приходит MVCC (MultiVersion Concurrency Control), то есть контроль конкурентного доступа к данным через создание множества “версий” изменяемых данных. В упрощенном виде этот механизм можно представить следующим образом: все операции с данными можно условно разделить на чтение (select), вставку (insert), удаление (delete), обновление (update). Вот что происходит при этих операциях:
- Select — считываются валидные записи таблицы. Запись считается валидной, если она создана транзакцией, которая была зафиксирована (commit) до начала текущей транзакции;
- Insert — новая запись просто добавляется в свободное место таблицы;
- Delete — запись в таблице помечается как не валидная, при этом сама запись не удаляется;
- Update — комбинация delete и insert. Сначала старая версия записи помечается как не валидная, затем добавляется новая запись с обновленными данными.
В целом, указанный подход можно реализовать при помощи всего одного дополнительного бита с флагом is_valid = 1 для валидных записей и 0 для не валидных. Но есть проблема с многопоточностью: при таком подходе будет возможен только последовательный доступ к данным (писатели будут блокировать как читателей, так и других писателей).
Допустим, у нас есть “Таблица А” со следующими данными:
И две транзакции, которые пытаются обновить данные следующим образом:
- Транзакция 1: update A set value = value || “ transaction 1” where ID = 1;
- Транзакция 2: update A set value = value || “ transaction 2” where ID = 1;
Если допустить их параллельную работу на одних и тех же данных, может получиться такой результат:
- Транзакция 1 прочитала валидное значение Value для строки с ID=1: “Hello”;
- Транзакция 2 прочитала валидные значение Value для строки с ID=1: “Hello”;
- Транзакция 1 посчитала новое значение для поля Value: “Hello transaction 1”;
- Транзакция 2 посчитала новое значение для поля Value: “Hello transaction 2”;
- Транзакция 1 обновила данные таблицы;
- Транзакция 2 обновила данные таблицы.
Вот результат такого сценария:
А вот результат их последовательного выполнения:
Очевидно, что требование изоляции нарушено: работающие одновременно транзакции повлияли друг на друга. Подобные ошибки можно условно разделить на Dirty Reads, Non-repeatable Reads и Phantom Reads.
Таким образом можно заключить, что:
- Одного флага валидности недостаточно, нужен более сложный механизм;
- Для некоторых типов операций необходимы блокировки. Если две транзакции пытаются обновить одну и ту же запись таблицы, вполне очевидно, что сделать они могут это только последовательно.
Рассмотрим как это работает в Postgres:
- Все транзакции в системе имеют последовательные номера (условно, по факту номер транзакции для каждой из таблиц свой и в рамках vacuum он сбрасывается во избежании переполнения 4-байтового целого, которое используется для хранения ID транзакции);
- Существует глобальный реестр транзакций, содержащий информацию о том, какие транзакции находятся в процессе выполнения, а какие были подвергнуты откату;
- Для каждой записи таблицы существуют технические поля Xmin и Xmax, хранящие информацию о транзакциях, модифицировавших эту запись. Xmin — идентификатор транзакции, которая добавила запись в таблицу. Xmax — идентификатор транзакции, удалившей запись из таблицы;
- Для каждой записи таблицы существуют технические поля Cmin и Cmax, использующиеся для обеспечения работы транзакций в несколько команд.
Допустим, транзакция сначала добавляет в таблицу строку, затем её обновляет, затем удаляет. Благодаря Xmin и Xmax до момента фиксации транзакции (commit) все эти операции будут невидимы для внешних транзакций. Но как быть с самой транзакцией, которая свои изменения должна видеть, даже если они противоречат друг другу (сначала запись добавили, затем удалили)? Для этого созданы Сmin и Сmax, работающие во многом аналогично Xmin и Xmax, но в рамках одной конкретной транзакции.
С таким набором метаданных мы намного ближе к реализации ACID, чем с одним флагом валидности. Теперь рассмотрим описанные выше операции в контексте MVCC:
- Select — считываются валидные записи таблицы. Для каждой из записей мы имеем Xmin, Xmax, Cmin, Cmax. Если Xmin находится в списке работающих или отмененных транзакций, запись не валидна. Если Xmax задан и транзакция с этим ID не находится в списке работающих или отмененных транзакций, запись не валидна. Если Xmin равен ID текущей транзакции, то для проверки валидности записи смотрим на Cmin и Cmax (Cmax должно быть задано, чтобы запись была не валидна). Если Xmax равен ID текущей транзакции, всё с точностью до наоборот. Если указанные выше проверки не сработали, запись валидна. На эту тему есть весьма хороший кусок кода из src/backend/utils/time/tqual.c с комментарием автора:
- Insert — новая запись просто добавляется в свободное место таблицы, ей проставляется Xmin в ID текущей транзакции и Cmin в ID текущей операции в рамках транзакции;
- Delete — запись в таблице помечается как не валидная, при этом сама запись не удаляется. Выполняется это следующим образом: Xmax ставится в ID текущей транзакции, Cmax в ID текущей операции;
- Update — комбинация delete и insert. Сначала старая версия записи помечается как не валидная, затем добавляется новая запись с обновленными данными;
- Commit транзакции выполняется через удаление идентификатора транзакции из списка работающих;
- Rollback транзакции выполняется через пометку идентификатора транзакции как транзакция подверженная откату.
Данная модель работы с транзакциями является упрощенной, так как не охватывает механизм блокировок, а также случаи с распределенными транзакциями. Тем не менее этот подход является общим для многих современных СУБД и его понимание позволит лучше разобраться в том, что же происходит внутри движка СУБД.
Более подробный разбор примеров для Postgres можно посмотреть здесь
Автор: 0x0FFF