mongodbaggregationperformancedatabasepythonbackendpatterns

Mi aggregation pipeline de MongoDB tardaba 30s — así lo diagnostiqué con explain()

Un aggregation pipeline en MongoDB es una secuencia de stages que transforman documentos. Con un índice correcto y stages en el orden adecuado, puede procesar millones de documentos en milisegundos. Sin esas condiciones, puede tardar minutos — o agotar la memoria del servidor.

Esto es lo que aprendí después de varios ciclos de “funciona en dev, explota en producción”.

El punto de partida: explain("executionStats")

Antes de optimizar cualquier query, leer el explain plan. No hay otro camino.

from pymongo import MongoClient

client = MongoClient("mongodb://...")
db = client["mi_db"]

pipeline = [
    {"$match": {"status": "active", "created_at": {"$gte": fecha_inicio}}},
    {"$lookup": {
        "from": "usuarios",
        "localField": "usuario_id",
        "foreignField": "_id",
        "as": "usuario",
    }},
    {"$unwind": "$usuario"},
    {"$group": {
        "_id": "$usuario.region",
        "total": {"$sum": "$monto"},
        "count": {"$count": {}},
    }},
]

# Ejecutar con explain
explain_result = db.command("aggregate", "pedidos", pipeline=pipeline,
                             explain=True, cursor={})

# Lo importante en el explain:
# - IXSCAN vs COLLSCAN en el primer $match
# - nReturned vs keysExamined en cada stage
# - executionTimeMillisEstimate por stage

Los números que importan:

El orden de los stages importa mucho

MongoDB puede hacer “stage pushing” — mover un $match antes de un $lookup si puede inferir que el filtro aplica al lado izquierdo. Pero no siempre lo hace. El orden explícito es más seguro:

# Mal — $match después de $lookup procesa todos los documentos unidos
pipeline_lento = [
    {"$lookup": {
        "from": "usuarios",
        "localField": "usuario_id",
        "foreignField": "_id",
        "as": "usuario",
    }},
    {"$unwind": "$usuario"},
    {"$match": {"status": "active"}},  # Filtra DESPUÉS del lookup
]

# Bien — $match antes de $lookup reduce el conjunto antes de hacer el join
pipeline_rapido = [
    {"$match": {"status": "active"}},  # Filtra ANTES del lookup
    {"$lookup": {...}},
    {"$unwind": "$usuario"},
]

Regla general: $match y $project lo más temprano posible. Reducir el conjunto de documentos antes de stages costosos como $lookup, $group, o $sort.

Índices compuestos y el orden de campos

Un índice compuesto {status: 1, created_at: -1} soporta:

El principio de prefix matching: MongoDB puede usar un índice compuesto para cualquier prefijo de sus campos. {a: 1, b: 1, c: 1} puede usarse para queries sobre a, a+b, o a+b+c, pero no para queries solo sobre b o c.

Para queries con rango ($gte, $lte, $in) el campo de rango va al final del índice compuesto. Campos de igualdad primero, rango después:

# Índice correcto para: {status: "active", created_at: {$gte: ..., $lte: ...}}
db.pedidos.create_index([("status", 1), ("created_at", -1)])

# Sin este índice, MongoDB hace COLLSCAN o usa solo uno de los dos campos

$lookup: cuándo usarlo y cuándo no

$lookup es un join. Como todos los joins, tiene costo. El costo depende de:

  1. Cuántos documentos del lado izquierdo llegan al $lookup (reducir con $match antes)
  2. Si el campo foreignField tiene índice en la colección del lado derecho
# Para que $lookup sea eficiente:
# 1. Índice en la colección referenciada
db.usuarios.create_index("_id")  # _id ya tiene índice, pero campos custom no

# 2. $lookup con pipeline permite filtrar en el lado derecho
{"$lookup": {
    "from": "pedidos_items",
    "let": {"pedido_id": "$_id"},
    "pipeline": [
        {"$match": {
            "$expr": {"$eq": ["$pedido_id", "$$pedido_id"]},
            "activo": True,  # Filtro adicional en el lado derecho
        }},
        {"$project": {"nombre": 1, "precio": 1}},  # Solo campos necesarios
    ],
    "as": "items",
}}

El $lookup con pipeline y let es más versátil y permite filtrar en el lado derecho antes de hacer el join.

$unwind + $group: el pattern más común y más costoso

# Pattern frecuente pero costoso para arrays grandes
pipeline = [
    {"$unwind": "$tags"},  # Explode array — multiplica documentos
    {"$group": {
        "_id": "$tags",
        "count": {"$sum": 1},
    }},
]

# Alternativa más eficiente para solo contar
pipeline_eficiente = [
    {"$project": {"tags": 1}},  # Solo el campo necesario
    {"$unwind": "$tags"},
    {"$sortByCount": "$tags"},  # $group + $sort en un solo stage
]

$sortByCount es azúcar sintáctica para {$group: {_id: ..., count: {$sum: 1}}} + {$sort: {count: -1}}. Un stage en lugar de dos.

allowDiskUse para aggregations grandes

El límite de memoria por defecto de un aggregation pipeline es 100MB. Para aggregations analíticas sobre grandes volúmenes, ese límite se alcanza con frecuencia en el $sort o $group.

# En PyMongo
result = list(db.pedidos.aggregate(
    pipeline,
    allowDiskUse=True,  # Permite spill to disk cuando supera 100MB
))

allowDiskUse permite usar disco temporal cuando el pipeline excede el límite de memoria. Es más lento que en memoria, pero no falla. Para queries analíticos que corren en batch (no en el path crítico de un request de usuario), es la opción correcta.

Índices parciales para subconjuntos frecuentes

Si la mayoría de queries solo acceden a un subconjunto de documentos, un índice parcial es más pequeño y más eficiente:

# Índice parcial — solo indexa documentos donde status = "active"
db.pedidos.create_index(
    [("created_at", -1)],
    partialFilterExpression={"status": "active"},
    name="idx_pedidos_activos_fecha",
)

Este índice ocupa menos espacio que un índice sobre todos los documentos y acelera queries que siempre incluyen status: "active" en el filtro.

El antipatrón: $where y expresiones JavaScript

# Nunca esto en producción
{"$where": "this.monto > 1000 && this.status === 'active'"}

$where ejecuta JavaScript en el servidor MongoDB. Es lento (no puede usar índices), peligroso (inyección JS), y deprecated en versiones recientes. Siempre usar expresiones nativas de MongoDB:

# Bien
{"$match": {"monto": {"$gt": 1000}, "status": "active"}}

Lo que aprendí

El explain plan no miente. “Este query parece eficiente” sin mirar el explain es suposición. COLLSCAN en una colección de 10M documentos es el origen de la mayoría de los problemas de performance que he visto.

Un $match antes de un $lookup puede ser 100x más rápido. Si el filtro reduce el conjunto de 100K a 500 documentos antes del join, el lookup hace 500 lookups en lugar de 100K. El orden de stages no es cosmético.

Los índices compuestos reemplazan múltiples índices simples. Un índice {status: 1, created_at: -1} es más eficiente que dos índices separados {status: 1} y {created_at: -1} para queries que filtran por ambos campos.

allowDiskUse para batch, no para requests de usuario. En el path de un request HTTP, si el aggregation pipeline necesita disco, el problema es el diseño del query o de la colección. Rediseñar. Para jobs analíticos batch, allowDiskUse es perfectamente válido.

Volver al blog