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单表查询orm多表查询聚合查询分组查询F查询 Q查询事务Django ORM执行原生SQL)