Предисловие
Решил немного упорядочить информацию в своей голове, а заодно сделать полезные заметки для себя и не только. И, пожалуй, статья — лучшее средство для этого.
А вот вам и предыстория или как я в это вляпался. Итак, достался мне проект с данной базой, архитектуры примерно такой: 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
По пунктам:
- служба запускается после запуска сетевых служб
- отключаем контроль запущенных задач, чтобы mongodb не тормозила
- Выставляю лимиты на процессор (2,5 ядра и еще 1,5 ядра из 4 уйдет на API и ОС), естественно по хорошему API и БД размещать на разных машинах, но уж как позволяют ресурсы.
- Пользователь под которым работает процесс mongodb (соответственно у него должны быть права на папки и файлы из конфига
- В переменные передаю файл конфига (можно изменить на другой)
- Указываю 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}); });