Le réflexe face à un endpoint de reporting lent, c’est souvent le cache. Un @cache_page, un cache.set(), et le problème disparaît… jusqu’à la prochaine expiration. Cette approche a une limite structurelle que les vues matérialisées PostgreSQL résolvent à la racine.

Le problème du cache sur les endpoints analytiques

Le cache Django stocke le résultat d’une vue Python. La requête SQL coûteuse s’exécute quand même à chaque expiration du cache. Pour un rapport construit sur plusieurs JOINs et agrégations, ça signifie que le premier utilisateur après chaque cache miss attend plusieurs secondes.

from django.views.decorators.cache import cache_page
from django.db.models import Count, Q, Sum

@cache_page(60 * 15)
def billing_dashboard(request):
    # Cette requête s'exécute à chaque expiration du 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)

Deux autres limites s’ajoutent à ça. D’abord, le cache stocke un résultat fixe : impossible d’y appliquer un filtre dynamique (?period=30d, ?region=eu). Chaque combinaison de paramètres génère un cache miss distinct avec recalcul complet. Ensuite, Redis vidé égale recalcul immédiat sur toutes les clés en même temps, ce qui peut saturer la base à un moment critique.

Ce que fait une vue matérialisée à la place

Une vue matérialisée PostgreSQL déplace le calcul coûteux hors du cycle requête/réponse. Le résultat est stocké physiquement sur disque, indexable, et disponible en quelques millisecondes.

Prenons une application SaaS avec trois tables : plans, subscriptions et usage_records. Le calcul des stats par plan (abonnements actifs, appels API, revenus) est fait une fois au moment du refresh, pas à chaque requête.

-- Vue matérialisée : résultat précalculé, stocké sur disque
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;

-- Index unique requis pour le REFRESH CONCURRENTLY
CREATE UNIQUE INDEX ON plan_usage_mv (plan_id);

La différence avec une vue SQL classique est fondamentale : une vue normale ré-exécute la requête à chaque SELECT. La vue matérialisée, elle, répond avec ce qui est déjà calculé.

Intégrer dans Django via une migration

La vue se gère dans une migration avec RunSQL. Elle vit dans l’historique des migrations au même titre qu’une table.

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)
    ]

Le modèle Django pointe sur la vue avec managed = False. Django ne touche pas à la structure, il se contente de lire.

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 vue s’interroge comme n’importe quelle table Django :

stats = (
    PlanUsageSummary.objects
    .order_by("-monthly_revenue")
)

Planifier le refresh avec Celery

La vue ne se met pas à jour seule. REFRESH MATERIALIZED VIEW CONCURRENTLY recalcule sans bloquer les lectures en cours, à condition qu’un index unique soit présent.

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 fréquence dépend du contexte : toutes les heures pour un rapport opérationnel, une fois par nuit pour un tableau de bord managérial. L’important est que le refresh soit découplé des requêtes utilisateurs.

Les deux ensemble en production

Les deux outils sont complémentaires, pas concurrents. La vue matérialisée garantit que la donnée est déjà calculée. Le cache Django évite même le SELECT sur la vue quand les paramètres sont identiques.

Plans / Subscriptions / UsageRecords
    │
    ▼  REFRESH CONCURRENTLY (Celery Beat, découplé)
Vue matérialisée  ← agrégats précalculés, indexés
    │
    ▼  SELECT simple (< 50ms)
Cache Django      ← court-circuite le SELECT pour les requêtes répétées
    │
    ▼
Réponse HTTP

Un cache miss sur cette architecture prend 50ms au lieu de plusieurs secondes. C’est la différence entre un problème résolu et un problème caché.

Quand utiliser quoi

SituationSolution
Requête déjà rapide (< 200ms)Cache seul
Rapport filtrable dynamiquementVue matérialisée
Dashboard en production avec fort traficVue matérialisée + cache
Équipe sans Redis ou CeleryVue matérialisée seule
Données temps réel obligatoiresOptimiser la requête source

Pour des benchmarks concrets sur ce pattern avec des mesures avant/après, cet article détaille un cas réel : vues matérialisées PostgreSQL Django avec benchmarks réels.