Scott Tiger Tech Blog

Blog technologiczny firmy Scott Tiger S.A.

VN:F [1.9.22_1171]
Rating: 0.0/5 (0 votes cast)

MongoDB

Wady MongoDB

Poza oczywistymi dla baz NoSQLowych (brak transakcji czy joinów):

  • Problemy z UTF-8. Na Wikipedii dla hasła MongoDB można przeczytać: "MongoDB wspiera jedynie w niewielkim stopniu kodowanie UTF-8, co stanowi poważny problem w przypadku przechowywania tekstu w języku innym niż angielski. Obecnie do sortowania łańcuchów znaków używana jest funkcja memcmp, która nie obsługuje poprawnie danych w UTF-8 w różnych ustawieniach regionalnych. Wprowadzenie zmian jest planowane, ale według twórców MongoDB wymaga to wprowadzenia szeregu poważnych modyfikacji programu, dlatego termin i wersja programu obsługująca poprawne sortowanie nie są jeszcze znane."
  • Brak wsparcia dla szyfrowania i wersjonowania danych
  • Brak perspektyw zmaterializowanych (niektóre systemy MapReduce pozwalają przechowywać wyniki w perspektywach zmaterializowanych, które muszą się tylko nieznacznie przeliczyć po dodaniu do bazy nowych danych)
  • Brak indeksów funkcyjnych

Zaczynamy

Na początek kilka podstawowych pojęć:

Kolekcja
(ang. collection) odpowiednik tabeli w relacyjnej bazie danych
Dokument
(ang. document) z grubsza odpowiednik rekordu w relacyjnej bazie danych, a dokładniej obiektu w JSONie — uporządkowany zbiór pól (par nazwa-wartość). Każdy dokument ma pole o nazwie _id z wartością będącą unikalnym identyfikatorem dokumentu w kolekcji. Dokumenty można dowolnie zagnieżdżać, tzn. wartością pola może być dokument, tablica dokumentów, itd.
Instancja
jedna instancja MongoDB może zawierać wiele baz danych, każda baza ma własne kolekcje

Nazwa pola dokumentu może zawierać dowolne znaki z wyjątkiem "$", "." i "\0".

Nazwa kolekcji nie powinna zawierać znaków "\0", "$" (ten drugi jest zarezerwowany dla kolekcji tworzonych przez sterowniki), może zawierać znak kropki (co pozwala imitować hierarchiczne przestrzenie nazw, np. blog.posts i blog.authors), i nie może zaczynać się od prefiksu "system." (kolekcje systemowe).

Poszczególne bazy danych mają osobne uprawnienia i osobne pliki na dysku.

Kilka baz danych ma szczególne znaczenie:

admin
każdy użytkownik tej bazy dziedziczy prawa dostępu dla pozostałych baz; poza tym niektóre polecenia MongoDB mogą być wykonywane tylko z poziomu tej bazy danych, np. wyświetlenie listy wszystkich baz danych czy wyłączanie serwera (ang. shutting down)
local
ta baza nigdy nie podlega replikacji, jest ograniczona do jednego serwera
config
gdy korzystamy z shardingu (będzie o tym mowa przy rozpraszaniu bazy), tutaj znajduje się konfiguracja shardów

Domyślnie serwer MongoDB nasłuchuje na porcie 27017. Na porcie o numerze o 1000 większym, tj. 28017 znajduje się prosty serwer HTTP pozwalający uzyskać podstawowe statystyki pracy serwera.

Dokumenty w kolekcjach MongoDB mają postać JSONa, ale wzbogaconego o kilka dodatkowych, użytecznych typoów danych. Tak więc oprócz typów null, boolean, string (UTF-8), number, array i object (jak w każdym JSONie, można zagnieżdżać dokumenty!) mamy dodatkowo:

NumberInt4-bajtowa liczba całkowita, np. x = NumberInt("3")
NumberLong8-bajtowa liczba całkowita, np. x = NumberLong("3")
Datedata i czas przechowywane wewnętrznie jako liczba milisekund od początku epoki, bez strefy czasowej. Przykłady użycia: x = ISODate("2013-02-25T16:01:50.742Z"), x = new Date() (bieżąca data i czas).
Regexwyrażenie regularne, np. /foobar/i
ObjectId12-bajtowa wartość generowana specjalnie z myślą o unikalnej (globalnie, w całym systemie rozproszonym) identyfikacji dokumentu w kolekcji (preferowany typ dla pola _id) i tworzeniu optymalnego drzewa indeksu; pierwsze 4 bajty zawierają stempel czasowy (z dokładnością do sekundy), potem 3 bajty identyfikacji maszyny na której powstał identyfikator, 2 bajty PIDu procesu który utworzył identyfikator, a na końcu 3 bajty inkrementowane przy generowaniu każdej kolejnej wartości — większość sterowników pozwala łatwo ekstrahować wymienione informacje z wartości typu ObjectId
BinaryWartością pola dokumentu może być np. zdjęcie. Danych binarnych nie da się utworzyć z poziomu shella mongo, jedynie z poziomu sterowników języków programowania.
FunctionW dokumentach możemy trzymać również funkcje, np. x = function() { /* ... */ }

Gdy wstawiany do kolekcji dokument nie zawiera pola _id, jest ono generowane automatycznie (przez klienta, np. sterownik Javy).

Klient wiersza poleceń

Standardowego klienta wiersza poleceń, który jest zasadniczo lekko zmodyfikowanym interpreterem języka JavaScript, uruchamiamy poleceniem:

$ mongo [--host host] [--port port] [-u username] [dbname]

Można też:

$ mongo host:port/dbname

Ponieważ domyślny host to localhost, port to 27017, uwierzytelnianie jest wyłączone, a domyślna nazwa bazy danych to test, na ogół po zainstalowaniu serwera na laptopie możemy uruchomić klienta po prostu poleceniem

$ mongo

Połączenie do bieżącej bazy danych znajduje się w zmiennej db, a przełączenie do innej bazy następuje po wydaniu polecenia use nazwa:

> db test
> use foobar
> db foobar

Przejście na inny serwer sprowadza się do wykonania poleceń:

> conn = new Mongo( "some-host:30000" );
> db = conn.getDB( "myDB" )

TIP: Trzykrotne naciśnięcie klawisza Enter powoduje przerwanie wprowadzania wielowierszowego polecenia i przejście do pętli głównej.

Polecenie help pozwala poznać pierwsze polecenia, takie jak show dbs czy show collections.

Polecenie db.help() wyświetla dostępne polecenia dla bieżącej bazy danych, a db.mycoll.help() — dla wskazanej kolekcji.

Jeśli pamiętamy nazwę metody, ale chcemy sobie przypomnieć jakie ma parametry lub co robi, to wystarczy podać jej nazwę bez nawiasów — wyświetlone zostaną źródła funkcji, wypróbuj np. polecenie db.mycoll.update.

Aby wykonać własne skrypty JavaScript zawarte w plikach, wykonujemy:

$ mongo --quiet host:port/dbname script1.js script2.js

Z wnętrza skryptów mamy dostęp do zmiennej db, możemy wykonać dowolny skrypt poleceniem load("myfile.js"), ale nie mamy dostępu do poleceń pomocniczych typu show collections (tzw. helpers), które trzeba zapisywać inaczej:

HelperOdpowiednik
use foodb.getSisterDB( "foo" )
show dbsdb.getMongo().getDBs()
show collectionsdb.getCollectionNames()

Dowolne polecenie linuksowego shella uruchamiamy tak:

> run( "cat", "/etc/passwd" )

W pliku ~/.mongorc.js możemy umieścić dowolny kod JavaScriptu uruchamiany na starcie klienta mongo. Na przykład możemy sobie przedefiniować funkcję wyświetlającą znak zachęty:

prompt = function() {
  if (typeof db == 'undefined') { return '(nodb)> '; }
  // Check the last db operation
  try {
    db.runCommand({getLastError:1});
  } catch (e) { print(e); }
  return db+"> ";
};

Nawiasem mówiąc zdecydowanie warto wywoływać getLastError po pierwsze żeby poznać ewentualne błędy wykonania poprzedniego polecenia, a po drugie automatycznie zestawia utracone połączenie do bazy gdy ta np. zostanie zrestartowana.

Do edycji dłuższych poleceń warto zdefiniować sobie zewnętrzny edytor:

> EDITOR="/usr/bin/emacs"
> var wap = db.books.findOne( { title: "War and Peace" } );
> edit wap

Tworzenie, modyfikacja i usuwanie dokumentów

Aby wstawić do kolekcji dokument:

> db.foo.insert( { "bar": "baz" } );

Wiele dokumentów naraz:

> db.foo.batchInsert( [ { "_id": 0 }, { "_id": 1 }, { "_id": 2 } ] );

Wstawiany dokument nie może być większy niż 16 MB.

Aby usunąć wybrane dokumenty, używamy metody remove z zapytaniem — usunięte zostaną elementy pasujące do wzorca. Brak zapytania oznacza usunięcie wszystkich elementów kolekcji. Nie ma możliwości wycofania operacji usuwania (w MongoDB nie ma transakcji!).

> db.foo.remove( { "opt-out": true } );

Takie usuwanie może potrwać. Jeśli chcemy szybko usunąć po prostu całą kolekcję, wystarczy napisać:

> db.foo.drop();

Polecenie modyfikacji rekordów update pobiera dwa parametry: zapytanie selekcjonujące dokumenty do modyfikacji, i dokument opisujący jakie operacje modyfikacji przeprowadzić. Modyfikacja pojedynczego dokumentu jest atomowa: jeśli dwa połączenia z bazą danych będą probowały jednocześnie zmodyfikować ten sam dokument, to efekt będzie taki jakby modyfikacje poszczególnych klientów zostały uszeregowane w jakiejś kolejności.

> joe = db.people.findOne( { "name": "joe", "age": 20 } );
{
  "_id": ObjectId( "4b29f572822ac5263ea" ),
  "name": "joe",
  "age": 20
}
> joe.age++;
> db.people.update( { "_id": ObjectId( "4b29f572822ac5263ea" ) }, joe )

Zwykle chcemy zmodyfikować fragment dokumentu, np. zwiększyć wartość jakiegoś pola o 3. Służą do tego modyfikatory aktualizacji (ang. update modifiers) o nazwach zaczynających się znakiem dolara:

> db.analytics.update( { "url": "www.example.com" }, { "$inc": { "pageviews": 3 } } )
> db.users.update( criteria,
  {"$set": {"favorite book": "War and Peace"}})
ustawia wartość wskazanego pola (tworzy je, jeśli nie ma)
> db.users.update( criteria,
  {"$unset": { "favorite book": 1 } } )
usuwa pole
> db.games.update( criteria,
  {"$inc": { "score": 50 } } )
inkrementuje wartość pola
> db.stock.update( criteria,
  { "$push": { "hourly": 562.102 }})
dodaje element na końcu tablicy w polu comments
> db.x.update( criteria,
  {
    "$push": {
      "hourly": {
        "$each": [562.776, 562.790, 559.123],
      }
  }})
j.w., ale dodaje wiele elementów naraz i ogranicza wielkość tablicy do 10 ostatnich elementów
> db.movies.update({"genre": "horror"},
  {
    "$push": {
      "top10": {
        "$each": [{"name": "Poltergeist",
                   "rating": 6.6},
                  {"name": "Saw",
	           "rating": 4.3}],
        "$sort": {"rating": -1},
        "$slice": -10
      }
  }})
j.w., ale po uzupełnieniu tablicy sortuje ją malejąco wg wartości pola rating i usuwa wszystkie elementy z wyjątkiem ostatnich 10
> db.users.update( criteria,
  {"$addToSet": {"emails": "joe@hotmail.com"}})
dodaje element do tablicy w polu emails traktowanej jako zbiór
> db.stock.update( criteria,
  {"$pop": {"hourly": 1}})
usuwa element z końca tablicy (-1 usunąłby z początku)
> db.users.update( criteria,
  {"$pull": {"emails": "joe@hotmail.com"}})
usuwa element tablicy o zadanej wartości

Dość typowy błąd: poniższy zapis nie ustawi wybranego pola, lecz zastąpi cały dokument dokumentem zawierającym jedno pole:

> db.coll.update( criteria, {"foo": "bar"})

Załóżmy że dokumenty w kolekcji mają postać:

> db.blog.posts.findOne()
{
  "_id" : ObjectId("4b329a216cc613d5ee930192"),
  "content" : "...",
  "comments" : [
    {
      "comment" : "good post",
      "author" : "John",
      "votes" : 0
    },
    {
      "comment" : "i thought it was too short",
      "author" : "Claire",
      "votes" : 3
    },
    {
      "comment" : "free watches",
      "author" : "Alice",
      "votes" : -1
    }
  ]
}

Aby zmienić wartość pola votes pierwszego komentarza:

> db.blog.update({"post": post_id}, {"$inc": { "comments.0.votes": 1}})

Gorzej, jeśli chcemy zmodyfikować komentarze spełniające pewne warunki, np. napisane przez konkretnego autora. Nie znamy wtedy indeksu modyfikowanego poddokumentu, i w tej sytuacji używamy pozycjonalnego operatora "$":

> db.blog.update({"comments.author": "John"},
  {"$set": {"comments.$.author": "Jim"}})

Operacja upsert działa jak zwykły update z wyjątkiem sytuacji gdy nie znaleziono rekordów spełniających kryteria: wtedy tworzony jest nowy dokument i natychmiast poddawany modyfikacji. Dzięki temu upraszczamy sobie kod, a dodatkowo sprawdzenie i modyfikacja wykonuje się atomowo. Składniowo, upsert różni się od update'a dodatkowym, trzecim parametrem:

> db.analytics.update({"url": "/blog"}, {"$inc": {"pageviews": 1}}, true)

Modyfikator $setOnInsert ustawia wartość tylko w przypadku gdy tworzony jest nowy dokument:

> db.users.update(criteria, {"$setOnInsert": {"createdAt": new Date()}}, true)

Innym sposobem na utworzenie lub modyfikację dokumentu jest funkcja save:

> var x = db.foo.findOne()
> x.num = 42
> db.foo.save( x )

Uwaga: funkcja update modyfikuje tylko pierwszy spełniający warunki dokument. Jeśli chcemy aby zmodyfikowane były wszystkie, należy użyć czwartego parametru z wartością true. Ponadto w przyszłych wersjach MongoDB warto sprawdzić jakie jest domyślne zachowanie funkcji update i jak je modyfikować, ponieważ zapowiadane są zmiany.

Aby dowiedzieć się ile rekordów zostało zmodyfikowanych, należy skorzystać z getLastError:

> db.runCommand({getLastError: 1})
{
  "err": null,
  "updatedExisting": true,
  "n": 5,
  "ok": true
}

Z wygody, ale również ze względu na wyścigi wątków, dobrze byłoby mieć możliwość jednoczesnego zmodyfikowania dokumentów i pobrania ich. Służy do tego polecenie findAndModify:

> ps = db.runCommand( {"findAndModify": "processes",
  "query": {"status": "READY"},
  "sort": {"priority": 1},
  "update": {"$set": {"status": "RUNNING"}})

Zamiast klauzuli update możemy dać delete aby jednocześnie usunąć wybrane dokumenty z kolekcji i pobrać je. Inne ciekawe klauzule to:

newCzy zwracać rekordy sprzed modyfikacji czy po; domyślnie false.
fieldsKtóre pola dokumentów zwracać (opcjonalne).
upsertCzy to ma być operacja upsert; domyślnie false.

Z każdą rozproszoną bazą danych wiąże się problem pewności udanego zapisu (ang. write concern). Na ogół interesuje nas jakaś forma potwierdzenia przez serwer ostatniego zapisu przed wysłaniem kolejnego, z wyjątkiem mało istotnych zapisów takich jak np. logi niskiego poziomu. Wykonanie polecenia db.getLastError() pobiera status ostatnio wykonanej operacji i ewentualny kod błędu, tym samym potwierdzając ją gdy zakończyła się sukcesem. Klient wiersza poleceń mongo wykonuje getLastError przed każdym wyświetleniem znaku zachęty.

Zapytania

Zasadniczo zapytanie to dokument-wzorzec dokumentów których szukamy. Jeśli podamy wiele pól, domyślnie używany jest operator "AND". Pusty dokument "{}" dopasowuje wszystkie elementy kolekcji.

> db.users.find({"username": "joe", "age": 27})

Metoda findOne, w odróżnieniu od find, zwraca tylko pierwszy znaleziony dokument.

Jeżeli nie chcemy dostawać w wyniku wszystkich pól dokumentów, możemy określić które pola interesują nas w wyniku:

> db.users.find({}, {"username": 1, "email": 1, "_id": 0})

Wartość 0 oznacza, że nie życzymy sobie danego pola w wyniku. Pole _id jest szczególne, ponieważ zawsze jest zwracane o ile jawnie go nie wykluczymy. Poniższy przykład zwróci dokumenty z wszystkimi polami z wyjątkiem pola username:

> db.users.find({}, {"username": 0})

Zapytanie o wartość null:

> db.users.find({"comment": null})

zwróci zarówno te dokumenty w których pole ma wartość null, jak i te w których w ogóle nie ma tego pola.

Jeśli chcemy koniecznie to rozróżnić i pominąć dokumenty z nieistniejącym polem comment, możemy napisać tak:

> db.users.find({"comment": {"$in": [null], "$exists": true}})

Jeśli wartością pola jest łańcuch, możemy używać wyrażeń regularnych zgodnych z PCRE (Perl Compatible Regular Expression):

> db.users.find({"name": /^joey?/i})
(Uwaga: MongoDB potrafi w zapytaniu z zakotwiczonym wyrażeniem regularnym skorzystać z indeksu tylko pod warunkiem że wyrażenie nie zawiera modyfikatora "i", tj. case insensitive).

W zapytaniach można używać operatorów, których nazwy — jak się pewnie domyślasz — zaczynają się od znaku dolara: "$".

> db.users.find({"age": {"$gte": 18, "$lt": 30}}})
operatory >= i <; można stosować nie tylko do liczb, ale też np. dat
> db.users.find({"username": {"$ne": "joe"}})
operator !=
> db.users.find({"user_id": {"$in": [725, "joe"]}})
wartość należy do zbioru; przeciwieństwem jest operator $nin
> db.raffle.find({"$or": [
    {"ticket_no": {"$not": {"$in": [725, 542, 390]}}},
    {"winner": true}
  ]})
Operatory OR i NOT

Gdy pytamy o pola zagnieżdżonych dokumentów, posługujemy się notacją kropkową:

> db.people.find({"name.first": "Joe", "name.last": "Schmoe"})

Tablice

Wyobraźmy sobie pole z wartością tablicową:

> db.food.insert({"fruit": ["apple", "banana", "peach"]})

Poniższe zapytanie znajdzie ten dokument:

> db.food.find({"fruit": "banana"})

Przy pytaniu o dozwolony zakres wartości elementu tablicy użyteczny będzie operator $elemMatch:

> db.test.find({"x": {"$elemMatch": {"$gt": 10, "$lt": 2}}})

Jeśli chcemy dopasować dokument na podstawie więcej niż jednej wartości tablicy, używamy operatora $all:

> db.food.find({"fruit": {"$all": ["apple", "banana"]}})

Gdybyśmy chcieli dopasować konkretny element tablicy, napisalibyśmy tak (elementy numerujemy od 0):

> db.food.find({"fruit.2": "peach"})

Możemy też zapytać o wartości tablicowe określonej wielkości:

> db.food.find({"fruit": {"$size": 3}})

Wiemy już jak w wyniku zapytania zwracać tylko niektóre pola dokumentów. W przypadku gdy wartością pola jest tablica, możemy zażyczyć sobie zwrócenia tylko jej wycinka:

> db.food.find({"fruit": {"$slice": 2}})

Wartość dodatnia N oznacza pierwszych N elementów tablicy, ujemna — ostatnich, a wartość [1,2] zwróci tablicę dwóch elementów począwszy od tego na pozycji 1.

W końcu, możemy chcieć wyszukać te elementy tablicy, które spełniają określone kryteria. Załóżmy że mamy kolekcję postów bloga, każdy post ma pole comments z komentarzami. Komentarz jest poddokumentem z polami author, email i content:

> db.blog.posts.find({"comments.name": "bob"}, {"comments.$": 1})
{
  "_id" : ObjectId("4b2d75476cc613d5ee930164"),
  "comments" : [
    {
      "name" : "bob",
      "email" : "bob@example.com",
      "content" : "good post."
    }
  ]
}

Klauzula $where

Dotychczas przedstawiona składnia jest dość elastyczna, ale czasami nie wystarcza. Wtedy trzeba sięgnąć po klauzulę $where, która realizuje dopasowanie dokumentu na podstawie funkcji JavaScriptu dostarczonej przez użytkownika. Powiedzmy, że chcemy wyszukać takie dokumenty, w których dwa różne pola mają tę samą wartość:

> db.foo.find({
  "$where": function() {
    for( var current in this ) {
      for( var other in this ) {
	if( current != other && this[current] == this[other]) {
	  return true;
	}
      }
    }
    return false;
  }})

Należy zdecydowanie podkreślić, że do klauzuli $where należy się uciekać w ostateczności, ponieważ jest ona mało wydajna: każdy dokument trzeba przekonwertować z formatu BSON do JSON i uruchomić na nim funkcję JavaScriptu.

Kursory

Wynik polecenia find jest kursorem. Jeśli w kliencie wiersza poleceń mongo nie przypiszemy wyniku do żadnej zmiennej, shell sam przeiteruje kursor i wypisze wyniki — jest to zachowanie z którym mieliśmy do czynienia do tej pory. Kursory pozwalają w dużym stopniu kontrolować sposób odbierania wyników zapytania. I tak, można ograniczać liczbę wynikowych dokumentów, opuszczać niektóre, sortować itp.

> var cursor = db.collection.find();
> while( cursor.hasNext() ) {
  obj = cursor.next();
  // zrob cos z obj
}

Alternatywnie, możemy skorzystać z forEach:

cursor.forEach( function( obj ) {
  print( obj.name );
}

Samo wywołanie find nie wykonuje jeszcze zapytania, wstrzymując się do chwili gdy zażądamy pierwszego wynikowego dokumentu (wywołamy cursor.hasNext()). Takie podejście pozwala do wywołania find dołączyć (ang. chain) inne metody modyfikujące zachowanie kursora:

> var cursor = db.foo.find().sort({"x": 1}).limit(1).skip(10);

Przy okazji: unikaj skip z dużymi wartościami, zamiast tego staraj się inaczej sformułować zapytanie tak aby korzystało z indeksów. Wiele baz danych trzyma dodatkowe metadane w indeksach, które pozwalają obsłużyć skip bardziej efektywnie, ale MongoDB póki co nie robi tego.

Aby wymusić zwracanie przez kursor dokumentów w kolejności ich występowania na dysku, można zastosować tzw. sortowanie naturalne:

> db.foo.find().sort({"$natural": 1})

Dość częsty sposób przetwarzania dokumentów w kolekcji polega na wyciąganiu ich, modyfikacji która może zmienić rozmiar dokumentu na dysku, i zapisywaniu z powrotem. Rozważmy taki kawałek kodu w skrypcie:

var cursor = db.foo.find();
while( cursor.hasNext() ) {
  var doc = cursor.next();
  doc = process( doc );
  db.foo.save( doc );
}

Problem polega na tym, że dla dużego zbioru wynikowego jest spora szansa, że... będą dokumenty które przetworzymy wielokrotnie. Dlaczego? Bo po zmianie rozmiaru i ponownym zapisie dokument zostanie usunięty z dotychczasowego miejsca na dysku w środku kolekcji i przeniesiony na koniec kolekcji, gdzie jest wolne miejsce na dysku. Kursor który przegląda kolejne dokumenty, w końcu dojdzie i tam, a więc ponownie przeczyta dokument który już był przetworzony. Aby tego uniknąć, należy wykonać zapytanie migawkowe:

db.foo.find().snapshot()

Dzięki temu zapytanie będzie wykorzystywało unikalny indeks na polu _id, co gwarantuje zajrzenie do każdego dokumentu dokładnie raz. Wadą takiego rozwiązania jest to, że jest ono wolniejsze w porównaniu z poprzednim.

Uwaga: Typowy kursor obsługiwany z poziomu skryptu klienta (np. PHP) po nieaktywności trwającej dłużej niż 10 minut jest zamykany, nawet jeśli nie wyciągnie wszystkich wyników. Jest to zabezpieczenie przed niewłaściwie zaimplementowanymi klientami. Jeśli nie życzymy sobie takiego zachowania, możemy utworzyć kursor z opcją immortal, która wyłącza timeout i aby zwolnić zasoby kursora musimy przeiterować go do końca lub jawnie zamknąć.

Agregacja

Zapytania pozwalają wyselekcjonować dokumenty o pożądanych własnościach, ale istnieją jeszcze inne sposoby na analizę i przetwarzanie dokumentów bazodanowych: Aggregation Framework (AF) i MapReduce (MR).

Aggregation Framework

AF pozwala transformować dokumenty w strumieniu bloków, na które składają się: filtrowanie, projekcja (ang. projection), grupowanie, sortowanie, obcinanie (ang. limiting) i pomijanie (ang. skipping).

Załóżmy, że w bazie trzymamy posty bloga pisane przez różnych autorów — chcemy poznać 5 najbardziej płodnych. Strumień zawierałby 4 bloki:

  • Projekcja wyciąga z postów samych autorów.
  • Grupowanie wylicza posty każdego autora.
  • Sortowanie porządkuje malejąco autorów po liczbie postów.
  • Obcinanie ogranicza wynik do 5 pierwszych autorów na otrzymanej do tej pory liście.
> db.articles.aggregate( { "project": { "author": 1 } },
    { "$group": { "_id": "$author", "count": { "$sum": 1 } } },
    { "$sort": { "count": -1 } },
    { "$limit": 5 } );

Wynik mógłby wyglądać mniej więcej tak:

{
  "result" : [
    {
      "_id" : "R. L. Stine",
      "count" : 430
    },
    {
      "_id" : "Edgar Wallace",
      "count" : 175
    },
    {
      "_id" : "Nora Roberts",
      "count" : 145
    },
    {
      "_id" : "Erle Stanley Gardner",
      "count" : 140
    },
    {
      "_id" : "Agatha Christie",
      "count" : 85
    }
  ],
  "ok" : 1
}

Każdy blok (operator) naszego strumienia otrzymuje na wejściu strumień dokumentów i wykonuje na nich jakąś transformację. Operatory każdego typu mogą być łączone w dowolnych ilościach i w dowolnym porządku, wedle uznania.

Operator filtrowania $match ma za zadanie wybrać podzbiór dokumentów na wejściu i ma taką składnię jak zwykłe zapytanie, np. {$match:{ "woj": "Mazowieckie" }}. Generalnie warto umieszczać filtrowanie jak najwcześniej w strumieniu, aby maksymalnie ograniczyć ilość przetwarzanych dalej dokumentów. Wynika to z faktu, że po wykonaniu dalszych przekształceń tracimy możliwość korzystania z indeksów.

Operator projekcji $project daje dużo większe możliwości niż typowe zapytanie — pozwala wyciągnąć pojedyncze pola z zagnieżdżonych dokumentów, zmieniać ich nazwy i wykonywać na nich wyrażenia np. łańcuchowe, matematyczne albo nawet warunkowe.

Poniższe zapytanie wyciągnie pole _id przemianowując je na userId (składnia $nazwa_pola pozwala odwołać się do wartości pola):

> db.users.aggregate( { "$project": { "userId": "$_id", "_id": 0 } } )

Wyrażenia projekcji:

Matematyczne
"$add": [expr1[, expr2, ..., exprN]]suma wyrażeń
"$subtract": [expr1, expr2]różnica wyrażeń
"$multiply": [expr1[, expr2, ..., exprN]]iloczyn wyrażeń
"$divide": [expr1, expr2]iloraz wyrażeń
"$mod": [expr1, expr2]reszta z dzielenia wyrażeń
Łańcuchowe
"$substr": [expr, startOffset, numToReturn]zwraca podciąg expr, począwszy od pozycji startOffset, o długości numToReturn
"$concat": [expr1[, expr2, ..., exprN]]konkatenacja łańcuchów
"$toLower": exprzamiana liter na małe
"$toUpper": exprzamiana liter na wielkie
Logiczne
"$cmp": [expr1, expr2]porównuje dwa wyrażenia i zwraca 0 gdy równe, -1 gdy expr1 < expr2, 1 wpp.
"$strcasecmp": [string1, string2]jak $cmp, ale z pominięciem wielkości liter w łańcuchach
"$eq"/"$ne"/"$gt"/"$gte"/"$lt"/"$lte": [expr1, expr2]porównanie wyrażeń, wynik typu logicznego
"$and": [expr1[, expr2, ..., exprN]]iloczyn logiczny wyrażeń logicznych
"$or": [expr1[, expr2, ..., exprN]]suma logiczna wyrażeń logicznych
"$not": exprnegacja logiczna wyrażenia logicznego
"$cond": [boolExpr, trueExpr, falseExpr]zwraca trueExpr gdy boolExpr ewaluuje do true, falseExpr wpp.
"$ifNull": [expr, replacementExpr]zwraca replacementExpr gdy expr ewaluuje do null; w przeciwnym wypadku zwraca expr
Data i czas
"$year"/"$month"/"$week" dateExpr
"$dayOfMonth"/"$dayOfWeek" dateExpr
"$dayOfYear"/"$hour" dateExpr
"$minute"/"$second" dateExpr
zwraca składową daty w postaci liczby całkowitej

Niestety wyrażenia są bardzo wrażliwe na nieprawidłowy typ danych lub brak danego pola w konkretnym rekordzie, stąd bardzo użyteczne w praktyce są wyrażenia $cond i $ifNull.

Przykład użycia projekcji:

> db.employees.aggregate({
  "$project": {
    "wiek": {
      "$subtract": [{"$year": new Date()}, {"$year": "$data_urodzenia"}]
    },
    "email" {
      "$concat": [
        { "$substr": ["$imie", 0, 1] },
        ".",
        "$nazwisko",
        "@example.com"
      ]
    }
  }
});

Operator grupowania $group pozwala pogrupować dokumenty w oparciu o wartości wybranych pól, i dla każdej grupy wyliczyć pewne wartości (najprostsza to np. suma dokumentów w grupie). Każda grupa jest dokumentem z polem _id i innymi utworzonymi wedle uznania. W poniższym przykładzie grupujemy po wartości pola country i dla każdej grupy tworzymy pola totalRevenue ze średnią wartości pola revenue dokumentów w grupie, oraz numSales z sumą dokumentów w grupie:

> db.sales.aggregate( {
  "$group": {
    "_id": "$country",
    "totalRevenue": { "$average": "$revenue" },
    "numSales": { "$sum": 1 }
  }
});

W przypadku gdybyśmy chcieli grupować po wartościach więcej niż jednego pola, zapisalibyśmy to tak:

{ "$group": { "_id": { "state": "$state", "city": "$city" } } }

Inne wyrażenia oprócz $average i $sum to:

Arytmetyczne
"$sum": exprdla każdego dokumentu, dodaje expr do wyniku
"$avg": exprwylicza średnią arytmetyczną dla wartości argumentu
Ekstrema
"$max"/"$min": exprwartość maksymalna/minimalna
"$first"/"$last": exprpierwsza/ostatnia wartość w grupie (ma sens gdy porządek jest znany, np. po sortowaniu)
Tablicowe
"$addToSet": exprdodaje wartość do akumulatora-zbioru reprezentowanego w postaci tablicy
"$push": exprdodaje wartość do akumulatora-tablicy (dopuszczalne są duplikaty)

Operator $unwind przekształca zagnieżdżoną w dokumencie tablicę poddokumentów w oddzielne dokumenty. Wyobraźmy sobie dokument reprezentujący posta na blogu z polem comments będącym tablicą komentarzy.

> db.blog.findOne();
{
  "_id" : ObjectId("50eeffc4c82a5271290530be"),
  "author" : "k",
  "post" : "Hello, world!",
  "comments" : [
    {
      "author" : "mark",
      "date" : ISODate("2013-01-10T17:52:04.148Z"),
      "text" : "Nice post"
    },
    {
      "author" : "bill",
      "date" : ISODate("2013-01-10T17:52:04.148Z"),
      "text" : "I agree"
    }
  ]
}
> db.blog.aggregate( { "$unwind": "$comments" } );
{
  "results" : [
    {
      "_id" : ObjectId("50eeffc4c82a5271290530be"),
      "author" : "k",
      "post" : "Hello, world!",
      "comments" : {
        "author" : "mark",
        "date" : ISODate("2013-01-10T17:52:04.148Z"),
        "text" : "Nice post"
      }
    },
    {
      "_id" : ObjectId("50eeffc4c82a5271290530be"),
      "author" : "k",
      "post" : "Hello, world!",
      "comments" : {
        "author" : "bill",
        "date" : ISODate("2013-01-10T17:52:04.148Z"),
        "text" : "I agree"
      }
    }
  ],
  "ok" : 1
}

Operator $unwind jest szczególnie użyteczny gdy chcemy operować na wybranych zagnieżdżonych poddokumentach dokumentu zwróconego przez zapytanie. Zwykłe zapytanie nie jest w stanie zwrócić wybranych komentarzy posta i tylko tych komentarzy.

Tak więc załóżmy że chcemy operować na komentarzach (a nie postach!), których autorem jest Marek:

> db.blog.aggregate( { "$project": { "comments": "$comments" } },
      { "$unwind": "$comments" },
      { "$match": { "comments.author": "Mark" } } );

Przykład działania pozostałych operatorów $sort, $skip (offset) oraz $limit (zwracamy max 10 rekordów, począwszy od 50-tego):

> db.employees.aggregate( {
    "$project": {
      "compensation": {
        "$add": [ "$salary", "$bonus" ]
      },
      "name": 1
    }
  },
  { "$sort": { "compensation": -1, "name": 1 } },
  { "$skip": 50 },
  { "$limit": 10 } );

Polecenia agregujące

Poniżej wymienione polecenia są historycznie wcześniejsze niż Aggregation Framework i mają mniejsze możliwości, ale często pozwalają na bardziej zwięzły zapis.

Liczba wyników zapytania:

> db.foo.count( { "x": 1 } );

Zbiór wszystkich wartości danego pola:

> db.runCommand( { "distinct": "people", "key": "age" } );
{ "values": [20, 35, 60], "ok": 1 }

Grupowanie:

> db.runCommand( {
  "group": {
    "ns": "stocks",
    "key": "day",
    "initial": { "time": 0 },
    "$reduce": function( doc, acc ) {
      if( doc.time > acc.time ) { acc.price = doc.price; acc.time = doc.time; }
    },
    "condition": { "day": { "$gt": "2010/09/30" } }
  } } } );

Znaczenie tej konstrukcji jest następujące: dokumenty kolekcji stocks spełniające warunek condition grupuj po polu day. Dla każdej grupy utwórz dokument zainicjowany w klauzuli initial i modyfikowany dla każdego dokumentu grupy w klauzuli $reduce.

Grupowania można dokonać nie tylko w oparciu o wartość wskazanego pola, ale również w oparciu o wynik funkcji, np.:

"$keyf": function( doc ) { return x.category.toLowerCase(); }

W grupowaniu dostępna jest jeszcze klauzula finalize: jest to funkcja wołana dla każdej grupy przed ostatecznym zwróceniem odpowiedzi użytkownikowi i to wynik tej funkcji jest zwracany dla każdej grupy.

MapReduce

MR to najbardziej zaawansowany mechanizm przetwarzania zawartości bazy danych, ale jednocześnie najwolniejszy.

Na proces MR składa się kilka etapów. Na etapie mapowania (ang. map) funkcja użytkownika wywoływana dla każdego dokumentu emituje dowolną liczbę (0 lub więcej) par klucz-wartość. W efekcie otrzymujemy olbrzymią ilość par klucz-wartość, przy czym klucz często powtarza się w wielu parach. Na etapie tasowania (ang. shuffle) pary o tym samym kluczu są agregowane w parę klucz-tablica_wartości. Funkcja redukcji (ang. reduce) napisana przez użytkownika pobiera na wejściu parametry klucz, tablica_wartości i redukuje tablicę do jednego elementu. Funkcja reduce musi mieć taką własność, że jej wynik może być użyty jako element tablicy przekazanej jako drugi argument do kolejnego wywołania reduce.

Czas na przykład. Powiedzmy że chcemy poznać listę wszystkich nazw kluczy we wszystkich dokumentach kolekcji, wraz z liczebnością każdego klucza (w ilu dokumentach występuje dany klucz). Funkcja map wygląda następująco:

> mapFun = function() {
  for( var key in this ) {
    emit( key, { count: 1 });
  }
};

Natomiast reduce wygląda tak:

> reduceFun = function( key, emits ) {
  total = 0;
  for( var i in emits ) {
    total += emits[i].count;
  }
  return { "count": total };
};

Wywołanie procesu MR:

> mr = db.runCommand( { "mapreduce": "collName", "map": mapFun, "reduce": reduceFun } );

Przykładowy rezultat:

{
  "result": "tmp.mr.mapreduce_1266787627382_1",
  "timeMillis": 12,
  "counts": {
    "input": 6,
    "emit": 14,
    "output": 5
  },
  "ok": true
}

Oznacza on, że wynik obliczeń umieszczony został w tymczasowej kolekcji tmp.mr.mapreduce_1266787627382_1 (zostanie ona usunięta po zamknięciu połączenia przez proces który wywołał MR), obliczenia trwały 12 milisekund, funkcja map została uruchomiona na 6 dokumentach i wygenerowała 14 par klucz-wartość, natomiast kolekcja wynikowa zawiera 5 elementów. Obejrzyjmy je:

> db[mr.result].find();
{ "_id": "_id", "value": { "count": 6 } }
{ "_id": "a", "value": { "count": 4 } }
{ "_id": "b", "value": { "count": 2 } }
{ "_id": "x", "value": { "count": 1 } }
{ "_id": "y", "value": { "count": 1 } }

W wywołaniu MR oprócz wspomnianych już parametrów mapreduce, map i reduce można jeszcze użyć innych:

"finalize": functionfunkcja wołana na wyniku ostatniego wykonania reduce
"keeptemp": booleanczy wynikowa kolekcja ma zostać po zamknięciu połączenia z bazą (domyślnie nie)
"out": stringnazwa kolekcji wynikowej, domyślnie jest generowana
"query": stringzapytanie, którego wynik zostanie skierowany do MR; domyślnie MR wykonuje się na wszystkich dokumentach kolekcji
"sort": documentspecyfikacja sortowania dokumentów przed wykonaniem na nich map
"limit": integerograniczenie liczby dokumentów na których wykonana zostanie funkcja map
"scope": documentzmienne których można używać we własnym kodzie JavaScript
"verbose": booleanczy generować w logach dodatkowe informacje diagnostyczne podczas wykonywania procesu MR

W funkcjach map, reduce i finalize można używać funkcji print do wypisywania własnych danych — pojawią się one w logach MongoDB.

Projektowanie aplikacji

Indeksy

Tworzenie indeksu na jednej kolumnie, zagnieżdżonej kolumnie i kilku kolumnach:

> db.users.ensureIndex( { "username": 1 } )
> db.users.ensureIndex( { "loc.city": 1 } )
> db.users.ensureIndex( { "username": 1, "age": 1 } )

Jeżeli mamy rekordy postaci:

{
  "username": "sid",
  "loc": {
    "ip": "1.2.3.4",
    "city": "Springfield",
    "state": "NY"
  }
}

to założenie indeksu na polu loc indeksuje cały zagnieżdżony dokument, a nie jego pojedyncze pola. Taki indeks nie pomoże w zapytaniu db.users.find({"loc.city": "Shelbyville"}), a jedynie w zapytaniach postaci:

> db.users.find( { "loc": {"ip": "123.456.789.000", "city": "Shelbyville", "state": "NY" } } )

Aby się dowiedzieć czy operacja tworzenia indeksu zakończyła się sukcesem, korzystamy z getLastError.

Aby poznać plan zapytania (w szczególności czy zostanie użyty indeks i jaki), należy użyć klauzuli explain:

> db.users.find( { username: "user101" } ).explain()
cursorrodzaj i nazwa użytego indeksu
nliczba znalezionych dokumentów
nscannedliczba przejrzanych wpisów indeksu (liczba przejrzanych rekordów gdy nie użyto indeksu)
nscannedObjectsliczba przejrzanych dokumentów
scanAndOrdertrue gdy było sortowanie, ale nie można było użyć dla niego indeksu
isMultiKeyczy któraś z indeksowanych wartości jest tablicą
indexOnlygdy wszystkie zwracane wartości zapytania były w indeksie i nie trzeba było sięgać do dokumentów (covered index)

Można wymusić użycie określonego indeksu przy użyciu klauzuli hint:

> db.users.find( { "age": { "$gte": 21, "$lte": 30 } } ).
    sort( { "username": 1 } ).
    hint( { "username": 1, "age": 1 } )

Liczność pola kolekcji (ang. cardinality) określa liczbę jego różnych wartości. Im jest większa, tym bardziej opłaca się założyć indeks. Gdy zapytanie zwraca ponad 30% zawartości kolekcji, użycie indeksu może być nieopłacalne.

Indeks na tablicy oznacza po jednym wpisie indeksu dla każdego elementu tablicy (jest to tzw. multikey index), a nie dla całej tablicy. Jeśli choć jedna wartość w indeksowanej kolumnie jest tablicą, to indeks staje się multikey, co można zobaczyć w wyniku explain() zapytania (pole isMultikey).

Gdy optymalizator MongoDB stwierdzi że do wykonania zapytania można wykorzystać kilka indeksów, wypróbowuje je równolegle i ten który najszybciej zwróci 100 rekordów jest wybierany do kontynuacji zapytania, a pozostałe plany zapytania są porzucane. Wybrany plan jest cache'owany aby kolejne wykonanie tego samego zapytania nie musiało już robić doświadczeń. Plan jest wyliczany ponownie gdy kolekcja zmieni się znacząco, po utworzeniu indeksu, oraz po wykonaniu zapytania 1000 razy.

Tworzenie indeksów jest operacją która pochłania dużo pamięci RAM. Jądro Linuksa może agresywnie zareagować na próby wypełnienia jej po brzegi i ubić proces MongoDB. Dlatego warto utworzyć małą partycję wymiany z której MongoDB nie będzie korzystać, ale jądro będzie spokojniejsze.

Na jednej kolekcji można utworzyć max. 64 indeksy. Zapytanie potrafi wykorzystać tylko jeden indeks. Wielkość żadnego wpisu indeksu nie może przekroczyć 1024 bajtów, co oznacza np. że nie da się zindeksować dłuższych łańcuchów.

Próba sortowania bez indeksu może skończyć się wyjątkiem gdy danych do sortowania jest więcej niż 32 MB.

Covered index to indeks zawierający wszystkie atrybuty rekordów, które chcemy zwrócić w zapytaniu. Dzięki temu aby wykonać zapytanie wystarczy sam indeks i nie trzeba w ogóle sięgać do samych dokumentów, co jeszcze bardziej przyspiesza wykonanie zapytania.

Zapytania z operatorem $ne mogą korzystać z indeksu, ale nie jest to zbyt efektywne.

Uwagi dotyczące indeksów wielokolumnowych:

  • na wcześniejszych pozycjach warto umieścić pola o większej liczbie różnych wartości,
  • tylko jedna kolumna indeksu może zawierać tablicę — to ograniczenie wynika z chęci uniknięcia budowania indeksów zbyt dużych rozmiarów,
  • indeks {"key1": 1, "key2": 1, "key3": 1} zawiera w sobie dwa inne indeksy: {"key1": 1} oraz {"key1": 1, "key2": 1},
  • dobry indeks wielokolumnowy często ma postać { "sortKey": 1, "queryCriteria": 1 },
  • na wcześniejszych pozycjach warto umieszczać pola, na których będą wykonywane dokładne dopasowania (np. "x": "foo"), a na dalszych te gdzie będą zakresy (np. "y": {"$gt": 3, "$lt": 5}).

Indeksy mogą być unikalne:

> db.users.ensureIndex( { "username": 1 }, { "unique": true } )

Opcja "dropDups": true usunęłaby wszystkie rekordy z duplikatami indeksowanego pola.

Z punktu widzenia indeksów, brak danego pola w dokumencie jest tożsamy z istnieniem tego pola z wartością null. Czysty indeks unikalny nie pozwala na istnienie wielu wartości null. Jeśli chcemy mieć indeks unikalny z wartościami unikalnymi gdy są niepuste, trzeba dodać opcję "sparse": true. Jeszcze innym przydatnym parametrem tworzenia indeksu jest "name" — pozwala nadać indeksowi własną nazwę, która domyślnie generowana jest automatycznie na podstawie nazw indeksowanych pól.

Budowanie indeksu jest operacją zasobożerną i domyślnie blokuje wszystkie operacje odczytu i zapisu. Opcja "background": true powoduje uruchomienie budowania indeksu w tle, co znacząco wydłuża całą operację, ale pozwala na w miarę normalne działanie bazy przez ten czas.

Wszystkie indeksy na danej kolekcji możemy poznać wykonując polecenie:

> db.colName.getIndexes()

Usunięcie indeksu:

> db.people.dropIndex( "x_1_y_1" )

Usunięcie wszystkich dokumentów z kolekcji nie usuwa indeksów. Aby usunąć wszystkie indeksy (z wyjątkiem _id) można użyć polecenia:

> db.runCommand( { "dropIndexes": "colName", "index": "*" } )

Indeksy i kolekcje specjalnego przeznaczenia

Kolekcja ograniczona (ang. capped collection) to kolekcja, w której dokumenty można tylko dodawać (na końcu) lub modyfikować w sposób nie zmieniający ich dotychczasowego rozmiaru, a po zapełnieniu dostępnego miejsca najstarsze są usuwane. Typowe zastosowanie to np. dziennik logów lub kolejka zadań do wykonania. Ograniczenie musimy podać jako wielkość kolekcji w bajtach a dodatkowo możemy określić maksymalną liczbę rekordów (w przypadku podania obu, liczy się pierwszy osiągnięty limit).

> db.createCollection("my_collection", {"capped": true, "size": 100000, "max": 100});

Można także przekształcić zwykłą kolekcję w kolekcję ograniczoną (ale nie ma drogi odwrotnej):

> db.runCommand( { "convertToCapped": "test", "size": 10000 } );

W przypadku kolekcji ograniczonych szczególnego znaczenia nabiera pojęcie sortowania naturalnego, które oznacza zastosowanie porządku wstawiania rekordów (wartość -1 oznaczałaby porządek odwrotny):

> db.my_collection.find().sort( { "$natural": 1 } )

W kolekcjach ograniczonych z poziomu sterowników (np. w PHP) można stosować tzw. tailable cursor, czyli kursor który po dojściu do końca nie jest zamykany natychmiast, lecz czeka max. 10 minut na pojawienie się kolejnych dokumentów — jest to odpowiednik linuksowego polecenia tail -f. W ten sposób aplikacja może zrealizować kolejkę zadań do wykonania.

Indeks TTL (ang. Time To Live) pozwala ustawić czas życia dla dokumentów. Typowe zastosowanie to np. obiekty sesji użytkownika. Indeks tego typu można założyć tylko na kolumnach przechowujących datę lub tablicę dat. Gdy minie określona liczba sekund od daty będącej wartością pola, dokument jest usuwany (dla tablicy dat brana jest pod uwagę najwcześniejsza):

> db.eventlog.createIndex( { "lastModifiedDate": 1 }, { expireAfterSeconds: 3600 } )

Domyślnie MongoDB przegląda indeksy TTL co minutę, można ten czas zmienić poleceniem:

> db.runCommand( { "collMod": "someapp.cache", "expireAfterSecs": 3600 } );

Na jednej kolekcji można założyć wiele indeksów TTL. Nie mogą one być złożone (wielokolumnowe), ale mogą być wykorzystywane jak wszystkie inne indeksy do sortowania i optymalizacji zapytań.

Indeks pełnotekstowy (ang. full-text index) pozwala na przeszukiwanie tekstu w dokumentach. Proces tworzenia tego rodzaju indeksów jest szczególnie zasobożerny, kosztowna jest również operacja wstawiania rekordu. W MongoDB 2.4 indeksy pełnotekstowe były wciąż w wersji beta, stąd konieczność włączenia ich obsługi przy uruchamianiu serwera:

$ mongod --setParameter textSearchEnabled=true

Aby założyć indeks pełnotekstowy na polu title:

> db.collName.ensureIndex( { "title": "text" }, { "default_language": "french" )

Opcja default_language to domyślnie english, niestety póki co nie ma polskiego. Można natomiast użyć wartości none, aby uniknąć jakichkolwiek językowych obróbek słów (stemming, stop-words itp.).

Na jednej kolekcji można założyć tylko jeden indeks pełnotekstowy, ale może on być wielokolumnowy. W razie wielu kolumn, można przeszukiwaniu każdej kolumny przypisać dodatkowo wagę (domyślnie 1):

> db.collName.ensureIndex( { "title": "text", "desc": "text", "author": "text" },
    { "weights": { "title": 3, "author": 2 } } );

Ponadto, można zażyczyć sobie założenia indeksu pełnotekstowego na wszystkich polach tekstowych, nawet tych w zagnieżdżonych dokumentach i tablicach:

> db.collName.ensureIndex( { "$**": "text" } );

Operację wyszukiwania realizujemy tak (zwróć uwagę na możliwość wyszukiwania fraz i operator NOT):

> db.runCommand( { "text": "collName", "search": "\"Pan Tadeusz\" Mickiewicz -niedostępny" } );

Wynik zawiera listę znalezionych dokumentów, każdy opakowany dodatkową informacją o stopniu dopasowania (score).

MongoDB udostępnia dwa rodzaje Indeksów przestrzennych (ang. geospatial index): 2dsphere dla współrzędnych geograficznych i 2d dla map płaskich i współrzędnych całkowitych (np. w grach komputerowych, ale także dane zawierające czas).

Indeks 2dsphere pozwala indeksować punkty, linie i wielokoąty w formacie GeoJSON:

{
  "name": "New York City",
  "loc": {
    "type": "Point",
    "coordinates": [50, 2]
  }
}

Indeks zakłada się tak:

> db.world.ensureIndex( { "loc": "2dsphere" } );

Zapytania przestrzenne mogą dotyczyć takich relacji jak przecięcie (ang. intersection), zawieranie (ang. within) i sąsiedztwo (ang. nearness). W indeksie złożonym można mieszać typ przestrzenny z pozostałymi typami indeksów.

Poniższe zapytanie zwróci wszystkie punkty, linie i wielokąty mające choć jeden punkt w obrębie wskazanego wielokąta:

> var eastVillage = {
  "type": "Polygon",
  "coordinates": [
    [-73.9917900, 40.7264100],
    [-73.9917900, 40.7321400],
    [-73.9829300, 40.7321400],
    [-73.9829300, 40.7264100]
  ]}
> db.open.street.map.find( { "loc": { "$geoIntersects": { "$geometry": eastVillage } } } )

Pozostałe operatory to $within i $near. Ten ostatni zwraca listę obiektów posortowanych od najbliższego do najdalszego.

Indeksy typu 2d są historycznie wcześniejsze od 2dsphere, nie używają GeoJSONa i potrafią indeksować wyłącznie punkty. Tablica punktów zostanie zindeksowana nie jako linia, lecz lista punktów. Nie jest dostępna operacja wyszukiwania przecięcia. Przy tworzeniu indeksu 2d można określić rozmiar planszy (domyślnie od -180 do 180):

> db.hyrule.ensureIndex( { "tile": "2d"}, {"min": -1000, "max": 1000 } )

Przykłady zapytań:

> db.hyrule.find( { "tile": { "$near": [20, 21] } } );
> db.hyrule.find( { "tile": { "$within": { "$box": [[10,20], [15,30]] } } } );
> db.hyrule.find( { "tile": { "$within": { "$center": [[12,25], 5] } } } ); // okrąg
> db.hyrule.find( { "tile": { "$within": { "$polygon": [[0, 20], [10, 0], [-10, 0]] } } } );

GridFS to mechanizm pozwalający przechowywać w bazie MongoDB duże pliki binarne. Dlaczego ktoś mógłby chcieć czegoś takiego?

  • ponieważ MongoDB jest rozproszoną bazą danych, dostajemy rozproszony system plików,
  • unikamy pewnych problemów związanych z klasycznymi systemami plików (np. duża liczba plików w jednym katalogu).

Są też pewne wady, m.in. wolniejsze działanie w porównaniu z klasycznym systemem plików i brak możliwości modyfikacji pliku (można tylko usunąć i dodać nowy).

Najprostszy sposób interakcji z GridFS to użycie narzędzia wiersza poleceń o nazwie mongofiles:

$ echo "Hello, world" > foo.txt
$ mongofiles put foo.txt
$ mongofiles list
$ rm foo.txt
$ mongofiles get foo.txt
$ cat foo.txt

Można go użyć również do wyszukiwania plików po nazwie i usuwania plików.

W rzeczywistości GridFS jest zaimplementowany bez potrzeby modyfikacji jądra MongoDB. Plik jest dzielony na kawałki (ang. chunks) przechowywane w kolekcji fs.chunks, a w kolekcji fs.files dokument reprezentujący plik zawiera metadane pliku (data utworzenia, skrót MD5) oraz listę identyfikatorów kawałków pliku w kolekcji fs.chunks.

Rozkruszanie

Rozkruszanie (ang. sharding), zwane także partycjonowaniem (ang. partitioning) to rozrzucenie danych w miarę równomiernie po wielu maszynach (shardach), aby zrównoleglić wykonywane na tych danych operacje. MongoDB implementuje tzw. autosharding, to znaczy ukrywa przed serwerem aplikacji mechanizm rozkruszania i automatycznie równomiernie rozrzuca dane w klastrze rozproszonych maszyn, również np. w reakcji na dodanie nowego shardu do klastra.

Na ogół decydujemy się na rozkruszanie danych ze względów wydajnościowych. Nie warto tego robić zbyt wcześnie gdy danych jest mało, ponieważ wymaga to dodatkowych zasobów i jest trudniejsze w administracji. Z drugiej strony nie należy tego robić zbyt późno gdy baza jest mocno obciążona, bo zmiana konfiguracji przy dużej ilości danych nie jest natychmiastowa i proces przejścia na rozkruszanie może źle się odbić na wydajności z punktu widzenia użytkowników.

Uwaga: Albo co najmniej trzy shardy, albo żadnego. Dwa shardy są nieopłacalne z uwagi na narzuty związane z rozkruszaniem (zarządzanie metadanymi, dodatkowa komunikacja po sieci itp.).

Główne powody rozkruszania to:

  • zwiększenie dostępnej pamięci RAM,
  • zwiększenie dostępnej przestrzeni dyskowej,
  • redukcja obciążenia serwera,
  • zwiększenie liczby operacji odczytu/zapisu.

Serwer aplikacji komunikuje się z procesem mongos, który pełni funkcję rutera zapytań (ang. query router): otrzymując zapytanie rozbija je na podzapytania dla poszczególnych shardów, a następnie scala otrzymane od nich wyniki. Jeśli z bazy MongoDB korzystają różne aplikacje, to dla każdego serwera aplikacji uruchamiamy osobny proces mongos. Gdzieś musi być przechowywany "spis treści", który mówi w którym shardzie są które dane — takim magazynem metadanych jest serwer konfiguracji (ang. config server), który jest osobnym procesem. Na ogół, w celu zapobiegania awariom, uruchamiamy zbiór trzech (identycznych) serwerów konfiguracji, na różnych maszynach.

Rozkruszanie włączamy na poziomie pojedynczych kolekcji (w klastrze rozproszonych shardów mogą istnieć kolekcje których nie chcemy rozpraszać — każda z nich będzie w całości zawierała się na którymś shardzie). Zanim włączymy rozkruszanie dla danej kolekcji, wszystkie jej dane znajdują się początkowo na którymś shardzie (tzw. primary shard). Dopiero włączenie rozkruszania spowoduje migrację danych tej kolekcji pomiędzy shardy.

Rozkruszenie kolekcji wymaga podania tzw. klucza rozkruszania (ang. shard key), czyli pola dokumentów kolekcji, którego MongoDB będzie używać do rozpraszania danych (dopuszcza się również klucz złożony z dwóch pól). Na tym polu musi być założony indeks (nie może być specjalny np. geoprzestrzenny). Ponadto, wartością pola nigdy nie może być tablica.

MongoDB dzieli przestrzeń wartości klucza rozkruszania na przedziały, zbiór dokumentów z wartościami klucza w danym przedziale zwany jest kawałkiem (ang. chunk):

Taki kawałek jest jednostką transferu danych między shardami. Dedykowany proces równoważenia obciążenia (ang. balancer) działa w tle i równomiernie rozrzuca kawałki po shardach. Informacja o tym w którym shardzie znajduje się który kawałek jest przechowywana w serwerach konfiguracji. Gdy jakiś kawałek w wyniku dodawania kolejnych dokumentów nadmiernie się rozrośnie (domyślnie maksymalna wielkość kawałka wynosi 64 MB), wówczas następuje podział kawałka na dwa. Zauważ, że podział może nastąpić tylko w tym miejscu przedziału (ang. split point), w którym wartość klucza rozkruszania ulega zmianie. Dlatego bardzo ważne jest, aby klucz rozkruszania przyjmował możliwie jak najwięcej różnych wartości.

Załóżmy, że kluczem rozkruszania wybraliśmy pole z datą wstawienia dokumentu, wyrażoną w postaci YYYY-mm-dd. Jeśli nasza aplikacja nagle zyska dużą popularność i w ciągu jednego dnia będą wstawiane dokumenty o łącznej pojemności znacznie przekraczającej 64 MB, to powstaną kawałki-kolosy (tzw. jumbo chunks) których nie da się podzielić, a z racji przekroczenia maksymalnego rozmiaru nie będą mogły być migrowane przez balancera.

Klucz rozkruszania

Jak już się pewnie domyślamy, wybór dobrego klucza rozkruszania ma strategiczne znaczenie, a niewłaściwa decyzja jest trudna do naprawienia w przypadku dużej bazy danych. Na każdym kluczu rozpraszania musi być założony indeks, co wynika z faktu że oba pojęcia są dość mocno skorelowane. Do tego stopnia, że nierzadko kluczem rozpraszania jest najczęściej używany w zapytaniach indeks.

Zasadniczo wyróżniamy trzy rodzaje kluczy: rosnący, losowy i bazujący na lokalizacji. Rozważmy co się dzieje gdy wykonujemy wiele operacji wstawiania nowych rekordów w każdym z tych trzech przypadków.

W kluczu rosnącym (np. kolejna liczba całkowita z sekwencji, ale także wartość typu ObjectId, ponieważ zaczyna się timestampem) operacje wstawiania będą skupiały się w jednym kawałku, czyli ograniczały się do jednego shardu. Mówimy wtedy, że mamy w klastrze tylko jeden "gorący punkt" (ang. hotspot). Poza nierównomiernym obciążeniem maszyn klastra, jest to również obciążenie dla balancera, który musi rozrzucać produkowane przez hotspot kawałki między pozostałe shardy. Czasami taki klucz rozkruszania ma sens — gdy jedna z maszyn klastra ma wyraźnie (np. 10-krotnie) lepszą wydajność niż pozostałe (np. dysk SSD i dużo RAM w porównaniu z HDD).

Przykładami losowego klucza rozkruszania są: skrót MD5, nazwa użytkownika, adres email. Istotny jest tu brak wyraźnego schematu generowania wartości. Taki klucz oczywiście bardzo ładnie rozprasza po całym klastrze operacje wstawiania nowych rekordów. Jedną z wad takiego klucza jest to, że MongoDB nie jest wydajny w losowym dostępie do danych nie mieszczących się w pamięci RAM.

Klucz bazujący na lokalizacji to np. IP, współrzędne GPS, adres. Jest to klasa abstrakcji szersza niż fizyczna lokalizacja: chodzi raczej o sposób grupowania wartości odzwierciedlający jakieś podobieństwo dokumentów.

Możemy zażyczyć sobie, aby kawałki zawierające określony przedział wartości klucza były przypisane do konkretnego shardu. Robimy to nadając wybranemu przedziałowi wartości nazwę, a następnie przypisując tag o tej nazwie do wybranego shardu. Powiedzmy że kluczem rozkruszania jest adres IP:

> sh.addShardTag( "shard0000", "USPS" )
> sh.addTagRange( "test.ips", {"ip": "056.000.000.000"}, {"ip": "057.000.000.000"}, "USPS" )

Kawałki nie zawierające wartości z podanego przedziału będą przez balancera rozrzucane między shardy tak jak zwykle. Zamiast konkretnej wartości można użyć stałych MinKey i MaxKey na oznaczenie odpowiednio minimalnej i maksymalnej wartości. Do usuwania tagów służy polecenie removeShardTag.

Klucz rosnący jest wygodny z punktu widzenia logiki aplikacji i późniejszych zapytań, a wartość losowa z punktu widzenia architektury rozproszonej bazy danych. Istnieje wyjście kompromisowe, w którym na rosnącym kluczu rozkruszania zakładamy indeks typu hashed (nie może być unikalny):

> db.users.ensureIndex({"username": "hashed"})
> sh.shardCollection("app.users", {"username": "hashed"})

Wada takiego podejścia to brak możliwości wydajnego wykonywania zapytań zakresowych. Ponadto skrót (ang. hash) dla wartości liczbowych zmiennoprzecinkowych nie rozróżnia części ułamkowej, tzn. wartości 1 i 1.99999 mają taki sam skrót.

Rozważmy jeszcze zalety klucza rozkruszania złożonego z dwóch pól, w którym pierwsze pole jest mało różnorodne (np. identyfikator województwa), a drugie rosnące (np. ObjectId). Wówczas operacje wstawiania nowych dokumentów będą tworzyć tyle hotspotów ile jest województw, co będzie dość dobrze współgrało z rozproszoną architekturą klastra.

Po wstawieniu dokumentu, wartości jego klucza rozkruszania nie wolno zmieniać chyba że wyjmiemy dokument z bazy i wstawimy go jeszcze raz, z inną wartością pola. Dlatego też na klucz rozpraszania należy wybierać pole które ma zasadniczo niezmienną wartość.

Z uwagi na wspomniane wyżej zjawisko kawałków-kolosów, bardzo ważne jest żeby klucz rozkruszania miał dużo różnych wartości — co najmniej tyle, żeby rozmiar wszystkich dokumentów z tą samą wartością klucza rozkruszania nie przekroczył zdefiniowanego maksymalnego rozmiaru kawałka.

Administracja

Serwery konfiguracyjne

Serwer konfiguracyjny to uruchomiony z odpowiednimi opcjami na porcie 27019 znajomy proces mongod (zwróć uwagę na brak opcji --replSet, serwer konfiguracyjny nie należy do żadnego zbioru replik):

$ mongod --configsvr --dbpath /var/lib/mongodb -f /var/lib/config/mongod.conf

Należy uruchomić 3 serwery konfiguracyjne, na różnych maszynach i najlepiej w różnych lokalizacjach geograficznych (3 zapewnia dostateczną redundancję, a więcej powodowałoby zbyt wiele narzutów na komunikację).

Serwer konfiguracyjny nie wymaga dużych zasobów i może być uruchamiany na maszynach o innym przeznaczeniu (np. serwer aplikacji, ruter zapytań itp.). Szacuje się, że na każde 200 MB danych przechowywanych w MongoDB serwer konfiguracji potrzebuje 1 KB miejsca dla siebie.

Przed wykonaniem każdej poważniejszej operacji administracyjnej na klastrze MongoDB warto zrobić backup serwera konfiguracyjnego.

Rutery zapytań (mongos)

Ruter zapytań (po jednym dla każdego serwera aplikacji, najlepiej na tej samej maszynie co serwer aplikacji) uruchamiamy podając namiary na serwery konfiguracji:

$ mongos --configdb config-1:27019,config-2:27019,config-3:27019 -f /var/lib/mongos.conf

Każdy mongos powinien dostać namiary na tą samą listę serwerów konfiguracji (w tej samej kolejności).

Shardy

Prawdopodobnie mamy już jeden zbiór replik obsługujący serwer aplikacji — to będzie nasz pierwszy shard, który należy wskazać po zalogowaniu się do któregoś z procesów mongos:

> sh.addShard( "spock/server-1:27017,server-2:27017,server-4:27017" )

Przed ukośnikiem podajemy nazwę zbioru replik. Nie trzeba wymieniać wszystkich serwerów wchodzących w skład zbioru, wystarczy jeden — reszta zostanie odkryta automatycznie.

Teoretycznie możemy podpiąć jako shard serwer mongod działający w trybie standalone, ale na produkcji nie jest to zalecane ponieważ przejście później na zbiór replik będzie kłopotliwe. Dużo lepiej jest zacząć od sharda który jest jednoelementowym zbiorem replik.

Teraz możemy już przepiąć serwer aplikacji tak, aby komunikował się z procesem mongos, a nie bezpośrednio ze zbiorem replik spock.

Aby dodać do klastra nowe shardy postępujemy podobnie, z tym że nowo dodawane zbiory replik powinny mieć inne (unikalne) nazwy i nie mogą zawierać baz danych o nazwach już istniejących w klastrze.

Jak już wcześniej wspomniano, rozkruszanie włączamy na poziomie pojedynczych kolekcji. Powiedzmy że w bazie test mamy kolekcję o nazwie users, którą chcemy rozkruszyć po polu username. W tym celu łączymy się z ruterem mongos i wykonujemy polecenia:

> sh.enableSharding("test")
> db.users.ensureIndex({"username": 1})
> sh.shardCollection("test.users", {"username": 1})

Ostatnie polecenie rozpoczyna proces dzielenia kolekcji na kawałki i rozrzucania ich po dostępnych shardach, co dla dużej ilości danych może potrwać.

Do usuwania shardu służy polecenie:

> db.adminCommand({"removeShard": "nazwa"})

To polecenie można wykonywać wielokrotnie aby zobaczyć postęp procesu przesuwania kawałków z usuwanego shardu na inne shardy (ang. draining). Ostatnią przeszkodą przed usunięciem shardu może być fakt, że dany shard może być głównym serwerem dla jakichś baz danych (o czym dowiemy się w wynikach polecenia removeShard). Wtedy należy przenieść te bazy na inne shardy:

> db.adminCommand({"movePrimary": "blog", "to": "shard002"})

Gdy bazy zostaną przeniesione, ponowne wywołanie removeShard ostatenicznie usuwa shard z klastra.

Monitorowanie

Podstawowym poleceniem wyświetlającym informacje o shardach, bazach danych i kolekcjach, jest:

> sh.status()

Przy małej ilości kawałków (lub gdy dodamy parametr o wartości true) dostaniemy nawet rozpiskę jaki kawałek jest na którym shardzie. W przypadku dużej ilości kawałków dowiemy się tylko ile kawałków ma każdy shard.

Poniżej opisano pokrótce zawartość serwerów konfiguracyjnych. Uwaga: nigdy nie łącz się bezpośrednio z serwerem konfiguracyjnym, wszelkie operacje na konfiguracji wykonuj wchodząc na ruter zapytań (mongos) po wykonaniu polecenia use config.

config.shardsOpisuje każdy shard klastra: gdzie są serwery shardu, czy są przypisane tagi. Pole _id to nazwa zbioru replik shardu.
config.databasesWszystkie bazy danych klastra, zarówno te rozpraszane jak i nierozpraszane. W pierwszym przypadku pole partitioned będzie miało wartość true. Dla każdej bazy podany jest shard bazowy (ang. primary).
config.collectionsInformacje o rozkruszanych kolekcjach, w szczególności nazwa kolekcji i klucz rozkruszania.
config.chunksKawałki: na którym shardzie aktualnie się znajduje, wartość minimalna i maksymalna. Pole lastmod zawiera wartości t: ile razy był migrowany między shardami, i: ile razy był dzielony na dwa.
config.changelogLogi o każdej operacji podziału i migracji kawałków. Dla migracji tworzone są 4 wpisy odpowiednio dla: rozpoczęcia migracji, z perspektywy shardu źródłowego, z perspektywy shardu docelowego i zatwierdzania migracji.
config.tagsTutaj są informacje o każdym otagowanym przedziale, na użytek przyszpilania kawałków do wybranego shardu.
config.settingsUstawienia balancera i maksymalny rozmiar kawałka. Tutaj możesz włączyć/wyłączyć balancer i ustawić maksymalny rozmiar kawałka.

Na shardach i ruterach można zobaczyć statystyki połączeń z innymi maszynami klastra za pomocą polecenia:

> db.adminCommand({"connPoolStats": 1})

Jedna maszyna klastra może domyślnie przyjąć maksymalnie 20 tys. połączeń (parametr maxConns wiersza poleceń przy uruchamianiu serwera). Każde zapytanie wydane przez serwer aplikacji do rutera zapytań otwiera połączenie przynajmniej do jednego sharda. Zauważ, że jeśli masz 5 aplikacji, a więc 5 ruterów zapytań, a każdy ma powiedzmy 10 tys. połączeń od klientów, to może wywołać próbę utworzenia 50  połączeń do jednego shardu.

Narzut związany z działaniem balancera podczas obsługi użytkowników może okazać się wydajnościowo nie do przyjęcia (np. gdy dodaliśmy nowy shard i mamy dużo migracji). Dlatego można zażyczyć sobie, aby balancer włączał się w określonej porze dnia:

> db.settings.update({"_id": "balancer"}, 
    {"$set": {"activeWindow": {"start": "13:00", "stop": "16:00"}}}, true)

Możemy też całkowicie wyłączyć balancer i migrować kawałki ręcznie:

> sh.setBalancerState(false)

Uwaga: nie wyłączy to trwającej iteracji, a jedynie nie uruchomi nowej. Polecenie:

> db.locks.find({"_id": "balancer"})["state"]

zwróci 0 gdy balancer rzeczywiście jest wyłączony.

Do ręcznego zmigrowania kawałka zawierającego dokument z określoną wartością klucza rozkruszania służy polecenie postaci:

> sh.moveChunk("test.users", {"user_id": NumberLong("184428392837892209")}, "spock")

Aby zmienić maksymalny rozmiar kawałka (domyślnie 64 MB) na 32 MB (zmiana dotyczy całego klastra, wszystkich jego baz danych i kolekcji poddanych shardingowi):

> db.settings.save({"_id": "chunksize", "value": 32})

Zwiększenie rozmiaru kawałka ma sens gdy dojdziemy do wniosku że MongoDB wykonuje zbyt dużo migracji lub gdy nasze dokumenty są duże.

Aby zatrzymać cały klaster:

> cluster.stop()

Kopie zapasowe

Nie sposób wykonać "migawki" działającego klastra. Backup pojedynczego shardu jest kłopotliwy o tyle, że po jego wykonaniu na tą maszynę zostanie zmigrowanych wiele nowych kawałków danych, które znikną po odtworzeniu backupu. Im więcej shardów, tym mniejsze prawdopodobieństwo że w ogóle będziemy chcieli robić backup. Dla bardzo małych klastrów można się pokusić o wykorzystanie poleceń mongodump i mongorestore na ruterze zapytań mongos.

Zestaw replik

O ile bawimy się instancją MongoDB na swoim laptopie i nie myślimy o rozpraszaniu danych, możemy nie przejmować się awariami i pracować w trybie samodzielnym (ang. standalone), który jest domyślny zaraz po zainstalowaniu. W środowisku produkcyjnym jednak, a zwłaszcza gdy dane są rozproszone na wielu (dziesiątkach, setkach) shardach klastra, prawdopodobieństwo awarii któregoś z serwerów w ciągu najbliższej nocy jest realne i dlatego potrzebujemy replikacji.

Replikacja (ang. replication) uzupełnia na bieżąco identyczną kopię danych na innych maszynach. W MongoDB aby uzyskać efekt replikacji tworzymy tzw. zestaw replik (ang. replica set). Jest to grupa serwerów, w której istnieje serwer podstawowy (ang. primary server) oraz pewna liczba serwerów podrzędnych (ang. secondary servers). Gdy serwer podstawowy odmówi posłuszeństwa, jego rolę przejmie któryś z serwerów podrzędnych, w wyniku głosowania. Z punktu widzenia klienta nie ma różnicy czy komunikuje się on z serwerem samodzielnym czy serwerem podstawowym zbioru replik.

Operacje zapisu można wykonywać wyłącznie na serwerze podstawowym. Domyślnie również odczyt jest dopuszczalny tylko na serwerze podstawowym, ale jeśli jesteśmy zdeterminowani to możemy użyć ustawienia "wiem co robię" i zrównoleglić sobie odczyt o ile nie upieramy się przy spójności danych (dane na serwerach podrzędnych mogą być nieaktualne, a opóźnienie (ang. lag), zwykle wynoszące kilka milisekund, nie jest gwarantowane). Zdecydowanie bardziej zalecanym sposobem balansowania obciążenia jest użycie rozkruszania (ang. sharding), o czym jest mowa w innym rozdziale.

W zbiorze replik replikacji podlegają wszystkie kolekcje wszystkich baz danych z wyjątkiem bazy local. Tam możemy umieszczać kolekcje, których nie chcemy replikować.

Każdy zbiór replik ma swoją nazwę. Jest ona istotna o tyle, że jeśli będziemy stosować rozkruszanie danych w rozproszonym klastrze, to każdy okruch (ang. shard) będzie zbiorem replik o unikalnej nazwie.

W zbiorze replik kluczowe jest pojęcie większości (ang. majority), rozumianej jako "więcej niż połowa członków zbioru replik". Większość decyduje o elekcji serwera podstawowego, zapis do serwera podstawowego uznajemy za bezpieczny gdy zostanie utrwalony na większości serwerów w zbiorze replik. Pojęcie większości odnosi się do serwerów zdefiniowanych w konfiguracji niezależnie od tego, czy są wśród nich serwery niedziałające (w wyniku awarii, podziału sieci itp.).

Sporo uwagi warto poświęcić zagadnieniu rozplanowania ilości rozmieszczenia serwerów zbioru replik.

  • Najbardziej oszczędna opcja w której mamy dwuelementowy zbiór replik zgodnie z dotychczasowym opisem nie zadziała, ponieważ z chwilą utraty serwera podstawowego serwer podrzędny nie będzie mógł przejąć jego funkcji (nie stanowi większości!). W obliczu braku dodatkowych zasobów możemy wspomóc się dodatkowym lekkim procesem arbitra, którego jedyną funkcją będzie głosowanie (nie przechowuje żadnych danych).
  • Bodaj najbardziej optymalną konfiguracja: serwery zbioru replik podzielone po połowie na dwa centra danych, plus dodatkowy, "przełamujący remis" (ang. tie-breaker) serwer w trzeciej lokalizacji.

Każdy serwer umieszcza informacje o wykonanych operacjach zapisu w tzw. dzienniku operacji (ang. oplog). Oplog rezyduje w bazie danych local i jest kolekcją ograniczoną (tzn. jej najstarsze wpisy są usuwane) o nazwie oplog.rs. Dziennik operacji ma tę własność, że wielokrotne odtworzenie zawartych w nim zapisów ma taki sam efekt, jakby odtworzono je tylko raz. Serwer podrzędny po dodaniu do zbioru replik zaczyna kopiować wszystkie dane (tzw. initial sync), a następnie przechodzi w tryb "nadążania" za bieżącymi zmianami na serwerze nadrzędnym, co realizuje odczytując jego oplog. Jest ważne, żeby rozmiar kolekcji ograniczonej przechowującej oplog był wystarczająco duży, aby wystarczał na okres wykonywania initial sync przez serwer podrzędny, w przeciwnym razie serwer podrzędny nie będzie miał szans na utworzenie pełnej, aktualnej repliki. Każdy serwer który może w wyniku elekcji objąć funkcję serwera podstawowego powinien mieć oplog pozwalające na przechowywanie logów z co najmniej 24 ostatnich godzin. Na oplog nie należy żałować miejsca na dysku, ponieważ jego obsługa zużywa mało RAMu i CPU.

Poszczególnym serwerom zbioru replik możemy nadawać dodatkowe funkcjonalności:

  • priority: liczba całkowita od 0 do 100 (domyślnie 1) która mówi jak bardzo dany serwer "chce" być podstawowym. Priorytet 0 oznacza, że serwer nigdy nie będzie wybrany na podstawowy (tzw. serwer pasywny).
  • hidden: wartość logiczna (domyślnie false); w razie true (tylko dla priority = 0) informuje że na tym serwerze nigdy nie należy wykonywać zapytań.
  • slaveDelay (musi być połączone z priority = 0, zaleca się również hidden: true): celowo opóźnia replikę o podaną liczbę sekund; w MongoDB nie ma transakcji, więc nie da się cofnąć przypadkowego usunięcia potrzebnych danych — w takim przypadku uratować nas może replika która ma dane sprzed np. godziny
  • buildIndexes wartość logiczna (domyślnie true); serwer podrzędny będzie działał szybciej jeśli nie będzie musiał robić indeksów; w razie ustawienia na false wymaga priority = 0. Jeśli zdecydujemy się nie tworzyć indeksów, to nie możemy tego cofnąć.

W praktyce ze względow oszczędnościowych rzadko będziemy mogli sobie pozwolić na więcej niż dwie maszyny w zbiorze replik. Co więcej, serwer podrzędny na ogół będzie miał dużo gorsze parametry sprzętowe niż serwer główny. Aby wymusić działanie zbioru replik z dedykowanym serwerem głównym nie przejmującym się ewentualną awarią serwera podrzędnego (patrz dyskusja o większości), w serwerze podrzędnym należy ustawić następujące opcje: priority: 0, hidden: true, buildIndexes: false, votes: 0 (ostatnia opcja nie jest potrzebna jeśli zdecydujemy się na postawienie procesu arbitra).

Bicie serca (ang. heartbeat) to krótkie komunikaty wysyłane co 2 sekundy przez każdy serwer zbioru replik do wszystkich pozostałych serwerów zbioru. Mają one na celu sprawdzenie osiągalności i szybką reakcję w razie problemów (np. rozpoczęcie wyboru nowego serwera podstawowego).

Aby w sieci nie latało zbyt wiele pakietów, jak również żeby nie wydłużać czasu elekcji nowego serwera podstawowego, MongoDB narzuca ograniczenie w postaci max. 12 serwerów w zbiorze replik, przy czym co najwyżej 7 może brać udział w głosowaniu (pozostałe muszą mieć ustawioną opcję votes na 0).

Źródłem danych do replikacji dla serwera podrzędnego nie musi być bezpośrednio serwer podstawowy — jest to zwykle serwer, do którego komunikat ping idzie najkrócej. Rozważmy sytuację przedstawioną na poniższym rysunku (tzw. chaining):

Tutaj z racji tego że komunikacja w obrębie centrum danych jest bardziej wydajna, serwer "New Secondary" prawdopodobnie będzie replikował dane z "Secondary", a nie z "Primary". Możemy wymusić inne źródło replikacji przy użyciu polecenia rs.syncFrom("server-x:27017"). Ponadto możemy w konfiguracji zbioru replik wymusić replikację zawsze bezpośrednio z serwera podstawowego:

> var config = rs.config()
> config.settings = config.settings || {}
> config.settings.allowChaining = false
> rs.reconfig( config )

Administracja

Uruchamiamy serwery zbioru replik (na maszynach server-1, server-2, server-3). Każdy z nich z opcją --replSet:

$ mongod --replSet spock -f mongod.conf --fork

Serwery podrzędne powinny mieć pusty katalog danych lub zawierać kopię danych z innego serwera. Dane o objętości większej niż 100 GB warto ręcznie skopiować do serwerów podrzędnych (export/import), ponieważ ich replikacja będzie się przeciągać i mocno obciążać dawcę.

Następnie łączymy się do bazy test którymkolwiek serwerze i wykonujemy:

> config = {
  "_id": "spock",
  "members": [
    {"_id": 0, "host": "server-1:27017"},
    {"_id": 1, "host": "server-2:27017"},
    {"_id": 2, "host": "server-3:27017"}
  ]
}
> rs.initiate( config )

Konfiguracja zostanie sparsowana i rozesłana do pozostałych członków zbioru, następnie w drodze głosowania wybrany zostanie serwer podstawowy, po czym zacznie się synchronizowanie danych.

Aby dodać do zbioru replik nowy serwer:

> rs.add( "server-4:27017" )

Gdy chcemy dodatkowo podać opcje:

> rs.add({"_id": 5, "host": "server-4:27017", "priority": 0, "hidden": true})

Arbitra uruchamiamy jak każdy inny serwer, ale z dodatkową opcją:

> rs.addArb( "server-5:27017" )

Aby usunąć serwer ze zbioru replik:

> rs.remove( "server-1:27017" )

Bieżąca konfiguracja zbioru replik jest wyświetlana poleceniem:

> rs.config()

Każda modyfikacja konfiguracji zwiększa wartość pola version w wynikowym dokumencie o 1.

Konfigurację możemy — po połączeniu się z serwerem podstawowym — modyfikować również tak (np. gdy dodajemy kilka nowych serwerów naraz lub modyfikujemy parametry serwerów):

> var config = rs.config();
> config.members[1].host = "server-2:27017"
> rs.reconfig( config )

Gdy zdarzy się sytuacja że w zbiorze replik na stałe utracimy większość serwerów i zechcemy wymusić elekcję nowego serwera podstawowego, wówczas możemy wysłać zmianę konfiguracji do serwera podrzędnego, ale z opcją "force" ustawioną na true:

> rs.reconfig( config, {"force": true} )

Aby zdegradować serwer podstawowy do podrzędnego na określony czas (w sekundach):

> rs.stepDown()

Jeśli w tym czasie nie zostanie wybrany nowy serwer podstawowy, zdegradowany serwer zainicjalizuje reelekcję próbując wrócić do poprzedniego stanu.

Jeśli na czas manipulowania przy serwerze podstawowym nie chcemy żeby ktokolwiek przejmował jego rolę, wówczas na każdym serwerze podrzędnym wykonujemy:

> rs.freeze( 600 )

co zamrozi sewer podrzędny na 10 minut. Gdy zechcemy odmrozić serwer przed upływem tego czasu, wystarczy wykonać to polecenie ponownie z parametrem 0.

Podstawowym poleceniem do monitorowania zbioru replik jest

> rs.status()

Możemy tu się zorientować w jakim stanie są poszczególne serwery względem serwera z którego wykonano polecenie, i jak bardzo są opóźnione względem swego źródła danych. Ciekawsze pola dla każdego serwera to m.in.:

optimeDateStempel czasowy ostatniej wykonanej modyfikacji danych na danym serwerze
lastHeartbeatStempel czasowy ostatniego bicia serca jaki dotarł od danego serwera
pingMsCzas przesłania po sieci ostatniego bicia serca od danego serwera
errmsgKomunikat przysłany w ostatnim bicu serca (często nie jest to błąd, a jedynie informacja np. o wykonywaniu wstępnej synchronizacji danych)

Gdy wykonamy polecenie rs.status() na serwerze podrzędnym, znajdziemy pole syncingTo — jego wartość mówi który serwer jest źródłem replikowanych danych.

Polecenie db.printReplicationInfo() (na serwerze podstawowym) lub db.printSlaveReplicationInfo() (na serwerze podrzędnym) wyświetli bardziej szczegółowe informacje o synchronizacji i opóźnieniach.

Innym poleceniem przydatnym przy monitorowaniu stanu zbioru replik (nie pokazuje serwerów ukrytych) jest:

> db.isMaster()

Aby zatrzymać cały zestaw replik:

> rs.stopSet()

Obsługa zbioru replik przez aplikację

Z punktu widzenia aplikacji zbiór replik zachowuje się jak serwer pracujący w trybie standalone. Otwierając połączenie ze zbiorem replik klient może podać namiary na jeden lub kilka serwerów zbioru replik, a pozostałe zostaną automatycznie wykryte przez sterownik. URL połączenia będzie miał postać:

mongodb://server-1:27017,server-2:27017

W razie przejęcia funkcji serwera podstawowego przez któryś serwer podrzędny, sterownik automatycznie wykrywa to. Tym niemniej do czasu zakończenia elekcji sterownik będzie zgłaszał błąd i to do aplikacji należy ustalenie, czy ostatni zapis na starym serwerze podstawowym się powiódł.

Aby upewnić się, że ostatni zapis został rozpropagowany do większości serwerów zbioru replik, klient musi wykonać polecenie getLastError z odpowiednimi parametrami w i wtimeout:

> db.runCommand({"getLastError", "w": "majority", "wtimeout": 1000})

Parametr wtimeout podaje czas w milisekundach jaki należy poczekać na zakończenie operacji i zgłosić błąd w razie przekroczenia tego czasu.

Typowym zastosowaniem parametru w jest dławienie zapisu. MongoDB pozwala na wykonywanie zapisów na tyle szybko że serwery podrzędne nie będą w stanie nadążyć. Dlatego też co jakiś czas warto wykonać getLastError z parametrem w ustawionym na "majority" i sensownym timeoutem, aby wstrzymać bieżące połączenie klienckie do czasu zakończenia replikacji ostatnio wykonanej operacji zapisu. Opuszczenie parametru w spowoduje, że getLastError zwróci sukces po udanym zapisie na serwerze podstawowym, bez czekania na replikację.

Biorąc pod uwagę lokalizację poszczególnych serwerów zbioru replik, można zdefiniować bardziej wyrafinowane warunki "udanego zapisu" niż tylko "majority". Np. jeśli serwery zostały rozrzucone po kilku centrach danych, możemy sobie zażyczyć aby udana operacja zapisu oznaczała że zostanie ona zreplikowana w co najmniej jednym serwerze podrzędnym każdego centrum danych. Aby to zrobić, każdemu serwerowi przyporządkowujemy określone tagi:

> var config = rs.config()
> config.members[0].tags = {"dc": "us-east"}
> config.members[1].tags = {"dc": "us-east"}
> config.members[2].tags = {"dc": "us-east"}
> config.members[3].tags = {"dc": "us-east"}
> config.members[4].tags = {"dc": "us-west"}
> config.members[5].tags = {"dc": "us-west"}
> config.members[6].tags = {"dc": "us-west"}

Kolejnym krokiem jest zdefiniowanie reguły, o nazwie np. eachDC która mówi w ilu grupach co najmniej jeden serwer musi potwierdzić zapis:

> config.settings = {}
> config.settings.getLastErrorModes = [{"eachDC": {"dc": 2}}]
> rs.reconfig( config )

Teraz możemy wywoływać getLastError tak:

> db.runCommand({"getLastError": 1, "w": "eachDC", "wtimeout": 1000})

Jak już wcześniej wspomniano, zrównoleglanie odczytu za pomocą replik nie jest zalecane. Jeśli się jednak uprzemy, to dla każdej operacji odczytu można zdefiniować preferencję odczytu inną niż domyślne Primary:

  • Primary preferred; ponieważ procedura elekcji może potrwać w niesprzyjających okolicznościach nawet kilka minut, można zażądać aby w "okresie bezkrólewia" można było czytać z serwerów podrzędnych
  • Nearest: będziemy czytać z tego serwera, który ma najkrótszy średni czas podróży pakietu TCP (ping); jeśli chcemy podobnie zrobić z operacjami zapisu, musimy użyć rozkruszania
  • Secondary: gdy chcemy aby na serwerze podstawowym miały miejsce tylko zapisy, a odczyty na serwerach podrzędnych
  • Secondary preferred: jak Secondary, ale gdy zdarzy się sytuacja że nie będzie żadnych serwerów podrzędnych, odczyty będą wykonywane na serwerze podstawowym.

Gdy mimo wszystko uprzemy się przy zrównoleglaniu odczytu na poziomie zbioru replik, rozważmy scenariusz w którym na serwerze podstawowym wykonujemy operację tworzenia indeksu. Na ogół jest to operacja mocno zasobożerna i w istotny sposób ogranicza obsługę bieżących zapytań i poleceń modyfikacji. Ponieważ serwery podrzędne postępują w ślad za podstawowym, wszystkie serwery zbioru replik zaczynają tworzyć indeks — w takiej sytuacji nasze nadzieje na szybszy równoległy odczyt z wielu serwerów zbioru replik naraz okazują się naiwne.

Backupy

Zalecany sposób robienia kopii zapasowych zbioru replik to snapshot lub kopia katalogu danych w systemie plików serwera podrzędnego. Polecenia mongodump i mongorestore są bardziej kłopotliwe, ponieważ problemem jest odtworzenie oploga.

Administracja Bazy

Aktualnie wykonywane operacje i ubijanie

> db.currentOp()

To polecenia zwraca informacje o aktualnie wykonywanych zapytaniach. Szczególnie warte uwagi są pola:

opidunikalny identyfikator, który podajemy żeby ubić zapytanie
activefalse oznacza że operacja czeka na blokadę lub przepuściła inne zapytanie.
secs_runningjak długo wykonuje się zapytanie
optyp operacji (query, insert, update, remove); polecenia bazy danych są interpretowane jako zapytania
descpozwala skojarzyć zapytanie z logami (prefiks wiersza logów)
locksrodzaje blokad zajętych przez zapytanie, "^" oznacza blokadę globalną
lockstats.timeAcquiringMicrosjak dużo czasu operacja do tej pory musiała czekać na zajęcie potrzebnych jej blokad
clientkto wykonuje zapytanie (IP i port klienta)

Parametrem currentOp może być zapytanie, aby wybrać tylko pasujące rekordy (np. operacje wykonujące się dłużej niż 10 sekund). Niektóre długo wykonujące się operacje są potrzebne i nie należy ich ubijać (np. związane z replikacją i shardingiem).

Aby ubić zapytanie:

> db.killOp( opid )

Zwykle udaje się ubicie operacji typu find, update i remove, natomiast z ubiciem operacji zajmujących blokady lub czekających na blokadę może być problem. W wyniku wykonania currentOp w rekordzie ubitej operacji będzie widoczne pole killed, do czasu usunięcia z listy wykonywanych operacji.

Profiler

Aby wyłapać wszystkie wolno wykonujące się informacje na poziomie całej bazy, możesz użyć wbudowanego w MongoDB profilera, który w kolekcji system.profile gromadzi rekordy o wybranych operacjach. Uwaga: profiler może znacząco spowolnić działanie całej bazy, więc raczej nie należy go trzymać włączonego cały czas.

Domyślnie profiler jest wyłączony, a włączamy go poleceniem

> db.setProfilingLevel( level )

Poziom 0 oznacza wyłączony, 2 — zbieraj statystyki o wszystkich operacjach odczytu i zapisu, 1 — zbieraj informacje tylko o tych zapytaniach które wykonują się dłużej niż określony czas (domyślnie 100 ms):

> db.setProfilingLevel( 1, 500 )

Aktualny poziom pracy profilera zwraca polecenie db.getProfilingLevel().

Szacowanie rozmiaru danych w bazie

Rozmiar dokumentu na dysku poznajemy wykonując polecenie:

> Object.bsonsize( document )

Obliczenia nie uwzględniają jednak dodatkowej przestrzeni przeznaczonej na rozrost rekordu (ang. padding) oraz odpowiadających pozycji indeksów, które łącznie mogą znacznie zwiększyć zajmowany na dysku obszar.

Statystyki wskazanej kolekcji wyświetla polecenie stats (parametr pozwala określić jednostki inne niż bajt, tutaj megabajt):

> db.collName.stats(1024*1024)

Co ciekawsze pola:

sizesuma bsonsize na wszystkich elementach kolekcji
countliczba elementów kolekcji
avgObjSizesize/count
storageSizerozmiar kolekcji na dysku (z uwzględnieniem dopełnienia rekordów i wolnej przestrzeni na końcu)
nindexesliczba indeksów kolekcji

Indeks zwykle zajmuje na dysku znacząco więcej miejsca niż indeksowane dane, zwłaszcza z uwzględnieniem dopełnienia na rozbudowę indeksu.

Analogiczne statystyki możemy sobie zażyczyć dla całej bazy:

> db.stats()

Pole objects zawiera łączną liczbę dokumentów we wszystkich kolekcjach, a fileSize sumaryczną objętość plików danych bazy. Z kolei dataSize to miejsce na dysku zajmowane przez rekordy wraz z dopełnieniem, ale bez wolnej przestrzeni na końcu plików kolekcji.

Monitorowanie wydajności: mongotop i mongostat

Obydwa polecenia to narzędzia wiersza poleceń linuksowego shella, stylizowane na linuksowe polecenia top i iostat.

Polecenie mongotop odświeża co sekundę statystyki aktywności odczytu/zapisu w poszczególnych kolekcjach bazy. Uruchomienie z opcją --locks pokazuje statystyki blokad dla każdej bazy danych.

Co ciekawsze kolumny wyniku polecenia mongostat:

insert/query/update/
delete/getmore/command
liczniki operacji
flushesile razy zapisano na dysku zbuforowane dane
mappedilość mapowanej pamięci, odpowiadająca z grubsza rozmiarowi danych w bazie
vsizeilość pamięci wirtualnej używanej przez serwer, z grubsza dwukrotność mapped (mapowane pliki + journaling)
reszużywana ilość pamięci RAM
qr/qwliczba zakolejkowanych operacji odczytu i zapisu
netIn/netOutliczba bajtów odebranych/wysłanych po sieci
connliczba otwartych połączeń

Uwierzytelnianie

MongoDB udostępnia jedynie gruboziarniste uwierzytelnianie dla połączenia. Jedna baza danych może mieć wielu użytkowników. Gdy uwierzytelnianie jest włączone (domyślnie nie jest), jedynie uwierzytelnieni użytkownicy mogą wykonywać operacje odczytu i zapisu. Użytkownicy baz admin i local mają uprawnienia administratorskie: mogą czytać i pisać do dowolnej bazy i wykonywać dodatkowe polecenia takie jak listDatabases czy shutdown.

Aby włączyć uwierzytelnianie, należy zdefiniować jakichś użytkowników i zrestartować serwer z opcją --auth. Poniżej tworzymy trzech użytkowników: administratora, zwykłego użytkownika z prawami odczytu/zapisu i użytkownika z prawami tylko do odczytu. (Polecenie addUser służy również do zmiany hasła i statusu read-only).

> use admin
> db.addUser("root", "password")
> use test
> db.addUser("test_user", "password2")
> db.addUser("read_user", "password3", true)

Uwierzytelnienie wymaga wykonania polecenia:

db.auth( "read_user", "password3" )

MongoDB ma jedno dziwactwo: dopóki nie zdefiniujesz przynajmniej jednego użytkownika bazy admin, lokalni klienci serwera mogą wykonywać operacje odczytu i zapisu.

Utworzeni użytkownicy są przechowywani w kolekcji system.users, hasło jest przechowywane w postaci skrótu.

Aby usunąć użytkownika:

> db.system.users.remove( { "user": "test_user" } )

Podgrzewanie bazy

Aby umieścić zawartość wybranej kolekcji w pamięci (razem z indeksami):

> db.runCommand( { "touch": "collName", "data": true, "index": true } )

Ściskanie danych

Po wykonaniu wielu operacji wstawiania, aktualizacji i usuwania danych pliki danych na dysku mogą zawierać wiele "dziur". Aby ścisnąć dane (uwaga: jest to operacja czasochłonna i zasobożerna), można użyć polecenia:

> db.runCommand( { "compact": "collName", "paddingFactor": 1.5 } )

Jeśli dokumenty kolekcji mogą być w przyszłości aktualizowane, być może warto uwzględnić dodatkowe miejsce obok każdego z nich, aby zapobiec przenoszeniu rekordu na koniec pliku. Współczynnik dopełnienia (ang. padding factor) można podać jako współczynnik wielkości rekordu: od 1 (brak dopełnienia) do 4 (dopełenienie o wielkości 3-krotnej wielkości rekordu).

Ściskanie danych nie powoduje zmniejszenia wielkości plików bazy danych — dane są jedynie przenoszone na początek pliku. Aby jednocześnie spakować dane i zmniejszyć rozmiar plików danych, można wymusić aby serwer przepisał wszystkie dane bazy do nowych, mniejszych plików (wymaga to dodatkowego miejsca na dysku o wielkości mniej więcej takiej jak wszystkie do tej pory powstałe pliki bazy):

db.repairDatabase()

Ten sam efekt daje uruchomienie mongod z opcją --repair, co dodatkowo pozwala na podanie opcji --repairpath ze wskazaniem miejsca (np. na innym dysku, jeśli na bieżącym dysku z danymi już nie ma) potrzebnego na reorganizację.

Zmiana nazwy kolekcji jest prosta:

> db.sourceColl.renameCollection( "newName", true )

Drugi argument mówi co zrobić jeśli kolekcja o docelowej nazwie już istnieje: true oznacza usunąć ją.

Aby skopiować kolekcje między bazami danych tego samego serwera, musisz zrobić backup i odtworzyć go na innej bazie, albo zrobić find i wyniki wstawić (insert) do innej bazy.

Jeśli natomiast chcesz skopiować kolekcję między różnymi serwerami MongoDB, możesz na docelowym serwerze użyć do tego polecenia:

> db.runCommand( { "cloneCollection": "collName", "from": "hostname:27017" } )

Trwałość zapisu danych i odtwarzanie po awarii

Podobnie jak w przypadku relacyjnych baz danych, MongoDB tworzy dziennik w celu odtwarzania stanu bazy na użytek awarii. Zawiera on dane z ostatnich co najwyżej 60 sekund, ponieważ domyślnie co 60 sekund brudne dane są flushowane na dysk. Pliki dziennika znajdują się zwykle w katalogu /data/db/journal.

Zwykle zapis do dziennika następuje co 100 ms lub co kilka megabajtów (w zależności od tego co będzie prędzej). Można ten parametr modyfikować poleceniem:

> db.adminCommand( { "setParameter": 1, "journalCommitInterval": 10 } )

Aby mieć pewność że dotychczas wykonane operacje zapisu znajdą się na dysku zanim zlecisz kolejne, należy wykonać polecenie:

> db.runCommand( { "getLastError": 1, "j": true } )

Aby sprawdzić czy dane kolekcji nie są uszkodzone:

> db.colName.validate()

W wyniku dostajemy JSONa, w którym pole valid odpowiada na pytanie, a w przypadku true możemy w innym polu JSONa znaleźć dokładniejszy opis. Pole deletedCount zwraca liczbę dokumentów usuniętych z kolekcji w trakcie jej życia. Niestety, nie ma sposobu na analogiczne sprawdzenie poprawności indeksu — pozostaje jedynie wykonać zapytanie które wymusi przejście po nim.

Istnieją dwa sposoby naprawiania danych po awarii.

Pierwszy jest wbudowany w mongod. Poniższe polecenie włącza serwer w trybie naprawy danych zamiast nasłuchiwać na porcie serwera — postępy procesu naprawy widoczne są w logach. Ponieważ naprawa wymaga dodatkowego miejsca na dysku (o wielkości mniej więcej równej objętości plików danych bazy), można użyć opcji --repairpath wskazującej miejsce na innym dysku niż bieżący.

$ mongod --dbpath /path/to/corrupt/data --repair --repairpath /media/external-hd/data/db

Drugi sposób (pozwalający potencjalnie odzyskać więcej danych, ale za cenę dłuższego czasu działania):

$ mongodump --repair

Instalacja

Instalacja sprowadza się do rozpakowania archiwum ściągniętego z mongodb.org/downloads, utworzenia katalogu na dane i nadania mu uprawnień.

Schemat numeracji wersji jest taki, że parzysta druga liczba po przecinku oznacza wersję stabilną (np. 2.2, 2.4), a nieparzysta deweloperską (np. 2.5). Kolejne stabilne wersje są wypuszczane co ok. 6 miesięcy.

Administracja Serwera

Start i stop

Typowe opcje uruchamiania:

$ ./mongod --port 5586 --logpath mongodb.log --logappend

Klienta wiersza poleceń uruchamiamy poleceniem

$ ./mongo [--host localhost --port 27017]

Jest to zasadniczo lekko zmodyfikowany interpreter JavaScriptu.

Kolekcja local.startup_log zawiera podstawowe informacje o systemie: wersję MongoDB, system operacyjny, flagi uruchomienia.

> use local
> db.startup_log.findOne()<pre class="output">{
	"_id" : "paradox-1433072547151",
	"hostname" : "paradox",
	"startTime" : ISODate("2015-05-31T11:42:27Z"),
	"startTimeLocal" : "Sun May 31 13:42:27.151",
	"cmdLine" : {
		"bind_ip" : "127.0.0.1",
		"config" : "/etc/mongodb.conf",
		"dbpath" : "/var/lib/mongodb",
		"journal" : "true",
		"logappend" : "true",
		"logpath" : "/var/log/mongodb/mongodb.log"
	},
	"pid" : 24207,
	"buildinfo" : {
		"version" : "2.4.9",
		"gitVersion" : "nogitversion",
		"sysInfo" : "Linux orlo 3.2.0-58-generic #88-Ubuntu SMP Tue Dec 3 17:37:58 UTC 2013 x86_64 BOOST_LIB_VERSION=1_54",
		"loaderFlags" : "-fPIC -pthread -rdynamic",
		"compilerFlags" : "-Wno-unused-local-typedefs -Wnon-virtual-dtor -Woverloaded-virtual -fPIC -fno-strict-aliasing \
                    -ggdb -pthread -Wall -Wsign-compare -Wno-unknown-pragmas -Winvalid-pch -Werror -pipe -fno-builtin-memcmp -O3",
		"allocator" : "tcmalloc",
		"versionArray" : [ 2, 4, 9, 0 ],
		"javascriptEngine" : "V8",
		"bits" : 64,
		"debug" : false,
		"maxBsonObjectSize" : 16777216
	}
}

Eleganckie zatrzymywanie serwera odbywa się poprzez wysłanie mu sygnału SIGINT (kill -2) lub SIGTERM (kill), albo z konsoli:

> use admin
> db.shutdownServer()

Domyślnie połączenia z MongoDB nie są szyfrowane (problemy licencyjne), ale można go skompilować ze wsparciem dla SSL.

Poziom logowania można zmienić z domyślnego (0) na max. 5:

> db.adminCommand( { "setParameter": 1, "logLevel": 3 } )

Backupy

Backupy można robić na trzy sposoby.

Jeśli system plików pozwala na snapshoty i masz włączony journaling MongoDB, możesz po prostu wykonać snapshot.

Drugi sposób to zablokowanie zapisu na dysk poleceniem db.fsyncLock() (od tej pory wszystkie zlecenia zapisu będą kolejkowane), skopiowanie katalogu danych, np. poleceniem

$ cp -R /data/db/* /mnt/external-drive/backup

i odblokowanie zapisu poleceniem db.fsyncUnlock().

Trzeci sposób (najmniej efektywny, ale pozwala wykonać backup na poziomie kolekcji lub fragmentu kolekcji), to użycie polecenia mongodump.

Wykonane na serwerze MongoDB polecenie

$ mongodump -p [port]

utworzy katalog dump w którym dla każdej bazy i kolekcji utworzone zostaną odpowiednie katalogi, a w nich dane w plikach z rozszerzeniem .bson (można je eksplorować poleceniem bsondump).

Gdy serwer jest wyłączony, ten sam efekt można osiągnąć poleceniem

$ mongodump --dbpath /data/db

Do odtworzenia danych używamy polecenia

$ mongorestore -p [port] --oplogReplay dump/ --drop

Opcja --drop usuwa z bazy kolekcję przed jej odtworzeniem z backupu.

Aby odtworzyć pojedynczy plik .bson:

$ mongorestore -db newDb --collection someOtherColl dump/oldDB/oldColl.bson

Uwaga: Ponieważ między poszczególnymi wersjami programów mongodump i mongorestore były spore zmiany, ważne jest żeby używać odpowiadających sobie wersji tych programów (opcja --version).

Wdrożenie

MongoDB najlepiej się sprawuje na 64-bitowych Linuksach. Oprogramowanie serwera stosuje mechanizm mapowania pamięci na dysk, co w przypadku systemów 32-bitowych ogranicza pojemność bazy do ok. 2GB.

Zdecydowanie zaleca się ustawienie liczby możliwych wątków i otwartych deskryptorów plików na unlimited. Nawet dla małych wdrożeń standardowa liczba otwartych deskryptorów (1024) to za mało — warto ustawić co najmniej 4096.

O ile tylko możliwe, należy wystarać się o jak najwięcej pamięci RAM i dyski SSD. Zysk z dysków SSD w porównaniu z HDD jest jeszcze większy niż w przypadku relacyjnych baz danych z uwagi na to że MongoDB wykonuje wiele małych niesekwencyjnych dostępów do dysku. Z uwagi na wzorzec wykorzystania dysku warto zredukować wartość readahead dla dysku (polecenie blockdev -setra, wymaga ponownego restartu MongoDB) przy czym wartość od 16 do 256 można uznać za zadowalającą (aby sprawdzić, patrz polecenie blockdev --report). Z tego samego powodu warto zrezygnować z opcji hugepages (rozmiar strony pamięci większy niż 4KB) gdy jest włączona.

Journal można umieścić na HDD bez większego wpływu na wydajność. W przypadku macierzy zaleca się RAID10, odradza się RAID5.

Warto przy montowaniu dysku w /etc/fstab użyć opcji noatime (zwłaszcza na starszych kernelach), co wyłączy aktualizowanie stempla czasowego w metadanych pliku po każdej modyfikacji — poprawi to wydajność serwera MongoDB, ale może uniemożliwić działanie programów wykorzystujących stempel, np. narzędzi do wykonywania backupów.

Lepiej zainwestować w RAM niż CPU. CPU jest używany mocno przy budowaniu indeksów i w obliczeniach MapReduce, ale w obecnej wersji MongoDB zwiększenie ilości rdzeni nie poprawia wydajności - ważniejsza jest wydajność pojedynczego rdzenia CPU niż liczba rdzeni.

Zbyt duże obciążenie procesora oznacza zwykle brak indeksu na często wykonywanym zapytaniu lub dużo wykonywanego JavaScriptu (np. MapReduce).

Sposób składowania danych jest identyczny na wszystkich systemach operacyjnych obsługiwanych przez MongoDB - można przenosić pliki danych między Windows i Linux, jak również używać w klastrze maszyn z różnymi systemami operacyjnymi.

MongoDB nie używa przestrzeni wymiany, ale zaleca się utworzyć małą jej rezerwę z uwagi na dość nerwowe zachowanie jądra Linuksa w sytuacji niedoboru pamięci. Przy tworzeniu dużego indeksu jądro może ubić proces mongod, ale mały swap zapobiega temu.

W przypadku Linuksa zaleca się używać systemu pliku Ext4 lub XFS, a zdecydowanie odradza się Ext3 (z uwagi na częste prealokowanie dużych plików i wypełnianie ich zerami). Odradza się także używanie NFS z uwagi na problemy z blokowaniem plików i flushowaniem danych.

W przypadku uruchamiania serwera na maszynie wirtualnej warto wyłączyć memory overcommiting (nie jest konieczny restart serwera):

$ echo 2 > /proc/sys/vm/overcommit_memory

W architekturach NUMA dardzo ważne jest wyłączenie NUMA najlepiej na poziomie BIOSu (np. opcja numa=off w grub.cfg) a w razie braku takiej możliwości, uruchamiać serwer MongoDB za pomocą polecenia

$ echo 0 > /proc/sys/vm/zone_reclaim_mode
$ numactl --interleave=all mongod [opcje]

Pod maską

MongoDB przechowuje dokumenty na dysku w binarnym formacie BSON. Kodowanie i dekodowanie BSONa odbywa się po stronie sterownika klienta.

Share and Enjoy:
  • del.icio.us
  • Facebook
  • Google Bookmarks
  • Śledzik
  • Blip
  • Blogger.com
  • Gadu-Gadu Live
  • LinkedIn
  • MySpace
  • Wykop

1 Komentarz do “MongoDB”

  1. Jarosław napisał(a):

    Szkoda, że tylko z linii poleceń. A nie ma aplikacji do uruchomienia.

Zostaw komentarz

XHTML: Możesz użyć następujących tagów: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>