写Web项目,最花时间的其实不是页面,而是每天都在重复的“数据管理”:后台里要把记录查出来、筛一下、改一改,前端接口里要根据条件取数、关联表、算个总数。Django在这块有一对黄金组合——Admin后台和ORM。Admin管人和数据,ORM管数据库读写。这个系列前两篇把项目环境和模型定义讲清楚了,这篇把重点放在“怎么用”:Admin后台的常用定制、ORM单表/多表查询、以及最容易被忽略的聚合查询。读完你可以直接把手里的模型升级成好用的后台,也能把那些需要跨表、分组的查询写利索。适合已经会创建Django项目和模型、想快速上手日常开发的同学,也适合想系统梳理ORM查询能力的开发者当手册查。
这个系列前两篇假设你已经知道怎么建项目、怎么定义模型。如果还没看过,也不影响,这篇里的例子会很详细,跟着敲就能跑通。下面直接进入正题。
1. Admin后台:把模型变成可用的管理界面
1.1 为什么值得认真学Admin
很多人误以为Admin是“自动生成的破后台”,实际用下来才明白它是Django对“约定优于配置”的极致实践。模型定义好了,数据表建好了,Admin能直接给出一套增删改查页面,带搜索、带过滤、带分页。对小型项目、内部系统、运营工具来说,Admin本身就是交付物,不需要再单独做管理界面。对大型项目,Admin还能用于日常运维,比如手动修正脏数据、查看用户注册情况。
我见过一些开发者在Admin只写admin.site.register(Model)就完事。这在开发期没问题,但真正上线就会发现几个痛点:列表页字段太多找不到重点、外键关联的数据要来回切换编辑页、运营同事搜索不到想要的内容。我建议从第一天就把ModelAdmin配好,这不是浪费时间——后续每次查数据都用得上,而且配置一次能省很多手工操作。
1.2 注册模型的三种姿势
Django注册模型到Admin有三种写法,先看代码再解释。
python复制# admin.py
from django.contrib import admin
from .models import Article, Author, Category, Comment
# 方式一:最省事,直接注册
admin.site.register(Article)
# 方式二:装饰器方式(推荐)
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
list_display = ("name", "email", "create_time")
# 方式三:单独定义类再注册
class CommentAdmin(admin.ModelAdmin):
list_display = ("content", "article", "author")
admin.site.register(Comment, CommentAdmin)
三种方式结果一样,但装饰器方案把类和注册写在一起,代码更集中。如果项目里模型很多,建议每个app建一个admin.py,不要全塞到一个文件里。文件一大了,找起来非常痛苦。
顺带一提,在同时使用了自定义User模型的项目里,注册方式会有一些不同。如果你继承了AbstractUser,需要在Admin里扩展UserAdmin而不是直接注册,否则密码字段会显示成明文编辑框。这个属于开发中很常见的坑,先说一句预警。
1.3 list_display / list_filter / search_fields 三板斧
这是Admin最核心的三样配置。先看一组示例。
python复制@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "author", "category", "status", "views", "pub_date")
list_filter = ("status", "category", "author")
search_fields = ("title", "content", "author__name")
ordering = ("-pub_date",)
要点拆解:
list_display里可以放模型字段,也可以放自定义函数。比如显示一个“更新于多少分钟前”的列,或者把外键对象的某个属性拼出来。list_filter可以放普通字段,也可以放自定义过滤器类。分类、作者这类外键直接放进去会自动生成下拉选项,不需要额外处理。search_fields写外键字段的时候要用双下划线贯穿关系,比如author__name,这样搜索框里输入作者姓名也能找到文章。ordering放在这里只为后台生效,它不是模型层的默认顺序。模型Meta里的ordering会影响所有查询,后台的ordering只影响列表页展示。
补充一个坑:list_display里不能直接放多对多字段,比如list_display = ("tags",)会报错。需要的方法是自定义一个函数,返回标签的字符串或数量。这也是Admin进阶里最常见的问题。
1.4 编辑页定制与Inlines
除了列表页,编辑页也是重点。实际业务里,创建Article时往往要同时维护评论或标签,这时用Inlines可以实现在同一个编辑页里维护关联数据,避免跳来跳去。
python复制from django.contrib import admin
class CommentInline(admin.TabularInline):
model = Comment
extra = 1
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
inlines = [CommentInline]
fields = ("title", "author", "category", "content", "status", "views")
readonly_fields = ("views", "create_time")
TabularInline是表格样式,StackedInline是字段堆叠样式。一般关联字段少、结构规整的用TabularInline,字段多且需要说明的用StackedInline。extra表示默认预留几行空表单,运营同学可以一次填多条评论。
fields控制字段显示顺序和隐藏,readonly_fields让浏览量这种后台自增字段在编辑页只读,避免运营误改统计数字。如果业务里有些字段是系统计算出来的,加到readonly_fields里非常稳妥。
1.5 Admin里的小技巧和易错点
这部分是实际项目里被问得最多的地方。
第一,list_select_related。列表页如果有外键,比如list_display里显示了作者名,Django默认会在每个对象上单独查一次作者。数据量一多,几十条记录就是几十次SQL。在ModelAdmin里加上:
python复制list_select_related = ("author", "category")
这行字节能让列表页查询从N+1降成1次JOIN。
第二,list_editable。比如list_editable = ("status",),能直接在列表页改状态,不用点进编辑页。注意,list_editable的字段必须也在list_display里,而且不能是第一个字段,否则前端交互会乱。
第三,actions。自定义批量操作,比如批量把文章置顶、批量下线:
python复制def make_published(modeladmin, request, queryset):
queryset.update(status="published")
make_published.short_description = "批量发布所选文章"
第四,删除对象的大坑。Admin的删除操作如果是列表页勾选后批量删除,走的是queryset.delete(),它是SQL级别的批量删除,不会触发模型实例的delete()方法,所以pre_delete和post_delete信号不会执行。这个坑网上问的人很多,尤其是依赖信号做日志审计的项目,删了一批数据日志完全没动静。如果业务依赖信号,需要自定义一个Action,循环遍历对象逐条调用obj.delete(),当然这样性能会差一些,但信号逻辑能保住。
1.6 界面定制再往前一步
默认Admin的样式比较“朴素”。第三方库如django-unfold、django-jazzmin、django-grappelli可以快速换皮肤。django-unfold走现代管理后台风格,颜值高,带侧边栏和暗黑模式,小项目想要“拿得出手”的后台界面可以直接引入。
但注意,这类库通常需要调整TEMPLATES配置,安装前查一下与当前Django版本的兼容性,不要直接照抄新版文档。引入第三方库需要额外维护,生产环境升级Django版本时会比较难受。内网项目用默认Admin完全够用,外网产品需要给客户看的再考虑换皮。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ORM单表查询:从filter到懒加载思维
2.1 QuerySet的懒加载
Django的查询并不是写了代码就执行。Article.objects.all()只是构造了一个QuerySet对象,并没有真正访问数据库。真正触发SQL的时机包括迭代、切片(带步长除外)、布尔判断、使用str/repr、调用list()、在模板里循环等。
python复制articles = Article.objects.all() # 不执行SQL
print(articles) # 执行SQL,因为输出repr会求值
first = articles[0] # 执行SQL并加LIMIT 1
这个特性决定了:可以不断链式加过滤条件后再执行。例如Article.objects.filter(status="published").order_by("-pub_date")[:10],SQL只会在切片或迭代那一刻执行。要调试也可以直接print(articles.query)查看将要执行的SQL。
懒加载听起来不重要,但不知道它时容易犯一个错误:在视图里对QuerySet做了多次判断,导致重复SQL。比如先if articles.exists():再for article in articles:,就会执行两次查询。正确做法是用一次迭代,或者先把list(articles)拿出来。实战中想看真实SQL,推荐在settings里配置LOGGING输出django.db.backends的语句,或者安装django-debug-toolbar,这个后面排查章节细说。
2.2 Field Lookups:查询关键字的完整用法
Django的filter/exclude/get里允许使用形如字段__运算符的写法,叫做Field lookups。我整理了最常见的组合,按实用频率排的:
- 精确匹配:
title="xxx"或title__exact="xxx" - 大小写不敏感精确匹配:
title__iexact - 包含:
content__contains="xx",SQL里是LIKE %xx% - 大小写不敏感包含:
content__icontains - 开头匹配:
title__startswith="Django",结尾匹配:title__endswith="教程" - 空值判断:
pub_date__isnull=True - 范围:
pub_date__range=(start, end),views__range=(100, 500) - 在列表中:
status__in=["published", "draft"] - 大于小于:
views__gt=100、views__gte=100、views__lt=50、views__lte=50 - 日期:
create_time__date="2025-01-01"、create_time__year=2025、create_time__month=3、create_time__week_day=1
几个容易踩的细节:
- 日期查询在SQLite和MySQL上的行为可能不同,
__date有时会把整列转成字符串再比较,导致无法利用索引。大数据量时直接用>=和<的组合更稳。 __gt、__lt在字符串字段上会按字典序比较,不要拿字符串当数字用。- 如果模型里用了
DateTimeField且开启了USE_TZ=True,查询日期范围时要注意时区问题。最好统一用make_aware处理时间,而不是直接拼字符串。
2.3 exclude与F表达式:查询里做“排除”和“列间比较”
filter负责“只要”,exclude负责“排除”。看一个容易混淆的例子:
python复制# 排除草稿状态
Article.objects.exclude(status="draft")
# 这个写法要注意!
Article.objects.exclude(
status="draft",
views__lte=100
)
第二段代码的语义是排除“status等于draft 或 views小于等于100”的行。因为exclude里多个条件在SQL中是OR关系。要表达“非草稿且点击量大于100”,应该链式写两个exclude:
python复制Article.objects.exclude(status="draft").exclude(views__lte=100)
列与列之间的比较要用F表达式。比如“评论数大于点赞数的文章”:
python复制from django.db.models import F
Article.objects.filter(comment_count__gt=F("like_count"))
F表达式的价值是直接在数据库层面计算,避免把整表取出来在Python里跑循环。它还能用在update里,比如:
python复制Article.objects.filter(status="draft").update(views=F("views") + 1)
这是原子性操作,并发场景下不会丢更新。用Python写for obj in ...: obj.views += 1; obj.save()在并发时会产生覆盖行为,F表达式就安全得多。
2.4 排序、去重与分页
order_by可以多字段排序,也可以加负号逆序。distinct()去重。一句提醒:distinct在PostgreSQL和MySQL上行为不同,PG的distinct支持按指定字段去重,MySQL不支持。通用场景用distinct()就好,别指望数据库方言差异能给你什么额外加成。
分页方面,我日常用QuerySet切片配合分页器:
python复制from django.core.paginator import Paginator
article_list = Article.objects.filter(status="published").order_by("-pub_date")
p = Paginator(article_list, 10)
page_obj = p.get_page(1)
p.count取总数时会执行COUNT查询,并不会把所有数据取到内存。这个在列表页性能上很重要。新手经常担心分页会把全表查出来,实际完全不会。
补充一点:如果数据量在几万以内,直接用切片没问题。如果几十万上百万,建议在ORM层按游标翻页,或者引入搜索引擎,而不是依赖limit/offset深翻页。教材里一般不写,但实际项目就是一个性能分水岭。offset越大,数据库扫描的行越多,深翻页会明显变慢。
2.5 更新与删除时容易忽略的细节
查询除了select,还有update和delete。
- 单对象更新:
obj.title = "新标题"; obj.save(update_fields=["title"]) - 批量更新:
Article.objects.filter(status="draft").update(status="published") - 单对象删除:
obj.delete() - 批量删除:
Article.objects.filter(create_time__year=2020).delete()
批量更新和删除的坑都集中在“信号不触发”。QuerySet.update()和QuerySet.delete()是SQL级别的直接操作,不经过save()/delete()实例方法,因此pre_save、post_save、pre_delete、post_delete信号都不会被触发。如果你依赖缓存同步、日志记录、ES同步等业务逻辑,必须改用逐条遍历处理,或者在这些逻辑里主动重新查询。
delete还有一个使用风险:如果模型有外键且未设置on_delete=models.CASCADE,删除时会受保护,Django会给出ProtectedError。开发时可以故意触发一次,看看保护级别是否符合业务预期。比如用户被文章引用,如果希望删除用户时把文章也删掉,就用CASCADE;如果希望阻止删除用户,就设PROTECT。
3. 多表关联查询:跨模型取数不再写一堆循环
3.1 关联关系回顾:FK/O2O/M2M的查询方向
三种关系:
ForeignKey(多对一):比如多个评论属于一篇文章。正向:comment.article;反向:article.comment_set.all()。OneToOneField(一对一):比如一篇文章对应一个详情扩展表。正向:article.detail;反向:detail.article。ManyToManyField(多对多):比如标签。正向:article.tags.all();反向:tag.article_set.all()。
设计模型时给外键设置related_name能大幅提升可读性。比如author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="articles"),之后反向查询就写成author.articles.all()而不是author.article_set。同一个模型被多个外键引用时,Django会强制要求指定related_name,不然反向名冲突。
3.2 select_related与prefetch_related:两个减少SQL的关键方法
这是多表查询的重头戏。先看一个经典痛点:
python复制articles = Article.objects.all()
for a in articles:
print(a.author.name) # 每循环一次,多一次SQL查询
如果Articles有100条,就要101次查询,这就是N+1问题。解决办法是select_related:
python复制articles = Article.objects.select_related("author", "category")
for a in articles:
print(a.author.name) # 这次只有1次SQL,使用JOIN
select_related能作用于ForeignKey和OneToOneField,内部用SQL JOIN,一次查出来。它不能直接用于多对多字段。多对多用prefetch_related:
python复制articles = Article.objects.prefetch_related("tags")
for a in articles:
print([t.name for t in a.tags.all()]) # 2次查询:先查文章,再一次性查tag
prefetch_related的机制是:先用主查询拿到所有文章ID,再执行WHERE tags.article_id IN (...)这样的查询,然后在Python端完成关联组合。对照一下:
| 方法 | 适用关系 | SQL模式 | 查询次数 |
|---|---|---|---|
| select_related | FK、O2O | JOIN | 1次 |
| prefetch_related | M2M、反向FK、多态 | 分两次查,内存合并 | 2次 |
实际项目里可以组合使用:Article.objects.select_related("author", "category").prefetch_related("tags", "comment_set")。不过也别无限链,写的关联字段越多,JOIN的表越多,SQL会变得很重。我一般习惯先在列表页看日志确认SQL条数,多出来的才加。
3.3 跨表过滤:双下划线贯穿关系
在filter里,双下划线可以直接跨表。
python复制# 查询“已发布文章里、作者名字以李开头”的文章
Article.objects.filter(
status="published",
author__name__startswith="李"
)
# 查询“含有Python标签”的文章
Article.objects.filter(
tags__name="Python"
)
# 按作者排序
Article.objects.order_by("author__name")
这里面有个小坑:跨多对多做过滤时,如果同一个关系出现两次,查询条件会产生JOIN重复。比如Article.objects.filter(tags__name="Python", tags__name="Django")在多数数据库里会查不到任何行,因为这是要求同一行关联记录同时满足两个标签名。正确写法是用Q对象拆开:
python复制from django.db.models import Q
Article.objects.filter(Q(tags__name="Python") & Q(tags__name="Django"))
Django会合并成两次INNER JOIN,这样“既含Python又含Django标签”的文章才能被筛出来。不拆开的话,这个查询结果会让人困惑半天。
3.4 反向查询、exists与annotate配合
反向查询的语义在业务中非常常用。比如:
python复制# 找出“没有评论”的文章
Article.objects.filter(comment_set__isnull=True)
# 找出“评论数大于5”的作者——需要配合annotate
from django.db.models import Count
Author.objects.annotate(total=Count("articles")).filter(total__gt=5)
这里体现了annotate的强大:先给每个作者计算文章总数,再基于聚合结果做过滤。第4节会详细讲。这里先记住,多表关联中用反向外键做条件过滤时,语义往往比prefetch更直观,性能也可能更好,因为是在数据库层面完成的。
另外,我在项目里经常用prefetch_related加Prefetch对象来做过滤后的预取:
python复制from django.db.models import Prefetch
published_comments = Prefetch(
"comment_set",
queryset=Comment.objects.filter(status="published"),
)
Article.objects.prefetch_related(published_comments)
这样只预取已发布的评论,而不是把全部评论都拉出来。在权限过滤、状态过滤场景里非常有用。如果你只想要评论数量,其实用annotate更合适,不用把评论对象本身取出来。
3.5 实战场景:博客首页API的多表取数
把多表查询串起来做一个真实的接口逻辑。假设要写一个博客首页视图,返回文章列表,每篇文章带作者名、分类名、前3条评论、标签列表。
python复制# views.py
from django.db.models import Prefetch
def home(request):
articles = (
Article.objects.filter(status="published")
.select_related("author", "category")
.prefetch_related(
"tags",
Prefetch(
"comment_set",
queryset=Comment.objects.filter(status="published").order_by("-create_time")[:3],
),
)
.order_by("-pub_date")[:20]
)
data = []
for article in articles:
data.append({
"title": article.title,
"author": article.author.name,
"category": article.category.name,
"tags": [t.name for t in article.tags.all()],
"top_comments": [
{"content": c.content, "author": c.author.name}
for c in article.comment_set.all()
],
})
return JsonResponse({"list": data})
整个过程只执行了:文章主查询1次、tag查询1次、评论查询1次,绝无N+1。这个写法在教程里看着简单,但很多人写的时候会直接在循环里再查数据库,所以这一节值得反复练习。拿到线上项目里对比日志SQL条数,就能明显看到性能差异。
4. 聚合查询与分组统计:aggregate和annotate的正确打开方式
4.1 aggregate:算总数/最值/平均值
聚合查询解决的是“统计”需求。aggregate返回一个字典,聚合结果是对整个QuerySet生效的。
python复制from django.db.models import Count, Sum, Avg, Max, Min
result = Article.objects.aggregate(
total=Count("id"),
total_view=Sum("views"),
avg_view=Avg("views"),
max_view=Max("views"),
min_view=Min("views"),
)
# 结果: {'total': 320, 'total_view': 15520, 'avg_view': 48.5, ...}
几个细节:
Count("id")和Count("*")都可以统计行数。但如果字段是NULL,Count(字段)不会计入。想数“有多少篇文章有标题”,用Count("title")合理;想数总行数,用Count("id")或Count("pk")。Sum和Avg在涉及字段全部为NULL时会返回None,不是0。前端拿到数据建议做空值处理。- aggregate后面可以继续接filter,先过滤再统计,因为QuerySet是懒执行的,代码顺序不会影响最终SQL。
4.2 annotate:给每行挂上“统计值”
aggregate是全体一个数,annotate是每一行都带一个计算结果,并且会创建一个新的属性供后续查询使用。
python复制from django.db.models import Count
articles = Article.objects.annotate(comment_count=Count("comment_set"))
for a in articles:
print(a.title, a.comment_count)
这条SQL最终会生成LEFT JOIN,所以如果没有评论,comment_count是0,不是None。这个细节和上面Sum的行为不同,聚合函数遇到没有匹配行时的返回要专门记忆。
annotate之后还能继续filter、order_by:
python复制Article.objects.annotate(
comment_count=Count("comment_set")
).filter(
comment_count__gte=3
).order_by("-comment_count")
注意:annotate生成的字段可以在filter里用双下划线筛选,但不能在values里随意乱来,后面讲到。
4.3 分组统计:values + annotate 的组合
要对某个字段做GROUP BY,用values先指定分组字段,再加annotate。
python复制from django.db.models import Count
# 按状态分组
Article.objects.values("status").annotate(total=Count("id"))
# 按作者分组
Article.objects.values("author__name").annotate(total=Count("id")).order_by("-total")
# 按分类分组,统计浏览量总和
Article.objects.values("category__name").annotate(total_views=Sum("views"))
这里的SQL本质是SELECT status, COUNT(id) FROM article GROUP BY status。Django的values + annotate组合是写分组统计最顺手的写法。
一个常见陷阱是values的顺序和字段选择。如果你在annotate之后再加values,会把查询集变成“每行一个字典”。比如:
python复制Article.objects.annotate(cc=Count("comment_set")).values("title", "cc")
这么写是OK的。但如果你漏掉“cc”这个注释字段,就只能拿到title。总结一句:values之后,QuerySet不再以模型对象为主体,后面再想用模型属性必须明确写出来。
另外,分组统计的排序不要依赖模型默认顺序。models.Meta里的ordering如果是非分组字段,生成SQL时可能报错或者结果不符合预期。我习惯在分组统计后面显式加order_by,写清楚分组字段和统计字段。
4.4 聚合里最常见的坑
这部分是实战中被问爆的。
第一,Count条件去重。多对多关联下,SQL会产生多条关联行,Count会把这些都算进去。要数“被多少人点赞”时,加Count("likes", distinct=True)。不加distinct,只要一个用户点过两次赞(多对多中间表有两条),count就会多算。
第二,聚合后再filter的语义。filter(comment_count__gte=1)和exclude(comment_count__lt=1)并不完全等价。前者的条件在聚合后执行,后者如果放在annotate之前使用,可能过滤掉“没有评论”这种GROUP BY为NULL的组,语义会走偏。
第三,annotate受已有order_by影响。在某些数据库方言如PostgreSQL下,模型默认排序字段如果不在GROUP BY里,会报“column must appear in the GROUP BY clause”。解决办法是分组统计前显式清空排序:.order_by()。
第四,Avg和distinct混用。Avg("score")配合distinct没有意义,distinct只对Count/Sum有效。用了也不报错,但结果未必是你想要的。
这些坑在网上几篇博客里讲得少,但在真实项目里非常常见。我个人的建议是:写聚合查询的时候,先把实际SQL打印出来看一眼。心里有底了,再往后写。
4.5 实战:几行代码写一个运营统计接口
这个例子很实用。很多内部系统需要“今天新增了多少文章,总浏览多少,哪个分类最火”。直接上代码:
python复制from django.db.models import Count, Sum
from .models import Article
# 总览统计
overview = Article.objects.aggregate(
total_articles=Count("id"),
total_views=Sum("views"),
)
# 按分类统计
category_stats = (
Article.objects.filter(pub_date__year=2025)
.values("category__name")
.annotate(
article_count=Count("id"),
total_views=Sum("views"),
)
.order_by("-total_views")
)
这两条查询搞定运营看板接口。前端拿到JSON直接渲染。同时还可以在Admin后台用readonly_field或自定义template加一条统计入口。Admin里也可以自定义一个Action弹窗输出统计数据,但更通用的做法是单独写一个视图,放在urls里挂在运营后台。这样前端、接口、管理后台三端都吃同一份统计逻辑。
5. 综合实战:把Admin、多表查询和聚合组合起来
5.1 需求描述与模型设计
这个部分我们做一个“作者管理后台带统计”的小功能。模型沿用上面的Article/Author/Comment。需求:
- Admin后台作者列表要显示该作者的文章总数和总浏览量。
- 作者列表按照总浏览量排序。
- 新增一个Action,能对所选作者按文章数统计并导出CSV。
5.2 在ModelAdmin里实现统计列
用get_queryset配合annotate来实现:
python复制from django.contrib import admin
from django.db.models import Count, Sum
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
list_display = ("name", "article_count", "total_views", "email")
list_filter = ("articles__status",)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.annotate(
article_count=Count("articles", distinct=True),
total_views=Sum("articles__views"),
)
def article_count(self, obj):
return obj.article_count
article_count.short_description = "文章数"
article_count.admin_order_field = "article_count"
def total_views(self, obj):
return obj.total_views
total_views.short_description = "总浏览"
total_views.admin_order_field = "total_views"
解释一下几个关键点:
- 在
get_queryset里做好annotate,列表页的每个对象就直接带有聚合属性,不需要逐行取数。 admin_order_field写上聚合后的字段名,列头才能排序。list_filter写articles__status,就能按“作者名下是否存在某状态文章”过滤。Admin对外键关联过滤支持得非常直接。
这里用distinct=True是因为一篇文章只属于一个作者,但评论可能有多条,如果和评论表JOIN,Count就会重复。养成习惯,多表关联的Count尽量带上distinct,除非你明确知道不需要。
5.3 在Admin里加自定义Action
python复制import csv
from django.http import HttpResponse
def export_author_stats(modeladmin, request, queryset):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="author_stats.csv"'
writer = csv.writer(response)
writer.writerow(["作者", "文章数", "总浏览量"])
for author in queryset.annotate(
article_count=Count("articles", distinct=True),
total_views=Sum("articles__views"),
):
writer.writerow([author.name, author.article_count, author.total_views])
return response
export_author_stats.short_description = "导出作者统计CSV"
自定义Action在Django里非常简单,函数接收modeladmin、request、queryset,返回值是HttpResponse。加了这段之后,Admin列表页的操作下拉里就会多出一个“导出作者统计CSV”。
Action细节:默认Action的选中项会在列表页顶部显示数量,如果你想隐藏计数,可以返回None而不是response。这是一个不常见但实用的小技巧。
5.4 一个演示用的查询API
顺带把多表聚合查询写成View接口,挂到路由上,前端或报表工具可以直接用:
python复制from django.http import JsonResponse
from django.db.models import Count, Sum
def author_stats_api(request):
rows = (
Author.objects.values("name")
.annotate(
article_count=Count("articles", distinct=True),
total_views=Sum("articles__views"),
)
.order_by("-total_views")
)
return JsonResponse(list(rows), safe=False)
这基本是几行查询逻辑实现了接口。用values("name")做分组维度,用annotate挂统计字段,order_by按总浏览排序,完全够用。至此,Admin后台统计列、自定义Action、API三块就齐了。
6. 常见问题与排查技巧实录
6.1 怎么看ORM到底执行了什么SQL
办法有三个:
- 临时调试:
print(str(queryset.query))或print(queryset.query)。 - 全局开启查询日志,在settings.py里加:
python复制LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"django.db.backends": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
- 更舒服的方式:安装django-debug-toolbar,每个页面能看到SQL总数和时间。
开发环境输出SQL队列有助于发现问题,生产环境请关闭DEBUG,更不要把所有SQL打到日志文件里,那样既泄露数据又有拖垮性能的风险。
6.2 为什么我的查询这么慢
慢查询的排查顺序:
- 先数SQL条数。用工具看一次请求执行了多少条SQL。如果条数多,优先处理N+1。
- 看最慢的SQL,把慢SQL的查询条件拿出去EXPLAIN。
- 判断是否需要加索引。ORM里直接在Meta的indexes里加:
python复制class Article(models.Model):
# 字段定义...
class Meta:
indexes = [
models.Index(fields=["status", "pub_date"]),
]
注意事项:
- 复合索引的字段顺序有讲究,把等值查询的字段放前面,范围查询放后面。
- 不要乱加索引,索引越多写入性能越差。
- 在
ForeignKey、DateTimeField上经常做筛选的字段,让迁移自动生成的db_index发挥作用即可。如果你每次都查一个固定字段,却没有索引,全表扫描就会越来越慢。
6.3 Admin保存失败与表单错误
有时在Admin编辑页点保存没反应或者报错,排查思路:
POST /admin/... 500优先看后台日志traceback。IntegrityError多半是外键约束或唯一约束冲突。- 某些字段明明是空值却提示必填,可能是
blank和null设置不匹配。记住:null管数据库层面,blank管表单校验。想在Admin留空,需要blank=True;想在数据库里存NULL,才需要null=True。这两个经常被搞混。 - 自定义表单里如果隐藏了必填字段,也会报错。最好在ModelAdmin里把必填但不在表单展示的字段加入
exclude或readonly_fields,避免校验失败。
6.4 查询时的几个典型错误
FieldError: Cannot resolve keyword 'xxx' into field:多半是双下划线写错字段名,或related_name没有按预期生效。AttributeError: 'QuerySet' object has no attribute 'xxx':对QuerySet用了实例方法属性,忘了遍历。ValueError: Cannot use None as a query value:filter里传了None,Django不允许直接传None作为查询参数,改用isnull=True。TypeError: 'QuerySet' object is not callable:代码里写了objects.filter(...).get(...)但过滤条件拼错,或者把某个字段命名为objects覆盖了管理器。
我把这些问题整理成表格,方便大家对照排查。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| list_display里放m2m字段报错 | m2m不能直接放list_display | 自定义方法返回字符串或数量 |
| 删除大量对象后信号没触发 | QuerySet.delete()不走实例delete | 用for循环逐条delete() |
| 批量更新后缓存/ES不同步 | QuerySet.update()不走save() | 改为逐条更新或主动同步 |
| aggregate返回None | 字段全NULL | 空值转0处理 |
| count算多了 | 多对多JOIN产生重复行 | distinct=True |
| 分组统计报GROUP BY语法错误 | 模型默认ordering干扰 | 显式order_by或清空排序 |
到这里,Admin后台和ORM进阶的主要内容就讲完了。最后分享一个我的个人习惯:写ORM查询尤其是聚合、多表查询时,我通常先不急着写代码,而是先在草稿纸上画出模型关系图,标清楚正向、反向、需要哪张表的字段。然后把目标SQL想出来,再翻译成ORM写法。这样比直接在代码里试错快得多,也能避免大量不必要的查询。Admin后台的使用,说到底是把模型定义阶段的字段意义翻译成管理员的交互,配置得当真的能省下整个内部管理系统的开发时间。希望这篇能帮你把手里的Django项目往前推进一大步。
