Pull to refresh

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

Reading time 5 min
Views 53K
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*) основанные на MVCC. Ниже будет рассказ о том, как эти транзакции работают, а тем, кому не терпится посмотреть код — добро пожаловать на GitHub (осторожно, java).

Пост не о MongoDB, а о том, как использовать compare-and-set для создания транзакций, а 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


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


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

Я использую монгу в конфигурации из одной ноды, мне помогут транзакции?


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

Почему не использовать двух-фазные транзакции из официальной документации?


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

Иначе теряется консистентность и доступность (возможны зависшие транзакции — единственный разумный шаг отказ в чтении/записи объектов которые участвуют в этох транзакциях).
Tags:
Hubs:
+34
Comments 35
Comments Comments 35

Articles