如何在Django中预取关联模型的最新对象以避免N+1查询?
latest()时的N+1查询问题 问题背景
现有Django模型如下:
class Author(models.Model): name = models.CharField(max_length=100) class Book(models.Model): author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE) title = models.CharField(max_length=100) created_at = models.DateTimeField(auto_now_add=True)
执行以下代码时会出现预期的N+1查询问题:
queryset = Author.objects.all() for author in queryset: print(author.name) print(author.books.latest("created_at").title)
尝试通过queryset = Author.objects.prefetch_related('books')预取books关联,但未解决N+1问题——原因是预取执行的是SELECT * FROM book WHERE author_id IN (1,2,...),而latest()执行的是SELECT * FROM book WHERE author_id = 1 ORDER BY created_at DESC LIMIT 1,二者逻辑不同。
也尝试了queryset = Author.objects.prefetch_related(Prefetch('books', queryset=Book.objects.order_by("-created_at"))),但同样无效。
请问应如何编写预取语句,才能在使用latest()时避免N+1查询?
解决方案
要解决这个问题,核心是预取每个作者的最新单本图书,而非全部关联图书。以下两种方法可以实现:
方法一:子查询+预取指定图书ID
先通过子查询获取每个作者最新图书的ID,再仅预取这些ID对应的图书:
from django.db.models import Subquery, OuterRef from django.db.models import Prefetch # 子查询:获取每个作者最新的单本图书ID latest_book_subquery = Book.objects.filter( author=OuterRef('pk') ).order_by('-created_at').values('pk')[:1] # 先给作者标注最新图书ID,再预取对应图书到自定义属性 queryset = Author.objects.annotate( latest_book_id=Subquery(latest_book_subquery) ).prefetch_related( Prefetch( 'books', queryset=Book.objects.filter(pk__in=Subquery(latest_book_subquery)), to_attr='latest_book' ) ) # 使用时直接从预取的集合中取最新图书 for author in queryset: print(author.name) print(author.latest_book[0].title)
方法二:窗口函数筛选最新图书
利用Django的窗口函数,给每个作者的图书按创建时间倒序编号,仅保留编号为1的最新图书,再预取这些结果:
from django.db.models import Window, F from django.db.models.functions import RowNumber from django.db.models import Prefetch # 用窗口函数筛选每个作者的最新图书 latest_books_queryset = Book.objects.annotate( row_num=Window( partition_by=F('author'), # 按作者分组 order_by=F('created_at').desc() # 按创建时间倒序排序 ) ).filter(row_num=1) # 只取每组的第一本(最新的) # 预取筛选后的最新图书到自定义属性 queryset = Author.objects.prefetch_related( Prefetch( 'books', queryset=latest_books_queryset, to_attr='latest_book' ) ) # 使用方式一致 for author in queryset: print(author.name) print(author.latest_book[0].title)
为什么之前的方法无效?
之前的预取操作只是把该作者的全部图书(或排序后的全部图书)加载到内存,但author.books.latest()是触发数据库层面的查询,不会直接使用内存中预取的集合计算。而上面的方法是直接在数据库层面筛选出每个作者的最新图书,再预取到内存,循环时直接从预取的集合中读取,不会触发新的数据库查询。
内容的提问来源于stack exchange,提问作者Monoab

