资讯动态

Django Admin后台定制与ORM进阶:QuerySet、多表关联、聚合查询实战指南

发布时间:2026/10/3 2:02:11 来源:尧图企业网站定制
前两篇把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_length200) content models.TextField() view_count models.IntegerField(default0) 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__icontainskeyword) if category_id: qs qs.filter(category_idcategory_id) if start_date: qs qs.filter(created_at__date__gtestart_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(statuspublished)翻译成SQL是NOT status published这个在单字段时很好理解但换成exclude(authorTom, statuspublished)它翻译的是NOT (author Tom AND status published)这往往不是大多数人想要的排除Tom且排除published文章。正确的做法是分别写两个excludeArticle.objects.exclude(authorTom).exclude(statuspublished)如果不小心这个行区别会让统计结果偏得非常离谱。再往下是字段查询的修饰符比如__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, flatTrue) # flatTrue 直接返回单字段值列表如 [标题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(id1) article.view_count 1 article.save()这种做法有两个问题一是两步操作之间存在时间差并发请求下会丢失更新二是凭空多了一次SELECT。正确姿势是用F()表达式让数据库在SQL内部完成自增from django.db.models import F Article.objects.filter(id1).update(view_countF(view_count) 1)F表达式支持字段间的运算比如Article.objects.filter(view_count__ltF(comment_count) * 2)它也可以配合annotate()生成一个数据库层的计算列。比较经典的场景是计算文章互动指数from django.db.models import F, FloatField, ExpressionWrapper Article.objects.annotate( interaction_scoreExpressionWrapper( (F(view_count) * 0.5 F(comment_count) * 2.0) / 10.0, output_fieldFloatField() ) )这里ExpressionWrapper之所以必要是因为混合了小数和整数运算Django需要知道最终的输出类型。不加会提示Cannot resolve expression type之类的错误。这个案例在电商排行榜、内容热度排序里很常见学会了就能摆脱在Python里循环算分再排序的低效做法。3. 多表查询搞懂JOIN才算迈进ORM的门槛3.1 模型关系设计的常见梳理多表查询之前先理一下Django的三种关系字段。ForeignKey是一对多关系的核心建在多的一方OneToOneField本质是带唯一约束的外键适合一种数据在另一个表里只存在一条扩展信息比如用户表、用户资料表ManyToManyField则会自动生成一张中间表。设计时有一个经验不要贪图省事把所有关联都做成ManyToMany。实际业务里文章和作者永远是一对多文章和标签才是多对多文章和SEO描述通常是一对一。关系错了后面所有查询都是拧巴的。还有一个关于related_name的大坑。如果你不加related_nameDjango默认用模型名小写_set作为反向关系名比如article.comment_set.all()。一旦模型名改过所有反向引用全要跟着改。更麻烦的是两个Foreignkey指向同一个Model时不写related_name会直接报fields.E304错误因为Django无法区分反向名。规范做法是每个外键都写清楚related_name比如class Comment(models.Model): article models.ForeignKey(Article, on_deletemodels.CASCADE, related_namecomments) author models.ForeignKey(User, on_deletemodels.CASCADE, related_nameauthored_comments)这样语义清晰代码里article.comments.all()比article.comment_set.all()可读性好太多了。3.2 正向查询与反向查询的底层SQL语义正向查询就是从有外键的模型出发访问它关联的对象comment Comment.objects.get(id1) article comment.article # 自动做一次主键查询 article.title反向查询则是从被关联的模型出发获取外键指向它的所有对象article Article.objects.get(id1) comments article.comments.all() # 自动生成 WHERE article_id1 的查询理解正反向的根本在于Django把外键的关联查询翻译成SQL时正向方向用的是JOIN或直接子查询具体取决于表达式反向方向则是访问关联管理器RelatedManager生成带WHERE条件的查询。当你做跨多层的反向查询时比如通过文章查作者资料表的某个字段再排序onetoone和多层ForeignKey就会生成JOIN链。一个具体例子查所有作者注册时间在2024年之后的文章。按上一节的知识会想到用filter加跨表字段查询Article.objects.filter(author__registered_at__year__gte2024)这里的双下划线__是跨表JOIN的语法糖它会自动JOIN Article表和User表然后对registered_at加年份和大于等于条件。双下划线可以无限层传递比如article__category__name...但层数越多生成的JOIN越多性能越差这种写法在数据量上来以后一定要review。3.3 select_related 和 prefetch_related预加载是性能的分水岭多表查询最常见的性能杀手是N1查询问题。典型场景循环展示文章和它的作者。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(approvedTrue)Django不会自动应用这个过滤因为它用的是预先查询结果。要想过滤预取需要用Prefetch对象from django.db.models import Prefetch approved_comments Prefetch( comments, querysetComment.objects.filter(approvedTrue), to_attrapproved_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(totalCount(id), avg_viewsAvg(view_count)) # annotate每个作者的文章数和平均浏览量 author_stats Article.objects.values(author).annotate( article_countCount(id), avg_viewsAvg(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_countCount(id) ).filter(article_count__gt10)注意此时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( rnWindow( expressionRowNumber(), partition_by[F(author_id)], order_byF(created_at).desc() ) )然后在Python层过滤rn1或者用Subquery把它作为子查询使用。这是聚合查询里最容易被忽略的进阶技能。4.4 聚合查询的几种典型陷阱空集的聚合返回如果没有任何数据Count()返回0Sum()返回NoneAvg()返回None。很多人直接拿Sum结果做除法直接TypeError。稳妥写法是total_views Article.objects.aggregate(sSum(view_count))[s] or 0多表聚合的重复计数当聚合查询同时关联了多条一对多关系时比如统计一篇文章下的评论数和点赞数如果不加distinct可能出现评论和点赞互相笛卡尔积导致结果翻倍。Django的Count()支持distinctTrue比如Article.objects.annotate( comment_countCount(comments, distinctTrue), likes_countCount(likes, distinctTrue) )这个distinctTrue能不能救场取决于实际业务里中间表是否可能出现重复但多对多关联下基本要加。跨数据库兼容性__week_day、__year这类修饰符在不同数据库上翻译的SQL不一样。SQLite上很多日期函数支持不完整MySQL和PostgreSQL行为也会有差异。聚合查询里用到日期分组时尽量用TruncDate、TruncMonth这些数据库函数而不是直接在Python里按字符串截取分组。比如按月统计文章数from django.db.models.functions import TruncMonth monthly_stats Article.objects.annotate( monthTruncMonth(created_at) ).values(month).annotate(totalCount(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解决N1二是减少单条查询代价字段裁剪、索引优化、聚合下推。两个方向都要抓只压缩查询次数不压单次查询耗时数据量大了照样卡。5.2 在代码里“看到”隐藏的N1select_related和prefetch_related并不是自动的需要人为指定。项目里最常见的隐藏N1不是循环里访问外键而是在Template里访问外键。Django模板不支持你主动调用select_related你只能在View/QuerySet阶段处理好。有一个通用的排查思路随便打开后台或前台一个列表页看connection.queries的SQL数量如果跟列表条目数量线性相关就说明有N1。还有一种更隐蔽的情况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、加聚合统计全部过一遍这份代码以后就是你的标准答案库。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑