前两篇把Django的环境搭建、项目结构、View和Template的基本用法过了一遍之后,很多读者留言问:后台怎么管理数据?ORM除了简单的增删改查,还有哪些进阶玩法?单表、多表、聚合查询到底什么场景用?这次就用一篇把它讲透,重点聚焦三个东西:Admin后台的实用配置、ORM进阶的链式查询与跨表关联,以及聚合统计的完整落地。这篇适合已经能跑通Django基础流程、想写正经业务逻辑的读者,内容比较密,建议对着自己的项目边抄边试。
1. Admin后台定制:从"能用"到"顺手"
1.1 先谈注册机制:admin.site.register的三种姿势
Django的Admin后台本质是一个自动生成的管理界面,它读取你注册的Model,动态生成列表页、编辑页和删除页。多数初学者停留在最简单的那种——直接在admin.py里admin.site.register(Article),完事了。但对于稍微正式一点的项目,这种方式会暴露很多问题:列表页把所有字段全排出来,冗长没法看;搜索框没有;筛选器没有;编辑某个字段时误点就保存了,没有二次确认。
所以第一个台阶是掌握注册姿势。除了直接register,更常用的是用装饰器注册并同时传入定制配置类:
from django.contrib import admin from .models import Article @admin.register(Article) class ArticleAdmin(admin.ModelAdmin): list_display = ('title', 'category', 'author', 'view_count', 'created_at') list_filter = ('category', 'created_at') search_fields = ('title', 'content')这里有个容易忽略的点:@admin.register装饰器是在Django启动时执行的,如果你在同一个admin.py里重复注册同一个Model,会抛AlreadyRegistered异常。多个App里的admin代码最好保持命名空间清晰,不然排查起来很烦。
1.2 列表页的list_display和它的"三个坑"
list_display是后台定制里最核心、也最能提升效率的配置。它可以放三种东西:Model字段名、Model方法名、Admin类自定义方法名。很多人以为它只能放数据库字段,这是第一个误区。
模型里的方法也可以直接放进去,比如:
class Article(models.Model): title = models.CharField(max_length=200) content = models.TextField() view_count = models.IntegerField(default=0) def short_content(self): return self.content[:50] + '...' short_content.short_description = '内容摘要'然后在Admin里写list_display = ('title', 'short_content'),列表页就会渲染这个方法的结果。注意两点:
- 方法返回值默认不做HTML转义,如果返回的是带标签的字符串,需要用
format_html包装,否则XSS风险是真实存在的。 - 方法无法参与排序,如果你想按这个方法排序,Django会直接忽略,因为它对应的是Python层的计算结果,不是数据库字段。
第三个坑是list_display里放了多对多字段,比如文章和标签是ManyToMany关系。直接放上去,列表页每一行都会额外触发一次查询,而且显示出来的是形如Tag object (1)的字符串,很难看。更强的做法是在Admin类里定义一个方法,用逗号拼接标签名:
def tag_list(self, obj): return ', '.join(t.name for t in obj.tags.all())这样既可控展示内容,也能把多对多查询集中到一个方法里,后续统一加缓存也方便。
1.3 编辑页的细节:readonly_fields、actions、inlines
编辑页(change_form)的定制是很多人忽略的部分。默认情况下所有可编辑字段全都平铺在编辑页,如果字段多,视觉压力很大。fieldsets可以把字段分组:
fieldsets = ( ('基础信息', { 'fields': ('title', 'author', 'category'), }), ('正文与状态', { 'fields': ('content', 'status', 'view_count'), }), )readonly_fields用来做"只允许在后台展示、不允许在后台修改"的字段。最典型的就是created_at这类记录产生时间的字段,以及由代码计算得到的字段(比如总浏览量的缓存值)。注意它和list_display不太一样,编辑器右上角的"保存"仍然会提交,但被标记为readonly的字段不会被写入数据库。
再说Admin的actions。这是批量操作的入口,默认自带一个"删除所选对象"。真实项目里常用的是自定义action,比如批量把文章置为"已发布"、批量导出选中数据。写自定义action有几个经验:
- action函数第一个参数是
ModelAdmin实例,第二个是QuerySet(在3.x里是HttpRequest放前面,不同Django版本签名不同,注意看当前版本文档)。 - 为了安全,action里的QuerySet不要信任传进来的对象,尽量再按当前用户权限过滤一遍,防止越权批量操作。
actions = ['make_published']注册后,列表页下拉菜单才会出现这个操作。
inlines则是"在一个编辑页里同时编辑关联子表"的功能。典型场景是文章和评论:在文章编辑页里,下方直接内联展示该文章的评论列表,并且支持现场新增。TabularInline和StackedInline的区别就是排版不同,前者像表格更紧凑,后者字段堆叠更直观。内联用的Meta信息是extra = 1,表示默认显示几行空表单,这个值不是越大越好,填多了前端渲染压力大,通常1-2就够。
1.4 Admin自定义按钮和第三方增强
默认的Admin没有"跳转到前台查看文章"、"复制一篇新文章"这类操作。可以通过在Admin方法里返回HttpResponseRedirect实现跳转,或者渲染一个自定义模板按钮。这里有一个典型的操作:在列表页每条记录的右侧加一个"预览"链接,用list_display里自定义方法返回mark_safe('<a href="...">预览</a>')来完成。
不过说实话,如果只是想要一个更现代化的后台界面,现在社区里比较流行的方案是django-unfold,它会把默认Admin改造成带侧边栏、暗色模式、更现代的组件风格,而且基本不用大改现有代码。类似的还有django-jet、django-grappelli。选型建议:项目对后台依赖不强、只做内容维护,默认Admin加一点定制就够;如果后台是运营团队的日常主战场,界面需求占比高,就值得引入第三方增强。有一点提前提醒:第三方后台组件和Django版本升级的兼容性往往滞后,做好锁版本,不要每个Django小版本都跟着升。
2. ORM单表进阶:QuerySet的求值逻辑和方法链
2.1 惰性求值:过滤器不是立刻查数据库
很多新手对ORM的性能有误解,以为每写一次filter()就执行一次SQL。Django的QuerySet是惰性的:你调用filter()、exclude()、order_by()时,它只是在内存里拼接查询条件,直到真正需要数据的时刻才执行数据库查询。这个"时刻"包括:迭代、len()、list()、bool()、in判断、切片(步长切片在某些数据库上是例外),以及pickle序列化等。
理解惰性求值有个实际价值:可以分步构建复杂查询。比如根据多个可选筛选条件动态拼接:
qs = Article.objects.all() if keyword: qs = qs.filter(title__icontains=keyword) if category_id: qs = qs.filter(category_id=category_id) if start_date: qs = qs.filter(created_at__date__gte=start_date)这么写不仅没有性能损失,还让代码逻辑清晰很多。
但惰性求值的反面是"过期查询"问题。如果你把QuerySet存起来,后面又修改了其中的对象,重新使用时可能拿到的是旧缓存结果,因为QuerySet的结果默认是有缓存的。第一次迭代时Django会把结果缓存到_result_cache,之后再次迭代不会再次查库,而是直接读缓存。这意味着:对同一个QuerySet先遍历再修改对象属性,再遍历同一个QuerySet,看到的还是旧值。要做对象级更新,请直接用update()而不是先查后存。
2.2 细说filter、exclude、annotate的链式规则
filter和exclude可以无限链式调用,它们在SQL层面是用AND连接(多个filter等同于多个AND)。exclude的语义要特别小心:它不仅仅是"NOT",它实际上是"NOT (条件)"。比如Article.objects.exclude(status='published'),翻译成SQL是NOT status = 'published',这个在单字段时很好理解,但换成exclude(author='Tom', status='published'),它翻译的是NOT (author = 'Tom' AND status = 'published'),这往往不是大多数人想要的"排除Tom且排除published文章"。
正确的做法是分别写两个exclude:
Article.objects.exclude(author='Tom').exclude(status='published')如果不小心,这个行区别会让统计结果偏得非常离谱。
再往下是字段查询的修饰符,比如__icontains、__gte、__in、__range、__isnull、__year、__week_day等。它们是ORM的精髓,本质上对应SQL里的LIKE、BETWEEN、IN、IS NULL等语法。一个常被优化的点是__icontains在大多数数据库上翻译成LIKE '%xxx%',这意味着索引会失效。如果数据量大且频繁按前缀搜索,应该用__startswith配合数据库索引;如果非要模糊搜索,建议引入全文检索引擎(PostgreSQL自带SearchVector,或者用django-haystack、whoosh、meilisearch)。ORM的便利性掩盖了很多SQL性能问题,这是进阶路上必须拆掉的墙。
2.3 values()、values_list() 和 only() 的性能意义
默认的Article.objects.all()会查出所有字段并构造成完整的Model对象。但在列表只展示标题、时间两个字段的需求里,这就是一种浪费。Django提供了values()和values_list()来返回字典和元组,直接减少内存占用,并让生成的SQL只SELECT指定列。
titles = Article.objects.values('title', 'created_at') # 返回 QuerySet,元素是 dict title_list = Article.objects.values_list('title', flat=True) # flat=True 直接返回单字段值列表,如 ['标题1', '标题2']values()还有一个隐藏用法:配合order_by()做去重。比如取每个分类下创建时间最新的一篇文章,可以先按created_at倒序,再用distinct('category')(PostgreSQL支持,SQLite和MySQL不支持,要用子查询模拟)。这是ORM进阶里很常用的"取按某字段分组后的第一条"技巧。
only()和defer()则是反方向操作。only('title')表示"默认只加载这些字段,其他字段用到时再查";defer('content')表示"这个字段用到时再查"。它们适合大文本字段不常读的场景。这里有一个隐藏时间点:only()只影响第一次查询和后续再次访问数据库时的SELECT列,如果对象已经缓存,访问defer字段时并不会再查库,而是会抛FieldError吗?其实不会,Django会再执行一次补充查询。但如果你在only()之后把对象存到缓存里(比如Redis),下次取缓存时,延迟加载的字段可能根本不在缓存数据里,这就容易出严重BUG。所以only()/defer()更适合一次性请求里用完就丢的场景,不要和对象缓存混用。
2.4 F表达式:怎么在数据库层完成字段间运算
需要更新某个字段的"原值加1",新手会写:
article = Article.objects.get(id=1) article.view_count += 1 article.save()这种做法有两个问题:一是两步操作之间存在时间差,并发请求下会丢失更新;二是凭空多了一次SELECT。正确姿势是用F()表达式,让数据库在SQL内部完成自增:
from django.db.models import F Article.objects.filter(id=1).update(view_count=F('view_count') + 1)F表达式支持字段间的运算,比如:
Article.objects.filter(view_count__lt=F('comment_count') * 2)它也可以配合annotate(),生成一个数据库层的计算列。比较经典的场景是计算文章"互动指数":
from django.db.models import F, FloatField, ExpressionWrapper Article.objects.annotate( interaction_score=ExpressionWrapper( (F('view_count') * 0.5 + F('comment_count') * 2.0) / 10.0, output_field=FloatField() ) )这里ExpressionWrapper之所以必要,是因为混合了小数和整数运算,Django需要知道最终的输出类型。不加会提示Cannot resolve expression type之类的错误。这个案例在电商排行榜、内容热度排序里很常见,学会了就能摆脱在Python里循环算分再排序的低效做法。
3. 多表查询:搞懂JOIN,才算迈进ORM的门槛
3.1 模型关系设计的常见梳理
多表查询之前,先理一下Django的三种关系字段。ForeignKey是一对多关系的核心,建在多的一方;OneToOneField本质是带唯一约束的外键,适合"一种数据在另一个表里只存在一条扩展信息"(比如用户表、用户资料表);ManyToManyField则会自动生成一张中间表。
设计时有一个经验:不要贪图省事把所有关联都做成ManyToMany。实际业务里"文章和作者"永远是一对多,"文章和标签"才是多对多,"文章和SEO描述"通常是一对一。关系错了,后面所有查询都是拧巴的。
还有一个关于related_name的大坑。如果你不加related_name,Django默认用模型名小写_set作为反向关系名,比如article.comment_set.all()。一旦模型名改过,所有反向引用全要跟着改。更麻烦的是两个Foreignkey指向同一个Model时,不写related_name会直接报fields.E304错误,因为Django无法区分反向名。规范做法是每个外键都写清楚related_name,比如:
class Comment(models.Model): article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='comments') author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='authored_comments')这样语义清晰,代码里article.comments.all()比article.comment_set.all()可读性好太多了。
3.2 正向查询与反向查询的底层SQL语义
正向查询就是"从有外键的模型出发,访问它关联的对象":
comment = Comment.objects.get(id=1) article = comment.article # 自动做一次主键查询 article.title反向查询则是"从被关联的模型出发,获取外键指向它的所有对象":
article = Article.objects.get(id=1) comments = article.comments.all() # 自动生成 WHERE article_id=1 的查询理解正反向的根本在于:Django把外键的关联查询翻译成SQL时,正向方向用的是JOIN或直接子查询(具体取决于表达式);反向方向则是访问关联管理器(RelatedManager),生成带WHERE条件的查询。当你做跨多层的反向查询时(比如通过文章查作者资料表的某个字段再排序),onetoone和多层ForeignKey就会生成JOIN链。
一个具体例子:查所有"作者注册时间在2024年之后"的文章。按上一节的知识,会想到用filter加跨表字段查询:
Article.objects.filter(author__registered_at__year__gte=2024)这里的双下划线__是跨表JOIN的语法糖,它会自动JOIN Article表和User表,然后对registered_at加年份和大于等于条件。双下划线可以无限层传递,比如article__category__name...,但层数越多,生成的JOIN越多,性能越差,这种写法在数据量上来以后一定要review。
3.3 select_related 和 prefetch_related:预加载是性能的分水岭
多表查询最常见的性能杀手是N+1查询问题。典型场景:循环展示文章和它的作者。
articles = Article.objects.all() for article in articles: print(article.author.username)如果上面有100篇文章,这段代码会执行1条查文章列表的SQL,再加100条查作者信息的SQL,总计101条。而用select_related('author'),Django会在第一次查询时用LEFT OUTER JOIN把作者数据一起查出来,后续循环里每次article.author都直接从结果缓存里拿,不再触发新查询:
articles = Article.objects.select_related('author').all()这个优化只需要改一行代码,查询数量从101降到1。select_related适合ForeignKey和OneToOneField,因为它本质上就是JOIN,只能处理单值关系。这是它的核心局限。
而prefetch_related专门处理多值关系,比如ManyToManyField和外键的反向查询。它的原理不是JOIN,而是先查主表,再查关联表,然后用Python层把结果配对。比如:
articles = Article.objects.prefetch_related('tags').all() for article in articles: print(article.tags.all())这里会执行两条SQL:一条查文章,一条查所有文章-标签关联记录并连带标签表,之后Django在内存里把标签挂到对应文章上。使用prefetch_related时有一个容易踩的坑:如果你对预取的关联做了额外的filter(比如article.comments.filter(approved=True)),Django不会自动应用这个过滤,因为它用的是预先查询结果。要想过滤预取,需要用Prefetch对象:
from django.db.models import Prefetch approved_comments = Prefetch( 'comments', queryset=Comment.objects.filter(approved=True), to_attr='approved_comments' ) articles = Article.objects.prefetch_related(approved_comments).all()之后用article.approved_comments来访问过滤后的评论列表。这是很多老手都会忽略的一个细节。
另外,select_related和prefetch_related是可以混合用的:一个查询里既预取作者(外键JOIN),又预取标签(多对多二次查询),实际项目里非常常见。
3.4 反向管理器的方法别乱调
反向关联给每个Model一个"RelatedManager",它自带add()、create()、remove()、clear()、set()等方法。比如:
article.tags.add(tag1, tag2) article.tags.set([tag2, tag3]) # 替换当前全部标签 article.tags.clear() # 清空set()的语义是差量更新:它会自动处理新增和删除,但前提是你传入的是完整的新集合,不是增量。这相当于先clear()再add(),如果你想保留旧标签只加一个,那就直接用add()即可,不要用set()。还有一个细节:add()方法默认不会自动去重,如果连续两次add(same_tag),在中间表里会出现两条相同记录。数据库层面最好给中间表的两个外键加联合唯一约束(Django的ManyToManyField默认不会自动加这个约束,部分数据库需要手动迁移或用through表定义UniqueConstraint)。
4. 聚合与分组:annotate和aggregate的正确打开方式
4.1 两者怎么选,一句话说清楚
aggregate()返回一个字典,结果是对整个查询集的汇总,最终返回一行。annotate()返回的还是QuerySet,是对每个分组(通常是按某个字段分)加一列汇总结果。按官方说法:aggregate算整体总值,annotate算分组统计。
举一个易懂的类比:你们班考试成绩单。如果只看全班的平均分、最高分,那是aggregate;如果要每个学生的平均分、每个人都有一行自己的汇总,那是annotate。一个返回单一统计值,一个返回"行列都变长了"的查询集。
from django.db.models import Count, Sum, Avg, Max, Min # aggregate:全班整体统计 stats = Article.objects.aggregate(total=Count('id'), avg_views=Avg('view_count')) # annotate:每个作者的文章数和平均浏览量 author_stats = Article.objects.values('author').annotate( article_count=Count('id'), avg_views=Avg('view_count') )4.2 聚合函数在SQL层面到底干了什么
聚合函数最终会翻译成SQL的GROUP BY和聚合操作。比如上面的values('author').annotate(...),翻译成SQL就是:
SELECT author_id, COUNT(id), AVG(view_count) FROM article GROUP BY author_id有一个关键点:在values()之后的annotate中,你提前指定了分组的字段,之后添加的annotate就是真正的聚合列;但如果不加values()直接annotate(),Django会默认按主键分组,结果每一行都会有一个单独的分组,这几乎不是你想要的结果。
另一个关键点:annotate之后还可以继续filter。这个filter会作用于聚合结果上(相当于SQL里的HAVING)。比如只要文章数大于10的作者:
Article.objects.values('author').annotate( article_count=Count('id') ).filter(article_count__gt=10)注意,此时filter里用的字段名必须是annotate出来的别名article_count,不能再用数据库原始字段名。如果你需要的是"先过滤再聚合"和"先聚合再过滤"两种语义,写法和SQL执行顺序完全不同,先想清楚业务到底要哪种。
4.3 分组查询里order_by对结果的影响
annotate分组之后,结果集中每个分组的返回顺序通常是不确定的。尤其在生产环境数据库优化器可能调整Join顺序。要稳定分组内的取值,经典做法是在annotate之前先order_by,然后用values('分组字段')。这里有个Django官方也强调过的行为:在values()之后的annotate()的结果,会默认给每个分组取一条记录(通常是最先遇到的那一条,但实际取决于底层数据库实现),不能依赖"取到的一定是最新的"。
如果想要"每个作者的最新一篇文章",单靠annotate做不到,需要子查询或者窗口函数。窗口函数在Django 2.0之后支持了Window表达式,可以用RowNumber来取。示例:
from django.db.models import Window, F from django.db.models.functions import RowNumber ranked = Article.objects.annotate( rn=Window( expression=RowNumber(), partition_by=[F('author_id')], order_by=F('created_at').desc() ) )然后在Python层过滤rn=1,或者用Subquery把它作为子查询使用。这是聚合查询里最容易被忽略的进阶技能。
4.4 聚合查询的几种典型陷阱
空集的聚合返回:如果没有任何数据,Count()返回0,Sum()返回None,Avg()返回None。很多人直接拿Sum结果做除法,直接TypeError。稳妥写法是:
total_views = Article.objects.aggregate(s=Sum('view_count'))['s'] or 0多表聚合的重复计数:当聚合查询同时关联了多条"一对多"关系时,比如统计一篇文章下的评论数和点赞数,如果不加distinct,可能出现评论和点赞互相笛卡尔积,导致结果翻倍。Django的Count()支持distinct=True,比如:
Article.objects.annotate( comment_count=Count('comments', distinct=True), likes_count=Count('likes', distinct=True) )这个distinct=True能不能救场,取决于实际业务里中间表是否可能出现重复,但多对多关联下基本要加。
跨数据库兼容性:__week_day、__year这类修饰符在不同数据库上翻译的SQL不一样。SQLite上很多日期函数支持不完整,MySQL和PostgreSQL行为也会有差异。聚合查询里用到日期分组时,尽量用TruncDate、TruncMonth这些数据库函数,而不是直接在Python里按字符串截取分组。比如按月统计文章数:
from django.db.models.functions import TruncMonth monthly_stats = Article.objects.annotate( month=TruncMonth('created_at') ).values('month').annotate(total=Count('id')).order_by('month')TruncMonth在不同数据库上统一翻译成对应的日期截断函数,可移植性好很多,而且可以直接排序。
5. 实战中的几个高频坑和优化思路
5.1 从ORM日志看真实SQL,评估慢查询
在本地开发环境,有一个非常实用的手段:在settings.py里配置LOGGING,把Django的django.db.backends日志级别调到DEBUG,就能在终端看到每一次ORM查询翻译成的SQL和耗时。很多“不知道自己代码慢在哪”的问题,有了这个日志以后一目了然。
LOGGING = { 'version': 1, 'handlers': { 'console': {'class': 'logging.StreamHandler'} }, 'loggers': { 'django.db.backends': { 'handlers': ['console'], 'level': 'DEBUG', } } }生产环境千万不要开这个,日志量会非常大,而且暴漏SQL结构有一定安全风险(虽然字段和值本身不会原样打印,但表名、条件结构已经完全暴露了)。优化手段有两个方向:一是减少查询次数(用select_related/prefetch_related解决N+1);二是减少单条查询代价(字段裁剪、索引优化、聚合下推)。两个方向都要抓,只压缩查询次数不压单次查询耗时,数据量大了照样卡。
5.2 在代码里“看到”隐藏的N+1
select_related和prefetch_related并不是自动的,需要人为指定。项目里最常见的隐藏N+1不是循环里访问外键,而是在Template里访问外键。Django模板不支持你主动调用select_related,你只能在View/QuerySet阶段处理好。有一个通用的排查思路:随便打开后台或前台一个列表页,看connection.queries的SQL数量,如果跟列表条目数量线性相关,就说明有N+1。
还有一种更隐蔽的情况:only()和select_related混用可能导致多一次单独查询。比如你select_related('author')后,又在only()里写了author的外键字段,虽然奇怪,但如果管理不善就会多出几个补充SQL。建议复杂查询里不要同时用only()和select_related,要么全查,要么明确裁剪。
5.3 聚合查询之后的分页与缓存
annotate出来的结果仍然是一个QuerySet,理论上可以继续.paginate(配合django.core.paginator)。但要注意:对聚合结果的分页本质上是在一个带有GROUP BY的查询上做LIMIT/OFFSET,如果分组基数很大,这个LIMIT作用在分组结果上,不是原表上。数据库执行时是先完成整个聚合,再取一页,不会因为分页而减少聚合计算量。这在统计大规模数据时会非常慢。
所以在真实项目里,高频访问的聚合结果一定要做缓存。常见做法是:定时任务(Celery beat)每5分钟算一次排行榜/计数摘要,写入Redis;查询接口直接读缓存,缓存过期再触发重算。不要把每个页面请求都打在数据库的聚合计算上。这个思路在内容站、电商后台统计面板里都通用。
5.4 裸SQL的兜底方案
ORM再强大,遇到窗口函数、递归查询、复杂报表时仍然会捉襟见肘。Django提供了两个兜底入口:Model.objects.raw()用于返回Model实例的裸SQL查询,django.db.connection.cursor()用于完全自由度更高的原生SQL执行。建议只有在ORM真的写不出来的复杂查询时才用裸SQL,而且尽量在外面包一层,确保内容是可信的、参数是占位符。
from django.db import connection def get_category_stats(): with connection.cursor() as cursor: cursor.execute(""" SELECT category_id, COUNT(*) as cnt FROM article GROUP BY category_id ORDER BY cnt DESC """) rows = cursor.fetchall() return rows这里特别强调使用参数占位符,永远不要用Python字符串拼接SQL。虽然这是常识,但在ORM为王的时代,很多人直接裸SQL后反而忘了防注入。字符串拼接SQL在任何场景下都是红线。
我自己实际做项目的感觉是,Admin后台定制和ORM进阶这两个点是最能提升开发效率的:后台定制做好,运营提的需求一小时就能交付;ORM写对了,列表页从3秒降到毫秒级,根本不用上缓存中间件。尤其是多表查询里的预加载,几乎每一个Django项目到后期都要做这一层优化,越早养成熟练手感,后面重构越省心。建议手头有项目的话,先找一个列表页,按这篇文章里的方式把list_display、优化QuerySet、加聚合统计全部过一遍,这份代码以后就是你的标准答案库。