Salta el contingut

Índexs i rendiment en MongoDB

Els índexs a MongoDB funcionen de la mateixa manera conceptual que en qualsevol base de dades: permeten trobar documents sense recórrer tota la col·lecció. La diferència és que MongoDB admet tipus d'índexs específics per al model de documents, com els índexs de text, geoespacials, TTL i multikey per a arrays.

COLLSCAN vs IXSCAN: per qué els índexs importan

Quan MongoDB executa una consulta, el motor de consultes (query planner) decideix com obtenir els documents. Hi ha dues estratègies principals:

COLLSCAN (Collection Scan): Recorre tots els documents de la col·lecció d'un a un fins a trobar els que compleixen el filtre. En una col·lecció de 5 milions de documents, llegeix els 5 milions. El temps creix linealment amb el nombre de documents.

IXSCAN (Index Scan): Navega per l'estructura d'índex (un B-Tree) per trobar directament les posicions dels documents rellevants. En una col·lecció de 5 milions de documents amb un índex selectiu, pot retornar 100 documents llegint-ne uns pocs milers d'entrades de l'índex.

graph TD
    Q["Query: db.logs.find({ usuari: 'joan@exemple.cat' })"]
    QP["Query Planner"]
    COLL["COLLSCAN\nRecorre els 5.000.000 docs\n~30 segons"]
    IDX["IXSCAN\nNavega índex usuari\n~0.002 segons"]
    Q --> QP
    QP -->|"sense índex"| COLL
    QP -->|"amb índex a usuari"| IDX

EXPLAIN: llegir el pla d'execució

explain() és l'eina fonamental per entendre com MongoDB executa una consulta. Equivalent a EXPLAIN ANALYZE de PostgreSQL.

// Veure el pla d'execució d'una query
db.logs.find({ usuari: "joan@exemple.cat" }).explain("executionStats")

El camp més important del resultat és executionStats:

// Resultat d'explain (simplificat) SENSE índex:
{
  queryPlanner: {
    winningPlan: {
      stage: "COLLSCAN",      // alerta! recorre tota la col·lecció
      filter: { usuari: { $eq: "joan@exemple.cat" } }
    }
  },
  executionStats: {
    nReturned: 127,           // documents retornats
    totalKeysExamined: 0,     // claus d'índex examinades (0 = cap índex)
    totalDocsExamined: 5000000, // documents examinats (TOTS!)
    executionTimeMillis: 28450  // temps en mil·lisegons
  }
}

// Resultat d'explain AMB índex a "usuari":
{
  queryPlanner: {
    winningPlan: {
      stage: "FETCH",
      inputStage: {
        stage: "IXSCAN",      // usa l'índex
        indexName: "usuari_1"
      }
    }
  },
  executionStats: {
    nReturned: 127,
    totalKeysExamined: 127,   // una clau per document retornat: perfecte
    totalDocsExamined: 127,   // llegeix exactament els docs necessaris
    executionTimeMillis: 2    // 28450ms → 2ms gràcies a l'índex
  }
}

Métriques clau d'executionStats:

Mètrica Bon signe Mal signe
stage IXSCAN, FETCH COLLSCAN
totalDocsExamined = nReturned (selectiu) >> nReturned (poc selectiu)
totalKeysExamined nReturned >> nReturned
executionTimeMillis < 100ms (operacional) > 1000ms (problema)

Crear i gestionar índexs

// Crear un índex simple
db.logs.createIndex({ usuari: 1 })  // 1 = ascendent, -1 = descendent

// Crear un índex amb nom personalitzat
db.logs.createIndex({ usuari: 1 }, { name: "idx_usuari" })

// Llistar tots els índexs d'una col·lecció
db.logs.getIndexes()

// Eliminar un índex per nom
db.logs.dropIndex("idx_usuari")

// Eliminar tots els índexs menys _id
db.logs.dropIndexes()

Tipus d'índexs

Índex simple (Single Field)

L'índex més bàsic: un índex sobre un sol camp. Accelera les queries de filtre i ordenació sobre aquell camp.

// Índex sobre el camp "preu" de la col·lecció productes
db.productes.createIndex({ preu: 1 })

// Ara aquesta query és ràpida:
db.productes.find({ preu: { $gt: 100, $lt: 500 } })

// I aquesta ordenació:
db.productes.find().sort({ preu: -1 })

Cada col·lecció ja té un índex per defecte a _id. No cal crear-lo manualment.

Índex compost (Compound Index)

Un índex sobre múltiples camps. L'ordre dels camps importa perquè determina quines queries poden aprofitar l'índex (prefix rule).

// Índex compost: categoria ascendent, preu descendent
db.productes.createIndex({ categoria: 1, preu: -1 })

La Prefix Rule: Un índex compost { a: 1, b: 1, c: 1 } pot accelerar queries que usin: - { a: ... } — prefix de 1 camp - { a: ..., b: ... } — prefix de 2 camps - { a: ..., b: ..., c: ... } — índex complet

Però NO pot accelerar queries que usin només { b: ... } o { c: ... } o { b: ..., c: ... } (no comencen pel primer camp de l'índex).

// AMB índex { categoria: 1, preu: -1 }:
db.productes.find({ categoria: "peripherics" })               // usa l'índex (prefix)
db.productes.find({ categoria: "peripherics", preu: { $gt: 50 } }) // usa l'índex
db.productes.find({ preu: { $gt: 50 } })                     // NO usa l'índex (sense prefix)

L'ordre ESR (Equality, Sort, Range)

Quan dissenyes un índex compost, segueix l'ordre ESR: primer els camps d'igualtat ($eq), després els camps d'ordenació (sort), finalment els camps de rang ($gt, $lt, $in). Exemple per a la query find({ categoria: "peripherics", preu: { $gt: 50 } }).sort({ valoracio: -1 }): l'índex ideal és { categoria: 1, valoracio: -1, preu: 1 }.

Índex multikey (per a arrays)

Quan s'indexa un camp que conté un array, MongoDB crea automàticament un índex multikey: indexa cada element de l'array individualment. Permet cercar per valors dins d'arrays de manera eficient.

// Índex sobre el camp "tags" (que és un array)
db.productes.createIndex({ tags: 1 })

// Ara aquesta query usa l'índex:
db.productes.find({ tags: "wireless" })
db.productes.find({ tags: { $in: ["wireless", "Bluetooth"] } })

Limitació dels índexs multikey

Un índex compost no pot tenir dos camps multikey simultàniament. Si tags i especificacions.connexions són tots dos arrays, l'índex { tags: 1, "especificacions.connexions": 1 } no és vàlid. Pots crear índexs simples per a cadascun per separat.

Índex de text (Text Index)

Permet fer cerques de text complet (full-text search) en camps de tipus string. Cada col·lecció pot tenir un sol índex de text, però pot cobrir múltiples camps.

// Índex de text sobre el camp "nom" i "descripcio"
db.productes.createIndex({ nom: "text", descripcio: "text" })

// Cercar productes que continguin "portàtil" o "portàtil professional"
db.productes.find({ $text: { $search: "portàtil professional" } })

// Cercar la frase exacta
db.productes.find({ $text: { $search: "\"teclat mecànic\"" } })

// Excloure una paraula
db.productes.find({ $text: { $search: "teclat -mecànic" } })

// Ordenar per rellevància (score)
db.productes.find(
  { $text: { $search: "portàtil" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

Idioma de l'índex de text

Per defecte, MongoDB usa l'stemmer d'anglès. Per al català o castellà, especifica l'idioma: db.productes.createIndex({ nom: "text" }, { default_language: "spanish" }). El català no té stemmer natiu, però "spanish" és una aproximació acceptable.

Índex geoespacial (2dsphere)

Per a dades de coordenades GPS (GeoJSON). Permet cerques de proximitat, dins d'una àrea, etc.

// Col·lecció de botigues amb localització
db.botigues.insertOne({
  nom: "Botiga Central",
  localitzacio: {
    type: "Point",
    coordinates: [2.7904, 41.6747]  // [longitud, latitud] — Blanes
  }
})

// Índex 2dsphere
db.botigues.createIndex({ localitzacio: "2dsphere" })

// Cercar botigues en un radi de 5 km des d'un punt
db.botigues.find({
  localitzacio: {
    $near: {
      $geometry: { type: "Point", coordinates: [2.8000, 41.6800] },
      $maxDistance: 5000  // metres
    }
  }
})

Índex TTL (Time-To-Live)

Un índex TTL elimina automàticament documents passats un temps especificat. Ideal per a sessions, tokens d'autenticació, logs temporals o qualsevol dada amb caducitat.

// Crear sessió amb data d'expiració
db.sessions.insertOne({
  token: "abc123xyz",
  usuari_id: "CLI-001",
  creat_el: new Date(),
  expira_el: new Date(Date.now() + 3600000)  // 1 hora
})

// Índex TTL: eliminar automàticament 0 segons després del camp "expira_el"
db.sessions.createIndex(
  { expira_el: 1 },
  { expireAfterSeconds: 0 }
)

// Alternativa: TTL relatiu al camp "creat_el" (eliminar 3600 s = 1h després)
db.logs_temporals.createIndex(
  { creat_el: 1 },
  { expireAfterSeconds: 3600 }
)

El daemon de TTL

MongoDB executa un procés intern cada 60 segons que elimina els documents expirats. Això significa que els documents poden sobreviure fins a 60 segons més del temps especificat. El TTL no és de precisió de milisegon.

Índex wildcard

Per a col·leccions amb esquemes molt variables on els camps a cercar no es coneixen per endavant.

// Índex wildcard: indexa TOTS els camps del document
db.productes.createIndex({ "$**": 1 })

// Índex wildcard sobre un subdocument concret
db.productes.createIndex({ "especificacions.$**": 1 })

// Ara qualsevol query sobre camps d'especificacions usa l'índex:
db.productes.find({ "especificacions.processador": "Intel i7" })
db.productes.find({ "especificacions.ram_gb": { $gte: 16 } })
db.productes.find({ "especificacions.connexio": "Bluetooth" })

Índexs wildcard i rendiment

Un índex wildcard ocupa molt més espai que un índex de camp específic i és menys eficient. Usar-lo quan l'esquema és realment variable i els camps de cerca no es poden preveure. Per a la majoria de casos, els índexs específics per camp són millors.

Índex sparse

Un índex sparse només indexa els documents que contenen el camp indexat. Útil per a camps opcionals que no existeixen en tots els documents.

// Índex sparse sobre un camp opcional
db.usuaris.createIndex(
  { numero_empresa: 1 },
  { sparse: true }
)

// Índex sparse + unique: el camp ha de ser únic però és opcional
db.usuaris.createIndex(
  { numero_nif: 1 },
  { sparse: true, unique: true }
)

Sense sparse, un índex unique sobre un camp opcional fallaria si dos documents no tenen aquell camp (tots dos tindrien el valor implícit null i collidissin).

Índexs i l'Aggregation Pipeline

L'Aggregation Pipeline pot aprofitar els índexs, però només en els primers stages. En concret:

  • $match al principi del pipeline usa índexs per filtrar.
  • $sort immediatament després de $match (o al principi) pot usar índexs per ordenar sense carregar tots els documents en memòria.
  • Un cop el pipeline ha processat dades (p. ex. després de $group o $unwind), els stages posteriors treballen amb documents en memòria i els índexs no hi poden ajudar.
// Exemple: pipeline que aprofita índex a { pais: 1, data: 1 }
db.vendes.aggregate([
  // Stage 1: $match usa l'índex { pais: 1, data: 1 }
  {
    $match: {
      pais: "Espanya",
      data: { $gte: ISODate("2025-01-01") }
    }
  },
  // Stage 2: a partir d'aquí, treballa en memòria
  { $group: { _id: "$client.id", total: { $sum: "$total" } } },
  { $sort: { total: -1 } }
])

Per verificar si el pipeline usa un índex, usa explain() sobre l'agregació:

db.vendes.explain("executionStats").aggregate([
  { $match: { pais: "Espanya" } },
  { $group: { _id: "$estat", count: { $sum: 1 } } }
])

Cardinalitat i selectivitat

Cardinalitat és el nombre de valors únics d'un camp. Un índex és útil quan té alta cardinalitat (molts valors únics, alta selectivitat). Un índex sobre un camp de baixa cardinalitat pot ser pitjor que un COLLSCAN.

Camp Cardinalitat Índex útil?
_id Única (=N docs) Molt alt: ja és l'índex per defecte
email Alta Sí, molt útil
pais Baixa (5-200 països) Depèn: útil si el 1% dels docs és d'un país
disponible Binària (2 valors) Poc útil sol; millor en índex compost
categoria Mitjana (10-50) Depèn de la distribució

L'anti-patró de l'índex en un camp booleà

Crear un índex sobre un camp disponible: true/false sol ser inútil. Si el 90% dels productes estan disponibles, un find({ disponible: true }) amb índex és més lent que un COLLSCAN perquè MongoDB ha de llegir el 90% dels documents de tota manera. Millor crear un índex parcial que només indexi els documents rellevants:

db.productes.createIndex(
  { preu: 1 },
  { partialFilterExpression: { disponible: true } }
)

Resum: quins índexs crear i quan

Situació Solució
Query freqüent per un camp específic Single field index
Query amb múltiples filtres i ordenació Compound index (ordre ESR)
Camp és un array i es cerca per elements Multikey index (automàtic)
Full-text search en camps de text Text index
Dades de geolocalització 2dsphere index
Documents amb data de caducitat TTL index
Esquema molt variable, camps de cerca desconeguts Wildcard index
Camp opcional amb unique constraint Sparse + unique index
Query només sobre un subconjunt de documents Partial index

AC5074/03/04 — Miniactivitat

Identificar COLLSCAN i crear l'índex adequat.

En la col·lecció vendes de la pràctica (o una col·lecció de logs amb almenys 100.000 documents generats amb insertMany en un bucle), realitza les tasques següents:

  1. Executa la query db.vendes.find({ pais: "Espanya", estat: "completat" }).explain("executionStats") i captura la sortida. Identifica: stage, totalDocsExamined, nReturned i executionTimeMillis.
  2. Crea l'índex que creus que és més adequat per a aquesta query i torna a executar explain(). Compara les mètriques.
  3. Escriu un pipeline d'agregació que calculi el total de vendes per mes per al país "Espanya". Comprova amb explain() si el pipeline aprofita l'índex creat al pas 2.
  4. Crea un índex TTL sobre el camp data per eliminar les vendes amb més de 2 anys d'antiguitat. Verifica que l'índex es crea correctament amb getIndexes().
  5. Lliura: captures de les sortides d'explain() (abans i després) i el codi de creació d'índexs amb comentaris justificant les decisions.