Как не разочароваться в MongoDB

Предисловие

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

А вот вам и предыстория или как я в это вляпался. Итак, достался мне проект с данной базой, архитектуры примерно такой: 2 осколка и арбитр, API интерфейс, который почему-то не пользовался возможностями кластера, а читал только с primary (нет, ну серьезно?). Помимо всего этого никто из окружения особо в данной базе не шарил, как и в принципе в NoSQL. И тут начался некий трэш, потому как все типично не работало, падало, тупило и прочее.

Установка и настройка конфига MongoDB

Но вернемся к сути статьи и начнем, пожалуй с установки, для чего нам понадобиться добавить репозиторий MongoDB (ах да, windows в данной статье рассматриваться не будет в силу не самого адекватного решения ставить её на данную ОС):

/etc/yum.repos.d/mongodb-org.repo

С содержимым:

[mongodb-org-3.4]

name=MongoDB Repository

baseurl=https://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/3.4/x86_64/

gpgcheck=1

enabled=1 gpgkey=https://www.mongodb.org/static/pgp/server-3.4.asc

Устанавливаем MongoDB (на примере CentOS 7):

yum install mongodb-org

Редактируем конфиг базы данных

/etc/mongod.conf

Полную конфигурацию можно почитать на сайте, меня же в данном случае интересует:

  • Лог mongodb (им и будем пользоваться в случае возникновения проблем)
systemLog:
  destination: file
  logAppend: true
  path: /home/mongodb/log/mongodb/mongod.log
  • Место хранения БД (на него у mongodb должны быть полные права)
storage:
  dbPath: /home/mongodb/store
  journal:
    enabled: true
  • PID файл процесса
processManagement:
  fork: true  # fork and run in background
  pidFilePath: /var/run/mongodb/mongod.pid
  • Прослушиваемый интерфейс и порт (bindIp можно закомментировать, чтобы слушать все интерфейсы)
net:
  port: 27017
  bindIp: 0.0.0.0
  • Ключ авторизации кластера (возьмем его с любого осколка, у него должен быть владелец — пользователь, от которого запущен процесс mongodb и права 0600, иначе не запустится), данные строки пока закомментируем
security:
  authorization: enabled
  keyFile: /home/mongodb/mongodb-keyfile
  • Имя репликасета, укажем и пока закомментируем
replication:
  replSetName: myreplset

Собственно конфигурация на этом закончена. Далее нам необходимо создать службу, для чего создадим в папке служб пользователя /usr/lib/systemd/user/mongodb.service или системной /etc/systemd/system/mongodb.service файл службы с содержимым:

[Unit]
Description=High-performance, schema-free document-oriented database
After=network.target

[Service]
TasksAccounting=false
TaskMax=9999
CPUAccounting=true
CPUQuota=250%
MemoryAccounting=true
User=mongodb
Environment="OPTIONS=--quiet -f /etc/mongod.conf"
ExecStart=/usr/bin/mongod $OPTIONS run
PIDFile=/var/run/mongodb/mongod.pid

[Install]
WantedBy=multi-user.target

По пунктам:

  1. служба запускается после запуска сетевых служб
  2. отключаем контроль запущенных задач, чтобы mongodb не тормозила
  3. Выставляю лимиты на процессор (2,5 ядра и еще 1,5 ядра из 4 уйдет на API и ОС), естественно по хорошему API и БД размещать на разных машинах, но уж как позволяют ресурсы.
  4. Пользователь под которым работает процесс mongodb (соответственно у него должны быть права на папки и файлы из конфига
  5. В переменные передаю файл конфига (можно изменить на другой)
  6. Указываю PID файл из конфига MongoDB

Все бы хорошо, но у пользователя mongodb не будет прав на папку /var/run (данная папка находится в RAM сервера и монтируется пустой при загрузке). Чтобы это исправить создам файл /etc/tmpfiles.d/mongodb.conf c содержимым

d /var/run/mongodb 0755 mongodb mongodb

Далее можно перезагрузить сервер или применить tmpfiles перманентно: 

systemd-tmpfiles --create mongodb.conf

Если мы не перезагружали сервер, необходимо перезагрузить systemd:

 systemctl daemon-reload

Далее запустим службу mongodb

systemctl start mongodb

Если все хорошо, можно зайти в базу данных (если нет, смотрим ошибки в логе):

mongo --port 27017

Создадим администратора БД:

db.createUser({user:"ADMIN", pwd:"ADMINPASS", roles:[{role:"root", db:"admin"}]})

И сразу выйдем из консоли:

exit

И зайдем заново уже с авторизацией:

mongo --port 28017 -u ADMIN -p ADMINPASS --authenticationDatabase admin

Теперь выберем будущую базу данных, в моем случае имя должно совпадать с тем что в остальных осколках кластера:

use mydb

И создадим для нее суперпользователя:

db.createUser( { user: "USER", pwd: "USERPASS", roles: [ "userAdminAnyDatabase", "dbAdminAnyDatabase", "readWriteAnyDatabase" ] } )
exit

Теперь можно раскомментировать строки относительно авторизации и репликасета в конфиге, после чего остановим службу:

systemctl stop mongodb

Загрузка данных с помощью добавления нового осколка в кластер

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

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

mongo --host ADDRESS --port 28017 -u USER -p USERPASS --authenticationDatabase mydb

и добавим реплику (доступный осколок с самым высоким приоритетом получит роль ptimary):

rs.add( { host: "NEWADDRESS:27017", priority: 0, votes: 0 } )

После чего можно перезапустить службу MongoDB:

systemctl restart mongodb

Загрузка данных с помощью дампа существующего осколка

Если же вариант с введением нового осколка в кластер не устраивает по какой-либо причине, то можно получить снимок данных с secondary осколка кластера утилитой mongodump, выполнив на нем:

mongodump -u USER -p USERPASS --authenticationDatabase MYDB -d MYDB --gzip --archive=/home/mongodb/backup/MYDB.tar.gz

А после восстановить на нашем «новом» осколке:

mongorestore --port 27117  -u USER -p USERPASS --authenticationDatabase MYDB -d MYDB -c Data --gzip --drop --archive=/home/mongodb/backup/MYDB.tar.gz

В данном примере я восстанавливаю только коллекцию Data (-c Data), если данный флаг опустить восстановится вся база данных, базу данных назначения (-d MYDB) также можно изменить. Помимо данных восстанавливаются также индексы. Стоит обратить внимание на параметр —drop, он удалит текущую (имя совпадает с восстанавливаемой) коллекцию в базе данных перед восстановлением, если она существует. Это нужно, т.к. mongorestore не умеет обновлять документы, а только вставляет. Т.е. документы с одинаковыми идентификаторами будут пропущены, но не обновлены. При этом «новый» осколок абсолютно не обязательно должен быть в кластере и может служить тестовой базой или в качестве резервной копии (актуальность данных в нем конечно не поддерживается). Если вы повторно собираетесь восстанавливать в него данные (например каждый день/неделю) рекомендую отключить журнал mongodb, чтобы он не раздувался при каждой загрузке дампа. Это можно сделать, изменив службу mongodb.service (добавив флаг —nojournal в переменную OPTIONS):

Environment="OPTIONS=--nojournal --quiet -f /etc/mongod.conf"

Изменение роли реплики в кластере

Чтобы изменить роль осколка в кластере необходимо изменить его приоритет. Для этого нужно открыть консоль MongoDB и воспользоваться javascript:

cfg = rs.conf()
cfg.members[3].priority = 10
rs.reconfig(cfg)

Здесь я присвоил четвертому («новому», нумерация с нуля как обычно) элементу массива rs.conf() приоритет 10 (у остальных ниже). Теперь он получил роль primary

cfg = rs.conf()
cfg.members.splice(1,1)
rs.reconfig(cfg)

Здесь я удалил второй (тот что ранее был primary) элемент массива rs.conf(), т.е. вывел его из кластера. Сами роли можно посмотреть выполнив rs.conf() соответственно.

Строка соединения с кластером MongoDB

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

mongodb://USER:PASSWORD@SERVER1:27017,SERVER2:27017/mydb?replicaSet=myreplset&readPreference=secondary

Здесь я подключаюсь ко всем осколкам кластера (SERVER1 и SERVER2), т.е. если один из них упадет — API продолжит работать с небольшим лагом на переключение роли. При этом я принудительно выставляю приоритет чтения с SECONDARY, это позволяет разгрузить PRIMARY сервер и уйти от блокировок.

Индексы решат все ваши проблемы

С развертыванием осколков вроде бы разобрались, но наши запросы в базу данных были дико медленными. Структура объекта примерно такая:

BSON{
	param1: ...,
	param2: ...,
	...
	paramN: ...,
	r_param: [
		{
			id: ...,
			val: ...
		},
		{
			id: ...,
			val: ...
		}
		...
	],
	m_param: [
		{
			id: ...,
			r_params: [
				{
					r_param: [
						{
							id: ...,
							val: ...
						},
						{
							id: ...,
							val: ...
						}
						...			
					],
					r_param: [
						{
							id: ...,
							val: ...
						},
						{
							id: ...,
							val: ...
						}
						...			
					]
					...
				}
				...
			]
		}
	]
}

Для начала стоит упомянуть про профайлер запросов, чтобы получить из него данные по длительным запросам нужно в консоли MongoDB выполнить:

db.system.profile.find()

В моем случае сам профайлер также создавал лишнюю нагрузку, поэтому я изменил его конфигурацию, чтобы он отслеживал запросы длительностью дольше 600000мс (после финальной настройки его можно снизить до 3-4 минут):

db.setProfilingLevel(1, 600000)

Вывести конфигурацию профайлера:

db.getProfilingStatus()

Также я построил пару простых индексов типа:

db.Data.createIndex( { "param1": 1 }, { unique: true, name:"param1_1", background: true } );

Параметр unique: true позволяет контролировать уникальность на уровне базы, что ранее не соблюдалось (он не будет построен, если уникальность не соблюдена в существующих данных. мне, кстати, пришлось вычистить дубликаты). Параметр background: true позволяет строить индексы без перевода базы данных в монопольный режим и оставив её в рабочем состоянии. Параметр «param1»: 1 означает, что в индексе будет проведена сортировка по возрастанию, это также влияет на скорость работы

А также составные индексы по наиболее популярным запросам:

db.Data.createIndex( { "param1": 1, "param2": 1 }, { name:"param1_1_param2_2", background: true } );

Однако этого оказалось мало, запросы поиска в массиве жутко тупили. Поэтому также были построены сложные индекс:

db.Data.createIndex( { "param1": 1, "r_param.id": 1, "r_param.val": 1 }, { name:"param1_1_r_param_id_1_r_param_val_1", background: true } );

для поиска по массиву r_param, а также индекс:

db.Data.createIndex( { "param1": 1, "m_param.id": 1, "m_param.r_params.r_param.id": 1, "m_param.r_params.r_param.val": 1 }, { name:"param1_1_m_param_r_param_1", background: true } );

для поиску внутри m_param. MongoDB самостоятельно подставляет значение из массива в индекс. Т.о. скорость поиска, который ранее выполнялся пару часов стал выполняться за секунды. Обратной стороной медали стал рост потребления оперативной памяти «горячей» базой, но он не превысил адекватных значений. А также рост занимаемого индексами места. Соответствующие запрос для последних двух индексов:

db.Data.find({"param1": ..., "r_param" : { "$elemMatch" : { "id" : ..., "val" : ... } }})
db.Data.find({"param1":..., "m_param": {"$elemMatch":{"id":..., "r_params.r_param": {"$elemMatch":{"id" : ..., "val" : ...}}}} })

Запросы в MongoDB

Ну вот мы дошли и до запросов в MongoDB. Если у вас перед носом база, о архитектуре которой вы не знаете ничего и документации у вас нет — вам поможет можно сделать поиск без параметров, чтобы получить хоть что-то:

db.Data.find({});

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

db.Data.distinct("param1");

Если у нас param1 равен, например, «чек», то можно посчитать их общее количество в базе данных запросом:

db.Data.count({"param1":"чек"});

Объект запроса в find и count может быть сложным. В процессе написания API получил предупреждение о устаревшем методе update. Потому использовал инкрементальные updateOne и updateMany для обновления одного и всех документов соответственно. Особенности тут такие:

db.Data.updateMany({"param1":"чек"}, {"$set":{"param1":"документчек", "param2":"чек"}, "$unset":{"param3":null}}, {upsert: true})

Здесь у нас строка поиска {«param1″:»чек»}, найденным документам мы меняем param1 на документчек, а param2 на чек, param3 же удаляем. $set или $unset можем опустить. Третий параметр {upsert: true} также можно опустить, он указывает на то, что если документ удовлетворяющий параметрам поиска не будет найден, то он будет добавлен в базу.

Запрос с поиском по массиву (id и val должны удовлетворять условию одновременно):

db.Data.find({"param1": ..., "r_param" : { "$elemMatch" : { "id" : ..., "val" : ... } }})

Т.е. в массиве 

[
	{
		id: 1,
		val: "a"
	},
	{
		id: 2,
		val: "b"
	},
	{
		id: 3,
		val: "a"
	}
]

При запросе 

db.Data.find({"param1": ..., "r_param" : { "$elemMatch" : { "id" : 1, "val" : "a" } }})

будет найден один (первый) элемент.

При создании уникального индекса, я указал что мне пришлось удалить дубликаты. Найти их можно агрегацией:

db.Data.aggregate([{$group:{_id:"$param1", dups:{$push:"$_id"}, count: {$sum: 1}}}, {$match:{count: {$gt: 1}}}],{ allowDiskUse: true });

Агрегация получает массив действий первым аргументом и настройки вторым.

В данном случае первый аргумент массива действий агрегации группирует ($group) документы по ключу param1, при этом создается документ типа {_id: (param1), count: (number), dups:[…]}, где count — количество документов с данным param1, а dups — массив оригинальных _id документов.

Второй аргумент массива действий агрегации отбирает из выборки документы с count > 1 (т.е. наши дубликаты).

параметр allowDiskUse позволяет при агрегации использовать ПЗУ, т.к. агрегация может быть очень затратной операцией и объема ОЗУ может быть недостаточно, как было в моем случае.

Чтобы не удалять документы вручную, допишем в нашей функции агрегации перебор документов:

db.Data.aggregate([{$group:{_id:"$param1", dups:{$push:"$_id"}, count: {$sum: 1}}}, {$match:{count: {$gt: 1}}}],{ allowDiskUse: true }).forEach(function(doc){
  doc.dups.shift();
  db.Data.remove({_id : {$in: doc.dups}});
});

В данном скрипте, мы удаляем первый элемент массива dups, а после чего удаляем документы с _id совпадающими с оставшимися в массиве dups.

Агрегацией же, например, API удаляет устаревшие документы:

db.Request.aggregate([{
	$match:{type:"hash"}
},
{
	$project:{
		difference:{$subtract:[
			Date.now(),
			{$add:["$timestamp","$timeout"]}
		]},
		remove: []
	}
},
{
	$match:{difference:{"$gt":0}}
},
{
	$group:{_id:"removable", remove:{$push:"$_id"}}
}
],
{ allowDiskUse: true }).forEach(function(doc){
	db.Request.deleteMany({_id : {$in: doc.remove}});
});

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

client.db(CONFIG.mongodb.database).collection('Request').aggregate([{
	$match:{type:"hash"}
},
{
	$project:{
		difference:{$subtract:[
			Date.now(),
			{$add:["$timestamp","$timeout"]}
		]},
		remove: []
	}
},
{
	$match:{difference:{"$gt":0}}
},
{
	$group:{_id:"removable", remove:{$push:"$_id"}}
}
],
{ allowDiskUse: true }).forEach(function(doc){
	client.db(CONFIG.mongodb.database).collection('Request').deleteMany({_id : {$in: doc.remove}});
});

Хранимые «процедуры» (функции), mongoshell

Shell MongoDB поддерживает язык javascript, поисковые запросы в нем производятся синхронно. Т.е., запрос типа:

var arr = db.Data.distinct("param1");
for(var type in arr){
  result[arr[type]] = db.Data.count({"type" : arr[param1]});
}
print(tojson(result));

вернет список всех вариантов param1 и количества документов, соответствующих им.

В базе данных MongoDB существует некоторая поддержка хранимых процедур, они находятся в db.system.js. Например у меня есть версия, генерируемая API, но также иногда необходимо генерировать версию прямо в консоли MongoDB. Для этого создадим функию myversion:

db.system.js.save({
	_id : "myversion" ,
	value : function (){ 
		var accuracy = 30;
		var ProcStartDate = new Date(Date.now());
		var version = parseInt(ProcStartDate.toJSON().replace(/[-T:.Z]/g, ""), 10);
		version = version.toString();
		if(version.length > accuracy){
  			version = version.substr(0, accuracy);
		} else if(version.length < accuracy){
  			while(version.length !== accuracy){
  				version = version+'0';
  			}
		}
		return version;
	}
});

К сожалению, я не смог найти аналог performance.now() или process.hrtime() в MongoDB для более точной версии, так что она вышла менее точна, чем в API.

Однако, если мы попытаемся выполнить myversion() выдаст ошибку, т.к. по умолчанию MongoDB не загружает сохраненные функции. Перед выполнением команды необходимо их загрузить:

db.loadServerScripts();

После этого myversion() вернет результат функции

Прочее

MongoDB назначает поле _id каждому документу и назначает на нем первичный индекс.
По умолчанию MongoDB создает значения для поля _id типа ObjectID. Это значение определено в BSON spec и структурировано таким образом:
ObjectID (12 байтов HEX string) = Дата (4 байта, значение временной метки, представляющее количество секунд с эпохи Unix) + MAC-адрес (3 байта) + PID (2 байта) + Счетчик (3 байта)

пару команд для mongoshell:

  • rs.slaveOk(); — выполнение запросов на реплике (перед запросом), если не указан приоритет чтения secondary в подключении.
  • rs.status() — вывести статус кластера
  • rs.conf() — вывести конфигурацию кластера
  • rs.help() — вывести справку по командам
  • print(val) — вывести значение val
  • tojson(val) — преобразовать строку в json
  • db.listCommands() — вывести доступные команды

Послесловие

Статья вышла несколько суховата, но моей целью и не было переписать документацию. Скорее эти «минимальный» мануал перед использованием MongoDB, чтобы не отбить себе желание пользоваться ей под нагрузкой. Ну и, конечно, мне как js-нику искренне симпатизирует консоль, в которой можно писать такое:

  • получить по одному экземпляру документа каждого типа
var docs = [];
db.Data.distinct("type").forEach(function(doc){
  	db.Data.find({"type":doc}).limit(1).forEach(function(_doc){
  		docs.push(_doc);
  	});
});
docs;

Или вытащить значение из r_param и добавить его в paramA:

db.Data.find({"param1":"Чек", "paramA":{"$exists": false}}).forEach(function(doc){
	var paramA;
	if(Array.isArray(doc.r_param)){
		for(var i = 0; i < doc.r_param.length; i++){
			if(doc.r_param[i].id === "paramA")
				paramA = doc.r_param[i].val;
		}
	}
	db.Data.updateOne({_id : doc._id}, {"$set": {"paramA": paramA}}, {upsert: false});
});