查询太慢; prefetch_related 没有解决问题

Queries are too slow; prefetch_related not solving the problem

我们正在为 Speedy Net 使用 Django 2.1。我的页面每页显示大约 96 个用户,对于每个用户,我想显示他在 Speedy Match 上有多少朋友,以及一个有效的电子邮件地址。如果 (self.email_addresses.filter(is_confirmed=True).exists()) 为真,查询将检查每个用户:

def has_confirmed_email(self):
    return (self.email_addresses.filter(is_confirmed=True).exists())

对于 96 个用户中的每个用户,它会检查他的所有朋友并运行此查询 - 每个页面超过数百次。获取用户的查询是 User.objects.all().order_by(<...>),然后它为每个用户检查这个查询:

qs = self.friends.all().prefetch_related("from_user", "from_user__{}".format(SpeedyNetSiteProfile.RELATED_NAME), "from_user__{}".format(SpeedyMatchSiteProfile.RELATED_NAME), "from_user__email_addresses").distinct().order_by('-from_user__{}__last_visit'.format(SiteProfile.RELATED_NAME))

我在用户管理器模型中添加了prefetch_related

def get_queryset(self):
    from speedy.net.accounts.models import SiteProfile as SpeedyNetSiteProfile
    from speedy.match.accounts.models import SiteProfile as SpeedyMatchSiteProfile
    return super().get_queryset().prefetch_related(SpeedyNetSiteProfile.RELATED_NAME, SpeedyMatchSiteProfile.RELATED_NAME, "email_addresses").distinct()

但是将 "email_addresses" 和 "from_user__email_addresses" 添加到 prefetch_related 不会使页面加载速度更快 - 加载页面大约需要 16 秒。在不检查每个朋友是否有确认的电子邮件地址的情况下加载页面时,加载页面大约需要 3 秒。有没有一种方法可以一次加载用户的所有电子邮件地址,而不是每次检查用户时?实际上,我还希望朋友查询加载一次而不是每页加载 96 次(每个用户一次),但页面加载时间为 3 秒,所以这无关紧要。不过如果能查询一下朋友table一次就更好了

查询是由以下行(link)引起的:

if ((self.user.has_confirmed_email()) and (step >= self.activation_step)):

这由 is_active_and_valid 调用,由 get_matching_rank 调用,以检查用户是否与特定用户匹配。这是由模型中的方法 get_friends 调用的。

更新 #1: 如果我在模型中的 def has_confirmed_email(...) 中更改为 return True,页面加载速度仅快 3 秒(13 而不是 16 ) 因此此页面中可能存在更多与性能相关的问题。

如果我禁用 get_matching_rank 的功能并将其替换为普通的 return 5,页面加载速度会快得多。但是我们当然需要这个函数的功能。也许我们可以在为两个特定用户的集合调用时将此函数的结果缓存几分钟?

更新 #2: 我想向用户模型添加一个布尔字段,如果用户有确认的电子邮件地址,则该字段为真。每次保存或删除电子邮件地址时,此字段都会更新。我知道如何覆盖保存方法,但是当电子邮件地址被删除时如何更新此字段?也可能被管理员删除了。

我想我应该使用 post_savepost_delete 等信号。

要使预取产生任何效果,您必须在 User 模型上使用它 - 很难从您所包含的内容中判断您是否在这样做。

如果不为每个用户预取好友,则 self.friends.all() 将导致查询。要使用预取绕过查询,您可以执行以下操作之一:

User.objects.prefetch_related('friends')

或者您可以使用 Prefetch 对象进一步过滤:

User.objects.prefetch_related(Prefetch(
    'friends',
    queryset=Friend.objects.filter(is_confirmed=True)
)

使用 filter 关键字参数的 Count 注释会快得多。

from djang.db.models import Count, Q

qs = User.objects.annotate(
    friend_count=Count('friends', filter=Q(friends__is_confirmed=True)
)

我在用户模型中添加了一个字段:

has_confirmed_email = models.BooleanField(default=False)

以及方法:

def _update_has_confirmed_email_field(self):
    self.has_confirmed_email = (self.email_addresses.filter(is_confirmed=True).count() > 0)
    self.save_user_and_profile()

并且:

@receiver(signal=models.signals.post_save, sender=UserEmailAddress)
def update_user_has_confirmed_email_field_after_saving_email_address(sender, instance: UserEmailAddress, **kwargs):
    instance.user._update_has_confirmed_email_field()


@receiver(signal=models.signals.post_delete, sender=UserEmailAddress)
def update_user_has_confirmed_email_field_after_deleting_email_address(sender, instance: UserEmailAddress, **kwargs):
    instance.user._update_has_confirmed_email_field()

并且在用户模型中:

def delete(self, *args, **kwargs):
    if ((self.is_staff) or (self.is_superuser)):
        warnings.warn('Can’t delete staff user.')
        return False
    else:
        self.email_addresses.all().delete() # This is necessary because of the signal above.
        return super().delete(*args, **kwargs)

我还从管理员视图中删除了好友计数,现在管理员视图页面加载时间约为 1.5 秒。

But adding "email_addresses" and "from_user__email_addresses" to prefetch_related doesn't make the page load faster ...

那是因为self.email_addresses.filter(is_confirmed=True).exists()没有使用预取的QuerySet

要使用预取 self.email_addresses,内存中的过滤器:

def has_confirmed_email(self):
    if self.email_addresses.all()._result_cache is not None:
        return any(email_address.is_confirmed for email_address in self.email_addresses.all())

    return (self.email_addresses.filter(is_confirmed=True).exists())

注意:如果没有预取,那么改进后的实现仍然会在每次 has_confirmed_email 函数调用时访问数据库,因为 .filter 仍然会创建一个新的 QuerySet。要处理这个问题,请将 has_confirmed_email 设为 Django @cached_property.

说明

来自 https://docs.djangoproject.com/en/3.0/ref/models/querysets/#prefetch-related:

Remember that, as always with QuerySets, any subsequent chained methods which imply a different database query will ignore previously cached results, and retrieve data using a fresh database query. ...

>>> pizzas = Pizza.objects.prefetch_related('toppings')
>>> [list(pizza.toppings.filter(spicy=True)) for pizza in pizzas]

... The prefetched cache can’t help here; in fact it hurts performance, since you have done a database query that you haven’t used. So use this feature with caution!