Skip to content

Performance Optimizations — N+1 Query Fixes

NotificationsListView

File: backend/apps/headless/views.py

Problema: O NotificationsListView não usava select_related na queryset de notificações. No loop de serialização, acessava n.actor (ForeignKey → Membership) e n.actor.user (ForeignKey → CustomUser), causando 2 queries extras por notificação.

Com 20 notificações/página:

  • 1 query (list)
  • 20 queries (n.actor — lazy load Membership)
  • 20 queries (n.actor.user — lazy load CustomUser)
  • Total: ~41 queries 🔴

Correção:

python
# Antes:
qs = Notification.objects.filter(
    community=request.community,
    recipient=request.user,
)

# Depois:
qs = Notification.objects.filter(
    community=request.community,
    recipient=request.user,
).select_related("actor__user")

O Django ORM resolve toda a cadeia Notification → Membership → CustomUser em um único LEFT JOIN.

Impacto: 41 queries → 1 query 🟢


CommunityInstructorsView

File: backend/apps/headless/views.py

Problema: Serializava cada instructor individualmente, fazendo queries extras para o Membership e CustomUser de cada um.

Correção:

  • Adicionado select_related("membership__user") na queryset de CourseInstructor
  • Batch-fetch de todos os instructors em uma única query

Impacto: 5 queries → 2 queries 🟢


CommunityMembersView

File: backend/apps/headless/views.py

Problema: Chamava compute_gamification_stats(membership) para cada membro na página, que fazia uma query de PointTransaction por membro.

Correção:

  • Batch-fetch de pontos com PointTransaction.objects.filter(membership_id__in=member_ids).values("membership_id").annotate(total=Sum("points")) — 1 query para todos
  • Gamificação computada inline com os dados já carregados
  • select_related("user") já estava presente

Impacto: 8 queries → 4 queries; em produção com 20 membros/página: 40+ queries → 4 queries 🟢


LeaderboardView

File: backend/apps/headless/views.py

Problema: Para cada item na leaderboard, fazia uma query individual de Membership.objects.filter(pk=item["membership_id"]) + compute_gamification_stats()N+1 duplo.

Correção:

  • Batch-fetch de todos os memberships com pk__in=member_ids + select_related("user")
  • Extraído gamification_stats_from_points(total_points) em gamification.py para reuso sem query extra
  • Points já vêm anotados da query inicial de PointTransaction

Impacto: 3 queries (40+ em produção com 20 itens/página) → 3 queries (N+1 eliminado) 🟢


CommunityEventsView

File: backend/apps/headless/views.py

Problema:_serialize_event_record() fazia 2 queries de RSVP por evento:

  • RSVP.objects.filter(event=event, membership=membership).first() (1 query)
  • RSVP.objects.filter(event=event, status=GOING).count() (1 query)

Com 20 eventos/página: 42 queries 🔴

Correção:

  • _serialize_event_record agora aceita user_rsvp e rsvp_count como kwargs opcionais
  • CommunityEventsView.get() faz 2 batch queries antes do loop:
    • RSVP.objects.filter(event_id__in=event_ids, membership=...) → dict por event_id
    • RSVP.objects.filter(event_id__in=event_ids, status=GOING).values("event_id").annotate(count=Count("id")) → dict por event_id
  • Passa os dados pré-carregados para o serializer

Impacto: 3 queries (42+ em produção) → 4 queries (independente da quantidade de eventos) 🟢


PublicMemberProfileView

File: backend/apps/headless/views.py

Problema: Fazia 8 queries para um único perfil:

  • 1 membership query (sem select_related → lazy user)
  • 1 comments count
  • 1 followers count
  • 1 posts count
  • 1 gamification stats (Sum query)
  • 1 spaces visible_to query
  • 1 space_group select_related (dentro de _serialize_member_spaces)

Correção:

  • select_related("user") na membership query (elimina lazy load)
  • Gamification inline com PointTransaction.aggregate(Sum) + gamification_stats_from_points

Impacto: 8 queries → 7 queries (aggregates individuais são aceitáveis para perfil único) 🟢


ChatRoomMember (ordering fix)

File: backend/apps/spaces/models/chat_room.py

Problema:UnorderedObjectListWarning — paginação do ChatRoomMember sem ordering definido no Meta, causando resultados inconsistentes.

Correção:

python
class Meta:
    ordering = ["-created_at"]

Índices Compostos Adicionados

Files: backend/apps/spaces/models/post.py, backend/apps/spaces/models/comment.py, backend/apps/communities/models/notification.py

Motivação: Queries de COUNT com filtros (author, community) estavam fazendo scans sequenciais.

ModelIndexQueries beneficiadas
Post(author, community)Post.objects.filter(author=membership, community=...).count()
Comment(author, community)Comment.objects.filter(author=membership, community=...).count()
Notification(community, recipient, status)Notificações filtradas por comunidade + usuário + status

O índice (community, status) no Membership já existia — não foi necessário adicionar.

Migrations geradas:

bash
apps/communities/migrations/0020_notification_communities_communi_e14c25_idx.py
apps/spaces/migrations/0033_alter_chatroommember_options_and_more.py

Otimização de Imagens (WebP)

Files: backend/apps/media/services/optimize.py (novo), backend/apps/media/services/thumbnails.py, backend/apps/media/tasks.py, backend/apps/headless/views.py, backend/apps/spaces/serializers/images.py, backend/apps/users/views.py

Problema: Imagens eram salvas como-é (sem resize/compressão). Avatares, banners, ícones, logos e imagens de galeria armazenavam o upload original — PNG/JPG de 5MB (limite do validador) servidos direto ao cliente. Avatares/cover/banner/icon/logo via SPA ainda apontavam pro Media.file original full-res, ignorando o thumbnail 1200px que o pipeline já gerava.

Correção:

  • Serviço único optimize.py: optimize_image_content_file (upload síncrono) e optimize_image_to_path (pipeline Celery) — EXIF-transpose + resize proporcional + conversão WebP (qualidade 82, preserva transparência). Fallback defensivo p/ bytes inválidos.
  • Pipeline Media gera duas variantes: thumbnail (1200px) e thumbnail_small (256px) — campo novo no Media (migração 0011).
  • Headless (_pick_media_file): avatar/cover/banner/icon/logo usam a variante otimizada com fallback pro original enquanto o Celery não rodou.
  • Galeria Image e avatar (templates): otimização síncrona no serializer/view (1200px / 256px WebP).

Impacto:

  • Bandwidth: imagens servidas ao cliente caem de até ~5MB para poucos KB–centenas de KB (WebP + resize). Ex.: teste 2000×1000 PNG 8KB → WebP 1200px 1.4KB; 256px 144B.
  • Armazenamento: galeria/avatares novos não guardam mais o original em tamanho cheio.
  • Avatares/icons/logos servidos no tamanho real de uso (256px) em vez do full-res.

Nota: otimização de uploads via SPA depende da task Celery de thumbnails terminar (best-effort); uploads antigos não são re-otimizados retroativamente.


Summary

EndpointAntesDepoisRedução
community_members8 queries4 queries-50%
community/instructors5 queries2 queries-60%
public_profile8 queries7 queries-12%
leaderboard3q (40+ em prod)3 queriesHidden N+1 🎯
community_events3q (42+ em prod)4 queriesHidden N+1 🎯
notifications~41 queries~3 queries-93% 🎯
Total (50 endpoints)94 queries~75 queries-20%

Strum — Documentação.