El instinto ante un endpoint de reporting lento suele ser el cache. Un @cache_page, un cache.set(), y el problema parece desaparecer hasta la próxima expiración. Este enfoque tiene una limitación estructural que las vistas materializadas PostgreSQL resuelven desde la raíz.
El problema del cache en los endpoints analíticos
El cache Django almacena el resultado de una vista Python. La consulta SQL costosa se ejecuta igualmente en cada expiración del cache. Para un informe construido sobre múltiples JOINs y agregaciones, eso significa que el primer usuario tras cada cache miss espera varios segundos.
from django.views.decorators.cache import cache_page
from django.db.models import Count, Q, Sum
@cache_page(60 * 15)
def billing_dashboard(request):
# Esta consulta se ejecuta en cada expiración del cache
data = (
Plan.objects
.annotate(
active_subscriptions=Count(
"subscriptions", filter=Q(subscriptions__status="active")
),
monthly_revenue=Sum(
"subscriptions__monthly_price",
filter=Q(subscriptions__status="active"),
),
)
.order_by("-monthly_revenue")
)
return JsonResponse(list(data.values()), safe=False)
Dos limitaciones más se suman a esto. Primero, el cache almacena un resultado fijo: no es posible aplicar filtros dinámicos (?period=30d, ?region=eu). Cada combinación de parámetros genera un cache miss distinto con recálculo completo. Segundo, vaciar Redis provoca un recálculo inmediato en todas las claves a la vez, lo que puede saturar la base de datos en el peor momento.
Lo que hace una vista materializada en su lugar
Una vista materializada PostgreSQL desplaza el cálculo costoso fuera del ciclo petición/respuesta. El resultado se almacena físicamente en disco, es indexable y está disponible en pocos milisegundos.
Consideremos una aplicación SaaS con tres tablas: plans, subscriptions y usage_records. El cálculo de estadísticas por plan (suscripciones activas, llamadas API, ingresos) ocurre una vez en el momento del refresco, no en cada petición.
-- Vista materializada: resultado precalculado almacenado en disco
CREATE MATERIALIZED VIEW plan_usage_mv AS
SELECT
p.id AS plan_id,
p.name AS plan_name,
COUNT(s.id) AS active_subscriptions,
SUM(ur.api_calls) AS total_api_calls,
SUM(s.monthly_price) AS monthly_revenue
FROM plans p
JOIN subscriptions s ON s.plan_id = p.id AND s.status = 'active'
JOIN usage_records ur ON ur.subscription_id = s.id
GROUP BY p.id, p.name
WITH DATA;
-- Índice único requerido para REFRESH CONCURRENTLY
CREATE UNIQUE INDEX ON plan_usage_mv (plan_id);
La diferencia con una vista SQL clásica es fundamental: una vista normal re-ejecuta la consulta en cada SELECT. La vista materializada responde con datos ya calculados.
Integrar en Django mediante una migración
La vista se gestiona en una migración con RunSQL. Vive en el historial de migraciones igual que una tabla.
from django.db import migrations
CREATE_VIEW = """
CREATE MATERIALIZED VIEW plan_usage_mv AS
SELECT
p.id AS plan_id,
p.name AS plan_name,
COUNT(s.id) AS active_subscriptions,
SUM(ur.api_calls) AS total_api_calls,
SUM(s.monthly_price) AS monthly_revenue
FROM plans p
JOIN subscriptions s ON s.plan_id = p.id AND s.status = 'active'
JOIN usage_records ur ON ur.subscription_id = s.id
GROUP BY p.id, p.name
WITH DATA;
CREATE UNIQUE INDEX ON plan_usage_mv (plan_id);
"""
DROP_VIEW = "DROP MATERIALIZED VIEW IF EXISTS plan_usage_mv;"
class Migration(migrations.Migration):
dependencies = [("billing", "0005_usage_record")]
operations = [
migrations.RunSQL(sql=CREATE_VIEW, reverse_sql=DROP_VIEW)
]
El modelo Django apunta a la vista con managed = False. Django no toca la estructura, solo lee de ella.
class PlanUsageSummary(models.Model):
plan = models.OneToOneField(
"Plan",
on_delete=models.DO_NOTHING,
primary_key=True,
db_column="plan_id",
)
plan_name = models.CharField(max_length=100)
active_subscriptions = models.IntegerField()
total_api_calls = models.BigIntegerField()
monthly_revenue = models.DecimalField(max_digits=14, decimal_places=2)
class Meta:
managed = False
db_table = "plan_usage_mv"
La vista se consulta como cualquier tabla Django:
stats = (
PlanUsageSummary.objects
.order_by("-monthly_revenue")
)
Planificar el refresco con Celery
La vista no se actualiza sola. REFRESH MATERIALIZED VIEW CONCURRENTLY recalcula sin bloquear las lecturas en curso, siempre que exista un índice único.
from celery import shared_task
from django.db import connection
@shared_task
def refresh_plan_usage():
with connection.cursor() as cursor:
cursor.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY plan_usage_mv"
)
La frecuencia depende del contexto: cada hora para un informe operacional, una vez por noche para un dashboard de dirección. Lo importante es que el refresco esté desacoplado de las peticiones de los usuarios.
Los dos juntos en producción
Las dos herramientas son complementarias, no competidoras. La vista materializada garantiza que el dato ya está calculado. El cache Django evita incluso el SELECT sobre la vista cuando los parámetros son idénticos.
Plans / Subscriptions / UsageRecords
│
▼ REFRESH CONCURRENTLY (Celery Beat, desacoplado)
Vista materializada ← agregados precalculados, indexados
│
▼ SELECT simple (< 50ms)
Cache Django ← cortocircuita el SELECT para queries repetidas
│
▼
Respuesta HTTP
Un cache miss en esta arquitectura tarda 50ms en lugar de varios segundos. Esa es la diferencia entre un problema resuelto y un problema ocultado.
Cuándo usar cada uno
| Situación | Solución |
|---|---|
| Consulta ya rápida (< 200ms) | Solo cache |
| Informe filtrable dinámicamente | Vista materializada |
| Dashboard en producción con alto tráfico | Vista materializada + cache |
| Equipo sin Redis o Celery | Solo vista materializada |
| Datos en tiempo real obligatorios | Optimizar la consulta fuente |
Para benchmarks concretos sobre este patrón con mediciones antes/después, este artículo cubre un caso real: vistas materializadas PostgreSQL Django benchmarks reales.
