Летом 2018 года (т.е. прямо сейчас, на момент написания данной статьи) случилось невероятное — в MongoDB завезли честные ACID транзакции. С выходом четвёртой версии этой документ-ориентированной СУБД, её можно использовать для чуть более серьёзных приложений.
Для тех, кто в танке, в двух словах: транзакции позволяют нам провести серию изменений в нескольких документах и сохранить их разом, либо так же разом отменить все вносимые в рамках транзакции изменения, если что-то пошло не так, либо произошел сбой в приложении.
К сожалению, разработчику воспользоваться этой супер-фичей не так-то просто. Ниже я расскажу почему, и что с этим всем делать.
Если открыть документацию к СУБД на разделе Транзакции, можем увидеть следующую ремарку:
Multi-document transactions are available for replica sets only. Transactions for sharded clusters are scheduled for MongoDB 4.2
Это нам говорит о том, что простой сервер MongoDB не поддерживает транзакции, только кластер в режиме replica set. Поддержка в sharded кластерах также будет позже, в версии 4.2.
При этом, обычный сервер даст нам начать транзакцию, сохранить её, или отменить, но ничего в рамках неё сделать не получится, будет выведена примерно такая ошибка:
WriteCommandError({
"ok" : 0,
"errmsg" : "Transaction numbers are only allowed on a replica set member or mongos",
"code" : 20,
"codeName" : "IllegalOperation"
})
К счастью, каждый может запустить у себя кластер MongoDB, состоящий из одного сервера. На своих машинах, на которых я занимаюсь разработкой, все СУБД я запускаю в docker контейнерах. Например, запуск обычного сервера MongoDB выглядит так:
docker run -v ~/mongo/:/data/db --name mongo --restart=always -p 27017:27017 -d mongo mongod --smallfiles
Разберём ключи запуска:
- -v ~/mongo/:/data/db означает примонтировать локальную директорию ~/mongo/ в /data/db контейнера, таким образом, сама база будет храниться на хост-машине, что позволит нам удалять запущенный контейнер, обновлять версии и т.д. с сохраненим наших данных;
- --name mongo задаёт имя контейнеру;
- --restart=always говорит о том, что при любых падениях сервиса в контейнере, его следует перезапустить, а так же, стартовать контейнер после загрузки операционной системы;
- -p 27017:27017 «прокидывает» порт на хост машину;
- -d указывает на то, что нужно запустить контейнер в виде демона;
- mongo — имя образа для запуска контейнера;
- mongod --smallfiles — команда для запуска сервиса в контейнере.
Как запускать простой сервер, я привёл просто для справки. Теперь давайте разбираться, что необходимо сделать для запуска сервера, поддерживающего транзакции.
Первым делом, следует создать новую сеть внутри докера, в которой будут работать все серверы нашего кластера. Да, я писал выше, что сервер будет один, но сеть нужно создать обязательно, иначе ничего не получится.
docker network create mongo-cluster
Далее в параметрах запуска контейнера нужно указать использование новой сети --net mongo-cluster, а также передать параметр серверу, для работы в режиме replica set: --replSet rs0. Также, я намеренно опустил ключ --restart=always, т.к. не всегда использую MongoDB в работе в настоящее время и не хочу, чтобы она стартовала вместе с операционной системой.
docker run -v ~/mongo/:/data/db --name mongo -p 27017:27017 -d mongo mongod --smallfiles --replSet rs0
Отлично, контейнер запущен, в чем мы можем убедиться, выполнив команду docker ps и увидев примерно следующее:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2292d7e0778b mongo "docker-entrypoint.s…" About a minute ago Up About a minute 0.0.0.0:27017->27017/tcp mongo
Далее нам нужно инициализировать кластер, для этого войдём в консоль запущенного сервера, создадим конфигурацию нашего кластера и проведём инициализацию:
docker exec -it mongo mongo
# output omited #
> config = {
"_id" : "rs0",
"members" : [
{
"_id" : 0,
"host" : "mongo:27017"
}
]
}
> rs.initiate(config)
{
"ok" : 1,
"operationTime" : Timestamp(1531248932, 1),
"$clusterTime" : {
"clusterTime" : Timestamp(1531248932, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
rs0:SECONDARY>
rs0:PRIMARY>
Готово! Мы получили кластер из одного сервера MongoDB. Теперь можно проверить, что всё работает, как ожидается.
rs0:PRIMARY> session = db.getMongo().startSession()
session { "id" : UUID("7eb81006-983f-4398-adc7-5ed23e027377") }
rs0:PRIMARY> database = session.getDatabase("test")
test
rs0:PRIMARY> // Создадим несколько документов
rs0:PRIMARY> database.col.insert({name: "1"})
WriteResult({ "nInserted" : 1 })
rs0:PRIMARY> database.col.insert({name: "2"})
WriteResult({ "nInserted" : 1 })
rs0:PRIMARY> database.col.insert({name: "3"})
WriteResult({ "nInserted" : 1 })
rs0:PRIMARY> database.col.insert({name: "4"})
WriteResult({ "nInserted" : 1 })
rs0:PRIMARY> // Посмотрим, что у нас получилось
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "1" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "4" }
rs0:PRIMARY> // Начинаем транзакцию
rs0:PRIMARY> session.startTransaction()
rs0:PRIMARY> // Изменим один документ
rs0:PRIMARY> database.col.update({name: "4"}, {name: "44"})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
rs0:PRIMARY> // Проверим изменения
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "1" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "44" }
rs0:PRIMARY> // Можно открыть соседний терминал и убедиться в другой сесии, что документ выглядит по-прежнему:
rs0:PRIMARY> // { "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "4" }
rs0:PRIMARY> // Сохраняем изменения
rs0:PRIMARY> session.commitTransaction()
rs0:PRIMARY> // Проверяем результат
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "1" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "44" }
rs0:PRIMARY> // Попробуем изменить несколько документов
rs0:PRIMARY> session.startTransaction()
rs0:PRIMARY> database.col.update({name: "44"}, {name: "42"})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "1" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "42" }
rs0:PRIMARY> database.col.update({name: "1"}, {name: "21"})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "21" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "42" }
rs0:PRIMARY> session.commitTransaction()
rs0:PRIMARY> // Проверяем результат
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "21" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "42" }
rs0:PRIMARY> // А теперь убедимся, что работает отмена изменений
rs0:PRIMARY> session.startTransaction()
rs0:PRIMARY> database.col.update({name: "21"}, {name: "1"})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "1" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "42" }
rs0:PRIMARY> // Отменим изменения
rs0:PRIMARY> session.abortTransaction()
rs0:PRIMARY> database.col.find({})
{ "_id" : ObjectId("5b45026edc396f534f11952f"), "name" : "21" }
{ "_id" : ObjectId("5b450272dc396f534f119530"), "name" : "2" }
{ "_id" : ObjectId("5b450274dc396f534f119531"), "name" : "3" }
{ "_id" : ObjectId("5b450276dc396f534f119532"), "name" : "42" }
rs0:PRIMARY> // Отлично! Данные вернулись в прежнее состояние!
rs0:PRIMARY>
Таким образом, совершенно не напрягаясь, можно попробовать монговские транзакции уже сейчас без запуска многосерверного кластера. Советую заглянуть в документацию и прочитать об ограничениях транзакций. Например о том, что транзакции «живут» не более 1 минуты, если не успеть сохранить изменения, они будут отменены.
P.S.: целью данной статьи не является обучение пользованию докером или работе с монгой, а лишь быстрый способ попробовать новые инструменты этой интересной СУБД.
Автор: ivahaev