Django3:数据库——模型设计、关系与迁移
本系列目录
本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。
- Django1:部署——从本地启动到服务器运行
- Django2:配置——看懂项目结构与请求流程
- Django3:数据库——模型设计、关系与迁移
- Django4:ORM——增删改查、事务与查询优化
- Django5:页面——视图、模板、搜索与分页
- Django6:使用——表单、文章管理与 Admin
- Django7:权限——登录、会话与访问控制
- Django8:维护——测试、日志、备份与升级
下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。
博客的核心不是页面样式,而是文章能保存、能分类、能找到作者,并能区分草稿和公开内容。本篇先从业务规则出发设计模型,再说明迁移如何把 Python 定义变成数据库结构。
沿用 Django 5.2 和 blog 应用。学习时使用 SQLite;第一篇的容器部署使用 PostgreSQL 16。两者共用同一份模型,但数据库功能和并发行为仍有区别。Django 数据库说明
1. 先写业务规则,再写字段
我们的博客约定:一篇文章有一位作者;作者可以有多篇文章;文章最多属于一个分类;文章可以拥有多个标签,同一个标签也可以标记多篇文章;草稿默认不对游客展示。
User(用户) 1 ───── N Post(文章)
Category(分类) 1 ───── N Post(文章,分类可为空)
Post(文章) N ───── N Tag(标签)
↑
Django 创建中间关系表
| 业务数据 | 字段类型 | 原因 |
|---|---|---|
| 标题 | CharField |
有明确长度上限 |
| 网址标识 | SlugField(unique=True) |
适合作为 URL 片段,并要求唯一 |
| 正文 | TextField |
可保存较长文本 |
| 作者 | ForeignKey |
多篇文章属于一个用户 |
| 分类 | 可空的 ForeignKey |
可以暂时未分类 |
| 标签 | ManyToManyField |
一篇文章可对应多个标签 |
| 状态 | CharField 与枚举 |
使用有限的业务状态 |
| 创建/更新时间 | DateTimeField |
记录生命周期 |
slug 是读者看到的可读地址标识,数据库仍有自增 id 主键。编辑和删除页使用 pk,公开详情页使用 slug;不要假定两者必须相同。
2. 完整模型代码
将以下内容保存到 blog/models.py:
from django.conf import settings
from django.db import models
from django.urls import reverse
class Category(models.Model):
name = models.CharField("名称", max_length=50, unique=True)
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField("名称", max_length=30, unique=True)
def __str__(self):
return self.name
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "草稿"
PUBLISHED = "published", "已发布"
title = models.CharField("标题", max_length=200)
slug = models.SlugField("网址标识", max_length=200, unique=True)
body = models.TextField("正文")
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
related_name="posts", verbose_name="作者",
)
category = models.ForeignKey(
Category, on_delete=models.SET_NULL, null=True, blank=True,
related_name="posts", verbose_name="分类",
)
tags = models.ManyToManyField(Tag, blank=True, related_name="posts", verbose_name="标签")
status = models.CharField("状态", max_length=10, choices=Status.choices, default=Status.DRAFT)
views = models.PositiveIntegerField("阅读计数", default=0)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
ordering = ["-created_at", "-pk"]
indexes = [models.Index(fields=["status", "-created_at", "-id"], name="post_status_created_idx")]
constraints = [models.CheckConstraint(
condition=models.Q(status__in=["draft", "published"]),
name="post_status_valid",
)]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("blog:detail", kwargs={"slug": self.slug})
__str__() 决定对象在后台和调试输出中的可读名称。get_absolute_url() 用路由名称生成详情地址,不在模型里硬编码 /p/。
3. 几个容易混淆的字段参数
null=True 主要控制数据库是否允许 SQL NULL;blank=True 主要控制 Django 验证层是否允许空值。分类既可在数据库里为空,也可在表单里不选,所以两者都设置。普通文本字段一般使用空字符串表示空,不必再额外引入 NULL。
default=Status.DRAFT 为通过模型创建的新对象提供默认状态。choices 让表单生成选项并参与模型验证,但不能单独当成数据库级约束。本例另加 CheckConstraint,让直接 SQL 和绕过表单的更新也不能写入不存在的状态。
unique=True 会在数据库层要求网址标识唯一。ModelForm 可以提前提示冲突,数据库约束负责最后把关。save() 不会自动调用 full_clean();命令行脚本或批量导入如果需要完整模型验证,要主动调用。模型字段参考,模型验证
4. 删除关联对象时会发生什么
| 关系 | 本例策略 | 实际结果 |
|---|---|---|
| 文章 → 作者 | PROTECT |
仍有文章引用的作者不能直接删除 |
| 文章 → 分类 | SET_NULL |
删除分类后,文章保留并变成未分类 |
| 文章 ↔ 标签 | 多对多关系 | 移除标签关系不删除文章正文 |
删除账号是一个业务决定。本例保留文章并阻止直接删除作者;如果实际产品要求注销账号,需要另外设计归档、匿名化或内容删除流程。
related_name="posts" 提供反向查询入口,例如 user.posts.all()、category.posts.all()。关联用户时使用 settings.AUTH_USER_MODEL,避免把具体用户表写死。若要采用自定义用户模型,最好在新项目首次迁移前决定,详见第七篇。
5. 迁移到底做了什么
修改 models.py 并不会自动修改数据库。完整流程是:
python manage.py makemigrations blog
python manage.py sqlmigrate blog 0001
python manage.py migrate
python manage.py showmigrations blog
| 命令 | 作用 |
|---|---|
makemigrations |
对比模型状态与迁移历史,生成变更文件 |
sqlmigrate |
展示某个迁移在当前数据库后端上的 SQL |
migrate |
按依赖顺序把尚未执行的迁移应用到数据库 |
showmigrations |
查看迁移及其执行状态 |
完整示例已经带有 0001_initial.py。直接使用它时,第一次执行 makemigrations 显示 No changes detected 属于正常结果;执行 migrate 后,showmigrations 应显示 [X] 0001_initial。
迁移文件是项目历史的一部分,应与代码一起保存。数据库中的 django_migrations 记录哪些迁移已执行。不要为了消除报错随意删迁移文件、清空迁移记录或使用 --fake;它们可能让“Django 认为的表结构”与真实结构不一致。迁移机制
6. 插入一篇完整示例文章
先按第一篇执行 createsuperuser 创建名为 admin 的账号;如果用了其他名字,替换下面的用户名。进入项目 shell:
python manage.py shell
然后逐段执行:
from django.contrib.auth import get_user_model
from blog.models import Category, Post, Tag
author = get_user_model().objects.get(username="admin")
category, _ = Category.objects.get_or_create(name="技术")
tag, _ = Tag.objects.get_or_create(name="Django")
post, created = Post.objects.get_or_create(
slug="first-django-post",
defaults={
"title": "我的第一篇 Django 文章",
"body": "今天完成了数据库模型和迁移。",
"author": author,
"category": category,
"status": Post.Status.PUBLISHED,
},
)
post.tags.add(tag)
print(post.pk, post.title, post.get_absolute_url())
预期生成 /p/first-django-post/。重复执行不会创建同 slug 的第二篇文章;但 get_or_create() 找到已有对象时也不会用 defaults 覆盖原内容。tags.add() 必须在文章拥有主键后执行,因为中间表需要引用文章 ID。
可以在 shell 中运行 list(post.tags.values_list("name", flat=True)),预期包含 Django。若已使用完整示例,打开详情地址即可查看;若还在手工搭建,先完成第五篇的视图和模板。
7. 模型变更案例:给旧文章增加摘要
以下是独立练习,不属于配套示例的初始模型。假设数据库已经有 100 篇文章,现在新增可选摘要,添加:
summary = models.CharField("摘要", max_length=300, blank=True, default="")
然后执行:
python manage.py makemigrations blog
python manage.py migrate --plan
python manage.py migrate
如果业务要求摘要将来必填,更稳妥的步骤是先允许旧记录为空,再通过数据迁移或受控脚本回填,确认全部记录符合要求后收紧验证。直接为大量旧数据增加一个没有默认值的非空字段,需要处理已有行如何取值,不能仅修改 Python 类就期待数据库自行推断。
数据迁移使用迁移参数提供的历史模型,例如 apps.get_model("blog", "Post"),不直接导入当前 models.py。这样从零重放迁移时,才能使用当时的字段结构。大表变更还要评估锁表和执行时长,在接近实际数据规模的环境演练。
8. 从 SQLite 切换到 PostgreSQL
第一篇的 Compose 已配置 PostgreSQL。它通过以下变量让 settings.py 选择数据库:
DB_ENGINE=postgres
POSTGRES_DB=blog
POSTGRES_USER=blog_app
POSTGRES_PASSWORD=你的应用数据库密码
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_HOST=db 只在 Compose 网络中成立。本机 Python 连接宿主机安装的 PostgreSQL 时,通常使用 127.0.0.1;云数据库则填写对应地址,并按提供方要求配置网络与 TLS。第一篇的数据库没有公开端口,所以不能直接从宿主机连接它的 5432。
更换连接并运行 migrate 只会在新数据库建立结构,不会把 SQLite 里的文章自动复制过去。对于本教程规模的小数据集,可以在停止写入后进行逻辑导出与导入:
# 当前终端不应设置 PostgreSQL 相关变量;这里明确选择 SQLite。
DB_ENGINE=sqlite python manage.py dumpdata auth.user auth.group blog \
--natural-foreign --natural-primary --indent 2 --output blog-export.json
# 目标必须是新建且已完成初始化的 PostgreSQL 数据库。
docker compose run --rm web python manage.py migrate
docker compose run --rm -v "$PWD/blog-export.json:/tmp/blog-export.json:ro" \
web python manage.py loaddata /tmp/blog-export.json
本命令适用于本系列默认用户模型,导入前不要在目标库另外创建同名用户和文章。它搬运用户、用户组和博客数据,不搬会话与管理操作日志;由迁移重新生成的内容类型、权限通过自然键引用。导出文件包含账号信息和密码哈希,应限制访问,验证结束后妥善归档或删除。
导入后分别检查用户数、文章数、标签关系,实际登录并抽查页面。重要生产数据的跨库迁移需要独立演练、停写计划和回退方案,不能把这段小案例等同于通用无停机迁移。
9. 常见问题
no such table: blog_post 通常意味着当前数据库还没迁移,或程序连到了另一个库。UNIQUE constraint failed 表示唯一值冲突,例如重复 slug。ProtectedError 可能正是删除作者时触发了本例的保护策略。database is locked 常见于 SQLite 并发写入或长事务,应先缩短事务并检查并发来源;增加等待时间不等于获得 PostgreSQL 的锁机制。
下一篇 Django4:ORM 将在这些表上完成查询、更新、统计与事务练习。