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

时间:2014-10-12 07:58:30

标签: django django-models django-queryset

我有一个非常标准的基本社交应用程序 - 每个帖子都有状态更新(即帖子)和多条评论。

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

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()会抓取所有帖子和评论,但我想在每个帖子中检索有限数量的评论。

更新:

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

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

更新2:

使用Django ORM进行简单查询似乎没有直接和简单的方法来有效地完成这项工作。以下答案中有许多有用的解决方案/方法/解决方法,包括:

  • 缓存数据库中的最新注释ID
  • 执行原始SQL查询
  • 检索所有评论ID并在python中进行分组和“加入”
  • 限制您的申请仅显示最新评论

我不知道哪一个标记为正确,因为我还没有机会尝试所有这些方法 - 但我将赏金授予hynekcer以提供多种选择。

更新3:

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

3 个答案:

答案 0 :(得分: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

另一种变体:如果您的评论有时间戳,并且您希望按日期将评论限制为最新评论,那么看起来就像:

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

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

答案 1 :(得分:1)

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

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]]

但我认为许多数据库后端将第二个和第三个查询合并为一个会更快,因此立即要求所有注释字段。无用的评论将立即被遗忘。

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


修改
我同意这对你没用

  

...预取将数千条评论加载到内存中,其中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()

  • 如果有趣的帖子(您之前按类别等选择了等)过滤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”(并为少于两个注释的帖子设置为零):

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;

答案 2 :(得分:1)

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

我遇到了同样的问题,数据库是 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