Django4:ORM——增删改查、事务与查询优化
本系列目录
本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。
- Django1:部署——从本地启动到服务器运行
- Django2:配置——看懂项目结构与请求流程
- Django3:数据库——模型设计、关系与迁移
- Django4:ORM——增删改查、事务与查询优化
- Django5:页面——视图、模板、搜索与分页
- Django6:使用——表单、文章管理与 Admin
- Django7:权限——登录、会话与访问控制
- 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_at。save(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:页面 会把这些查询结果变成浏览器里的页面。