Ci sono molti aspetti che possono impattare sulle performance di MongoDB, ed in generale di un qualsiasi sistema informatico, soprattutto quando esso è distribuito. Proprio per questo motivo non esistono delle ricette che possano essere applicate in ogni contesto per avere il risultato desiderato. È invece necessaria una buona analisi per capire i punti critici che causano il decadimento delle performance avvertito dall’utente.
Problemi architetturali
Utilizzando gli strumenti di monitoraggio che abbiamo visto nella lezione precedente, possiamo individuare una classe di problemi che potremmo definire architetturali.
Se ad esempio, utilizzando lo strumento mongotop, osserviamo che i tempi necessari per le scritture sul database sono elevati, allora possiamo intervenire in vari modi, ad esempio utilizzando dischi rigidi più veloci oppure valutando se non sia opportuno applicare lo sharding nel sistema per distribuire il carico di lavoro su più server. Se lo sharding è già abilitato e le scritture impattano un solo nodo, allora forse abbiamo scelto male la shard-key (e possiamo fare ulteriori indagini con sh.status). Un’altra opzione per aumentare la velocità delle scritture (quando esse interessano più database) è quella di lavorare sulla configurazione del server per far in modo di usare più dischi rigidi (uno per ogni database) utilizzando la configurazione storage.directoryPerDB. Tale configurazione utilizzerà una cartella per ogni database (ogni cartella avrà il nome del database): in Linux (e in generale nei sistemi operativi Unix-like) è infatti abbastanza facile implementare una corrispondenza tra una cartella ed il contenuto di un disco rigido.
Se, invece, il problema è nelle letture possono esserci diverse possibilità:
-
se le letture sono moltissime in quantità (lo si può vedere con mongostat o con gli altri comandi che abbiamo visto nella lezione 16) allora potremmo:
- nuovamente considerare di utilizzare sharding, o ampliarlo, oppure indagare se la shard-key è stata scelta opportunamente;
- decidere di configurare la lettura da nodi secondari del Replica Set (dando per scontato che si utilizzino in produzione), come abbiamo visto nella lezione 14, per distribuire il carico di letture;
- se invece le query sono poche, allora probabilmente le query sono poco efficienti e conviene profilarle.
Ottimizzazione delle query
Possono esserci generalmente due motivi per cui si è giunti alla conclusione che è necessario ottimizzare una query: o perché essa viene eseguita un gran numero di volte e quindi il suo impatto è importante, oppure perchè, nonostante essa venga eseguita più di rado, risulta comunque molto lenta. Prima di fare una qualsiasi modifica è importante essere certi che il problema sia derivante dalla query e non da altre cause, altrimenti si rischiano inutili sforzi di ottimizzazione.
Per prima cosa dobbiamo capire perché la query è lenta. Infatti, si potrebbe anche arrivare alla conclusione che non si possa far nulla per velocizzarla se non intervenendo sull’architettura del sistema (sharding, lettura da repliche secondarie, etc...). Il primo passo dunque è la profilatura.
In MongoDB la profilatura si effettua tramite il metodo explain
di un cursore, da aggiungere subito dopo un find
. Vediamo subito un esempio. Supponiamo di usare lo stesso database inizializzato con i dati della lezione 12. Allora si può profilare una query in questo modo:
> db.logs.find({ tipo: "Click", operazioni: 3} ).explain()
che, nella versione 2.6, dà un risultato simile a questo:
{
"cursor" : "BasicCursor",
"isMultiKey" : false,
"n" : 20000,
"nscannedObjects" : 80000,
"nscanned" : 80000,
"nscannedObjectsAllPlans" : 80000,
"nscannedAllPlans" : 80000,
"scanAndOrder" : false,
"indexOnly" : false,
"nYields" : 625,
"nChunkSkips" : 0,
"millis" : 74,
"server" : "MONGOHT:27017",
"filterSet" : false
}
Questo risultato è molto negativo; vediamo infatti che:
- la collezione è stata scansionata integralmente, senza usare indici (BasicCursor);
- sono stati visitati 80000 documenti (nscannedObjects).
Inoltre notiamo che l’operazione impiega 74 millisecondi (millis) e che sarebbero restituiti 20000 documenti.
Creazione di indici
Una prima soluzione potrebbe essere quella di inserire un indice. Supponiamo di volerlo inserire sul campo tipo:
db.logs.ensureIndex({ tipo: 1 })
Se profiliamo nuovamente la query, otteniamo un risultato che, tra gli altri, contiene questi campi:
"cursor" : "BtreeCursor tipo_1",
"n" : 20000,
"nscannedObjects" : 40000,
"nscanned" : 40000,
"nscannedObjectsAllPlans" : 40000,
"nscannedAllPlans" : 40000,
"scanAndOrder" : false,
"millis" : 73,
Notiamo quindi che, pur essendoci, il miglioramento è stato modesto, perché la query ha visitato 40000 documenti usando l’indice (lo vediamo nel campo cursor) e ha impiegato 73 millisecondi. Se creiamo un indice sul campo operazioni, il risultato diventa più apprezzabile: 20000 nodi scansionati per circa 40 millisecondi in questo server. Ora è chiaro che abbiamo trovato l’indice ottimo per questa query, dal momento che il numero di nodi restituiti è uguale al numero di nodi scansionati.
Naturalmente, a monte dell’indicizzazione, potremmo chiederci se ha senso un risultato di 20000 record. Ad esempio, se la nostra applicazione deve mostrare in una pagina web questo risultato, mostrare i primi 100 documenti potrebbe essere più che sufficiente nella maggior parte dei contesti (ed in caso contrario si può ricorrere alla paginazione). Quindi, se eseguiamo:
> db.logs.find({ tipo: "Click", operazioni: 3} ).limit(100).explain()
le prestazioni risulteranno molti migliori: 100 documenti visitati in 1 millisecondo. Chiaramente, non esiste una "ricetta" da applicare ogni volta che si vuole ottimizzare una query. L’utilizzo di explain ci permette di capire il "perché", e sta a noi, in base all’uso che dobbiamo fare del risultato, intervenire.
Limitazione dei dati
Un altro modo di velocizzare le query è di ridurre la quantità di dati restituita, non soltanto limitando il numero di documenti, ma anche specificando i soli campi che ci interessano ai fini del risultato finale:
> db.logs.find({ tipo: "Click", operazioni: 3}, { page: 1 } ).limit(100)
Ciò è particolarmente vero quando i documenti contengono molti campi, alcuni dei quali possono essere anche piuttost grandi. In questo modo otterremo benefici non solo a livello del server, che deve recuperare meno dati dai dischi rigidi, ma anche a livello della rete, che beneficia di una minore richiesta di banda.
Novità in MongoDB 3
Dobbiamo notare che nella versione 3.0, rilasciata il 3 Marzo 2015, il metodo explain
è stato ulteriormente ampliato, e permette di ottenere informazioni aggiuntive su quale piano di esecuzione è stato scelto: in altre parole, saremo in grado di capire quale tra le varie opzioni che MongoDB aveva per eseguire effettivamente la ricerca sui dati verrà effettivamente usata. Tale informazione può essere utile per capire se la lentezza della query è dovuta al fatto che MongoDB, facendo delle assunzioni errate sui dati, abbia scelto un piano poco favorevole. Per esempio se, dovendo scegliere tra due indici, avesse scelto quello più svantaggioso nel caso della query da eseguire, è possibile suggerire a MongoDB l’indice da usare:
> db.logs.find({ tipo: "Click", operazioni: 2}).hint({tipo: 1}).explain()