Django: Сложные наборы запросов выполняются дольше

Если я сделаю фильтр поиска OR по полям модели и связанной с ней m2m-модели, аннотата, то запрос будет выполняться очень долго.(1500-2000ms)

Если я удалю Q(tags__name__icontains=value) из фильтра в следующем наборе запросов. он работает примерно за 30-50 мс, поэтому я думаю, что причина в проблеме с m2m.

Фильтрация поля m2m из модели, привязанной к m2m, приводит к циклическому просмотру всей сквозной таблицы, что, на мой взгляд, отнимает много времени. Как я могу переписать набор запросов, чтобы улучшить это?

Видео: 300k rows, Tag: 5k рядов, video_tag_through: 1.3m рядов

# models.py
class Tag(models.Model):
    name = models.CharField(unique=True, max_length=30)
    created_at = models.DateTimeField(default=timezone.now)
    ...


class Video(models.Model):
    title = models.CharField(max_length=300)
    tags = models.ManyToManyField(Tag, blank=True)
    updated_at = models.DateTimeField(auto_now=True)
    ...


class History(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    video = models.ForeignKey(Video, on_delete=models.CASCADE)
    ...


class Favorite(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE
    video = models.ForeignKey(Video, on_delete=models.CASCADE)
    ...


class Playlist(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    is_wl = models.BooleanField(default=False, editable=False)
    ...


class Track(models.Model):
    playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE, null=True)
    video = models.ForeignKey(Video, on_delete=models.CASCADE)
    ...

Это может показаться сложным и запутанным, но набор запросов выглядит следующим образом

# query
Video.objects.annotate(
    is_viewed=Exists(History.objects.filter(user=user, video=OuterRef("pk"))),
    is_favorited=Exists(
        Favorite.objects.filter(user=user, video=OuterRef("pk"))
    ),
    is_wl=Exists(
        Track.objects.filter(
            playlist__user=user, playlist__is_wl=True, video=OuterRef("pk")
        )
    ),
).filter(
    Q(title__icontains=value) | Q(tags__name__icontains=value),
    is_public=True,
    published_at__lte=timezone.now(),
).order_by(
    "-published_at"
).distinct()[:20]

Ниже представлен запрос, который был выдан, и план его выполнения.

SELECT DISTINCT "videos_video"."id",
                "videos_video"."published_at",
                EXISTS
  (SELECT (1) AS "a"
   FROM "videos_history" U0
   WHERE (U0."user_id" IS NULL
          AND U0."video_id" = "videos_video"."id")
   LIMIT 1) AS "is_viewed",
                EXISTS
  (SELECT (1) AS "a"
   FROM "videos_favorite" U0
   WHERE (U0."user_id" IS NULL
          AND U0."video_id" = "videos_video"."id")
   LIMIT 1) AS "is_favorited",
                EXISTS
  (SELECT (1) AS "a"
   FROM "videos_track" U0
   INNER JOIN "videos_playlist" U1 ON (U0."playlist_id" = U1."id")
   WHERE (U1."is_wl"
          AND U1."user_id" IS NULL
          AND U0."video_id" = "videos_video"."id")
   LIMIT 1) AS "is_wl"
FROM "videos_video"
LEFT OUTER JOIN "videos_video_tags" ON ("videos_video"."id" = "videos_video_tags"."video_id")
LEFT OUTER JOIN "videos_tag" ON ("videos_video_tags"."tag_id" = "videos_tag"."id")
WHERE ("videos_video"."is_public"
       AND "videos_video"."published_at" <= '2021-12-24 08:16:29.506387+00:00'
       AND (UPPER("videos_video"."title"::text) LIKE UPPER('%word%')
            OR UPPER("videos_tag"."name"::text) LIKE UPPER('%word%')))
ORDER BY "videos_video"."published_at" DESC
LIMIT 20;

ОБЪЯСНИТЬ ПРОАНАЛИЗИРОВАТЬ

Основной логической ошибкой, которую вы допускаете, является поиск Tag'name'%s ILIKE '%<QUERY>%', что приводит к наибольшему снижению производительности.

Обычным подходом для поиска по тегам является поиск списка ID тегов, а затем фильтрация сущностей с его помощью, например, так:

Video.objects.annotate(
    is_viewed=Exists(History.objects.filter(user=user, video=OuterRef("pk"))),
    is_favorited=Exists(
        Favorite.objects.filter(user=user, video=OuterRef("pk"))
    ),
    is_wl=Exists(
        Track.objects.filter(
            playlist__user=user, playlist__is_wl=True, video=OuterRef("pk")
        )
    ),
).filter(
    Q(title__icontains=value) | Q(tags__id__in=Tag.objects.filter(name__icontains=value)),
    is_public=True,
    published_at__lte=timezone.now(),
).order_by(
    "-published_at"
).distinct()[:20]

Даже это будет работать быстрее. Следующие шаги:

  • Чтобы сделать это лучше, можно придумать поиск тегов по имени на равенство. Для этого можно разработать синонимы Tag, например, с нулевым полем is_synonym_for, добавить все синонимы и искать с помощью name__exact.
  • Создайте индекс GIN на name, если вам нужен полнотекстовый поиск. Docs.

Желаю удачи :0

Вернуться на верх