Django ORM:在不执行 N+1 查询的情况下检索帖子和最新评论

Posted

技术标签:

【中文标题】Django ORM:在不执行 N+1 查询的情况下检索帖子和最新评论【英文标题】:Django ORM: Retrieving posts and latest comments without performing N+1 queries 【发布时间】:2014-12-07 00:28:00 【问题描述】:

我有一个非常标准、基本的社交应用程序——带有状态更新(即帖子),每个帖子有多个 cmets。

鉴于以下简化模型,是否有可能使用 Django 的 ORM 高效地检索所有帖子以及与每个帖子关联的最新两个 cmets,而无需执行 N+1 查询? (也就是说,无需执行单独的查询来获取页面上每个帖子的最新 cmets。)

class Post(models.Model):
    title = models.CharField(max_length=255)
    text = models.TextField()

class Comment(models.Model):
    text = models.TextField()
    post = models.ForeignKey(Post, related_name='comments')

    class Meta:
        ordering = ['-pk']

Post.objects.prefetch_related('comments').all() 获取所有帖子和 cmets,但我只想检索每个帖子的有限数量的 cmets。

更新:

我知道,如果这完全可以使用 Django 的 ORM 来完成,那么它可能必须使用 prefetch_related 的某个版本来完成。多个查询完全没问题,只要我避免每页进行 N+1 个查询。

在 Django 中处理此问题的典型/推荐方法是什么?

更新 2:

似乎没有直接简单的方法可以通过使用 Django ORM 的简单查询来有效地执行此操作。以下答案中有许多有用的解决方案/方法/解决方法,包括:

在数据库中缓存最新的评论 ID 执行原始 SQL 查询 检索所有评论 ID 并在 python 中进行分组和“加入” 将您的应用程序限制为仅显示最新评论

我不知道应该将哪一个标记为正确,因为我还没有机会尝试所有这些方法 - 但我将赏金奖励给了 hynekcer,因为它提供了许多选项。

更新 3:

我最终使用了@user1583799 的解决方案。

【问题讨论】:

我不确定 .select_related('comments') 是否获取 cmets。 .select_related可以获取ForeignKey、OneToOne关系和reverse-OneToOne @Igor,嗯,我没有意识到是这样的。我猜prefetch_related 上的文档暗示了这一点。感谢您的提醒。 获取所有相关的 cmets 有什么问题?您以后只能为每个帖子使用前两个。 posts[0].comments.all() 不会执行额外的查询。问题是有太多相关查询无法全部预取吗? @KrzysztofSzularz 感谢您的回复。每页有 20 个帖子,每个帖子有数百个 cmets。似乎我要么执行 31 次查询以获取将显示的 60 个 cmets。或者 2 个查询来预取并加载到内存中数千个 cmets,其中 99% 不会显示。 【参考方案1】:

此解决方案已针对内存要求进行了优化,正如您所期望的那样,它很重要。它需要三个查询。第一个查询请求帖子,第二个查询仅查询元组 (id, post_id)。第三个是过滤的最新 cmets 的详细信息。

from itertools import groupby, islice
posts = Post.objects.filter(...some your flter...)
# sorted by date or by id
all_comments = (Comment.objects.filter(post__in=posts).values('post_id')
        .order_by('post_id', '-pk'))
last_comments = []
# the queryset is evaluated now. Only about 100 itens chunks are in memory at
# once during iterations.
for post_id, related_comments in groupby(all_comments(), lambda x: x.post_id):
        last_comments.extend(islice(related_comments, 2))
results = 
for comment in Comment.objects.filter(pk__in=last_comments):
    results.setdefault(comment.post_id, []).append(comment)
# output
for post in posts:
    print post.title, [x.comment for x in results[post.id]]

但我认为对于许多数据库后端来说,将第二个和第三个查询合二为一会更快,因此可以立即询问 cmets 的所有字段。无用的 cmets 将立即被遗忘。

最快的解决方案是使用嵌套查询。该算法与上面的算法类似,但一切都是通过原始 SQL 实现的。它仅限于某些后端,例如 PostgresQL。


编辑 我同意这对你没有用

... 预取加载到内存中的数千个 cmets,其中 99% 不会显示。

因此我写了一个相对复杂的解决方案,其中 99% 将被连续读取而不加载到内存中。


编辑

所有示例均适用于您在 [1, 3, 5] 中使用 post_id 的条件(之前按类别选择的任何内容等) 在所有情况下为字段 ['post', 'pk'] 上的评论创建索引

A) PostgresQL 的嵌套查询

SELECT post_id, id, text FROM 
  (SELECT post_id, id, text, rank() OVER (PARTITION BY post_id ORDER BY id DESC)
   FROM app_comment WHERE post_id in (1, 3, 5)) sub
WHERE rank <= 2
ORDER BY post_id, id

如果我们不相信优化器,或者显式地要求更少的内存。它应该只从两个内部选择中的索引读取数据,这比从表中读取的数据少得多。:

SELECT post_id, id, text FROM app_comment WHERE id IN
  (SELECT id FROM
     (SELECT id, rank() OVER (PARTITION BY post_id ORDER BY id DESC)
      FROM app_comment WHERE post_id in (1, 3, 5)) sub
   WHERE rank <= 2)
ORDER BY post_id, id

B) 使用最旧显示评论的缓存 ID

在帖子中添加字段“oldest_displayed”

class Post(models.Model):oldest_displayed = models.IntegerField()

如果感兴趣的帖子(您之前按类别等选择的帖子)过滤 cmets 以获取 pk

过滤器

from django.db.models import F
qs = Comment.objects.filter(
       post__pk__in=[1, 3, 5],
       post__oldest_displayed__lte=F('pk')
       ).order_by('post_id', 'pk')
pprint.pprint([(x.post_id, x.pk) for x in qs])

嗯,很不错……Django是如何编译的?

>>> print(qs.query.get_compiler('default').as_sql()[0])      # added white space
SELECT "app_comment"."id", "app_comment"."text", "app_comment"."post_id"
FROM "app_comment"
INNER JOIN "app_post" ON ( "app_comment"."post_id" = "app_post"."id" )
WHERE ("app_comment"."post_id" IN (%s, %s, %s)
      AND "app_post"."oldest_displayed" <= ("app_comment"."id"))
ORDER BY app_comment"."post_id" ASC, "app_comment"."id" ASC

最初由一个嵌套 SQL 准备所有“oldest_displayed”(对于少于两个 cmets 的帖子设置为零):

UPDATE app_post SET oldest_displayed = 0

UPDATE app_post SET oldest_displayed = qq.id FROM
  (SELECT post_id, id FROM
     (SELECT post_id, id, rank() OVER (PARTITION BY post_id ORDER BY id DESC)
      FROM app_comment ) sub
   WHERE rank = 2) qq
WHERE qq.post_id = app_post.id;

【讨论】:

谢谢,hynekcer。我不确定,但遍历所有 cmets 可能不会提供您建议的好处,至少根据 this question. @tino:不。与预取相比,它确实读取的数据更少(相关 cmets 的 id,没有文本)并且保存的数据少得多(只有两个最新 cmets 的 id)。比它只读取您要显示的对象。我希望它比其他解决方案更快。我仍然不够,我可以通过缓存一个数字变量来提高速度 - 应该显示的两个 cmet 中最旧的主键。 啊,我现在看到内存优势了,谢谢!我必须对此进行分析以查看它是否有帮助,尽管总体上缓存最后两个评论 id 可能更有意义,因为在检索方面似乎没有一种简单的方法可以做到这一点。您提到最快的解决方案是嵌套查询……您将如何在带有 Postgres 后端的 Django 中做到这一点? 是的,我现在将这一切都添加到了答案中。 谢谢,hynekcer!我会尝试其中一些,找出最有效的方法。欣赏它。【参考方案2】:

如果您使用的是 Django 1.7,新的 Prefetch 对象(允许您自定义预取查询集)可能会很有帮助。

很遗憾,我想不出一种简单的方法来完全按照您的要求进行操作。如果您使用的是 PostgreSQL,并且愿意只获得每篇文章的最新评论,则以下内容应该适用于两个查询:

comments = Comment.objects.order_by('post_id', '-id').distinct('post_id')
posts = Post.objects.prefetch_related(Prefetch('comments',
                                               queryset=comments,
                                               to_attr='latest_comments'))

for post in posts:
    latest_comment = post.latest_comments[0] if post.latest_comments else None

另一种变化:如果您的 cmets 有一个时间戳,并且您希望将 cmets 限制为按日期最近的,则看起来像这样:

comments = Comment.objects.filter(timestamp__gt=one_day_ago)

...然后如上所述。当然,您仍然可以对结果列表进行后处理,以将显示限制为最多两个 cmets。

【讨论】:

非常感谢,凯文。我不能假设 cmets 会在特定的时间范围内,但如果我想不出办法做到这一点,也许我只会接受最新的评论。 (是的,新的 Prefetch 对象很酷——就在问我升级到 1.7 的问题之前,我认为它可能能够做到这一点。)【参考方案3】:

prefetch_related('comments') 将获取帖子的所有 cmets。

我遇到了同样的问题,数据库是Postgresql。我找到了办法:

添加一个额外的字段related_replies。注意 FieldType 是ArrayField,它在 django1.8dev 中支持。我将the code复制到我的项目中(django的版本是1.7),只需更改2行,就可以了。(或使用djorm-pg-array)

class Post(models.Model): related_replies = ArrayField(models.IntegerField(), size=10, null=True)

并使用两个查询:

posts = model.Post.object.filter()

related_replies_id = chain(*[p.related_replies for p in posts])
related_replies = models.Comment.objects.filter(
    id__in=related_replies_id).select_related('created_by')[::1]  # cache queryset

for p in posts:
    p.get_related_replies = [r for r in related_replies if r.post_id == p.id]

当有新评论出现时,更新related_replies

【讨论】:

谢谢!如果我无法在单独检索时找到一个好方法来完成此操作,我可能最终会完全按照您的建议跟踪数据库中的最新两个 cmets。我也不知道 ArrayField,所以感谢信息。

以上是关于Django ORM:在不执行 N+1 查询的情况下检索帖子和最新评论的主要内容,如果未能解决你的问题,请参考以下文章

如何在不创建 django 项目的情况下使用 Django 1.8.5 ORM?

python之路_django ORM相关补充

python 之 Django框架(orm单表查询orm多表查询聚合查询分组查询F查询 Q查询事务Django ORM执行原生SQL)

如何将 md5 函数应用于 django orm 中的字段?

django ORM 数据库连接配置

Django学习手册 - ORM 数据表操作