学Django有一阵子的人,大概率会有这种感受:网上教程一抓一大把,但很多都是照着敲一遍就过去了,回头自己写查询时,filter、get、all 用得倒是挺熟,可真碰到“查出来的数据怎么变了”、“为什么这么写能跑但性能崩了”这种问题,就卡住了。这篇文章不打算做那种大而全的 ORM 手册,就围绕一个单表演示项目,把我们日常开发里最常用的 Django ORM 操作从头到尾捋一遍。核心目标是让看完的人能照着写、照着查,并且知道每一步背后的逻辑。
我会用一个用户(User)信息表作为贯穿全文的例子,逐步覆盖模型定义、数据迁移、增删改查、进阶查询、表单联动这几个环节。因为是单表实例,不涉及外键关联,反而更容易看清 ORM 本身的执行逻辑和边界行为。凡是理解上有坑、调用方式有歧义、性能上有隐患的地方,我都会明确指出来,这些基本都是实际项目里反复踩过的。
1. 为什么ORM值得认真学:它到底解决了什么问题
1.1 从原生SQL到ORM的思维转变
接触 Django 之前,不少人是先写的原生 SQL。那时候的操作方式无非是:连数据库、拼 SQL 字符串、拿游标、一行行取数据,然后手动映射成 Python 对象。这套流程本身没毛病,问题出在几个地方。
第一,拼接 SQL 容易出注入风险。第二,表结构一改,所有 SQL 都要跟着改,牵一发动全身。第三,不同数据库方言有差异,MySQL 里能跑的语句换到 PostgreSQL 可能要调整。ORM(Object-Relational Mapping,对象关系映射)就是来解决这些痛点的:把表映射成类,把行映射成实例,把列映射成属性,把 SQL 查询封装成方法调用。
放在单表演示这个场景里,ORM 最大的价值是让你完全不用关心底层 SQL 长什么样,而是以“操作 Python 对象”的方式完成数据读写。上一行代码,ORM 帮你翻译成 INSERT,下一行代码,帮你翻译成 SELECT WHERE。这不是说底层 SQL 不重要,遇到复杂查询时你仍然需要理解 SQL 才能写出高效的 ORM 调用,但在绝大多数业务场景里,ORM 的抽象已经足够好用。
我见过不少刚转 Django 的同事,遇到查询需求第一反应是 connection.cursor() 写原生 SQL。这本身没错,但如果只是单表查询,用 ORM 能少写很多样板代码,而且迁移表结构时不用翻历史 SQL。理解 ORM 的核心思想,是掌握 Django 数据操作的底色。
1.2 Django ORM在MVT架构里的位置
Django 的 MVT 模式里,M(Model)就是数据层。很多教程会把 MTV 和 MVC 对比着讲,说 MVT 里的 View 对应 MVC 里的 Controller,Template 对应 View。这种说法能帮助理解,但我更倾向于这样解释:Model 是数据的最终来源,Template 负责展示,View 是连接两者的逻辑控制点。
在一套 Django 请求流程里,请求先进入 URLconf 路由,然后转到 View 视图函数,视图里调用 ORM 查询拿到数据,把数据交给 Template 渲染,最终返回给浏览器。这个过程里,ORM 层是 View 和数据库之间的桥梁。
单表实例的学习意义就在于,它能让你在不受外键、多表关联干扰的情况下,把这条链路彻底吃透:从模型定义,到数据库迁移,到视图查询,再到模板展示。把这套流程跑顺了,之后再加 ForeignKey、ManyToManyField,不过是给这个骨架添砖加瓦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搭一个能跑的单表演示项目
2.1 环境准备与项目初始化
要动手跑 Demo,第一步是准备环境。Django 目前主流的版本是 4.x 系列,Python 建议用 3.8 以上。如果你电脑上之前装过老版本 Django,建议在虚拟环境里重新安装,避免全局环境的依赖冲突。我习惯用 venv 创建虚拟环境,简单直接。
bash复制python -m venv venv
source venv/bin/activate # Windows系统执行 venv\Scripts\activate
pip install django
装好之后,创建项目和 app。项目名我通常取一个与业务相关的名字,app 名一般用复数形式,这也是 Django 官方文档里的惯例。
bash复制django-admin startproject mysite
cd mysite
python manage.py startapp users
这里注意,startapp 生成的 users 目录需要手动注册到项目配置里。打开 mysite/settings.py,找到 INSTALLED_APPS,把 'users' 加进去。漏掉这一步是新手最常见的低级错误,现象是执行迁移时报“No installed app with label”之类的错误。
2.2 用模型定义“用户表”:字段选择的门道
单表实例的模型设计虽然简单,但字段类型和参数选择仍然有讲究。假设我们要做一个用户信息表,包含以下字段:姓名、年龄、邮箱、手机号、注册时间、最后登录时间、是否激活。
在 users/models.py 里写:
python复制from django.db import models
class UserInfo(models.Model):
name = models.CharField(max_length=50)
age = models.IntegerField()
email = models.EmailField(unique=True)
phone = models.CharField(max_length=20, blank=True, null=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'user_info'
ordering = ['-created_at']
几个容易忽略的细节:
CharField必须指定max_length,这是 CharField 在底层变成varchar的硬性要求。不写会报错。EmailField实际上继承自CharField,底层也是字符串,只是在表单验证和模型校验时多了邮箱格式检查。存在数据库里仍然是varchar。auto_now_add=True表示第一次创建对象时自动写入当前时间,之后更新不会改变。auto_now=True表示每次save()时自动更新为当前时间。db_table用来指定数据库里的真实表名,不写的话默认是app名_类名小写,也就是users_userinfo。项目里如果对表名有规范要求,建议显式指定。ordering是 Meta 里的一个常用选项,会直接影响不带order_by的查询结果的排序方式。这里设成按创建时间倒序,符合“最新注册的排前面”的业务习惯。
模型定义的每一个字段类型,背后都对应着数据库层面的存储策略。比如 DateTimeField 在 MySQL 里对应 datetime(6),在 SQLite 里对应 datetime。这些差异 ORM 都帮我们屏蔽了,但你要知道它存在,否则写原生 SQL 做调试时会懵。
2.3 迁移不是玄学:makemigrations与migrate的工作原理
模型写完后,需要生成数据库表。Django 的迁移系统分两步:
bash复制python manage.py makemigrations users
python manage.py migrate
makemigrations 做的事情是:扫描 app 下的 models.py,对比历史迁移文件,计算模型自上次迁移以来的变更,生成新的迁移文件。迁移文件本质上是 Python 代码,记录了对数据库结构的一系列操作。你可以打开 users/migrations/0001_initial.py 看看内容,里面有一个 Migration 类,operations 列表里存放的是 CreateModel 操作。
migrate 把迁移文件中的操作翻译成真实的 SQL 并执行。默认情况下,Django 在 SQLite、PostgreSQL、MySQL 上都能跑。本地开发时我会直接用 SQLite,零配置,一个文件搞定;部署时换成 PostgreSQL,迁移文件仍然通用。这是 ORM 的又一层好处。
如果你改动了模型字段,比如给 age 加了 default=0,只需要重新执行:
bash复制python manage.py makemigrations users
python manage.py migrate
Django 会识别到模型和数据库之间的差异。执行迁移前,强烈建议先看一下生成的 SQL 是什么:
bash复制python manage.py sqlmigrate users 0001
sqlmigrate 这个命令能打印对应迁移的 SQL 语句,方便你检查 DDL 是否符合预期,尤其是生产环境执行迁移时,这一步几乎是必须的。
3. 单表增删改查:最常用也是最重要的基本功
3.1 新增数据:create与save的细微差别
ORM 新增一条记录有两种方式,本质上差别不大,但写出清晰、无歧义的代码还是有讲究的。
方式一,使用管理器 objects.create():
python复制from users.models import UserInfo
UserInfo.objects.create(
name='张三',
age=24,
email='zhangsan@example.com',
phone='13800000000',
is_active=True
)
方式二,实例化对象后手动调用 save():
python复制user = UserInfo(
name='李四',
age=28,
email='lisi@example.com',
phone='13900000000'
)
user.save()
两者都会立刻执行 INSERT 语句,但有一个容易被忽略的区别:当字段设置了 auto_now_add=True 或 auto_now=True 时,create() 和 save() 都会自动填充时间,这是模型层 pre_save() 钩子实现的。而如果在实例化时手动给 created_at 传了值,再用 save() 保存,auto_now_add 字段不会覆盖你传的值。换句话说,auto_now_add 只是在“没有显式赋值”时给你一个默认值。
单表场景下新增数据没有外键关联,看起来很简单,但有几个坑值得先说破:
- 不要在主键上做文章,除非你有非常明确的理由。否则让数据库自增。
- 如果业务上要求手机号唯一,应该加
unique=True,而不是靠应用层去判断。
为什么我建议在模型里把事情定死,而不是靠视图层约束?因为项目一大人就多,今天你靠视图层判断,明天别人在另外一个视图里又写了一段插入逻辑,重复数据就进来了。数据库层面的约束,是最后的底线。
3.2 查询操作:get、filter、all的边界与返回值
查询是 ORM 里使用频率最高的操作,把这几个方法的行为边界搞清楚,基本就掌握了单表查询的精髓。
all() 返回一个 QuerySet,代表表中所有记录:
python复制user_list = UserInfo.objects.all()
# 结果是一个 QuerySet,可迭代,元素是 UserInfo 实例
filter(**kwargs) 返回满足条件的 QuerySet,可能是一条,也可能是多条,也可能是空:
python复制active_users = UserInfo.objects.filter(is_active=True)
zhangsan = UserInfo.objects.filter(name='张三')
# 注意,即使只有一条记录,返回的仍然是 QuerySet,不是对象
get(**kwargs) 返回一个单独的对象。因为要求结果唯一,所以最多只能查出一条,否则抛出 MultipleObjectsReturned,一条都查不到则抛出 DoesNotExist:
python复制user = UserInfo.objects.get(email='lisi@example.com')
很多人用 get 时不做异常处理,结果页面一旦出现重复数据就直接 500。两种异常都继承自 django.core.exceptions.ObjectDoesNotExist,但在具体使用时要注意:UserInfo.DoesNotExist 是模型专属异常,可以精确捕获当前模型的空结果;而直接用 ObjectDoesNotExist 捕获范围更广,机制上会包含所有模型的 DoesNotExist。我个人的习惯是,只要是 get 一个明确标识(比如主键 id、唯一字段),就直接用 UserInfo.DoesNotExist 捕获。
同理,很多人问“明明只有一条数据,为什么 filter 之后取不到第一个”?答:filter 返回 QuerySet,要取第一条用 .first(),要取最后一条用 .last()。这两个方法在结果为空时返回 None,不会抛异常。
查询还有几个容易被忽略的边界情况:
filter(name='张三')是精确匹配。默认CharField精确匹配,即使你在数据库里存的是“张三”,查“张三 ”(多一个空格)也查不到。filter(pk=1)中的pk是主键的别名,不需要关心主键字段到底叫什么,只要知道pk永远指主键就行。
3.3 更新与删除:小心QuerySet的惰性求值
更新操作有两条路线:实例级更新和 QuerySet 级批量更新。
实例级更新适合单条记录:
python复制user = UserInfo.objects.get(pk=1)
user.age = 25
user.save()
这段代码实际执行了两条 SQL:一条 SELECT 查出对象,一条 UPDATE 更新修改过的字段。注意,save() 会更新模型实例的所有字段,而不仅是修改过的字段。也就是说,哪怕你只改了 age,updated_at 也会被 auto_now 更新,其他字段也会被写入。这在单表场景下问题不大,但在高并发、大字段场景下就有些浪费。
批量更新要用 update():
python复制UserInfo.objects.filter(is_active=False).update(is_active=True)
update() 是直接作用在数据库层面的一次 UPDATE 语句,不会加载 Python 对象,也不会触发模型 save() 方法,因此有 auto_now=True 的字段默认不会自动更新。这是个非常重要的区别。如果你希望批量更新时也自动更新时间戳,需要手动在 update 里加上:
python复制from django.utils import timezone
UserInfo.objects.filter(pk__in=[1, 2, 3]).update(
is_active=False,
updated_at=timezone.now()
)
删除操作同样有两条路线:
python复制# 单条删除
user = UserInfo.objects.get(pk=1)
user.delete()
# 批量删除
UserInfo.objects.filter(is_active=False).delete()
这里必须重点提 QuerySet 的惰性求值机制。QuerySet 并不是在你调用 filter 的那一刻就执行 SQL,而是在真正“需要数据”时才去数据库查询。比如:
python复制qs = UserInfo.objects.filter(age__gt=20) # 此时不执行 SQL
print(list(qs)) # 此时才执行 SQL
这个特性带来一个常见的坑:如果你先拿到一个 QuerySet,然后在 for 循环里修改了某些字段的值,这个 QuerySet 是不知道的。因为第一次迭代时 SQL 已经执行完了,QuerySet 内部缓存了结果。后面再迭代同一个 QuerySet,不会再查数据库,而是直接使用缓存。如果中间有别的地方修改了数据库,你拿到的还是旧数据。
解决方法是,明确需要最新数据时,重新触发一次查询,或者使用 qs.iterator() 避免缓存,但 iterator() 也意味着每次都需要重新查库,取舍要看场景。
另外,delete() 和 update() 一样,返回的是受影响的行数,而且不会触发模型的 delete() 和 save() 方法。如果你重写了这些方法来做日志审计,批量删除时不会生效。项目里如果有“删除用户时记录日志”的需求,要么单条删除,要么自己遍历处理。
4. 进阶查询技巧:单表也能玩出花
单表实例虽然只涉及一张表,但查询逻辑照样可以复杂。很多初学者觉得 ORM 的查询能力不如原生 SQL 灵活,其实是没掌握查找条件(field lookup)和表达式(expressions)的正确用法。
4.1 链式过滤与字段查找参数的组合
Django ORM 的 filter 支持一个关键字对应一种查找条件。写法是 字段名__查找类型=值,需要特别强调的是两个下划线。比如:
python复制# 年龄大于 20
UserInfo.objects.filter(age__gt=20)
# 年龄介于 20 和 30 之间
UserInfo.objects.filter(age__range=[20, 30])
# 姓名包含“张”
UserInfo.objects.filter(name__contains='张')
# 手机号为空
UserInfo.objects.filter(phone__isnull=True)
# 主键在指定列表里
UserInfo.objects.filter(pk__in=[1, 2, 3])
filter 可以多次链式调用,每次都是在上一次结果基础上添加条件,最终生成的 SQL 是 WHERE 子句的 AND 组合:
python复制UserInfo.objects.filter(is_active=True).filter(age__gte=18)
# 等价于 UserInfo.objects.filter(is_active=True, age__gte=18)
什么时候用链式,什么时候把所有条件写在一个 filter 里?我个人的经验是:条件少且固定时写在一个 filter 里,代码紧凑;条件需要基于业务逻辑动态追加时用链式,更灵活。两种方式最终生成 SQL 一致,没有性能差异。
还有一个容易踩坑的是 exclude(),它的语义是“排除满足条件的记录”,等价于 NOT (条件):
python复制# 查询年龄不是 24 的用户
UserInfo.objects.exclude(age=24)
但要注意多条 exclude 是并列关系还是嵌套关系。数据量大时,filter 和 exclude 混用的语义很容易出错,我建议遇到复杂条件时先写清晰的注释,再写代码。我当时开发一个筛选功能时,就是因为 exclude 和 filter 混用导致查询结果和预期不符,花了大半天排查。本质原因是多个 exclude 在 SQL 里生成的是 AND NOT 还是 OR NOT,取决于你的调用顺序,直接理解成“一条条排除”往往会错。
4.2 F表达式和Q对象:比较和复杂条件怎么处理
我最早用 ORM 查询时,遇到“某个字段的值大于另一个字段的值”这种需求,第一个想法是先把数据取出来,再在 Python 里做判断。这种做法在小数据量下没问题,但数据一多就慢到难以接受。正确做法是使用 F 表达式:
python复制from django.db.models import F
# 查询 updated_at 大于 created_at 的记录
UserInfo.objects.filter(updated_at__gt=F('created_at'))
F('updated_at') 不会解析成 Python 的值,而是直接翻译成 SQL 里的字段引用。比如这条查询最终生成的 SQL 片段是:
sql复制WHERE updated_at > created_at
这种操作在数据库层面完成,不需要把数据全部加载到 Python 层,性能和效率都明显更高。除了比较,F 还能做字段值的加减乘除:
python复制# 所有用户年龄加 1(谨慎操作)
UserInfo.objects.update(age=F('age') + 1)
Q 对象用来处理复杂的 OR 条件和 NOT 条件。filter 里多个关键字默认是 AND,但业务场景里常有“查出名字是张三或者李四”的需求。单个字段的 OR 条件可以用 __in,但不同字段之间的 OR 就必须靠 Q:
python复制from django.db.models import Q
# 姓名是张三,或者邮箱是 zhangsan@example.com
UserInfo.objects.filter(
Q(name='张三') | Q(email='zhangsan@example.com')
)
Q 对象可以和关键字混用,但 Q 必须放在关键字参数前面,否则会抛 TypeError:
python复制# 正确
UserInfo.objects.filter(
Q(name='张三') | Q(email='zhangsan@example.com'),
is_active=True
)
# 错误:SyntaxError
UserInfo.objects.filter(
is_active=True,
Q(name='张三') | Q(email='zhangsan@example.com')
)
关于组合查询的优先级,我的经验是:能拆就拆,尽量不要写一长串 Q 嵌套。因为复杂 Q 表达式虽然能跑,但等三个月后你自己回来看代码时,大概率要想很久才能反应过来它是什么意思。如果条件逻辑真的复杂,我通常会把这个查询封装成一个独立的函数,写上具体的业务含义。
4.3 聚合、分组与排序分页
单表查询里,聚合操作也经常用到。Django 提供了 aggregate 和 annotate 两组方法,它们之间的区别需要讲清楚。
aggregate() 返回一个字典,是对整张表或某个 QuerySet 做聚合计算后的结果,不会返回具体的记录:
python复制from django.db.models import Count, Avg, Max, Min, Sum
result = UserInfo.objects.aggregate(
total=Count('id'),
avg_age=Avg('age'),
max_age=Max('age')
)
# result 形如:{'total': 100, 'avg_age': 26.5, 'max_age': 35}
annotate() 则是给查询集的每一行加一个“注解字段”,通常和 values() 配合来做分组统计:
python复制from django.db.models import Count
# 统计每个年龄段的用户数量(按年龄分组)
result = UserInfo.objects.values('age').annotate(
count=Count('id')
).order_by('age')
# 输出形如:[{'age': 24, 'count': 3}, {'age': 25, 'count': 5}]
values() 在这里的作用是“只取 age 列,并按 age 分组”。不理解 values 和 annotate 配合的原理,是很多人学聚合时的最大障碍。换个角度理解:values('age') 先指定以 age 作为分组的维度,annotate(count=Count('id')) 在每组内统计数据条数。最终每个分组输出一行,而不是每个用户输出一行。
排序方面,order_by() 接收字段名字符串,支持负号表示倒序:
python复制UserInfo.objects.all().order_by('age') # 年龄升序
UserInfo.objects.all().order_by('-age') # 年龄降序
UserInfo.objects.all().order_by('age', '-id') # 年龄升序,id 降序
如果模型 Meta 里定义了 ordering,那么不写 order_by 时会默认按这个排序。但有一个常见的坑:加上了 order_by 之后,Meta 里的默认排序就失效了。还有个性能相关的点,分页操作不要对整个 QuerySet 做全表排序之后才切片,要利用数据库的 LIMIT / OFFSET。Django 的 QuerySet 切片语法会自动生成 LIMIT 子句:
python复制# 每页 10 条,取第 2 页
page_size = 10
page_num = 2
start = (page_num - 1) * page_size
end = page_num * page_size
users = UserInfo.objects.all().order_by('-created_at')[start:end]
这样生成的 SQL 是 LIMIT 10 OFFSET 10,而不是先把所有数据加载到 Python 再切片。记住,切片下标越界不会报错,返回空 QuerySet 而已。
5. 真刀真枪:把数据渲染到页面
5.1 视图函数的写法与ORM查询的结合
查询逻辑最终要服务用户界面。在 users/views.py 里写一个视图,把用户列表渲染到模板:
python复制from django.shortcuts import render
from .models import UserInfo
def user_list(request):
users = UserInfo.objects.all()
return render(request, 'users/user_list.html', {'users': users})
然后配置 URL,在 mysite/urls.py 里引入 app 的 urls:
python复制from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('users/', include('users.urls')),
]
在 users/urls.py 里:
python复制from django.urls import path
from . import views
urlpatterns = [
path('', views.user_list, name='user_list'),
]
视图函数里的 UserInfo.objects.all() 看着很简单,但这里有一个面试、工作中都常被问到的点:视图返回的 QuerySet 什么时候真正执行 SQL?答案是在模板里第一次迭代 users 的时候。也就是说,视图函数里的 render 调用结束前,SQL 可能还没执行。这个特性对性能的影响在单表场景下不大,一旦涉及多表关联,就会引出著名的 N+1 查询问题,后面会专门讲到。
5.2 模板中展示数据的小细节
模板文件 templates/users/user_list.html 内容大致如下:
html复制<table>
<thead>
<tr>
<th>姓名</th>
<th>年龄</th>
<th>邮箱</th>
<th>是否激活</th>
<th>注册时间</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ user.age }}</td>
<td>{{ user.email }}</td>
<td>{{ user.is_active }}</td>
<td>{{ user.created_at | date:"Y-m-d H:i" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">暂无用户</td></tr>
{% endfor %}
</tbody>
</table>
模板里的 {{ user.created_at | date:"Y-m-d H:i" }} 使用了 date 过滤器,否则显示的是一长串 ISO 格式时间。这一点虽然不是 ORM 本身的范畴,但视图和模型返回的 DateTimeField 对象在模板里如何格式化,是新手特别容易掉进去的坑。很多初学者遇到“时间显示不对,多出了 8 小时”的问题,基本都是时区设置不对。Django 项目里默认 USE_TZ = True,数据库存的是 UTC 时间,模板渲染时会自动转换到 TIME_ZONE 指定的时区。如果你的 TIME_ZONE 没配好,或者压根没配,就会看到“凭空多了 8 小时”的诡异现象。
回到 ORM 本身,模板里直接访问 user.created_at 实际上是访问模型实例的属性,最终和数据库查询出的 datetime 对象交互。在个别情况下,如果你想在 Python 代码里保持 UTC 时间,而只在展示层转成本地时间,那模板过滤器是最合适的位置。
6. 单表场景下的性能与安全陷阱
6.1 惰性查询带来的N+1隐患
前面提到,QuerySet 是惰性的。这个特性在单表演示里看着无害,但只要你往模型里加一个外键,问题马上就暴露出来。比如某个模型关联了 UserInfo,而你在模板里循环展示每个用户,同时又去访问它的关联对象,那每次访问都会触发一条新 SQL,总共就是 1 + N 条查询,这就是 N+1 问题。
单表实例没有外键,所以 N+1 不是当前场景的主角,但我要强调一个与此相关但经常被忽视的开发习惯:在视图里预先规划好要用的数据,不要依赖模板里的惰性访问。即使是在单表场景,如果你在循环里对每个对象又做了一次 filter,那同样会放大查询次数。
python复制# 不推荐:循环里查询
users = UserInfo.objects.all()
for user in users:
today_count = UserInfo.objects.filter(
created_at__date=timezone.localdate()
).count()
这种写法是性能杀手。单表时数据量小可能看不出问题,但数据一多,循环里的每一条查询都是额外开销。正确做法是把需要的聚合结果提前算好,放到一个字典里,循环时直接取值。
6.2 事务与并发写入
ORM 单表操作还有一个容易忽略的安全性问题:并发场景下的写入覆盖。
举个例子,两个请求同时读取了用户对象,都修改了 age 字段,然后各自 save(),最终哪个值生效取决于哪个请求最后执行 UPDATE。这就是典型的“丢失更新”问题。单表实例里如果有类似“计数器自增”的业务,尤其要小心。
Django 提供了 F 表达式来解决这类并发更新:
python复制UserInfo.objects.filter(pk=user.pk).update(age=F('age') + 1)
因为 F 表达式是在数据库层面完成“取当前值并加 1”,而不是先读出来,再写回去,所以不会出现丢失更新。事务层面,Django 用 transaction.atomic() 将多个操作包在同一个事务里:
python复制from django.db import transaction
with transaction.atomic():
user = UserInfo.objects.get(pk=1)
user.age = 26
user.save()
# 其他数据库操作...
atomic() 块里的操作要么全部成功,要么全部回滚,不会出现只写了一半数据的情况。单表操作时这个特性不常显山露水,但一旦涉及多步写入,它就是定心丸。
6.3 常见的坑:字段命名、静态文件与部署
单表实例本身不复杂,但围绕它的周边问题,我在网上看到最多的问题集中在三处。
第一,字段命名。Django 模型中,字段名不要和 Python 内置关键字冲突。比如不要叫 class、def、import 等。还有尽量不要用 object 这种看似无害的名字。虽然 Django 会允许你定义,但在某些序列化、反射场景下会引发诡异的错误。
第二,静态文件无法显示。这严格说不是 ORM 问题,但做用户管理项目时经常遇到头像、图片上传的需求。vscode 里写 <img src="{% static 'images/logo.png' %}"> 发现图片显示不了,大概率是 STATIC_URL、STATICFILES_DIRS 配置不对。模板里忘了写 {% load static %} 也是常见原因。这个问题和 ORM 无关,但排查起来会干扰你对数据层的判断。建议看 network 面板里图片请求返回 404 还是 500,这样能快速定位是不是路径错误。
第三,部署方式。单表实例的学习项目可以用 python manage.py runserver 跑,但正式部署需要换成熟的服务器。waitress + nginx 是 Windows 上比较省心的组合,waitress 负责跑 Django 应用,nginx 负责静态文件和反向代理。这里的关键点是:ORM 查询在开发环境跑得好好的,部署到生产环境突然变慢,基本可以从两个方向找问题:一是数据库连接数不够,二是没开查询缓存。单表查询的优化空间不大,但连上真实的云数据库后,网络延迟对查询耗时会放大,这种情况到生产环境才会遇到。
7. 单表实例怎么延伸到复杂项目
单表实例讲述完整个 ORM 的使用链路后,很多读者会想知道接下来怎么进一步学习。我的建议是:不要急着做多表关联,先把单表的模型设计基本功练扎实。
一个班级管理系统的项目里,学生表其实就是典型的单表实例。在此基础上增加班级表,学生表和班级表之间建立外键,就变成了多表实例。这个过程自然地引入了 select_related、prefetch_related 等关联查询优化手段。但如果你在单表阶段就把 filter、exclude、annotate、F、Q 这些操作弄熟,多表关联时只需要学会“怎么把外键调用转换成 SQL JOIN”,剩下的逻辑完全一致。
我自己在实际项目里体会最深的一点是:ORM 的学习曲线不是线性的,而是阶梯式的。单表实例阶段解决的是“怎么写代码”的问题;多表阶段解决的是“怎么把查询写得又快又准”的问题;到了真实业务里,还要面对迁移、部署、缓存、监控这些工程化问题。但所有上层建筑都建立在对基础操作的理解上。
如果你正在学习 Django,建议把文章里的代码亲手敲一遍,尤其是我提到的几个坑:get 的异常处理、update 与 save 的差异、exclude 的语义、时区配置、静态文件路径。只有把代码跑起来,才会真正理解 QuerySet 的惰性求值意味着什么,才知道为什么我们会反复强调“先想清楚再查库”。
最后分享一个我自己养成的习惯:每个 Django 项目里,我都会在模型所在的 app 下专门建一个 queries.py,把业务里常用的复杂查询封装成函数。这样视图层保持干净,查询逻辑可复用,遇到性能问题也更容易定位。单表实例阶段用这个方法,project 成长之后能省下很多琐碎的沟通成本。
