Всем привет, меня зовут Артём и я алкоголик долгое время не понимал базы данных. Ну, то есть я понимал концепт и как с ними работать, но всегда воспринимал их как чёрный ящик с понятным интерфейсом, который может сохранять и отдавать данные, если знать, как его об этом попросить. Механизмы, позволяющие магии случаться, были совершенно не понятны. И честно говоря, меня это особо не волновало. Бизнесу нужно, чтобы ты фичи фигачил, а не вот это вот всё.

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

Всё началось с того, что я задался вопросом, а как чёрт подери работает rollback, когда в результате работы моего воспалённого сознания получается такой код, который генерирует в базу кривые запросы и она вынуждена что-то делать, чтобы всё окончательно не сломалось? Прежде чем начинать копать в сторону механизма отката изменений, нужно понять что это вообще такое и зачем вообще оно нужно. И тут в игру вступает такая волшебная аббривиатура как ACID.

Так что же значит ACID? ACID это набор характеристик, которым должна соответствовать наша программа, чтобы считаться кошерным местом для хранения данных. Попробую в двух словах рассказать о каждой букве в аббривиатуре (ведь это не про кислоту в берлинских клубах):

A значит Atomicity — транзакция работает как единая команда и либо выполняется целиком, либо не выполняется вообще

С значит Consistency — по завершению транзакции данные не должны быть испорчены (такая противная штука как data corruption) или потеряны

I значит Isolation — по сути означает, что транзакции не должны пересекаться друг с другом. То есть если одна транзакция уже начала работу с данными, то следующая транзакция должна дождаться завершения предыдущей для того, чтобы начать выполнение операций с этими данными

D значит Durability гарантия того, что по завершению транзакции данные будут сохранены и не подвержены риску потери. Тут нужно понимать, что это не только про программные ошибки, но и про потопы, цунами, отключения электричества и прочие ЧП

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

Если сильно упростить, то при проектировании хранилища мы должны балансировать между скоростью и надёжностью.

Если мы хотим пойти по пути высокой скорости, то нам нужно хранить наши данные в как можно более быстром хранилище. Конечно, самым быстрым было бы хранить данные прямо в L1 кэше процессора, но объём хранилища не позволит нам развернуться. Следующее, что приходит на ум это RAM. Хранилище данных в оперативке несомненно даёт нам хорошую скорость, но о надёжности можно забыть, ведь любой скачок напряжения в датацентре будет означать неминуемую потерю данных. Примером такой базы данных может послужить какой-нибудь memcached.

Если же мы хотим получить высокую надёжность и нам не нужен частый и быстрый доступ к данным, то имеет смысл записывать все данные прямо на ssd или на hdd для хардкорных парней со специфическими задачами, например для хранения логов и редко запрашиваемой аналитики. Однако в таком случае придётся пожертвовать скоростью (что кстати может измениться с приходом быстрых nvm накопителей).

Популярные реляционные решения вроде Postgres и MySql используют гибридную схему, когда данные записываются на жёсткий диск, но также имеют слой, отвечающий за работы с кешированными в памяти данными для быстрого чтения. В этой статье мы будем говорить именно про этот тип хранилищ. Но прежде чем начинать лезть в гущи технической реализации, давайте с начала обозначим проблему.

Скажем, у нас есть заказчик Петя и фрилансер по имени Вася. Вася сделал Пете крутой лендос (одностраничный сайт) на тильде (конструктор сайтов) и ожидает, что Петя зашлёт ему за это денег. Петя не против и делает перевод через мобильный банкинг. Чтобы никто не присел нам нужно правильно выполнить несколько действий, а именно:

  1. Найти таблицу с балансом Пети

  2. Списать необходимую сумму с его баланса

  3. Найти аккаунт Васи

  4. Зачислить списанную у Пети сумму на счет Васи

Предположим, что первые два шага прошли хорошо. Однако потом выясняется, что Петя не правильно вбил реквизиты и мы не можем найти аккаунт Васи у нас в системе. Теперь мы имеем ситуацию, когда Петя стал беднее, а Вася богаче не стал. Тем самым мы нарушили правило консистентности, ведь данные испорчены. Это не очень крутая ситуация, ведь деньги не должны просто брать и исчезать.

Это справедливое утверждение, но тогда встаёт вопрос - а как сделать так, чтобы этого не произошло?

Чтобы ответить на этот вопрос, нам нужно понять, что вообще представляет собой база данных и как она хранит записи. Тут нет единого ответа, ибо кто во что горазд, но если постараться обобщить, то база данных эта программа (ага, кто бы мог подумать) которая состоит из нескольких слоёв:

Transport layer — отвечает за общение базы с внешним миром (нашим замечательным сервером) и также за общение нод в кластере между собой (в случае если мы, скажем, применяем шардинг)

Query processor — ответственен за то, чтобы принять запрос, распарсить его, оптимизировать и превратить в список операций (execution plan), которые должны быть выполнены базой для успешного завершения транзакции

Execution engine — делает запрос на выполнение операций и агрегирует результаты

Storage engine — отвечает за исполнение операций

Ок, с этим этим разобрались, но теперь нам нужно погружаться ещё глубже в кроличью нору.

Дело в том, что storage engine тоже не так прост и состоит из нескольких частей, каждая из которых отвечает за то, чтобы наша база соответствовала злополучному ACID. Тут тоже нет единого консенсуса, но чаще всего выделяют следующие слои:

Transaction Manager — отвечает за очередь транзакций и проверяет, что транзакция полностью завершилась перед тем, как покинуть очередь. Отвечает за букву A и D.

Lock Manager — блокирует объекты, которые участвуют в транзакции. Отвечает за букву I

Storage Structures — отвечает за доступ и организацию данных в хранилище. Не отвечает ни за какие буковки, но без него никуда

Buffer Manager — кэширует данные в памяти. Мы ведь хотим, чтобы наша база работала быстро, правда?

Recovery Manager — отвечает за то, чтобы откатить данные к исходному состоянию в случае, если что-то пошло не так.

Вот оно! Recovery Manager и есть та волшебная штука, которая может нам помочь сделать rollback и вернуть Пете его честно заработанные деньги, а нам избежать исков в суды и жалоб в тысячи всевозможных инстанций.

Теперь давайте вернёмся к сценарию, когда нам нужно провести несколько операций в рамках одной транзакции для того, чтобы Петя заплатил Васе за красивый лендинг. Для модификации хранилища используется семейство паттернов под названием write-ahead logging (WAL). В оперативной памяти создаётся специальный лог файл, в который вносится информация о том, в каких таблицах нужно изменить записи в рамках операции, а также старые и обновленные значения полей. Затем обновлённый лог файл сохраняется на диск. После чего следует изменение записей в таблицах с данными, хранимых в памяти. После чего измененные значения таблиц сохраняются на диск. Тут важно понимать, что цепочка событий отрабатывает не на уровне всей транзакции, а на уровне отдельных операций, которые сохраняются на диск с определённой периодичностью (fsync() kernel call).

Но чтоже происходит, если операция не успешна и по какой-то причине нам нужно откатить изменения и вернуть нашу базу в исходное состояние?

Для восстановления исходного состояния используется семейство алгоритмов под названием ARIES (Algorithms for Recovery and Isolation Exploiting Semantics).

Логику алгоритма можно описать следующими шагами:

  1. Analysis — стадия анализа и выявления таблиц, которые были модифицированы в ходе транзакции, а также анализ операций проведённых в пределах транзакции и их состояния (чекаем где сломалось).

  2. REDO — на этом этапе мы уже точно знаем где транзакция сломалась и в какие таблицы были внесены изменения. Однако пока не можем гарантировать, что наше хранилище находится в консистентном состоянии, так как не понимаем, какие данные уже были сохранены на диск, а какие нет. Так как ребята, которые писали ARIES, были большими любителями простоты, они решили не заморачиваться, а просто ещё раз пройтись по всем операциям необходимым для транзакции, с самого начала и до момента ошибки. И применить их ещё раз с сохранением на жёсткий диск. Это необходимо для того, чтобы гарантировать консистентность нашего хранилища и исключить возможность порчи данных.

  3. UNDO — тут всё просто. На последнем этапе мы делаем очередной проход по лог файлу и перезаписываем значения в изменённых таблицах. Но в отличие от предыдущего этапа на этот раз мы сохраняем значения таблицы, которые были в ней до того, как мы начали выполнение транзакции. Как результат, хранилище возвращается к изначальному состоянию и все счастливы.

После этого transaction manager сообщает нашему клиенту (в нашем случае это наш сервер) о том, что транзакция не прошла и всё, что нам остаётся сделать, это показать Пете сообщение о том, что перевод не случился и ему следует перепроверить реквизиты и попытаться ещё раз.

На этом всё. Надеюсь, данная статья помогла вам немного лучше понять, что происходит под капотом в популярных базах данных, что такое принципы ACID и как они поддерживаются и какие шаги предпринимаются хранилищем для следования этим принципам. Книги на почитать, если вдруг захотите копнуть глубже:

Database Internals by Alex Petrov

Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems

System Design Interview – An insider's guide, Second Edition

Комментарии (5)


  1. funny_falcon
    12.10.2021 09:24
    +12

    Я прошу прощения, но про rollback так ни чего и не сказали. Вы рассказали про recovery - штуку, работающую во время подъёма базы после катастрофического отключения (кабель выдернули / kernel-panic / segfault в коде базы / kill -9 ). Да и то в двух словах.

    Например, в PostgreSQL нет UNDO в WAL логе, и он спокойно живёт, восстанавливается, делает ROLLBACK незавершённым транзакциям при восстановлении и делает пользовательские ROLLBACK.

    И вот как-раз про пользовательские ROLLBACK вы ни слова и не сказали. И они случаются не только когда пришла команда ROLLBACK, но и, например, когда клиент "умер" или соединение от клиента порвалось или произошёл таймаут на соединении (для сервера БД все эти три случая эквивалентны в плане работы ROLLBACK).

    И при таком пользовательском ROLLBACK ни как не используется UNDO в WAL логе (обычно. Я не знаю, кто использует). В некоторых базах транзакция просто помечается как Aborted (в PostgreSQL, например). В других, используется совершенно отдельный UNDO лог, предназначенный только для пользовательских ROLLBACK (емнип, InnoDB, а может и в Oracle). В третьих вообще используются оптимистичные транзакции, и транзакция просто не достигает стораджа до команды COMMIT.

    (btw, PostgreSQL точно так же откатывает транзакции при восстановлении: все незавершившиеся на конец WAL лога получают пометку Aborted).


    1. ArtemWolynski Автор
      12.10.2021 10:25
      -4

      Хэй, спасибо за развёрнутый комментарий. Только ради этого и стоит писать статьи на хабр.


      1. akhkmed
        12.10.2021 13:12
        +1

        Как происходит откат транзакции в pg? Помечаем её как откатанную и игнорируем все изменения, которые она сделала в базе. Подробнее в статье.


    1. nick1612
      12.10.2021 11:13
      +1

      Я бы еще добавил, что в InnoDB используется отдельный Undo log, так как до коммита транзакции остальные запросы должны видеть старые версии записей для обеспечения MVCC.


  1. pankraty
    12.10.2021 10:39
    +7

    Увы, получился 100501-ый ликбез на тему, что такое транзакция и ACID.