← Назад к вопросам

Как сделаешь механизм подписки одних пользователей на других?

1.8 Middle🔥 151 комментариев
#Архитектура и паттерны#Базы данных (SQL)

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как сделаешь механизм подписки одних пользователей на других

Механизм подписки (follow/subscribe) — это база социальных сетей. Рассмотрю несколько подходов реализации, от простой до оптимизированной версии.

Вариант 1: Простая реализация с моделями

Базовая модель для хранения подписок:

from django.db import models
from django.contrib.auth.models import User

class Follow(models.Model):
    """Модель подписки: follower подписан на following"""
    follower = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='following'  # follower.following.all()
    )
    following = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='followers'  # user.followers.all()
    )
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ('follower', 'following')
        indexes = [
            models.Index(fields=['follower', 'created_at']),
            models.Index(fields=['following', 'created_at']),
        ]
    
    def __str__(self):
        return f"{self.follower.username} follows {self.following.username}"

Эта модель имеет проблему: самоподписка возможна. Нужна валидация:

from django.core.exceptions import ValidationError

class Follow(models.Model):
    # ... поля ...
    
    def clean(self):
        if self.follower == self.following:
            raise ValidationError("Нельзя подписаться на себя")
    
    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

Вариант 2: Использование ManyToMany отношения

Альтернативный подход — встроенное отношение:

from django.db import models
from django.contrib.auth.models import User

class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    follows = models.ManyToManyField(
        User,
        symmetrical=False,
        related_name='followers',
        through='Follow'  # Использовать промежуточную модель
    )
    
    def __str__(self):
        return self.user.username

class Follow(models.Model):
    follower = models.ForeignKey(
        UserProfile,
        on_delete=models.CASCADE,
        related_name='following_relations'
    )
    following = models.ForeignKey(
        UserProfile,
        on_delete=models.CASCADE,
        related_name='follower_relations'
    )
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ('follower', 'following')

Вариант 3: Оптимизированная реализация с кешированием

Для больших объёмов данных нужно кешировать количество подписчиков:

from django.db import models
from django.db.models import Count
import redis

redis_client = redis.Redis()

class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    followers_count = models.IntegerField(default=0)  # Кешированное значение
    following_count = models.IntegerField(default=0)
    
    def get_followers(self, limit=50, offset=0):
        # Получить подписчиков с пагинацией
        return self.followers.all()[offset:offset+limit]
    
    def get_following(self, limit=50, offset=0):
        # Получить подписки
        return self.follows.all()[offset:offset+limit]
    
    def is_following(self, user):
        # Быстрая проверка подписки (через Redis)
        return redis_client.sismember(
            f"follows:{self.user_id}",
            user.id
        )

class Follow(models.Model):
    follower = models.ForeignKey(
        UserProfile,
        on_delete=models.CASCADE,
        related_name='following'
    )
    following = models.ForeignKey(
        UserProfile,
        on_delete=models.CASCADE,
        related_name='followers'
    )
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        unique_together = ('follower', 'following')
        indexes = [
            models.Index(fields=['follower']),
            models.Index(fields=['following']),
        ]
    
    def save(self, *args, **kwargs):
        if self.follower == self.following:
            raise ValidationError("Нельзя подписаться на себя")
        
        super().save(*args, **kwargs)
        
        # Обновить Redis кеш
        redis_client.sadd(
            f"follows:{self.follower.user_id}",
            self.following.user_id
        )
        
        # Обновить счётчики
        UserProfile.objects.filter(pk=self.follower.pk).update(
            following_count=models.F('following_count') + 1
        )
        UserProfile.objects.filter(pk=self.following.pk).update(
            followers_count=models.F('followers_count') + 1
        )
    
    def delete(self, *args, **kwargs):
        super().delete(*args, **kwargs)
        
        # Удалить из Redis
        redis_client.srem(
            f"follows:{self.follower.user_id}",
            self.following.user_id
        )
        
        # Обновить счётчики
        UserProfile.objects.filter(pk=self.follower.pk).update(
            following_count=models.F('following_count') - 1
        )
        UserProfile.objects.filter(pk=self.following.pk).update(
            followers_count=models.F('followers_count') - 1
        )

API для подписки/отписки

from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required

@require_http_methods(["POST"])
@login_required
def follow_user(request, user_id):
    """Подписаться на пользователя"""
    target_user = get_object_or_404(User, id=user_id)
    
    if target_user == request.user:
        return JsonResponse({"error": "Нельзя подписаться на себя"}, status=400)
    
    follower_profile = UserProfile.objects.get(user=request.user)
    target_profile = target_user.userprofile
    
    follow, created = Follow.objects.get_or_create(
        follower=follower_profile,
        following=target_profile
    )
    
    return JsonResponse({
        "success": True,
        "is_following": True,
        "followers_count": target_profile.followers_count,
        "following_count": follower_profile.following_count
    })

@require_http_methods(["POST"])
@login_required
def unfollow_user(request, user_id):
    """Отписаться от пользователя"""
    target_user = get_object_or_404(User, id=user_id)
    
    follower_profile = UserProfile.objects.get(user=request.user)
    target_profile = target_user.userprofile
    
    deleted_count, _ = Follow.objects.filter(
        follower=follower_profile,
        following=target_profile
    ).delete()
    
    return JsonResponse({
        "success": deleted_count > 0,
        "is_following": False,
        "followers_count": target_profile.followers_count,
        "following_count": follower_profile.following_count
    })

@login_required
def get_followers(request, user_id):
    """Получить список подписчиков"""
    user = get_object_or_404(User, id=user_id)
    profile = user.userprofile
    
    page = int(request.GET.get('page', 1))
    limit = 20
    offset = (page - 1) * limit
    
    followers = profile.followers.all()[offset:offset+limit].values(
        'id', 'user__username', 'user__email', 'followers_count'
    )
    
    return JsonResponse({
        "followers": list(followers),
        "total": profile.followers_count,
        "page": page,
        "limit": limit
    })

Использование Celery для асинхронных уведомлений

from celery import shared_task
from django.core.mail import send_mail

@shared_task
def notify_new_follower(follower_id, following_id):
    """Отправить уведомление о новом подписчике"""
    follower = User.objects.get(id=follower_id)
    following = User.objects.get(id=following_id)
    
    # Отправить email
    send_mail(
        subject=f"У вас новый подписчик: {follower.username}",
        message=f"Пользователь {follower.username} подписался на вас",
        from_email="noreply@example.com",
        recipient_list=[following.email]
    )

class Follow(models.Model):
    # ... поля ...
    
    def save(self, *args, **kwargs):
        is_new = self.pk is None
        super().save(*args, **kwargs)
        
        if is_new:
            # Асинхронное уведомление
            notify_new_follower.delay(
                self.follower.user_id,
                self.following.user_id
            )

Поиск взаимных подписок (mutual follows)

from django.db.models import Q, Exists, OuterRef

def get_mutual_follows(user_profile):
    """Получить взаимные подписки"""
    # Пользователи, на которых подписан текущий пользователь
    following_ids = user_profile.following.values_list('id', flat=True)
    
    # Те из них, кто подписан на текущего пользователя
    mutual = UserProfile.objects.filter(
        id__in=following_ids,
        following__id=user_profile.id
    ).distinct()
    
    return mutual

def get_suggestions(user_profile, limit=10):
    """Предложить пользователей для подписки"""
    # Получить пользователей, на которых подписаны мои подписчики
    followed_by_followers = UserProfile.objects.filter(
        follower_relations__follower__in=(
            user_profile.followers.all()
        )
    ).exclude(
        id__in=user_profile.following.all()
    ).exclude(
        id=user_profile.id
    ).annotate(
        mutual_count=Count('id')
    ).order_by('-mutual_count')[:limit]
    
    return followed_by_followers

Оптимизация запросов

from django.db.models import Count, Prefetch

# ❌ Неправильно: N+1 запросы
for user in User.objects.all():
    print(user.userprofile.following_count)  # Каждый раз запрос!

# ✅ Правильно: select_related / prefetch_related
users = User.objects.select_related('userprofile').all()
for user in users:
    print(user.userprofile.following_count)  # Нет дополнительных запросов

# Для больших списков подписчиков
profile = UserProfile.objects.prefetch_related(
    Prefetch(
        'followers',
        queryset=UserProfile.objects.select_related('user').order_by('-created_at')[:100]
    )
).get(id=1)

Заключение

Механизм подписки можно реализовать:

  • Простой вариант: модель Follow с ForeignKey
  • С ManyToMany: встроенное отношение Django
  • Оптимизированный: с кешированием в Redis и счётчиками
  • Асинхронные операции: уведомления через Celery
  • Продвинутые операции: взаимные подписки, рекомендации
  • Важно: правильные индексы и оптимизация запросов