You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

优化多对多关系基于行数过滤的Django查询性能

问题背景

模型定义如下:

class RecipeTag(models.Model):
    tag = models.CharField(max_length=300, blank=True, null=True, default=None, db_index=True)
    recipes = models.ManyToManyField(Recipe, blank=True)
    description = models.TextField(blank=True, null=True, default=None)
    language_code = models.CharField(
        max_length=5,
        choices=[(lang[0], lang[1]) for lang in settings.LANGUAGES],
        default='en',
        db_index=True  # Index added here
    )
    slug = models.CharField(max_length=300, blank=True, null=True, default=None, db_index=True)  # Index added here
    is_visible = models.BooleanField(blank=True, null=True, default=True, db_index=True)  # Index added here

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.tag)
        super(NewRecipeTag, self).save(*args, **kwargs)

    def __str__(self):
        tag = self.tag if self.tag else "Tag"
        return f"{tag} - Visible - {self.is_visible}"

需要筛选出关联Recipe数量≥2的RecipeTag,当前实现代码:

tags = NewRecipeTag.objects.filter(language_code=meta['language'],
    is_visible=True).annotate(num_recipes=Count('recipes')).filter(num_recipes__gte=2)

分页代码:

paginator = Paginator(recipe_tags, 60)
page = paginator.page(page_number)
recipe_tags = page.object_list

该查询耗时约20秒,移除annotate后查询几乎瞬间完成。现有数据量为4391166行,希望找到更高效的分页展示方案。


更新内容

实际模型名为NewRecipeTag,Recipe模型定义:

class Recipe(models.Model):
    author = models.ForeignKey(CustomUser, blank=True, null=True, on_delete=models.CASCADE, related_name='author', default=None)
    category = models.ForeignKey(Category, related_name="dish_category", blank=True, null=True, on_delete=models.SET_DEFAULT, default=None)
    level = models.ForeignKey(Category, related_name="category_level", blank=True, null=True, on_delete=models.SET_DEFAULT, default=None)
    time = models.ForeignKey(Category, related_name="time", blank=True, null=True, on_delete=models.SET_DEFAULT, default=None)
    website = models.ForeignKey(Website, blank=True, null=True, default=None, on_delete=models.CASCADE, related_name='recipes')
    language = models.CharField(
        max_length=5,
        choices=[('en', 'en'), ('uk', 'uk')],
        default='uk'
    )
    name = models.CharField(max_length=250, blank=True, null=True, default="")
    description = models.TextField(blank=True, null=True, default="")
    image = models.TextField(blank=True, null=True, default=None)
    filed_image = models.ImageField(blank=True, null=True, default=None, upload_to="recipes_photos/")
    url = models.URLField(blank=True, null=True, default="")
    created_at = models.DateTimeField(blank=True, null=True, default=timezone.now)
    updated_at = models.DateTimeField(auto_now=True)
    views = models.IntegerField(blank=True, null=True, default=0)
    slug = models.CharField(max_length=300, blank=True, null=True, default=None)
    sitemap_loc_pk = models.IntegerField(blank=True, null=True, default=None)
    is_active = models.BooleanField(blank=True, null=True, default=True)
    kitchen = models.ForeignKey(Category, related_name="kitchen", blank=True, null=True, on_delete=models.SET_DEFAULT, default=None)
    cook_methods = models.ForeignKey(Category, related_name="cook_methods", blank=True, null=True, on_delete=models.SET_DEFAULT, default=None)
    vegetarian = models.BooleanField(blank=True, null=True, default=False)
    hot = models.BooleanField(blank=True, null=True, default=False)
    dietary = models.ForeignKey(Category, related_name="category_dietary", blank=True, null=True,
                                  on_delete=models.SET_DEFAULT, default=None)

    type_of_food = models.ForeignKey(Category, related_name="category_type_of_food", blank=True, null=True,
                                  on_delete=models.SET_DEFAULT, default=None)

    season = models.ForeignKey(Category, related_name="category_season", blank=True, null=True,
                                  on_delete=models.SET_DEFAULT, default=None)

    food_group = models.ForeignKey(Category, related_name="category_food_group", blank=True, null=True,
                               on_delete=models.SET_DEFAULT, default=None)
    new_tags_generated = models.BooleanField(blank=True, null=True, default=False)

生成的SQL查询语句:

SELECT "recipe_newrecipetag"."id", "recipe_newrecipetag"."tag", "recipe_newrecipetag"."description", "recipe_newrecipetag"."seo_h1", "recipe_newrecipetag"."seo_title", "recipe_newrecipetag"."seo_description", "recipe_newrecipetag"."seo_text", "recipe_newrecipetag"."language_code", "recipe_newrecipetag"."slug", "recipe_newrecipetag"."is_visible", "recipe_newrecipetag"."sitemap_loc_pk", COUNT("recipe_newrecipetag_recipes"."recipe_id") AS "num_recipes"
FROM "recipe_newrecipetag" LEFT OUTER JOIN "recipe_newrecipetag_recipes" ON ("recipe_newrecipetag"."id" = "recipe_newrecipetag_recipes"."newrecipetag_id")
WHERE ("recipe_newrecipetag"."is_visible" AND "recipe_newrecipetag"."language_code" = uk)
GROUP BY "recipe_newrecipetag"."id"
HAVING COUNT("recipe_newrecipetag_recipes"."recipe_id") >= 2
ORDER BY "recipe_newrecipetag"."tag" ASC

EXPLAIN输出结果:

Sort  (cost=3706458.92..3710089.69 rows=1452305 width=1555)
  Sort Key: recipe_newrecipetag.tag
  ->  GroupAggregate  (cost=4576.12..609237.64 rows=1452305 width=1555)
        Group Key: recipe_newrecipetag.id
        Filter: (count(recipe_newrecipetag_recipes.recipe_id) >= 2)
        ->  Merge Left Join  (cost=4576.12..525178.10 rows=5919621 width=1555)
              Merge Cond: (recipe_newrecipetag.id = recipe_newrecipetag_recipes.newrecipetag_id)
              ->  Index Scan using recipe_newrecipetag_pkey on recipe_newrecipetag  (cost=0.43..228510.04 rows=4356915 width=1547)
                    Filter: (is_visible AND ((language_code)::text = 'uk'::text))
              ->  Index Only Scan using recipe_newrecipetag_reci_newrecipetag_id_recipe_i_87147e82_uniq on recipe_newrecipetag_recipes  (cost=0.43..211664.16 rows=5966157 width=16)

目前考虑使用请求缓存,但希望找到更优的性能优化方案。


优化方案

1. 优化索引策略

  • 添加复合索引:针对language_code、is_visible、tag字段创建复合索引,直接覆盖过滤+排序逻辑,消除高成本的Sort操作:
    class NewRecipeTag(models.Model):
        # ... 现有字段 ...
        class Meta:
            indexes = [
                models.Index(fields=['language_code', 'is_visible', 'tag']),
            ]
    
  • 优化中间表索引:给中间表单独添加newrecipetag_id的普通索引,加速分组统计:
    CREATE INDEX idx_ntr_newrecipetag_id ON recipe_newrecipetag_recipes(newrecipetag_id);
    

2. 重写查询逻辑,减少全量扫描

先从中间表筛选出符合关联数要求的Tag ID,再关联主表查询,避免全量Left Join:

from django.db.models import Subquery, Count

# 先获取关联Recipe≥2的Tag ID列表
valid_tag_ids = NewRecipeTag.recipes.through.objects.values('newrecipetag_id').annotate(
    cnt=Count('recipe_id')
).filter(cnt__gte=2).values_list('newrecipetag_id', flat=True)

# 再筛选主表数据并排序
tags = NewRecipeTag.objects.filter(
    language_code=meta['language'],
    is_visible=True,
    id__in=Subquery(valid_tag_ids)
).order_by('tag')

3. 预计算关联数量,彻底消除分组统计

  • 新增存储字段:在NewRecipeTag模型中添加recipe_count字段,存储关联Recipe的数量:
    class NewRecipeTag(models.Model):
        # ... 现有字段 ...
        recipe_count = models.IntegerField(default=0, db_index=True)
    
  • 用信号维护字段值:在Tag与Recipe的关联关系变更时,自动更新recipe_count:
    from django.db.models.signals import m2m_changed
    from django.dispatch import receiver
    
    @receiver(m2m_changed, sender=NewRecipeTag.recipes.through)
    def update_recipe_count(sender, instance, action, **kwargs):
        if action in ['post_add', 'post_remove', 'post_clear']:
            instance.recipe_count = instance.recipes.count()
            instance.save(update_fields=['recipe_count'])
    
  • 简化查询:直接用预计算字段过滤,查询效率大幅提升:
    tags = NewRecipeTag.objects.filter(
        language_code=meta['language'],
        is_visible=True,
        recipe_count__gte=2
    ).order_by('tag')
    

4. 分页优化,避免全量计数

如果不需要跳转指定页码,改用keyset分页(游标分页),避免Paginator的全量COUNT操作:

# 以上一页最后一条的tag和id作为游标
tags = NewRecipeTag.objects.filter(
    language_code=meta['language'],
    is_visible=True,
    recipe_count__gte=2,
    # 组合条件保证排序唯一性
    (Q(tag__gt=last_tag) | Q(tag=last_tag, id__gt=last_id))
).order_by('tag', 'id')[:60]

内容的提问来源于stack exchange,提问作者Artem Mashin

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 05:39:53