1. 模型设计与核心概念拆解
1.1 先理解Django模型到底解决什么问题
我最早接触Django模型时,第一反应是这东西不就是个数据库表映射工具吗?后来被前辈点醒,才意识到模型层的价值远不止“把表变成类”这么简单。它本质上是Django整套框架的基石——ORM(对象关系映射)层负责把你脑子里的业务对象,翻译成数据库理解的行和列,同时反向把数据库查询结果包装成Python对象让你直接操作。
这个翻译过程省掉的不只是手写SQL的麻烦,更关键的是它让代码具备跨数据库能力。你在开发机上用SQLite跑得飞快,部署到生产环境切到PostgreSQL或者MySQL,只要模型写得规范,几乎不用改业务代码。这种特性在中小型项目中尤其吃香,我接手过好几个从单机迁到云端的项目,模型层基本是零改动转移的。
还有一点容易被忽视:Django模型自带Admin后台。只要定义好模型,后台管理界面几乎免费送给你,增删改查、搜索过滤、权限控制全都有。对于需要快速搭建内部工具、运营后台的场景,这简直是省时间的核武器。你没必要给每个表单独写管理接口,模型定义完毕,后台就是现成的。
模型同时也是数据校验的第一道关卡。字段类型、长度限制、唯一约束、外键关系,这些都在模型层面声明到位,Django会在写入数据库前帮你做检查,避免脏数据混进去。这一点特别重要,因为很多人图省事把校验全堆在表单层,结果绕过了Django表单直接操作模型时,错误数据就悄悄溜进库里了。
1.2 模型、数据库表、Python类三者的对应关系
刚开始上手Django的时候,很多人会纠结一个问题:模型类、数据库表、Python对象之间到底是什么关系?我用自己的话总结就是:
- 一个模型类 = 一张数据库表。类名通常用大写开头的名词单数,数据库表名自动生成(默认是“应用名_模型名”的小写形式)。
- 模型类的属性 = 表的字段(列)。你在类里定义的各种Field类型,比如CharField、IntegerField、DateTimeField,对应表中每一列的数据类型和约束。
- 类的实例 = 表中的一行记录。每当你创建一个对象并保存,就在表里插入了一行数据;查询时得到的每个对象,对应一行记录。
我自己常用的类比是“Excel表格”的思路:模型类就是定义了表格的列头(字段名、类型、规则),每一条对象就是表格里的一行内容。这样想,你对模型和表的关系基本不会有理解偏差。
还有一个细节初学者容易忽略:模型类是普通的Python类,但不直接用__init__初始化数据,而是用关键字参数传字段值。比如:
from django.db import models class Article(models.Model): title = models.CharField(max_length=200) content = models.TextField() # 创建对象但不入库 article = Article(title="Django 模型入门", content="本文讲解模型的基本用法。") # 真正写入数据库 article.save()注意我写在代码里的注释——Article(title=..., content=...)只是构造了一个内存对象,还没进入数据库;必须调用save()才会执行INSERT。这个看起来微不足道的区别,很多人刚学时栽过跟头:觉得“我都创建对象了,怎么数据库里没有?”其实就是忘了save,或者忘了在视图里把对象传给模板。
1.3 为什么说模型定义是全局规划,不是简单写类
我踩过很深的一个坑:一开始做项目图快,看到什么字段就往模型里加,完全没做统一规划。结果项目跑到两个版本之后,同一个业务对象在不同App里被定义了N个相似但不相同的模型,改名、迁移、对齐数据成了灾难。
后来切换到“先画模型设计草稿,再写代码”的方式,情况立刻好了很多。所谓设计草稿不一定是正经的UML图,一张白纸画出核心对象、对象之间的关系(一对一、一对多、多对多)、每个对象需要哪些字段、哪些字段要做索引、哪些字段允许为空,就够了。
模型定义过程中的一个关键决策点是:字段的约束到底放在数据库层面还是代码层面。我倾向于把能交给数据库的约束(唯一、非空、长度、默认值)全部放在模型字段参数里,代码里只需要处理业务规则的校验。这样数据的安全感来自数据库底层,而不是随时可能被改动的业务逻辑,整个系统更稳。
另外一个容易被低估的设计维度是逻辑删除 vs 物理删除。模型设计时如果直接物理删除数据,后面做审核、审计、恢复功能时就会非常被动。现在很多项目在模型里加一个is_active = models.BooleanField(default=True)或者deleted_at = models.DateTimeField(null=True, blank=True)的字段,查询时默认过滤已删除数据。这个设计思路放在模型定义之初,比后期改代码省事得多。
2. 字段类型、约束与元数据的正确打开方式
2.1 高频字段类型与选择逻辑
Django内置的模型字段类型很多,日常工作里真正高频使用的其实就十几个。我按使用频率列一个表,顺便写清楚适用场景:
| 字段类型 | 对应数据库类型(以PostgreSQL为例) | 适用场景 | 常用参数 |
|---|---|---|---|
| CharField | varchar | 短文本,如标题、姓名、手机号 | max_length(必填) |
| TextField | text | 长文本,如正文、描述 | 无 |
| IntegerField | integer | 整数,如年龄、数量 | 无 |
| FloatField | double precision | 浮点数,如价格、评分 | 无 |
| DecimalField | numeric | 精确保存小数,如金额 | max_digits, decimal_places |
| BooleanField | boolean | 标识位,如是否发布 | default |
| DateTimeField | timestamp with time zone | 时间点,如创建时间 | auto_now_add / auto_now |
| DateField | date | 日期,如生日 | 无 |
| ForeignKey | 外键 | 一对多关系 | on_delete |
| ManyToManyField | 多对多关系表 | 多对多关系 | 可选through指定中间表 |
| OneToOneField | 唯一外键 | 一对一关系 | on_delete |
| FileField / ImageField | varchar(保存路径) | 文件/图片上传 | upload_to |
字段选择的原则很简单:能用CharField就不要用TextField,因为前者在数据库层有长度限制,查询更快,还便于建索引;金额必须用DecimalField而不是FloatField,后者有二进制浮点精度误差,算钱会出事——这一点是我做支付相关的模拟项目时才深刻体会到的。
2.2 字段参数背后的学问:null、blank、default、unique
刚学模型时,null=True和blank=True的区别是我第一次接触Django就搞混的东西。后来我用一句话记牢了:null是数据库层面的“能不能为空”,blank是表单层面的“能不能不填”。
null=True:数据库中这个字段允许NULL值。blank=True:表单校验时允许为空字符串或空值。default:当不传值时,数据库和Django使用该默认值。
常见的组合写法:
class UserProfile(models.Model): # 允许数据库为空,但表单必须填 nickname = models.CharField(max_length=32, null=True, blank=False) # 允许表单不填,存空字符串而不是NULL bio = models.TextField(blank=True, default='')这里我特别想提醒:对于CharField/TextField这类字符串字段,推荐用空字符串''作为默认值,而不是用null=True。因为SQL的NULL会让查询逻辑变得复杂,比如排序、聚合、去重时NULL和空字符串的行为很不一样。你把数据全存成空字符串,查起来痛感低很多。
unique=True给字段加唯一约束,相当于数据库层的去重保护。比如手机号、邮箱、身份证这类业务上必须唯一的字段,一定要在模型上加unique,别只靠代码检查——数据库约束才是最底层的最后一道防线,代码检查可能因为并发而漏掉。
还有一个实战里很常用的组合:unique=True配合null=True。在MySQL某些版本里,NULL值可以重复插入多个,于是在“允许为空但非空时必须唯一”的场景,你会得到一个坑人的结果。我的习惯是宁可把空值统一变成空字符串,也别让NULL和唯一约束掺在一起,省得到时候排查数据问题查到怀疑人生。
2.3 Meta内部类:排序、表名、约束的艺术
每个模型都可以定义Meta内部类。它不直接影响字段,但决定了模型的“数据库层行为”和“查询默认行为”。我用到最多的有三个配置:
class Article(models.Model): title = models.CharField(max_length=200) created_at = models.DateTimeField(auto_now_add=True) class Meta: # 默认返回结果按创建时间倒序 ordering = ['-created_at'] # 自定义数据库表名 db_table = 'article_info' # 联合唯一约束:同一用户对同一文章的点赞唯一 # constraints = [ # models.UniqueConstraint(fields=['user', 'article'], name='unique_user_article') # ]ordering这个配置严重影响查询体验。如果某张表大多数查询都要按时间排序,放在Meta里设置默认排序,那么Django的查询集默认就带了排序逻辑,视图和模板里不需要反复写order_by。但注意,默认ordering也会影响所有查询,哪怕有的场景不需要排序——这时候可以用Article.objects.order_by()清空排序,代价是写代码多一层思考。所以我的建议是:排序需求非常稳定时才设置Meta.ordering;如果各场景排序方式差异很大,干脆不设,在查询时显式写order_by。
db_table默认由Django自动生成(应用名_模型名),大部分情况不用改。但如果你对接的是一张已经存在的旧表,或者表名太长影响SQL书写,可以自定义。要注意的是,改db_table之后,迁移工具不会自动改表名,需要你自己处理存量数据,所以这个决定最好做在项目早期。
2.4 关系字段:ForeignKey、OneToOneField、ManyToManyField的选型要点
关系字段是Django模型的核心功能,也是新手最容易写乱的部分。我见过不少人一头雾水不知道用哪个,其实判断标准很直接:
- ForeignKey(一对多):一个作者可以有多篇文章。文章模型里写
author = models.ForeignKey(Author, on_delete=models.CASCADE),它就是多的一方,带着外键指向一的一方。 - OneToOneField(一对一):一个用户只能有一个扩展资料。在扩展资料模型里写
user = models.OneToOneField(User, on_delete=models.CASCADE),字段上加unique约束,保证一个用户只有一份资料。 - ManyToManyField(多对多):一篇文章可以有多个标签,一个标签能属于多篇文章。任意一方定义
tags = models.ManyToManyField(Tag),Django自动建中间关系表。
on_delete的选择是个大考点。Django 2.0之后这个参数必须显式指定,我直接用经验给你讲明白:
- CASCADE:关联对象删除时,自己也跟着删。适合从属类数据,如“文章删除,评论跟着删”。
- PROTECT:有关联数据存在时,禁止删除被关联对象。适合强约束,比如“订单存在时不能删用户”。
- SET_NULL:被关联对象删除后,该字段置为NULL。要求字段设置
null=True。适合保留历史记录的场景,如“用户删除后,文章保留,作者字段置空”。 - SET_DEFAULT:被关联对象删除后,字段恢复默认值。要求设置
default。
用一句话概括我的经验:凡是“子跟着父走”的,用CASCADE;凡是“父没了子还要留证据”的,用SET_NULL。做日志类、历史存档类数据时,我几乎不会用CASCADE,因为他们得留着独立于主体的记录供审计回溯。
3. 迁移机制:为什么它是Django模型的“时间机器”
3.1 迁移解决了什么黑暗历史
在没有迁移机制之前,修改数据库表结构是个高危手工活。开发环境改了模型定义,得手动同步去数据库执行ALTER TABLE,稍微多个环境就会分叉,生产库和开发库没人说得清差在哪里。更可怕的是,加了列忘了填充旧数据,改了字段类型导致线上报错,这些都是团队协作里的经典事故。
Django迁移机制把数据库结构的变更变成了“可版本化、可回滚、可自动执行”的状态管理。每次修改模型后生成迁移文件,这些迁移文件记录了从上一个状态到最新状态之间所有的变更步骤,而且每个迁移都有依赖顺序。团队协作时,别人拉下代码,跑一次python manage.py migrate,数据库结构就自动更新到和你一致的状态——这个体验,相比手工改数据库表格,简直是天壤之别。
3.2 一个完整迁移流程的实操记录
我以实际项目为例子,带你走一遍标准的迁移流程。假设你的应用叫blog,里面有一个文章模型:
首先定义或修改模型:
# blog/models.py from django.db import models class Article(models.Model): title = models.CharField(max_length=200) subtitle = models.CharField(max_length=200, blank=True) content = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True)然后在项目根目录执行:
# 1. 根据模型变更生成迁移文件(不应用到数据库) python manage.py makemigrations # 2. 查看迁移会执行哪些SQL(可选但推荐) python manage.py sqlmigrate blog 0001 # 3. 将迁移应用到数据库 python manage.py migratemakemigrations的作用是比对模型定义和迁移记录,检测差异,生成新的迁移文件。sqlmigrate能把迁移翻译成可读的SQL语句。生产环境正式执行前先跑一遍sqlmigrate确认SQL符合预期,是我一直坚持的习惯。有时候模型写错,迁移也会生成“看起来对但实际会锁表”的DDL,提前看SQL能帮你避开不少雷。
migrate则会读取所有未应用过的迁移,按依赖顺序执行。Django在数据库里维护了一张django_migrations表,记录哪些迁移已经执行过,因此重复执行migrate不会重复建表——这是迁移机制可靠的根基。
3.3 给已有字段加约束的正确姿势
项目上线后改字段是最敏感的环节。比如要给Article增加一个status字段,最稳妥的做法是分两步:
第一步,加字段时允许为空或设默认值,避免存量数据报错:
status = models.CharField(max_length=16, default='draft')第二步,确认存量数据没问题后,再把默认值去掉(如果业务上不再需要):
status = models.CharField(max_length=16)对应会生成两个迁移文件,而不是一次性写死。这样做的理由是:加字段并赋默认值时,数据库要扫描全表写入默认值,表越大越耗时;如果你还要求NOT NULL且无默认值,那存量行直接报错。分两次迁移,每一步都能验证,出问题回滚的代价也更小。
3.4 迁移文件到底能不能手改
一个真实经验是:正常情况下永远不要手改已经应用过的迁移文件。因为迁移文件一旦被某次migrate执行,它的名字和哈希就进了django_migrations表。你如果改了历史迁移文件,数据库不会自动感知变更,别人拉取代码后跑的迁移记录还是旧的,两边的库就悄悄不一致了。
需要修改历史迁移文件只有一种例外情况:这个迁移还没有被任何环境应用过,包括本地的开发库。这时改掉它重新生成等于清洗了历史,风险可控。如果已经在生产库执行过了,正确姿势是新建一个迁移来“打补丁”——比如删除某个字段、修改参数、设置新默认值。
手改迁移之后还有个后遗症:如果你同时改了多个迁移文件又跑了migrate,可能导致依赖链断裂。排查起来极其痛苦,不要轻易尝试。
3.5 迁移冲突与遇到的典型坑
团队协作时,两个人同时改模型,各自跑了makemigrations,往往会产生两个编号相同或依赖冲突的迁移文件。Django在跑migrate时检测到冲突会直接报错。解决办法是不要硬解,先看git diff弄清楚两边各改了什么,然后把冲突的人工化解掉——保留一个迁移文件,把另一个的改动手动合并进去,删掉多余的迁移文件。
你还有一个工具能用:python manage.py makemigrations --merge,它能把有冲突的迁移链自动合并成一个。但自动合并不代表业务正确,只能解决技术冲突,合并完后仍需人工检查字段变更是否合理。
我在本地开发还遇到过一种很无聊但常见的坑:改了模型但忘了makemigrations,然后直接写代码依赖新字段,一跑视图就报“column does not exist”。这类问题的排查思路是:先看django_migrations表里最后一个迁移是什么,再对比git里迁移文件的最新状态,就能判断到底是没生成迁移,还是迁移没应用。
4. 模型实操:从建表到增删改查的完整流程
4.1 环境准备与项目初始化
我假设你已经创建好一个Django项目和某个应用(比如blog)。如果没有的话,步骤是:
# 创建虚拟环境(以venv为例) python -m venv myvenv source myvenv/bin/activate # Windows用 myvenv\Scripts\activate # 安装Django pip install django # 创建项目和app django-admin startproject mysite python manage.py startapp blog接下来在settings.py的INSTALLED_APPS里注册blog:
INSTALLED_APPS = [ # ... 默认应用, 'blog', # 注册自己的app ]这里要提醒:不注册app的话,Django不知道你的模型存在,makemigrations会提示“No changes detected”。我见过好几个人卡在这一步,明明模型写了,迁移就是生成不了,检查半天才发现是app没注册。
4.2 定义模型:从简单到关联
用一个贴近实际的例子:博客文章加上作者和标签。我们定义三个模型:
from django.db import models from django.contrib.auth.models import User class Tag(models.Model): name = models.CharField(max_length=32, unique=True) def __str__(self): return self.name class Article(models.Model): title = models.CharField(max_length=200) content = models.TextField() author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='articles') tags = models.ManyToManyField(Tag, related_name='articles', blank=True) is_published = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-created_at'] def __str__(self): return self.title这里有几个细节要解释一下:
related_name给反向关系起一个自定义名字。比如通过user.articles.all()就能查到这个用户的所有文章。如果不指定,Django默认使用模型名小写_set,比如article_set。起个短名字让代码更清晰,尤其是反向查询多的时候,可读性提升一截。
auto_now_add表示创建时自动取当前时间,之后不再变;auto_now表示每次调用save()时都更新为当前时间。这两个参数省掉了手动填时间的麻烦,是时间字段的正确打开方式。
__str__方法定义了对象在Admin后台、Shell、模板里以什么文本显示。每次调试打印对象时,如果显示的是<Article: Article object (1)>而不是标题,你会极其难受。所以每个核心模型都建议加上__str__,这是开销极低、收益极高的习惯。
然后执行:
python manage.py makemigrations blog python manage.py migrate4.3 新增与更新记录的实现方式
新增记录有两种写法,效果相同:
# 方式一:构造对象后save article = Article(title="迁移机制详解", content="...") article.save() # 方式二:使用objects.create(推荐,一步到位) article = Article.objects.create(title="迁移机制详解", content="...")objects.create()内部其实就是“构造对象+save”,但它直接返回结果,还能配合批量创建优化。注意:save()方法在模型继承、自定义保存逻辑时行为可能不同,而create调用的也是自定义后的save,所以两者并不本质区别。
更新记录时,优先用QuerySet级别的更新而不是逐对象save:
# 方式一:逐对象更新(先查后改),适合需要触发save()自定义逻辑的场景 article = Article.objects.get(id=1) article.title = "修改后的标题" article.save() # 方式二:批量更新(直接UPDATE),适合只改简单字段的场景 Article.objects.filter(id=1).update(title="修改后的标题")update()走的是数据库层直接UPDATE,不会调用模型的save(),所以不会触发save里的自定义逻辑,效率高很多。如果只是改几个普通字段,用update();但如果模型里重写了save来做某些同步操作(比如更新缓存、写日志),那就得用逐对象保存。
4.4 查询:get、filter、exclude与链式操作
查询集是Django模型最有魅力的部分。Article.objects是模型的默认管理器,所有查询都从它开始。最常用的方法:
# 取全部 articles = Article.objects.all() # 过滤:等于、大于、包含 published = Article.objects.filter(is_published=True) recent = Article.objects.filter(created_at__gte="2024-01-01") keyword = Article.objects.filter(title__icontains="Django") # 排除 unpublished = Article.objects.exclude(is_published=True) # 排序与切片 top_articles = Article.objects.order_by('-created_at')[:10] # 取单条:get必须有且仅有一条 article = Article.objects.get(id=1) # 取第一条/最后一条 first_article = Article.objects.first()这些方法返回的都是QuerySet(懒加载),只有在你实际用到它们时(比如遍历、计数、序列化),Django才会真正执行SQL。所以你可以放心地链式叠加过滤条件:
result = (Article.objects .filter(is_published=True) .exclude(author__username="admin") .select_related('author') .filter(tags__name__icontains="Python")) # 真正触发SQL的时刻:迭代、bool()、len()、list()等 for item in result: print(item.title)链式的图层感很强:每一层filter返回的还是QuerySet,所以能一直点下去。我调试时常常在Shell里把QuerySet打出来看SQL(print(queryset.query)),确认过滤逻辑没有偏差。
查询集还有一个陷阱:取回来是个惰性对象,如果你在还没执行查询时修改了过滤条件,最终查询是叠加后的结果。有些场景下这符合直觉,有些场景却会出错——比如在循环里给QuerySet叠加filter,你会得到一堆彼此影响的条件。遇到这种情况,建议每次循环重新用Book.objects去查,别复用同一个QuerySet变量。
4.5 跨表查询:双下划线语法与select_related/prefetch_related
查询关联表是模型操作的精髓。Django用双下划线__来表示“跨越关系取字段”。举例:
# 查询某作者发表过的文章 articles = Article.objects.filter(author__username='xiaoming') # 查询包含标签名为“Django”的文章 tagged = Article.objects.filter(tags__name='Django') # 查询所有文章,并输出作者名——注意这里的列表推导会触发N+1问题,后面细说 for article in Article.objects.all(): print(article.author.username)双下划线也可以用在过滤、排除、排序、按字段统计等场景。它解决了一个大问题:不需要手写SQL JOIN,Django自动帮你构造关联查询。
但一不注意就会踩N+1查询的坑。上面那个循环看起来很简单,实际每次访问article.author都会执行一条SQL,100篇文章就是101条查询。优化方式就一个:用select_related(针对ForeignKey和OneToOne,使用JOIN)或者prefetch_related(针对ManyToMany和反向查询,用额外查询+Python合并)。
# select_related:适合ForeignKey,性能最优 for article in Article.objects.select_related('author'): print(article.author.username) # 不会再产生额外查询 # prefetch_related:适合ManyToMany,先查主表再批量查关联表 for article in Article.objects.prefetch_related('tags').all(): print(article.tags.all())我的判断标准:你需要“多对一”关联数据,用select_related;你需要“一对多”或“多对多”的反向数据,用prefetch_related。另一个实用技巧是,在Admin后台的list_display里显示关联字段时,也别忘了设置list_select_related,否则后台列表也会变成慢查询重灾区。
4.6 删除与软删除实践
删除操作比较简单:
# 删除单个对象 article = Article.objects.get(id=1) article.delete() # 批量删除 Article.objects.filter(is_published=False).delete()我在前面已经说过软删除的设计思路。现在给一个完整实现示例:
class Article(models.Model): # 逻辑删除标记 is_active = models.BooleanField(default=True) class Meta: db_table = 'article' # 查询时默认过滤掉已删除数据 class ActiveManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(is_active=True) class Article(models.Model): is_active = models.BooleanField(default=True) # 默认管理器 objects = models.Manager() # 自定义管理器(只查未删除数据) active_objects = ActiveManager()以后业务里默认用Article.active_objects.all(),需要含已删除数据时再切换到Article.objects.all()。这个模式在需要审计恢复的场景(订单、内容、用户)特别好用,底层数据永远保留着,前台展示又是干净的。
5. 模型性能优化:查询集技巧与实战调优
5.1 索引设计:让数据库查询“不磨洋工”
模型字段上加索引,是对查询性能影响最直接的优化之一。Django支持几种方式:
class Article(models.Model): title = models.CharField(max_length=200, db_index=True) author = models.ForeignKey(User, on_delete=models.CASCADE, db_index=True) created_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: indexes = [ models.Index(fields=['author', 'created_at'], name='author_created_idx'), ]db_index=True给单个字段加索引。Meta.indexes可以定义联合索引,比如作者+时间组合查询时,一个联合索引比两个单独索引好得多。
索引不是越多越好——每个索引都会在插入、更新、删除时带来额外开销,索引多了,写入性能反而下降。我给自己定的原则是:先看慢查询日志和项目里真实执行的SQL,找到最常出现在WHERE、JOIN、ORDER BY里的字段,再对这些字段建索引;一开始不要铺开建,避免索引爆炸。
5.2 慢查询定位:从Django连接看到SQL耗时
模型层看性能,不能靠猜,得靠工具。Django提供了一些辅助,比如django-debug-toolbar能在页面底部展示每条SQL及耗时;生产环境则更依赖数据库的慢查询日志,再结合Django的connection.queries做采样。
from django.db import connection # 在视图或Shell里看本次请求执行了哪些SQL queries = connection.queries for q in queries: print(q['sql'], q['time'])排查N+1问题有一套固定的操作手法:找到循环内访问关联对象的地方,改成select_related/prefetch_related;或者先线性查出主表,再一次性查出关联表用字典匹配。我实测过最夸张的一次优化:把一段页面从1200条SQL降到15条,响应时间从4秒降到300毫秒——纯粹就是加了一组select_related和prefetch_related。
5.3 F表达式:避免并发更新踩坑
当你需要“在原值基础上加减”,比如给文章阅读数加1,很多人会写成:
article = Article.objects.get(id=1) article.views += 1 article.save()这个写法在并发环境下有经典的竞态问题:两个请求同时读到views=100,各自加1再写回,结果变成101而不是102。Django提供了F表达式,让加减操作发生在数据库层面:
from django.db.models import F Article.objects.filter(id=1).update(views=F('views') + 1)这条SQL会在数据库里执行“UPDATE SET views = views + 1”,原子性有保证,不需要先查后改。凡是涉及计数增减、库存扣减这类操作,一律用F表达式,这是我在做带有并发写需求的模拟项目时总结出的硬经验。
5.4 大量数据的批量处理
一次性创建几百条记录时,如果用循环save(),每一条都是一次INSERT,速度慢得让人抓狂。用bulk_create能快出几个数量级:
articles = [ Article(title=f"文章{i}", content=f"内容{i}") for i in range(1000) ] Article.objects.bulk_create(articles, batch_size=500)batch_size指定每条SQL插入多少条,避免单条SQL太大。更新大量数据时对应用bulk_update:
for article in Article.objects.all(): article.status = 'published' Article.objects.bulk_update(articles, ['status'], batch_size=500)注意:bulk_create和bulk_update不会调用每个模型的save()方法,也不会触发auto_now字段的自动更新。如果你依赖这些自动行为,就老老实实循环save,或者手动设置时间字段。日常的大数据处理,我经常用bulk_create导入初始数据,配合事务整体回滚,安全高效。
5.5 查询穷举时的分页与延迟加载
数据量上来之后,一次性Article.objects.all()读全部内存会爆。Django的查询集本身支持切片分页:
# 每页20条 page_size = 20 page_articles = Article.objects.filter(is_published=True)[0:20]也可以配合Django内置的Paginator,在视图中做分页:
from django.core.paginator import Paginator articles = Article.objects.filter(is_published=True) paginator = Paginator(articles, 20) page_obj = paginator.get_page(request.GET.get('page', 1))我提醒一句:分页时如果你用了order_by(),每翻一页都会重新排序,表很大时很费时间。再加上count()统计时,Django会执行一次SELECT COUNT(*),这也是大数据量页面的常见瓶颈。某些高并发场景,建议预计算并缓存总数,而不是每次请求都count。
6. 常见问题与排查技巧实录
6.1 模型修改后迁移不生效的典型排查
症状:改完模型字段,跑makemigrations提示“No changes detected”。
排查步骤:
- 检查app是否注册在
INSTALLED_APPS里。 - 检查模型文件是否真的保存了(这个听起来离谱,但确实有人开了多个编辑器窗口,改了另一个文件的模型)。
- 确认你运行
makemigrations时指定的app名称对不对。 - 删除
migrations目录中冲突的本地迁移文件(确定别人没在用时)再重新生成。
还有一种隐蔽情况:你修改的是模型基类(abstract base class)而不是实际模型,Django对抽象基类本身不会生成迁移,只有继承它的具体模型才会。
6.2 字段变更导致数据丢失的抢救办法
线上运行中一个危险的迁移动作:把某个CharField字段从max_length=100改成max_length=50的时候,如果库里有长度超过50的数据,数据库会报错或者截断。执行这种方法前,一定要先查出超长数据,决定是清理还是压缩:
python manage.py shellfrom blog.models import Article # 查出超长记录 bad_rows = Article.objects.exclude(title__regex=r'^.{0,50}$') print(bad_rows.count()) for row in bad_rows[:10]: print(row.pk, row.title)发现异常后统一清洗:修复数据,再改模型字段长度,再迁移。顺序反过来就会出数据事故。
6.3 外键删除保护与循环引用的处理
模型之间的循环引用很常见,比如Category和Article互相引用。在Django里处理循环引用的通用办法是使用字符串形式的路径:
class Category(models.Model): parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE) class Article(models.Model): category = models.ForeignKey("Category", on_delete=models.SET_NULL, null=True)字符串引用模型路径,Django会在运行时解析,不用管类的定义顺序。这对处理业务对象互相依赖时特别实用,也避免了你为了加载顺序把模型堆在一起的糟糕代码结构。
外键删除保护的一个使用场景:分类下还有文章时,禁止删除分类。这个限制除了模型层的on_delete=models.PROTECT,还需要在业务代码中给用户明确的错误提示,而不是让数据库抛一个生硬的外键错误。所以我的常见做法是:先查关联数据,给出友好提示,再决定是否允许删除。
6.4 QuerySet惰性求值导致的结果丢失
我在博客项目里遇到一个很强的迷惑现象:先给QuerySet赋值给一个变量,然后修改模型数据,再迭代这个变量,你可能会看到迭代结果不是最初查询时的快照,而是执行时的最新数据。原因就是QuerySet是懒加载,SQL真正执行发生在第一次迭代时——如果迭代前数据变了,拿到的就是变后的数据。
解决办法:如果你需要快照结果,先强制求值,比如list(QuerySet)再复用。毕竟“惰性求值”和“缓存结果”是两回事,理解这一点能省下很多排查时间。
6.5 常见问题速查表
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| makemigrations提示No changes | app未注册或模型未改动 | 检查INSTALLED_APPS,检查模型文件 |
| 迁移后字段缺失 | 迁移没应用或迁移顺序混乱 | 用migrate app_name 000x逐步执行 |
| 查询关联字段报错RelatedObjectDoesNotExist | 外键关系数据为NULL | 用select_related或判断NULL |
| 页面慢,SQL数量爆炸 | N+1查询 | 加select_related/prefetch_related |
| 并发下计数不准 | 读-改-写竞态 | 用F表达式 |
| 模型改了但Admin后台没变化 | Admin缓存或需要重启 | 重启开发服务器并清浏览器缓存 |
| 迁移冲突 | 多人同时改模型 | makemigrations --merge,手动合并 |
7. 自己动手时的一点总结
Django模型这个主题,写多少资料都不如自己动手跑一遍。我在实际项目里最大的体会是:模型设计阶段多花一小时思考,后续开发能省下一下午调BUG的时间。不要着急写代码,先把字段、关系、约束、默认值画清楚,变更代价最低的窗口就是你刚建模型、还没往里面灌数据的时候。
另一个被反复验证的经验是:给模型写一个漂亮的__str__,在Shell里调试会有非常愉悦的体验;反之看到一个Object(3)完全不知道是什么,排查效率直接减半。看似小细节,长期积累差距非常大。
如果你是刚接触Django,建议从一对一的ForeignKey开始练手,把一个简单的博客系统(文章、作者、标签、评论)完整做一遍。走一遍定义模型、迁移、增删改查、关联过滤的流程,你对Django模型的把握就能超过大部分“看教程不动手”的人。以后遇到任何模型相关的问题,也建议保持一个习惯:先在python manage.py shell里把模型和查询集的行为试一遍,确认无误后再写进视图代码。这才是标准Django开发者的工作方式。