本系列目录

本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。

  1. Django1:部署——从本地启动到服务器运行
  2. Django2:配置——看懂项目结构与请求流程
  3. Django3:数据库——模型设计、关系与迁移
  4. Django4:ORM——增删改查、事务与查询优化
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:使用——表单、文章管理与 Admin
  7. Django7:权限——登录、会话与访问控制
  8. Django8:维护——测试、日志、备份与升级

下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。

查看完整项目源码 · 查看示例验证范围

上一篇已经建立文章、分类、标签与作者的关系。本篇用这些数据练习 ORM:查出所有公开文章、按作者筛选、更新状态、统计分类文章数,以及让多步写入一起成功或一起回滚。

进入 manage.py 所在目录,执行 python manage.py shell。示例假设第三篇的 first-django-post 已创建,当前操作的是练习数据库。Shell 中的 ORM 没有网站视图里的作者权限过滤,执行者需要自行控制操作范围。

1. ORM 把业务对象映射到查询

from blog.models import Category, Post, Tag

posts = Post.objects.filter(status=Post.Status.PUBLISHED)

Post 是模型类,objects 是管理器,filter() 返回 QuerySet。可以把 QuerySet 理解为一个尚可继续组合的查询;它不等于已经载入内存的 Python 列表。

常见查询在迭代、list() 或求长度等操作时才执行。count()exists() 则适合只需要数量或存在性判断的情况。打印 QuerySet 也可能触发查询,调试时要知道观察本身可能访问数据库。查询基本用法

2. 创建记录与建立关系

from django.contrib.auth import get_user_model

author = get_user_model().objects.get(username="admin")
category, _ = Category.objects.get_or_create(name="技术")
tag, _ = Tag.objects.get_or_create(name="Python")

post = Post(
    title="使用 ORM 创建一篇文章",
    slug="orm-practice-post",
    body="这篇文章专门用于查询练习。",
    author=author,
    category=category,
)
post.full_clean()
post.save()
post.tags.add(tag)

这个创建片段只运行一次;重复运行会因为 slug 唯一而失败。full_clean() 在这里做模型验证,save() 写入主表,tags.add() 写入多对多中间表。若需要两次写入必须一起成功,应使用后面的事务。

也可以使用 Post.objects.create(...) 一步保存,但它不会自动执行 full_clean()get_or_create() 适合“存在则复用”的场景,数据库唯一约束仍然是并发下防止重复记录的重要基础。

3. 查一条、查多条和处理不存在

post = Post.objects.get(slug="first-django-post")
posts = Post.objects.filter(author=author, status=Post.Status.PUBLISHED)
latest = posts.first()
has_posts = posts.exists()
total = posts.count()
写法 返回值 没有匹配时
get(...) 单个对象 抛出 DoesNotExist,多条则抛 MultipleObjectsReturned
filter(...) QuerySet 返回空 QuerySet
first() 第一个对象 返回 None
exists() 布尔值 返回 False
count() 整数 返回 0

网页详情查询通常使用 get_object_or_404() 把未找到对象转换为 HTTP 404。数据导入脚本则可能需要捕获异常、记录问题,再决定是否继续;两种场景不应机械套用同一处理方式。

4. 字段查找、Q 条件与排序

from django.db.models import Q
from django.utils import timezone
from datetime import timedelta

recent = Post.objects.filter(created_at__gte=timezone.now() - timedelta(days=7))
matches = Post.objects.filter(
    Q(title__icontains="Django") | Q(body__icontains="Django"),
    status=Post.Status.PUBLISHED,
)
by_author = Post.objects.filter(author__username="admin")
by_tag = Post.objects.filter(tags__name__in=["Django", "Python"]).distinct()
latest_ten = Post.objects.filter(status="published").order_by("-created_at", "-pk")[:10]

双下划线有两类常见用途:created_at__gte 表示查找条件,author__username 表示跨关系访问。Q(...) | Q(...) 表示“或”,普通多个筛选参数通常按“且”组合。

多对多筛选可能让同一篇文章出现多行,例如同时匹配 Django 与 Python 两个标签;这里用 distinct() 去重。排序追加 -pk 是为了在创建时间相同时仍有稳定顺序,这对分页很重要。

icontains 适合小规模包含匹配;大小写、排序与比较细节受数据库影响。数据量大时要根据语言和检索需求考虑全文检索,而不是给所有搜索直接套 %关键词%

5. 只取需要的数据与分类统计

from django.db.models import Count

rows = list(Post.objects.values("id", "title", "status")[:5])
titles = list(Post.objects.values_list("title", flat=True)[:5])

categories = Category.objects.annotate(
    published_count=Count("posts", filter=Q(posts__status=Post.Status.PUBLISHED))
).order_by("-published_count", "name")

for category in categories:
    print(category.name, category.published_count)

values() 返回字典形式的数据,适合导出、统计或接口构造;它不返回完整模型实例,不能再调用实例方法。annotate() 给每一个分类增加统计结果;aggregate() 则适合整组数据的一条汇总结果。

条件统计把未发布文章排除在数量之外,同时仍可保留没有公开文章的分类。关联多个多对多表时要检查 JOIN 是否放大行数,不能看到数值就默认它代表独立文章数量。聚合查询

6. 更新:save 与 update 的差别

post = Post.objects.get(slug="orm-practice-post")
post.title = "使用 ORM 修改一篇文章"
post.save(update_fields=["title", "updated_at"])

changed = Post.objects.filter(pk=post.pk, status=Post.Status.DRAFT).update(
    status=Post.Status.PUBLISHED,
    updated_at=timezone.now(),
)
print(changed)

实例 save() 走模型保存流程;QuerySet 的 update() 直接执行 SQL 更新,不调用每个对象的 save(),也不会自动运行保存信号。本例因此在批量更新中显式赋值 updated_atsave(update_fields=...) 只处理列出的字段,需要更新时间就把它包含进去。QuerySet update 参考

changed 是匹配并更新的行数。这个例子把“仍是草稿”放入条件,让调用者能知道状态是否符合预期;它本身不构成整个文章系统的多用户版本冲突控制。

7. F 表达式:让计数在数据库中完成

以下是独立的计数练习,本系列页面不会自动调用它:

from django.db.models import F

Post.objects.filter(pk=post.pk).update(views=F("views") + 1)
post.refresh_from_db()
print(post.views)

如果两个请求同时执行“读出 10、各自加 1、保存 11”,可能丢失一次更新。F("views") + 1 把加法放进同一条数据库更新,让数据库基于当前值递增。refresh_from_db() 用于重新读取内存中已经过时的对象字段。

这个数值也不等同于真实独立访客数。刷新、爬虫、预取和重复请求都会影响阅读统计;上线前应先确定指标口径,而不是把每次 GET 都当成一次独立阅读。F 表达式

8. 事务:文章与标签一起保存

假设我们要求“文章保存成功时,标签关系也必须成功”。可以写成:

from django.db import transaction

with transaction.atomic():
    post = Post.objects.get(slug="orm-practice-post")
    post.title = "事务中修改的文章标题"
    post.save(update_fields=["title", "updated_at"])
    post.tags.set([tag])

事务块内发生未处理异常时,数据库写入会回滚;正常退出时提交。事务不覆盖已经发送的邮件、写出的文件或外部 HTTP 请求。需要提交成功后再发送通知,可以使用 transaction.on_commit()

捕获数据库错误时,一般把 try/except 放在 atomic() 块外面,使 Django 能正常结束失败的事务块。事务也不应包住耗时的外部网络调用,以免长时间占用连接和锁。数据库事务

对于必须“先读取再决定后续多步修改”的 PostgreSQL 流程,可以在 atomic() 内用 select_for_update() 锁定现有记录。SQLite 不提供相同的行锁行为;本地 SQLite 测试通过,不能证明 PostgreSQL 下的并发逻辑正确。常见的文章编辑冲突还可采用版本号或更新时间条件,按业务选择悲观锁或乐观控制。

9. 关联查询与 N+1 问题

假设一页展示 10 篇文章,每篇都显示作者、分类和标签。直接循环访问关系可能产生很多额外 SQL。可以在列表查询中提前安排:

posts = (
    Post.objects.filter(status=Post.Status.PUBLISHED)
    .select_related("author", "category")
    .prefetch_related("tags")
)

select_related() 适合外键或一对一等单值关系,通过 JOIN 获取。prefetch_related() 适合多对多和反向一对多,额外查询后在 Python 中关联。

本系列的非空列表页数据部分通常是三次查询:分页总数、当前页文章及作者分类、当前页标签。登录会话等中间件还可能产生其他查询,所以这个“三次”不是整个 HTTP 请求的绝对承诺。第八篇用测试单独验证列表数据查询。

预取后再调用 post.tags.filter(...) 可能触发新的查询,因为那是不同的筛选结果。优化前先确认模板实际访问哪些关系,再观察 SQL 或 QuerySet.explain(),避免只凭代码长短判断性能。数据库访问优化

10. 删除操作与本篇练习

删除时先明确对象范围。本例只删除专门创建的练习文章:

practice = Post.objects.get(slug="orm-practice-post")
print(practice.pk, practice.title)
practice.delete()

这会删除文章和对应标签关系,但不会删除标签本身。不要在有重要数据的数据库上随手执行 Post.objects.all().delete()

完成本篇后,可以独立尝试:列出最近七天的公开文章;统计每位作者的已发布数量;把指定作者的某一篇草稿改为发布;确认删除分类不会删除文章。下一篇 Django5:页面 会把这些查询结果变成浏览器里的页面。


上一篇:Django3:数据库——模型设计、关系与迁移 · 下一篇:Django5:页面——视图、模板、搜索与分页