Транзакции в MongoDB

в 7:23, , рубрики: java, mongodb, nosql, transactions, метки: , , ,

image 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(".."),
  name : "gov",
  balance : 600
}
{
  _id : ObjectId(".."),
  version : 0,
  value : {
    name : "gov",
    balance : 600
  }
}
Изменение данных
db.accounts.update( 
  { _id:ObjectId("...") }, 
  { name:"gov", balance:550 }
);
db.accounts.update({ 
    _id: ObjectId("..."), version: 0
  },{ 
    version : 1, 
    value : { name:"gov", balance:550 } 
});

Далее, я не буду акцентировать внимание на том, что у объекта есть версия, и что любое изменение объекта проходит с учетом его версии, но это нужно помнить и понимать, что любое изменение объекта может вылетить с ошибкой из-за конкурентного доступа.

На самом деле добавление версии — это не все изменения, которые нужно провести над моделью, чтобы она поддерживала транзакции, полностью измененная модель выглядит так:

{
  _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 объекта представляющего транзакцию, но сам объект уже удален.

Транзакция

  1. Читаем объекты, которые участвуют в транзакции
  2. Создаем объект представляющий транзакцию (tx)
  3. Пишем в updated каждого объекта новую значение, а в tx — tx._id
  4. Удаляем объект tx
  5. Пишем в value каждого объекта значение из updated, а tx и updated обнуляем

Чтение

  1. Читаем объект
  2. Если он чистый — возвращаем его
  3. Если грязный закомиченный — пишем в value значение из updated, а tx и updated обнуляем
  4. Если грязное незакомиченный — изменяем версию tx, обнуляем updated и tx
  5. Переходим на шаг #1

Для тех кому теперь не очивидна корректность, домашнее задание — проверить, что выполняются все свойства и утверждения, а затем используя их доказать ACID ☺

Заключение

Мы добавили в MongoDB транзакции. Но на самом деле это не панацея и у них есть ограничения, некоторые перечислены ниже, а некоторые в комментариях

  • все работает хорошо (база консистентна, транзакции не теряются) в предположении, что если мы получили подтверждение от хранилища, что запись прошла, она действительно прошла и эти данные не потеряются (монга обеспечивает это при включенном журналировании)
  • все работает относительно хорошо (база консистентна, могут потеряться последние транзакции), если все запросы упорядочены и потерять мы можем только их хвост (монга может это обеспечивать только при отсутствии шардинга)
  • транзакции оптимистические, проэтому при изменении объекта с высокой частотой из разных потоков их лучше не использовать
  • для изменения n объектов в одной транзакции используется 2n+2 запросов
  • со временем у нас будут накапливаться tx объекты от упавших транзакций — периодически мы должны удалять старые

.

FAQ

Как могут помочь подобные транзакции при отключенном журналировании и шардировании?

В случае ошибки сервера мы действительно можем получить неконсистентное состоянии базы, но защищаемся от неконсистентного состояния, вызванного падением клиента в момент записи. Если риск второго больше первого, то используя транзакции мы все-равно повышаем надежность системы.

Автор: shai_xylyd

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js