back to top

MongoDB: migliorare le prestazioni delle query grazie agli indici

Questa lezione sarà una breve introduzione agli indici. Dal momento che si tratta di un argomento abbastanza complesso ed importante, in questa guida ci limiteremo ad illustrare solo i concetti fondamentali. Per maggiori dettagli è consigliato consultare la documentazione ufficiale.

Cos’è un indice

Un indice in MongoDB è una struttura in cui vengono conservate porzioni di dati di una collezione. Per gli indici vengono di solito sfruttate delle strutture dati efficienti e veloci da scansionare in modo da rendere le query estremamente performanti. MongoDB usa un B-albero (BTree).

Per ogni collezione potremo creare diversi indici in base al tipo di query da eseguire. Gli indici saranno poi usati interamente o come struttura di supporto per individuare i dati in modo più efficiente, riducendo così il numero di documenti da esaminare.

Bisogna comunque prestare particolare attenzione quando si creano degli indici. Se è vero da una parte che possono ridurre i tempi di lettura in modo significativo, dall’altra dobbiamo tenere bene in mente che per ogni indice sarà necessaria una maggiore occupazione dello spazio di archiviazione. Inoltre, poiché gli indici devono essere aggiornati, riscontreremo tempi più lunghi per le operazioni di scrittura e modifica dei documenti.

L’analogia più frequente quanto si parla di indici di un database è quella dell’indice di un libro. Se dovessimo infatti cercare una precisa infomazione in un libro, abbiamo 2 possibilità. La prima consiste nello sfogliare il libro pagina per pagina. All’aumentare del numero di pagine del libro, diventa un’operazione lunga e poco efficiente. In alternativa, se è presente un indice e se questo contiene le voci richieste, riusciremo a trovare le informazioni in minor tempo.

Per quanto riguarda gli indici in MongoDB, una query che non utilizza un indice deve eseguire la scansione di tutti i documenti di una collezione finché non trova quelli richiesti. MongoDB si riferisce a questo tipo di operazione con il termine Collection Scan. Proprio per evitare lunghe e lente operazioni di scansione di intere collezioni, vengono creati gli indici.

Come creare un indice in MongoDB

Vediamo come creare un indice e partiamo da un esempio. Nella quinta lezione abbiamo visto come caricare uno script ed eseguire una serie di istruzioni per popolare una collezione del database. Usufruendo sempre di Faker, generiamo 1 milione di documenti con dati fittizi relativi a degli utenti.

const { faker } = require('@faker-js/faker');

let index = 0
const NUM_OF_USERS = 1e6;

// equivalente al comando 'use ecommerce'
db.getSiblingDB('ecommerce'); 

db.users.drop();

db.createCollection('users');

const createUser = () => ({
  first_name: faker.name.firstName(),
  last_name: faker.name.lastName(),
  email: faker.internet.email(),
  city: faker.address.cityName(),
  age: faker.datatype.number({min: 18, max: 100})
});

const users = Array.from({ length: NUM_OF_USERS }, createUser);

db.users.insertMany(users);

Una volta creata la collezione caricando lo script con il comando load() (abbiamo visto sempre nella lezione 5 come fare), possiamo eseguire una query sulla collezione ‘users’ ed osservare la differenza in termini di prestazioni nel caso in cui venga usato un indice o meno.

Invochiamo allora il metodo db.users.find() e cerchiamo i documenti con un campo ‘city’ uguale a ‘Sunrise’. Usiamo inoltre il metodo cursor.explain() per capire in che modo vengono prelevate le informazioni dalla collezione. Partiamo dal caso in cui non è ancora stato creato un indice per il campo ‘city’.

> db.users.find({city: 'Sunrise'}).explain('executionStats')
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: { city: { '$eq': 'Sunrise' } },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'COLLSCAN',
      filter: { city: { '$eq': 'Sunrise' } },
      direction: 'forward'
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 1024,
    executionTimeMillis: 1053,
    totalKeysExamined: 0,
    totalDocsExamined: 1000000,
    executionStages: {
      stage: 'COLLSCAN',
      filter: { city: { '$eq': 'Sunrise' } },
      nReturned: 1024,
      executionTimeMillisEstimate: 19,
      works: 1000002,
      advanced: 1024,
      needTime: 998977,
      needYield: 0,
      saveState: 1000,
      restoreState: 1000,
      isEOF: 1,
      direction: 'forward',
      docsExamined: 1000000
    }
  },
  command: { find: 'users', filter: { city: 'Sunrise' }, '$db': 'ecommerce' },
  ok: 1
}

Come possiamo osservare, abbiamo invocato cursor.explain() passando la stringa ‘executionStats’ che consente di visualizzare qual è il piano di esecuzione scelto dal Query Optimizer per eseguire la query. Sono anche riportate le statistiche che descrivono l’esecuzione del piano vincente nel campo ‘executionStats’ del documento restituito.

Dal risultato del metodo explain() capiamo che i documenti che soddisfano la query ({city: 'Sunrise'}) sono 1024 (campo executionStats.nReturned). Il tempo totale in millisecondi necessario per la selezione del piano di query e l’esecuzione della query (executionStats.executionTimeMillis) è pari a 1053 ms. Non essendo presente, non è stata consultata alcuna voce di un indice (executionStats.totalKeysExamined). Al contrario sono stati esaminati 1 milione di documenti della collezione (executionStats.totalDocsExamined).

A conferma del fatto che è stato esaminato ciascun documento della collezione e che non è stato consultato alcun indice, notiamo che è presente una sola fase del processo di esecuzione della query e il campo executionStats.executionStages.stage è pari a ‘COLLSCAN’, termine col quale ci si riferisce proprio ad una scansione della collezione.

A questo punto possiamo creare un indice sul campo ‘city’ con il metodo il metodo db.collection.createIndex().

db.users.createIndex({"city": 1})
city_1

Nella sua forma più semplice basterà passare un solo argomento il quale rappresenta un documento che contiene delle coppie campo/valore. In questo modo indichiamo per quale campo dei documenti della collezione vogliamo creare un indice. Il valore assegnato descrive il tipo di indice per quel campo. Per un indice crescente specifichiamo un valore pari a 1, per l’indice decrescente, un valore uguale a -1. Nell’esempio creiamo quindi un indice per il campo ‘city’ in ordine crescente. Otteniamo in risposta l’identificativo che contraddistingue l’indice appena creato.

Prima di ripetere la query eseguita in precedenza, presentiamo velocemente alcuni metodi per gestire gli indici.

Abbiamo già visto come crearne uno, se volessimo invece elencare tutti gli indici relativi ad una collezione, potremmo farlo con il metodo db.collection.getIndexes() il quale restituisce un array di documenti che contengono le informazioni relative agli indici come il nome, il campo a cui sono riferiti ed eventuali altre opzioni indicate in fase di creazione.

db.users.getIndexes()
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  { v: 2, key: { city: 1 }, name: 'city_1' }

Notiamo che oltre all’indice da noi scelto, MongoDB crea automaticamente un indice per il campo ‘_id’ di una collezione.

Se volessimo ora eliminare un indice, sarà sufficiente invocare db.collection.dropIndex() a cui passiamo come primo argomento una stringa che rappresenta il nome dell’indice da eliminare. Per esempio potremmo eliminare l’indice appena creato eseguendo:

db.users.dropIndex('city_1')
{ nIndexesWas: 2, ok: 1 }

Dopo aver visto come gestire gli indici, torniamo al nostro primo esempio e dopo esserci assicurati che è presente un indice per il campo ‘city’ della collezione ‘users’, eseguiamo nuovamente la query per cercare tutti i documenti relativi agli utenti che vivono a ‘Sunrise’.

db.users.find({city: 'Sunrise'}).explain('executionStats')
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: { city: { '$eq': 'Sunrise' } },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { city: 1 },
        indexName: 'city_1',
        isMultiKey: false,
        multiKeyPaths: { city: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: { city: [ '["Sunrise", "Sunrise"]' ] }
      }
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 1024,
    executionTimeMillis: 12,
    totalKeysExamined: 1024,
    totalDocsExamined: 1024,
    executionStages: {
      stage: 'FETCH',
      nReturned: 1024,
      executionTimeMillisEstimate: 0,
      works: 1025,
      advanced: 1024,
      needTime: 0,
      needYield: 0,
      saveState: 1,
      restoreState: 1,
      isEOF: 1,
      docsExamined: 1024,
      alreadyHasObj: 0,
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 1024,
        executionTimeMillisEstimate: 0,
        works: 1025,
        advanced: 1024,
        needTime: 0,
        needYield: 0,
        saveState: 1,
        restoreState: 1,
        isEOF: 1,
        keyPattern: { city: 1 },
        indexName: 'city_1',
        isMultiKey: false,
        multiKeyPaths: { city: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: { city: [ '["Sunrise", "Sunrise"]' ] },
        keysExamined: 1024,
        seeks: 1,
        dupsTested: 0,
        dupsDropped: 0
      }
    }
  },
  command: { find: 'users', filter: { city: 'Sunrise' }, '$db': 'ecommerce' },
  ok: 1
}

Dettagli di esecuzione di una query con cursor.explain()

Analizziamo l’output del metodo cursor.explain() dell’esempio precedente per capire quali informazioni vengono fornite.

Vediamo subito che è presente un campo ‘queryPlanner’ in cui troviamo la chiave ‘parsedQuery’ che indica quale query è stata eseguita. Per questa query, il Query Planner di MongoDB seleziona un piano vincente ovvero il metodo che ritiene più efficiente per restituire i documenti richiesti. Nel campo ‘winningPlan’ troviamo proprio queste informazioni. Notiamo che sono state completate due fasi. Nella prima (stage: ‘IXSCAN’) è stato consultato l’indice ‘city_1’. Nella seconda fase (stage: ‘FETCH’), dopo aver individuato la città richiesta nell’indice, sono stati recuperati i documenti relativi nella collezione.

Il campo ‘executionStats’ contiene invece le informazioni che ci permettono di vedere che sono stati restituiti 1024 documenti, ma non è stato necessario esaminare tutti i documenti (1 milione) della collezione. È stato sufficiente consultare 1024 voci dell’indice (‘totalKeysExamined’) e successivamente sono stati letti solo i documenti necessari nella collezione (‘totalDocsExamined’). Notiamo che siamo passati dai 1053ms della query senza indice a soli 12 ms (‘executionTimeMillis: 12’). L’uso di un indice corretto consente dunque di minimizzare il rapporto fra il numero di documenti analizzati e il numero di documenti restituiti.

È anche presente un campo ‘rejectedPlans’ di tipo array che nel nostro esempio è vuoto. Per capire di cosa si tratta, è necessario illustrare in che modo viene selezionato un indice dal Query Planner per eseguire una query.

Come viene selezionato un indice da MongoDB

Per scegliere il piano da utilizzare, MongoDB analizza la query ricevuta ed estrae infomazioni come i campi usati per filtrare o ordinare i documenti. In base a questi verifica quali indici sono presenti per la collezione ed identifica alcuni potenziali indici che potrebbero essere usati per velocizzare la query.

Per ciascun indice candidato, MongoDB crea un piano e lo esegue in parallelo agli altri. Durante questo periodo di prova, vengono raccolti i risultati che vengono poi usati per determinare qual è il piano più efficace per una determinata query. Il piano vincente è quello in grado di retituire prima i risultati richiesti o evitare operazioni dispendiose come l’ordinamento dei documenti in memoria. In quest’ultimo caso infatti tutti i documenti prelevati vengono caricati nella RAM e ordinati successivamente. L’ordinamento in memoria è inefficiente, può richiede molto tempo e se si tratta di un gran numero di documenti può essere un’operazione lenta e dispendiosa.

Una volta scelto un piano, questo viene salvato all’interno di un cache che viene mantenuta finché non si riavvia il processo mongod. Per le query successive che hanno la stessa forma di quella appena analizzata, MongoDB conosce già quale indice selezionare. Se una collezione viene modificata con conseguente aggiornamento degli indici, MongoDB rivaluta i piani salvati in cache e potrebbe decidere di eventualmente rimuovere quelli che non ritiene più validi.

Indici composti

Oltre agli indici semplici su un singolo campo, MongoDB supporta anche indici composti. Un indice composto contiene riferimenti a più campi.

Possiamo creare un indice composto elencando i campi che ne devono far parte e l’ordinamento per ciascun campo (crescente o decrescente).

Per esempio possiamo creare un indice composto per ‘last_name’ e ‘first_name’ della collezione ‘users’ con il metodo db.collection.createIndex().

> db.users.createIndex({last_name: 1, first_name: 1})

L’ordine dei campi è rilevante. Infatti le voci dell’indice appena creato saranno ordinate in base al cognome (campo ‘last_name’). Per ciascun cognome, le voci saranno poi ordinate in base al nome (‘first_name’) così come avverrebbe in una rubrica telefonica.

Ciò significa che l’indice appena creato soddisfa le query che filtrano i documenti in base a ‘last_name’ e ‘first_name’, ma anche per query sul solo campo ‘last_name’. Al contrario se cerchiamo un documento in base al nome di un utente (‘first_name’), non sarà possibile usare l’indice creato.

Possiamo verificare quanto affermato, eseguendo nuovamente il metodo explain() come mostrato sotto.

> db.users.find({last_name: 'Gibson', first_name: 'Letha'})
  .explain('executionStats') 

{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: {
      '$and': [
        { first_name: { '$eq': 'Letha' } },
        { last_name: { '$eq': 'Gibson' } }
      ]
    },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { last_name: 1, first_name: 1 },
        indexName: 'last_name_1_first_name_1',
        isMultiKey: false,
        multiKeyPaths: { last_name: [], first_name: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          last_name: [ '["Gibson", "Gibson"]' ],
          first_name: [ '["Letha", "Letha"]' ]
        }
      }
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 1,
    executionTimeMillis: 0,
    totalKeysExamined: 1,
    totalDocsExamined: 1,
    executionStages: {
      stage: 'FETCH',
      nReturned: 1,
      executionTimeMillisEstimate: 0,
      works: 2,
      advanced: 1,
      needTime: 0,
      needYield: 0,
      saveState: 0,
      restoreState: 0,
      isEOF: 1,
      docsExamined: 1,
      alreadyHasObj: 0,
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 1,
        executionTimeMillisEstimate: 0,
        works: 2,
        advanced: 1,
        needTime: 0,
        needYield: 0,
        saveState: 0,
        restoreState: 0,
        isEOF: 1,
        keyPattern: { last_name: 1, first_name: 1 },
        indexName: 'last_name_1_first_name_1',
        isMultiKey: false,
        multiKeyPaths: { last_name: [], first_name: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          last_name: [ '["Gibson", "Gibson"]' ],
          first_name: [ '["Letha", "Letha"]' ]
        },
        keysExamined: 1,
        seeks: 1,
        dupsTested: 0,
        dupsDropped: 0
      }
    }
  },
  command: {
    find: 'users',
    filter: { last_name: 'Gibson', first_name: 'Letha' },
    '$db': 'ecommerce'
  },
  ok: 1
}

È bene evidenziare che l’ordine in cui sono elencati i campi nella condizione di filtro { last_name: 'Gibson', first_name: 'Letha' } non ha importanza. Avremmo potuto anche indicare { first_name: 'Letha', last_name: 'Gibson' }, ma filtreremmo sempre i documenti in base al cognome e nome.

db.users.find({first_name: 'Letha'}).explain('executionStats')
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: { first_name: { '$eq': 'Letha' } },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'COLLSCAN',
      filter: { first_name: { '$eq': 'Letha' } },
      direction: 'forward'
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 352,
    executionTimeMillis: 597,
    totalKeysExamined: 0,
    totalDocsExamined: 1000000,
    executionStages: {
      stage: 'COLLSCAN',
      filter: { first_name: { '$eq': 'Letha' } },
      nReturned: 352,
      executionTimeMillisEstimate: 21,
      works: 1000002,
      advanced: 352,
      needTime: 999649,
      needYield: 0,
      saveState: 1000,
      restoreState: 1000,
      isEOF: 1,
      direction: 'forward',
      docsExamined: 1000000
    }
  },
  command: {
    find: 'users',
    filter: { first_name: 'Letha' },
    '$db': 'ecommerce'
  },
  ok: 1
}

Quest’ultima query invece filtra i documenti in base al solo ‘first_name’, per questo non possiamo usare l’indice creato.

In generale per capire se un indice composto è valido o meno per una query, dovremo considerare i prefissi dell’indice, cioè i sottoinsiemi continui dei campi indicizzati, a partire dal primo campo dell’indice.

Facendo riferimento all’indice {last_name: 1, first_name: 1} (e in questo caso al contrario della query l’ordine è importante perché creiamo un indice in cui ogni voce è composta dalla coppia ‘last_name, first_name’), i prefissi sono:

  • {last_name: 1}
  • {last_name: 1, first_name: 1}

In base a quanto detto, l’indice composto {last_name: 1, first_name: 1} potrà essere quindi usato per query sui campi:

  • ‘last_name’
  • ‘last_name’ e ‘first_name’

Ma non potrà essere usato per query che filtrano i documenti in base al solo campo ‘first_name’, come abbiamo visto nel precedente esempio.

Indici composti e ordinamento

Per gli indici composti bisogna anche prestare attenzione all’ordinamento. L’indice che abbiamo creato ordina le voci prima in base al cognome in ordine crescente e per ogni cognome vengono poi ordinati i nomi sempre in modo crescente. Ciò consente all’indice di supportare operazioni di ordinamento in un’unica direzione.

Saranno supportate query che richiedono di ordinare i risultati in ordine crescente per entrambi i campi, ma anche in ordine decrescente (basta sostanzialmente invertire l’ordine). L’indice creato non può invece supportare l’ordinamento in un verso per un campo e in senso opposto per l’altro. Per esempio non possiamo usarlo per query che richiedono di ordinare i risultati in modo crescente per il cognome e nello stesso tempo in modo decrescente per il nome.

È inoltre fondamentale evidenziare che è possibile indicare un ordinamento dei documenti in base a una parte o a tutti i campi dell’indice, ma i campi di ordinamento devono essere elencati nello stesso ordine in cui appaiono nell’indice. Per esempio, il nostro indice { last_name: 1, first_name: 1 } supporta query che prevedono un ordinamento del tipo { last_name: 1 }, { last_name: 1, first_name: 1 } o { last_name: -1, first_name: -1 }, ma non su { first_name: 1, last_name: 1 } o { first_name: -1, last_name: -1 } perché le voci dell’indice sono ordinate prima in base al cognome e per ogni cognome i nomi sono elencati in ordine crescente.

Ordinamento e prefissi dell’indice

Se i campi di ordinamento di una query corrispondono a quelli dell’intero indice o a un suo prefisso, MongoDB può utilizzare l’indice per ordinare i risultati della query stessa evitando così dispendiose operazioni di ordinamento in memoria.

Riprendiamo allora l’indice creato in precedenza.

{ last_name: 1, first_name: 1 }

Questo indice soddisfa le seguenti query e operazioni di ordinamento evitando così di dover ordinare i risultati successivamente in memoria.

  • db.users.find().sort( { last_name: 1 } ) usa il prefisso dell’indice {last_name: 1}.
  • db.users.find().sort( { last_name: -1 } ) usa il prefisso dell’indice {last_name: 1} per ordinare in senso inverso i documenti.
  • db.users.find().sort( { last_name: 1, first_name: 1 } ) usa il prefisso dell’indice {last_name: 1, first_name: 1}
  • db.users.find().sort( { last_name: -1, first_name: -1 } ) usa il prefisso dell’indice {last_name: 1, first_name: 1}

Ordinamento e sottoinsieme non prefisso di un indice

Un indice può supportare operazioni di ordinamento su un suo sottoinsieme che non è un prefisso. In questo caso però la query deve includere anche delle condizioni di uguaglianza su tutti i campi del prefisso che precede i campi usati per ordinare i documenti.

Se quanto detto può sembrare astratto e di difficile comprensione, cerchiamo di chiarire meglio attraverso un esempio.

Supponiamo di creare un indice come quello mostrato sotto.

{ last_name: 1, first_name: 1, age: 1 }

Le seguenti query sono in grado di usare il solo indice per ottenere dei risultati ordinati in base ai campi passati come argomento del metodo cursor.sort().

  • db.users.find( { last_name: 'Willms' } ).sort( { first_name: 1, age: 1 } ) usa l’indice { last_name: 1, first_name: 1, age: 1 } dal momento che abbiamo una condizione di uguaglianza sul prefisso { last_name: 1 } ed ordiniamo i risultati sui restanti campi { first_name: 1, age: 1 }.
  • per una query del tipo db.users.find( { last_name: 'Willms', first_name: 'Samara' } ).sort( { age: 1 } ) abbiamo una condizione di uguaglianza sul prefisso { last_name: 1, first_name: 1 } ed ordiniamo i risultati sui restanti campi { age: 1 }.
  • Anche db.users.find( { last_name: 'Willms', first_name: {$gt: 'S'} } ).sort({first_name: 1, age: 1}) usa l’indice correttamente in quanto solo i campi dell’indice che precedono il sottoinsieme di ordinamento devono essere compresi nelle condizioni di uguaglianza della query. Gli altri campi dell’indice possono specificare qualsiasi altra condizione.

Indici composti e la ‘regola’ Uguaglianza, Ordinamento, Intervallo

Continuiamo a parlare di indici ed ordinamento dei documenti restituiti da una query. Vediamo per questo motivo un altro esempio. Dopo aver eliminato eventuali indici creati sulla collezione ‘users’ in precedenza, eseguiamo la seguente query che restituirebbe l’elenco ordinato in modo crescente dei cognomi degli utenti con più di 40 anni che abitano a ‘Sunrise’. Usiamo cursor.explain() per visualizzare in che modo viene gestita la query.

db.users.find({city: 'Sunrise', age: {$gt: 40}}).sort({last_name: 1})
  .explain('executionStats')
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: {
      '$and': [ { city: { '$eq': 'Sunrise' } }, { age: { '$gt': 40 } } ]
    },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'SORT',
      sortPattern: { last_name: 1 },
      memLimit: 104857600,
      type: 'simple',
      inputStage: {
        stage: 'COLLSCAN',
        filter: {
          '$and': [ { city: { '$eq': 'Sunrise' } }, { age: { '$gt': 40 } } ]
        },
        direction: 'forward'
      }
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 743,
    executionTimeMillis: 1412,
    totalKeysExamined: 0,
    totalDocsExamined: 1000000,
    executionStages: {
      stage: 'SORT',
      nReturned: 743,
      executionTimeMillisEstimate: 42,
      works: 1000746,
      advanced: 743,
      needTime: 1000002,
      needYield: 0,
      saveState: 1000,
      restoreState: 1000,
      isEOF: 1,
      sortPattern: { last_name: 1 },
      memLimit: 104857600,
      type: 'simple',
      totalDataSizeSorted: 119779,
      usedDisk: false,
      inputStage: {
        stage: 'COLLSCAN',
        filter: {
          '$and': [ { city: { '$eq': 'Sunrise' } }, { age: { '$gt': 40 } } ]
        },
        nReturned: 743,
        executionTimeMillisEstimate: 42,
        works: 1000002,
        advanced: 743,
        needTime: 999258,
        needYield: 0,
        saveState: 1000,
        restoreState: 1000,
        isEOF: 1,
        direction: 'forward',
        docsExamined: 1000000
      }
    }
  },
  command: {
    find: 'users',
    filter: { city: 'Sunrise', age: { '$gt': 40 } },
    sort: { last_name: 1 },
    '$db': 'ecommerce'
  },
  ok: 1
}

Dal momento che non è presente ancora alcun indice, il piano scelto per soddisfare la query prevede 2 fasi dispendiose in termini di tempo e risorse. Prima vengono esaminati (stage: ‘COLLSCAN’) tutti i documenti totalDocsExamined: 1000000 della collezione e vengono individuati 743 documenti che soddisfano la query. Questi documenti vengono poi ordinati in memoria durante la seconda fase (stage: ‘SORT’). In totale sono necessari 1412ms prima di restituire i documenti.

Abbiamo appena visto un esempio di query che segue un modello ricorrente, ovvero si esegue l’uguaglianza su un certo numero di campi, vengono nuovamente filtrati i documenti con un operatore di confronto per scegliere solo quelli che rientrano entro un determinato intervallo di valori e vengono poi ordinati i risultati rispetto ad un altro gruppo di campi.

Per questo tipo di query, MongoDB suggerisce di usare il principio denominato "Equality, Sort, Range" (Uguaglianza, Ordinamento, Intervallo) per costruire un indice che soddisfi la query senza eseguire operazioni di ordinamento in memoria. Si tratta di un modello che è quasi sempre efficace, ma di cui possiamo comunque valutare la bontà attraverso il metodo explain().

In pratica, per costruire un indice si sceglie come primo campo quello su cui viene effettuata l’operazione di uguaglianza. Nel nostro esempio si tratta del campo ‘city’. Affinché una porzione dell’indice che non è un prefisso possa essere usata per il processo di ordinamento, deve seguire i campi utilizzati per l’uguaglianza. Per cui il secondo campo dell’indice sarà quello su cui effettuiamo l’ordinamento, nel nostro caso si tratta del campo ‘last_name’. Infine come terzo campo dell’indice si sceglie quello impiegato per filtrare i documenti in base ad un intervallo. Nel nostro esempio sceglieremo ‘age’.

Da qui deriva il nome del procedimento "Equality, Sort, Range" che suggerisce quale deve essere la posizione dei campi nell’indice.

Creiamo allora l’indice secondo questo principio ed eseguiamo nuovamente la query.

> db.users.createIndex({city: 1, last_name: 1, age: 1})
city_1_last_name_1_age_1

Se eseguiamo nuovamente explain(), vediamo che il piano vincente prevede 2 fasi: la prima (stage: ‘IXSCAN’) consulta solo 954 voci dell’indice invece di 1 milione di documenti della collezione, la seconda (stage: ‘FETCH’) recupera i documenti selezionati dalla collezione seguendo l’ordine dell’indice. Notiamo che non è necessaria alcuna operazione di ordinamento in memoria.

Non solo siamo riusciti a restituire i documenti in modo più efficiente, ma anche il tempo necessario crolla e passa da 1412ms a soli 4ms.

> db.users.find({city: 'Sunrise', age: {$gt: 40}}).sort({last_name: 1}).explain('executionStats')
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: {
      '$and': [ { city: { '$eq': 'Sunrise' } }, { age: { '$gt': 40 } } ]
    },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { city: 1, last_name: 1, age: 1 },
        indexName: 'city_1_last_name_1_age_1',
        isMultiKey: false,
        multiKeyPaths: { city: [], last_name: [], age: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          city: [ '["Sunrise", "Sunrise"]' ],
          last_name: [ '[MinKey, MaxKey]' ],
          age: [ '(40, inf.0]' ]
        }
      }
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 743,
    executionTimeMillis: 4,
    totalKeysExamined: 954,
    totalDocsExamined: 743,
    executionStages: {
      stage: 'FETCH',
      nReturned: 743,
      executionTimeMillisEstimate: 0,
      works: 954,
      advanced: 743,
      needTime: 210,
      needYield: 0,
      saveState: 0,
      restoreState: 0,
      isEOF: 1,
      docsExamined: 743,
      alreadyHasObj: 0,
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 743,
        executionTimeMillisEstimate: 0,
        works: 954,
        advanced: 743,
        needTime: 210,
        needYield: 0,
        saveState: 0,
        restoreState: 0,
        isEOF: 1,
        keyPattern: { city: 1, last_name: 1, age: 1 },
        indexName: 'city_1_last_name_1_age_1',
        isMultiKey: false,
        multiKeyPaths: { city: [], last_name: [], age: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          city: [ '["Sunrise", "Sunrise"]' ],
          last_name: [ '[MinKey, MaxKey]' ],
          age: [ '(40, inf.0]' ]
        },
        keysExamined: 954,
        seeks: 211,
        dupsTested: 0,
        dupsDropped: 0
      }
    }
  },
  command: {
    find: 'users',
    filter: { city: 'Sunrise', age: { '$gt': 40 } },
    sort: { last_name: 1 },
    '$db': 'ecommerce'
  },
  ok: 1
}

Covered Query

Col termine ‘Covered Query’ ci si riferisce ad un tipo particolare di query estremamente veloci. Si tratta delle query che richiedono solo informazioni contenute nell’indice senza dover consultare i documenti corrispondenti.

Facciamo riferimento all’esempio precedente. Se tramite proiezione, indichiamo esplicitamente che siamo interessati ai soli campi ‘age’, ‘city’ e ‘last_name’, allora non abbiamo più bisogno di consultare la collezione ‘users’, ma possiamo restituire direttamente le informazioni presenti nell’indice in cui ciascuna voce presenta tutti e tre i campi richiesti.

db.users.find(
  {city: 'Sunrise', age: {$gt: 40}}, 
  {_id: 0, age: 1, city: 1, last_name: 1}
)
.sort({last_name: 1})
.explain('executionStats')

  {
  explainVersion: '1',
  queryPlanner: {
    namespace: 'ecommerce.users',
    indexFilterSet: false,
    parsedQuery: {
      '$and': [ { city: { '$eq': 'Sunrise' } }, { age: { '$gt': 40 } } ]
    },
    maxIndexedOrSolutionsReached: false,
    maxIndexedAndSolutionsReached: false,
    maxScansToExplodeReached: false,
    winningPlan: {
      stage: 'PROJECTION_COVERED',
      transformBy: { _id: 0, age: 1, city: 1, last_name: 1 },
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { city: 1, last_name: 1, age: 1 },
        indexName: 'city_1_last_name_1_age_1',
        isMultiKey: false,
        multiKeyPaths: { city: [], last_name: [], age: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          city: [ '["Sunrise", "Sunrise"]' ],
          last_name: [ '[MinKey, MaxKey]' ],
          age: [ '(40, inf.0]' ]
        }
      }
    },
    rejectedPlans: []
  },
  executionStats: {
    executionSuccess: true,
    nReturned: 743,
    executionTimeMillis: 2,
    totalKeysExamined: 954,
    totalDocsExamined: 0,
    executionStages: {
      stage: 'PROJECTION_COVERED',
      nReturned: 743,
      executionTimeMillisEstimate: 0,
      works: 954,
      advanced: 743,
      needTime: 210,
      needYield: 0,
      saveState: 0,
      restoreState: 0,
      isEOF: 1,
      transformBy: { _id: 0, age: 1, city: 1, last_name: 1 },
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 743,
        executionTimeMillisEstimate: 0,
        works: 954,
        advanced: 743,
        needTime: 210,
        needYield: 0,
        saveState: 0,
        restoreState: 0,
        isEOF: 1,
        keyPattern: { city: 1, last_name: 1, age: 1 },
        indexName: 'city_1_last_name_1_age_1',
        isMultiKey: false,
        multiKeyPaths: { city: [], last_name: [], age: [] },
        isUnique: false,
        isSparse: false,
        isPartial: false,
        indexVersion: 2,
        direction: 'forward',
        indexBounds: {
          city: [ '["Sunrise", "Sunrise"]' ],
          last_name: [ '[MinKey, MaxKey]' ],
          age: [ '(40, inf.0]' ]
        },
        keysExamined: 954,
        seeks: 211,
        dupsTested: 0,
        dupsDropped: 0
      }
    }
  },
  command: {
    find: 'users',
    filter: { city: 'Sunrise', age: { '$gt': 40 } },
    sort: { last_name: 1 },
    projection: { _id: 0, age: 1, city: 1, last_name: 1 },
    '$db': 'ecommerce'
  },
  ok: 1
}

Come possiamo osservare, non è più presente la fase ‘FETCH’ che è stata invece sostituita da ‘PROJECTION_COVERED’ ad indicare che le informazioni richieste sono tutte presenti nell’indice e non è stato necessario consultare la collezione.

Bisogna prestare però particolare attenzione perché non è possibile usare questo tipo di query se esiste nell’indice un campo di tipo Array o un documento annidato.

Indicizzazione di documenti annidati

In MongoDB possiamo creare degli indici sui campi dei documenti annidati in modo simile a quanto abbiamo visto finora.

Supponiamo di avere una collezione di utenti in cui sono presenti dei documenti come quello mostrato sotto.

{
    "_id": ObjectId("622b3140e991f738e3f0e9a8"),
    "name": 'John Doe',
    "email": '[email protected]',
    "age": 44,
    "address": {
      "city": "Leeds",
      "street": "114, Wellington St",
      "postcode": "LS14LT"
    }
  }

Possiamo creare un indice su uno dei campi del documento associato ad ‘address’. Per esempio possiamo creare un indice sul campo ‘address.postcode’.

db.users.createIndex({"address.postcode": 1})

Indicizzazione di array

Si definiscono multichiave (Multikey indexes) gli indici su campi di tipo array. Il nome deriva dal fatto che per ogni elemento dell’array viene creato una voce nell’indice.

Per esempio, se indicizziamo il campo ‘email’ del seguente documento, verranno create 2 voci, una per ciascuna e-mail (db.users.createIndex({email: 1})).

{
    "_id": ObjectId("622b3140e991f738e3f0e9a8"),
    "name": 'John Doe',
    "email": [
      '[email protected]',
      '[email protected]',
    ],
    "age": 44,
    "address": {
      "city": "Leeds",
      "street": "114, Wellington St",
      "postcode": "LS14LT"
    }
  }

Ma è possibile creare un indice non solo su un campo array che contiene valori semplici come numeri e stringhe. Infatti possiamo indicizzare anche array che hanno come elementi dei documenti.

Supponiamo di avere dei documenti come quello mostrato sotto.

{
    "_id": ObjectId("622b3140e991f738e3f0e9a8"),
    "name": 'John Doe',
    "contact": [
      {type: 'mail', value: '[email protected]'}
      {type: 'phone', value: '0102030405'}
    ],
    "age": 44,
    "address": {
      "city": "Leeds",
      "street": "114, Wellington St",
      "postcode": "LS14LT"
    },
    hobby: [
      'Fotografia',
      'Giardinaggio',
      'Trekking',
      'Nuoto',
      'Musica'
    ]
  }

Possiamo creare un indice sul campo ‘contact.type’ come mostrato sotto.

db.users.createIndex({"contact.type": 1})

Ed anche in questo caso saranno create 2 voci nell’indice, una per ciascun documento dell’array.

È importate ricordare che è possibile indicizzare al massimo un solo campo di tipo array per ciascun indice, per cui nell’ultimo esempio non possiamo creare un indice che contenga sia il campo ‘contact’ che ‘hobby’. Detto in altri termini un indice composto potrà presentare solo un campo che fa riferimento ad un valore di tipo array.

Se eseguiamo il metodo explain() per una query che può utilizzare un indice contenente un campo di tipo array, nel documento restituito osserviamo che è presente una chiave ‘isMultiKey’ il cui valore sarà uguale a true.

Prossima lezione…

Nella prossima lezione parleremo di un nuovo tipo di campi, ovvero dei campi Geospaziali. Vedremo che tipo di query possiamo eseguire su questi campi e come ottimizzare le interrogazioni attraverso degli opportuni indici.

Pubblicitร