django-perf-review

--- Examen du code de performance Django. À utiliser quand on vous demande de « examiner la performance Django », « trouver les requêtes N+1 », « optimiser Django », « vérifier la performance du queryset », « performance de la base de données », « problèmes ORM Django », ou vérifier le code Django pour les problèmes de performance.

npx skills add https://github.com/getsentry/skills --skill django-perf-review

Examen de Performance Django

Examinez le code Django pour les problèmes de performance validés. Recherchez dans la base de code pour confirmer les problèmes avant de les signaler. Ne signalez que ce que vous pouvez prouver.

Approche d'Examen

  1. Recherchez d'abord - Tracez le flux de données, vérifiez les optimisations existantes, vérifiez le volume de données
  2. Validez avant de signaler - La correspondance de modèles n'est pas une validation
  3. Zéro découverte est acceptable - Ne créez pas de problèmes pour paraître minutieux
  4. La gravité doit correspondre à l'impact - Si vous vous surprenez à écrire « mineur » dans une découverte CRITIQUE, ce n'est pas critique. Rétrogradez ou ignorez-la.

Catégories d'Impact

Les problèmes sont organisés par impact. Concentrez-vous sur CRITIQUE et HAUTE - ce sont ceux qui causent de vrais problèmes à grande échelle.

Priorité Catégorie Impact
1 Requêtes N+1 CRITIQUE - Se multiplie avec les données, provoque des délais d'expiration
2 Querysets Non Bornés CRITIQUE - Épuisement mémoire, tue OOM
3 Index Manquants HAUTE - Analyses complètes de table sur les grandes tables
4 Boucles d'Écriture HAUTE - Contention de verrous, requêtes lentes
5 Modèles Inefficaces BASSE - Rarement digne d'être signalé

Priorité 1 : Requêtes N+1 (CRITIQUE)

Impact : Chaque N+1 ajoute O(n) allers-retours de base de données. 100 lignes = 100 requêtes supplémentaires. 10 000 lignes = délai d'expiration.

Règle : Préchargez les données associées accédées dans les boucles

Validez en traçant : View → Queryset → Template/Serializer → Accès en boucle

# PROBLÈME : N+1 - chaque itération interroge le profil
def user_list(request):
    users = User.objects.all()
    return render(request, 'users.html', {'users': users})

# Template :
# {% for user in users %}
#     {{ user.profile.bio }}  ← déclenche une requête par utilisateur
# {% endfor %}

# SOLUTION : Préchargez dans la vue
def user_list(request):
    users = User.objects.select_related('profile')
    return render(request, 'users.html', {'users': users})

Règle : Préchargez dans les serializers, pas seulement les vues

Les serializers DRF accédant à des champs associés causent N+1 si le queryset n'est pas optimisé.

# PROBLÈME : SerializerMethodField interroge par objet
class UserSerializer(serializers.ModelSerializer):
    order_count = serializers.SerializerMethodField()

    def get_order_count(self, obj):
        return obj.orders.count()  # ← requête par utilisateur

# SOLUTION : Annotez dans le viewset, accédez dans le serializer
class UserViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return User.objects.annotate(order_count=Count('orders'))

class UserSerializer(serializers.ModelSerializer):
    order_count = serializers.IntegerField(read_only=True)

Règle : Les propriétés Model qui interrogent sont dangereuses dans les boucles

# PROBLÈME : La propriété déclenche une requête lors de l'accès
class User(models.Model):
    @property
    def recent_orders(self):
        return self.orders.filter(created__gte=last_week)[:5]

# Utilisé dans une boucle template = N+1

# SOLUTION : Utilisez Prefetch avec un queryset personnalisé, ou annotez

Liste de Vérification de Validation pour N+1

  • [ ] Tracé le flux de données de la vue au template/serializer
  • [ ] Confirmé que le champ associé est accédé dans une boucle
  • [ ] Recherché dans la base de code select_related/prefetch_related existants
  • [ ] Vérifié que la table a un nombre significatif de lignes (1000+)
  • [ ] Confirmé que c'est un chemin critique (pas d'administration, pas d'action rare)

Priorité 2 : Querysets Non Bornés (CRITIQUE)

Impact : Charger des tables entières épuise la mémoire. Les grandes tables causent des tués OOM et des redémarrages de workers.

Règle : Pagininez toujours les points de terminaison de liste

# PROBLÈME : Pas de pagination - charge toutes les lignes
class UserListView(ListView):
    model = User
    template_name = 'users.html'

# SOLUTION : Ajoutez la pagination
class UserListView(ListView):
    model = User
    template_name = 'users.html'
    paginate_by = 25

Règle : Utilisez iterator() pour le traitement par lot volumineux

# PROBLÈME : Charge tous les objets en mémoire à la fois
for user in User.objects.all():
    process(user)

# SOLUTION : Streamer avec iterator()
for user in User.objects.iterator(chunk_size=1000):
    process(user)

Règle : N'appelez jamais list() sur des querysets non bornés

# PROBLÈME : Force l'évaluation complète en mémoire
all_users = list(User.objects.all())

# SOLUTION : Gardez comme queryset, découpez si nécessaire
users = User.objects.all()[:100]

Liste de Vérification de Validation pour Querysets Non Bornés

  • [ ] La table est volumineuse (10 000+ lignes) ou croîtra sans limites
  • [ ] Pas de classe de pagination, paginate_by, ou découpage
  • [ ] Ceci s'exécute sur une requête avec interface utilisateur (pas d'emploi en arrière-plan avec découpage)

Priorité 3 : Index Manquants (HAUTE)

Impact : Analyses complètes de table. Négligeable sur les petites tables, catastrophique sur les grandes.

Règle : Indexez les champs utilisés dans les clauses WHERE sur les grandes tables

# PROBLÈME : Filtrage sur un champ non indexé
# User.objects.filter(email=email)  # analyse complète sans index

class User(models.Model):
    email = models.EmailField()  # ← pas de db_index

# SOLUTION : Ajoutez un index
class User(models.Model):
    email = models.EmailField(db_index=True)

Règle : Indexez les champs utilisés dans ORDER BY sur les grandes tables

# PROBLÈME : Le tri nécessite une analyse complète sans index
Order.objects.order_by('-created')

# SOLUTION : Indexez le champ de tri
class Order(models.Model):
    created = models.DateTimeField(db_index=True)

Règle : Utilisez les index composites pour les modèles de requête courants

class Order(models.Model):
    user = models.ForeignKey(User)
    status = models.CharField(max_length=20)
    created = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=['user', 'status']),  # pour filter(user=x, status=y)
            models.Index(fields=['status', '-created']),  # pour filter(status=x).order_by('-created')
        ]

Liste de Vérification de Validation pour Index Manquants

  • [ ] La table a 10 000+ lignes
  • [ ] Le champ est utilisé dans filter() ou order_by() sur le chemin critique
  • [ ] Coché le modèle - pas d'entrée db_index=True ou Meta.indexes
  • [ ] Pas une clé étrangère (déjà indexée automatiquement)

Priorité 4 : Boucles d'Écriture (HAUTE)

Impact : N écritures de base de données au lieu de 1. Contention de verrous. Requêtes lentes.

Règle : Utilisez bulk_create au lieu de create() dans les boucles

# PROBLÈME : N insertions, N allers-retours
for item in items:
    Model.objects.create(name=item['name'])

# SOLUTION : Insertion groupée unique
Model.objects.bulk_create([
    Model(name=item['name']) for item in items
])

Règle : Utilisez update() ou bulk_update au lieu de save() dans les boucles

# PROBLÈME : N mises à jour
for obj in queryset:
    obj.status = 'done'
    obj.save()

# SOLUTION A : Instruction UPDATE unique (même valeur pour tous)
queryset.update(status='done')

# SOLUTION B : bulk_update (valeurs différentes)
for obj in objects:
    obj.status = compute_status(obj)
Model.objects.bulk_update(objects, ['status'], batch_size=500)

Règle : Utilisez delete() sur le queryset, pas dans les boucles

# PROBLÈME : N suppressions
for obj in queryset:
    obj.delete()

# SOLUTION : DELETE unique
queryset.delete()

Liste de Vérification de Validation pour Boucles d'Écriture

  • [ ] La boucle itère sur 100+ éléments (ou sans limites)
  • [ ] Chaque itération appelle create(), save(), ou delete()
  • [ ] Ceci s'exécute sur une requête avec interface utilisateur (pas un script de migration unique)

Priorité 5 : Modèles Inefficaces (BASSE)

Rarement digne d'être signalé. Incluez seulement comme notes mineures si vous signalez déjà de vrais problèmes.

Modèle : count() vs exists()

# Légèrement non optimal
if queryset.count() > 0:
    do_thing()

# Légèrement mieux
if queryset.exists():
    do_thing()

Habituellement ignorez - la différence est <1ms dans la plupart des cas.

Modèle : len(queryset) vs count()

# Récupère toutes les lignes à compter
if len(queryset) > 0:  # mauvais si queryset pas encore évalué

# Requête COUNT unique
if queryset.count() > 0:

Signalez seulement si le queryset est volumineux et pas déjà évalué.

Modèle : get() dans de petites boucles

# N requêtes, mais si N est petit (< 20), souvent correct
for id in ids:
    obj = Model.objects.get(id=id)

Signalez seulement si la boucle est volumineuse ou ceci est sur un chemin très critique.


Exigences de Validation

Avant de signaler UN PROBLÈME :

  1. Tracez le flux de données - Suivez le queryset de sa création à sa consommation
  2. Recherchez les optimisations existantes - Grep pour select_related, prefetch_related, pagination
  3. Vérifiez le volume de données - Vérifiez si la table est réellement volumineuse
  4. Confirmez le chemin critique - Tracez les sites d'appel, vérifiez que ceci s'exécute fréquemment
  5. Excluez les atténuations - Vérifiez la mise en cache, limitation de débit

Si vous ne pouvez pas valider toutes les étapes, ne signalez pas.


Format de Sortie

## Examen de Performance Django : [Nom Fichier/Composant]

### Résumé
Problèmes validés : X (Y Critique, Z Haute)

### Découvertes

#### [PERF-001] Requête N+1 dans UserListView (CRITIQUE)
**Localisation :** `views.py:45`

**Problème :** Le champ associé `profile` est accédé dans une boucle template sans préchargement.

**Validation :**
- Tracé : UserListView → queryset users → user_list.html → `{{ user.profile.bio }}` en boucle
- Recherché la base de code : aucun select_related('profile') trouvé
- Table User : 50 000+ lignes (vérifié dans l'administration)
- Chemin critique : lié depuis la navigation de la page d'accueil

**Preuve :**
```python
def get_queryset(self):
    return User.objects.filter(active=True)  # pas de select_related

Correctif :

def get_queryset(self):
    return User.objects.filter(active=True).select_related('profile')

Si aucun problème trouvé : « Aucun problème de performance identifié après examen de [fichiers] et validation de [ce que vous avez vérifié]. »

**Avant de soumettre, vérifiez l'intégrité de chaque découverte :**
- La gravité correspond-elle à l'impact réel ? (« Inefficacité mineure » ≠ CRITIQUE)
- Est-ce un vrai problème de performance ou juste une préférence de style ?
- La correction de ceci améliorerait-elle mesurément la performance ?

Si la réponse à l'une d'elles est « non » - supprimez la découverte.

---

## Ce QUE NE PAS Signaler

- Fichiers de test
- Vues d'administration uniquement
- Commandes de gestion
- Fichiers de migration
- Scripts uniques
- Code derrière des drapeaux de fonctionnalités désactivés
- Tables avec <1000 lignes qui ne croîtront pas
- Modèles dans les chemins froids (code rarement exécuté)
- Micro-optimisations (exists vs count, only/defer sans preuve)

### Faux Positifs à Éviter

**L'affectation de variable queryset n'est pas un problème :**
```python
# C'est BON - pas de différence de performance
projects_qs = Project.objects.filter(org=org)
projects = list(projects_qs)

# vs ceci - performance identique
projects = list(Project.objects.filter(org=org))

Les querysets sont paresseux. L'affectation à une variable n'exécute rien.

Les modèles de requête unique ne sont pas N+1 :

# C'est UNE requête, pas N+1
projects = list(Project.objects.filter(org=org))

N+1 nécessite une boucle qui déclenche des requêtes supplémentaires. Un appel list() unique est correct.

Manquer select_related lors d'une récupération d'objet unique n'est pas N+1 :

# C'est 2 requêtes, pas N+1 - signalez comme BASSE au maximum
state = AutofixState.objects.filter(pr_id=pr_id).first()
project_id = state.request.project_id  # deuxième requête

N+1 nécessite une boucle. Un seul objet effectuant 2 requêtes au lieu de 1 peut être signalé comme BASSE si pertinent, mais jamais comme CRITIQUE/HAUTE.

Les préférences de style ne sont pas des problèmes de performance : Si votre seule suggestion est « combinez ces deux lignes » ou « renommez cette variable » - c'est du style, pas de performance. Ne le signalez pas.