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:
# 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 deCourseInstructor - 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)emgamification.pypara 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_recordagora aceitauser_rsvpersvp_countcomo kwargs opcionaisCommunityEventsView.get()faz 2 batch queries antes do loop:RSVP.objects.filter(event_id__in=event_ids, membership=...)→ dict por event_idRSVP.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:
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.
| Model | Index | Queries 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:
apps/communities/migrations/0020_notification_communities_communi_e14c25_idx.py
apps/spaces/migrations/0033_alter_chatroommember_options_and_more.pyOtimizaçã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) eoptimize_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) ethumbnail_small(256px) — campo novo noMedia(migração0011). - Headless (
_pick_media_file): avatar/cover/banner/icon/logo usam a variante otimizada com fallback pro original enquanto o Celery não rodou. - Galeria
Imagee 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
| Endpoint | Antes | Depois | Redução |
|---|---|---|---|
| community_members | 8 queries | 4 queries | -50% |
| community/instructors | 5 queries | 2 queries | -60% |
| public_profile | 8 queries | 7 queries | -12% |
| leaderboard | 3q (40+ em prod) | 3 queries | Hidden N+1 🎯 |
| community_events | 3q (42+ em prod) | 4 queries | Hidden N+1 🎯 |
| notifications | ~41 queries | ~3 queries | -93% 🎯 |
| Total (50 endpoints) | 94 queries | ~75 queries | -20% |