MongoDB — замечательная база данных, которая становится все популярнее в последнее время. Все больше людей с SQL опытом начинают её использовать, и один и первых вопросов, который у них возникает: MongoDB transactions?.
Если поверить ответам со stackoverflow, то все плохо.
MongoDB doesn't support complex multi-document transactions. If that is something you absolutely need it probably isn't a great fit for you.
If transactions are required, perhaps NoSQL is not for you. Time to go back to ACID relational databases.
MongoDB does a lot of things well, but transactions is not one of those things.
Но мы не поверим и реализуем полноценные транзакции (ACID* и lock-free). Ниже будет рассказ о том, как эти транзакции работают, а тем, кому не терпится посмотреть код — добро пожаловать на GitHub (осторожно, java).
Durability обеспчивается ровно в той степени, в которой её обеспечивает хранилище.
Модель данных
В отличии от многих других NoSQL решений, MongoDB поддерживает compare-and-set. Именно поддержка CAS позволяет добавить ACID транзакции. Если вы используете любое другое NoSQL хранилище с поддержкой CAS (например, HBase, Project Voldemort или ZooKeeper), то описанный подход можно применить и там.
Что такое CAS
Это механизм, который гарантирует отказ в изменении объекта, если с момента последнего чтения объект был изменен другим клиентом. Знакомый всем пример - система контроля версий, которая откажет вам в коммите, если ваш коллега успел закомититься раньше.
Собственно все объекты, которые мы хотим изменять в транзакции должны быть под защитой CAS, это влияет на модель данных. Допустим мы моделируем работу банка, ниже приведена модель счета как с защитой, так и без неё, надеюсь из этого ясно как нужно изменить остальные.
Беззащитные | Подзащитные | |
Модель |
|
|
Изменение данных |
|
|
Далее, я не буду акцентировать внимание на том, что у объекта есть версия, и что любое изменение объекта проходит с учетом его версии, но это нужно помнить и понимать, что любое изменение объекта может вылетить с ошибкой из-за конкурентного доступа.
На самом деле добавление версии — это не все изменения, которые нужно провести над моделью, чтобы она поддерживала транзакции, полностью измененная модель выглядит так:
{
_id : ObjectId(".."),
version : 0,
value : {
name : "gov",
balance : 600
},
updated : null,
tx : null
}
Добавились поля — updated и tx. Это служебные данные, которые используются в процессе транзакции. По структуре updated совпадает с value, по смыслу — это измененная версия объекта, которая превратится в value, если транзакция пройдет; tx — это объект класса ObjectId — foreign key для _id объекта, представляющий транзакцию. Объект представляющий транзакцию так же находится под защитой CAS.
Алгоритм
Объяснить алгоритм просто, объяснить его так, что его корректность была очевидна, сложнее; поэтому придется то, что некоторыми сущностями я буду оперировать до того, как их определю.
Ниже идут верные утверждения, определения и свойства из которых позже будет составлен алгоритм.
- value всегда содержит состояние, которое было верным на какой-то момент в прошлом
- операция чтения может изменять данные в базе
- операция чтения идемпотентна
- объект может быть в трех состояниях: чистое — c, грязное незакомиченное — d, грязное закомиченное — dc
- в транзакции изменяются только объекты в состоянии: c
- возможные переходы между состояниями: c →d, d→c, d→dc, dc→c
- переходы инициированные транзакцей: c →d, d→dc, dc→c
- возможный переход при чтении: d→c
- если произошел переход d→c, то транзакция, внутри которой был переход c →d, упадет при коммите
- любая операция при работе с базой может упасть
- упавшию операцию чтения нужно повторить
- при упавшей записи нужно начать новую транзакцию
- при упавшем коммите нужно проверить прошел ли он, если нет — повторить транзакцию заново
- транзакция прошла, если объект представляющий транзакцию (_id = tx) удален
Состояния
Чистое состояние описывает объект после успешной транзакции: value содержит данные, а upated и tx — null.
Грязное незакомиченное состояние описывает объект в момент транзакции, updated содержит новую версию, а tx — _id объекта представляющего транзакцию, этот объект существует.
Грязное закомиченное состояние описывает объект после успешной транзакции, но которя упала до того, как успела подчистить за собой, updated содержит новую версию, tx — _id объекта представляющего транзакцию, но сам объект уже удален.
Транзакция
- Читаем объекты, которые участвуют в транзакции
- Создаем объект представляющий транзакцию (tx)
- Пишем в updated каждого объекта новую значение, а в tx — tx._id
- Удаляем объект tx
- Пишем в value каждого объекта значение из updated, а tx и updated обнуляем
Чтение
- Читаем объект
- Если он чистый — возвращаем его
- Если грязный закомиченный — пишем в value значение из updated, а tx и updated обнуляем
- Если грязное незакомиченный — изменяем версию tx, обнуляем updated и tx
- Переходим на шаг #1
Для тех кому теперь не очивидна корректность, домашнее задание — проверить, что выполняются все свойства и утверждения, а затем используя их доказать ACID ☺
Заключение
Мы добавили в MongoDB транзакции. Но на самом деле это не панацея и у них есть ограничения, некоторые перечислены ниже, а некоторые в комментариях
- все работает хорошо (база консистентна, транзакции не теряются) в предположении, что если мы получили подтверждение от хранилища, что запись прошла, она действительно прошла и эти данные не потеряются (монга обеспечивает это при включенном журналировании)
- все работает относительно хорошо (база консистентна, могут потеряться последние транзакции), если все запросы упорядочены и потерять мы можем только их хвост (монга может это обеспечивать только при отсутствии шардинга)
- транзакции оптимистические, проэтому при изменении объекта с высокой частотой из разных потоков их лучше не использовать
- для изменения n объектов в одной транзакции используется 2n+2 запросов
- со временем у нас будут накапливаться tx объекты от упавших транзакций — периодически мы должны удалять старые
.
FAQ
Как могут помочь подобные транзакции при отключенном журналировании и шардировании?
В случае ошибки сервера мы действительно можем получить неконсистентное состоянии базы, но защищаемся от неконсистентного состояния, вызванного падением клиента в момент записи. Если риск второго больше первого, то используя транзакции мы все-равно повышаем надежность системы.
Автор: shai_xylyd