如果你准备认真啃Django,我建议你先把模型和数据这块彻底搞明白。原因很简单:Django所有业务逻辑最终都落在数据库上,而模型层就是代码和数据库之间的那座桥。桥搭得稳,后面做迁移、写QuerySet、排查慢查询都会很顺手;桥搭得随意,迟早要回头改表结构,成本比你想象中大得多。这篇内容我会从模型设计切入,一直聊到迁移、QuerySet、关系建模、并发控制,最后落到性能优化,争取把Django模型与数据的知识串成一条清晰的线。
1. 模型设计:从业务需求到表结构
1.1 模型类到底在做什么
很多初学者把模型类当成“一张表的描述”,这个理解方向对,但还不够。模型类本质上是ORM帮你做的映射:一个Python类对应一张表,类属性对应表的字段,类的实例对应表中的一行记录。开发时你操作的是Python对象,实际Django会在底层把代码翻译成SQL执行。
看一个最简单的例子:
from django.db import models class User(models.Model): name = models.CharField(max_length=50) created_at = models.DateTimeField(auto_now_add=True)执行迁移后,数据库里会出现一张默认表名app_user的表,包含id、name、created_at三个字段。你几乎不需要手写建表语句,但你必须理解这套映射规则,否则后面很多设计决策会跑偏。
我见过不少新手给字段起UserName这种驼峰命名,跟Python社区规范完全不一致。Django虽然能容忍,但数据库列名、查询条件、API序列化都会变得别扭。更关键的是,一旦业务逻辑复杂起来,模型类就不是简单的“字段容器”,它还要承担校验、默认值、索引、关系维护等职责。所以建模阶段多花一点时间,后面能省十倍功夫。
1.2 字段类型这样选,才能少走弯路
字段类型是模型设计的核心中的核心。选错了字段类型,轻则多写一堆校验,重则迁移大表时锁表卡死生产环境。我常用的字段选择逻辑是:先判断业务数据本质,再考虑索引和查询需求,最后才落到Django字段类。
常用的几类字段:
CharField:必须指定max_length,适合短字符串,比如姓名、手机号、订单号。TextField:不需要指定长度,适合文章正文、日志详情这类长文本。IntegerField、FloatField、DecimalField:数字类型,注意金额一定要用DecimalField,避免浮点误差。BooleanField、DateTimeField、DateField:状态和时间记录。JSONField:适合存储动态结构的数据,比如第三方回调的原始报文。ForeignKey、OneToOneField、ManyToManyField:关系字段,用来表达表之间的关联。
这里有一个容易被忽视的细节:字符串字段的null=True。如果你在CharField上设置null=True,数据库里会出现两种“空”的状态:NULL和空字符串''。查询时你要么兼容两者,要么强制统一,非常痛苦。我个人的经验是:字符串字段一律用default='',不要开null=True;日期时间字段和数字字段按需设置null=True,因为它们很多场景下确实允许缺失。
另外,choices选项要谨慎使用。如果枚举值以后会经常变动,choices会让部署和扩展变得难受,因为新值必须改代码。对于状态机这类高频变更的业务,我宁愿单独建一张字典表,或者直接存字符串,在代码里做常量定义。
1.3 Meta配置:那些容易被忽略的表级操作
模型内部类Meta提供了表级别的能力,很多开发者只会改ordering,其实它还藏着不少好东西。
class Order(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) order_no = models.CharField(max_length=32, unique=True) amount = models.DecimalField(max_digits=10, decimal_places=2) status = models.CharField(max_length=16, default='pending') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'shop_order' ordering = ['-created_at'] indexes = [ models.Index(fields=['user', 'status'], name='idx_user_status'), ] constraints = [ models.UniqueConstraint(fields=['order_no'], name='uniq_order_no'), ]db_table可以自定义表名,否则默认表名是应用名_模型名。需要直连数据库或者跨系统同步时,一个稳定的表名非常重要。
ordering会影响默认查询的排序,但千万别以为这是免费的。只对超高频列表页设置ordering,否则每个查询都会多一个ORDER BY,大表上很容易拖慢速度。更稳妥的做法是:不加全局ordering,在具体QuerySet里用order_by显式控制。
indexes和constraints是数据库层面的优化和约束。UniqueConstraint比老的unique_together灵活,支持条件唯一约束和表达式索引。例如只对未删除的订单约束唯一性,就可以写条件约束。所以能用新版约束就尽量别用老接口。
2. 迁移系统:让数据库跟着代码走
2.1 migrate的完整工作流程
Django迁移系统相当于把数据库结构变更做成一份“版本历史”,每次修改模型后,生成迁移文件,再应用到数据库。这个机制让多环境部署、多人协作变得可控。
常规流程是三步:
python manage.py makemigrations python manage.py migrate python manage.py sqlmigrate app_name 0001makemigrations会对比当前模型和历史迁移文件,生成新的迁移文件;migrate按顺序执行未应用过的迁移;sqlmigrate可以预览某次迁移要执行的SQL,上线前检查特别有用。
迁移文件本质上是一个Python脚本,它描述了字段增删改、表创建删除等操作。Django在数据库里维护一张django_migrations表,记录哪些迁移已经执行过,所以重复执行migrate不会重复建表。
如果数据表已经存在,但你又不想从零开始迁移,可以使用python manage.py migrate --fake-initial,这只会标记迁移为已应用,不会真实建表。但用之前务必确认表结构完全一致,否则后面字段变更时会报错。
2.2 迁移合并、冲突解决与改名
多人协作时,最常见的冲突是两个人同时生成相同编号的迁移文件。Django检测到分支迁移时会提示,这时用makemigrations --merge生成一个合并迁移。
还有一类冲突是两个人修改同一个字段,比如A改成IntegerField,B改成BooleanField,合并时需要你手动解决。原则上保留一个方向的改动,另一个修改需要重新生成迁移。
我的建议是:历史迁移文件一旦提交到主干,就不要手改。很多新手图省事直接编辑已执行过的迁移,结果本地和测试环境状态不一致,部署时到处报警。正确做法是:开发期间可以重置迁移,但上线后只能通过新迁移文件来变更。
如果迁移文件数量多到吓人,可以用squashmigrations压缩,把一段历史合并成一个或几个文件。压缩后的迁移文件通常包含initial = True,表示作为基线。
2.3 我踩过的三个迁移坑
第一个坑:在迁移文件里写RunPython做数据清洗。当时我把一段业务逻辑直接塞进迁移,后来模型改了,这段迁移在新的环境上重新执行时报错。原因是迁移文件应当是一次性的历史脚本,不要依赖当前模型方法。正确做法是在迁移里用apps.get_model获取历史模型,并且把操作写成纯数据脚本。
第二个坑:删字段时没同步删代码。当时删掉了模型里的一个字段,却没检查代码里还有其他地方引用它,结果迁移一执行,页面直接报FieldError,排查了半天。现在我在删字段前会全局搜索字段名,包括模板、序列化器、任务脚本。
第三个坑:给大表加唯一约束。生产环境订单表已经千万级数据,我直接加了UniqueConstraint,结果数据库锁表了好久,业务差点停摆。后来改用先创建唯一索引,再分批次排查重复数据,最后再上约束的方式。大数据量表的结构变更,一定要评估锁表时间,能后台执行的不要前台跑。
3. QuerySet:日常数据操作的正确姿势
3.1 增删改查的保姆级拆解
先看创建。create()和save()都能插入数据,但区别要注意:
user = User.objects.create(name='张三') # 等价于 user = User(name='张三') user.save()create在内部也是构造对象并调用save(),但它一步完成,语义更清晰。有时你需要覆盖save()做自动填充,那么用先构造再save的方式更灵活。
批量创建不要用循环create,那样会产生大量数据库往返。用bulk_create可以一次插入多条,缺点是它不会触发模型的save()方法,也不保证返回自增ID。
更新操作分成两类。一类是user.save(),它会先加载对象再写全字段,适合需要业务逻辑的场景;另一类是QuerySet.update(),它直接生成UPDATE语句,不会加载对象,也不触发save信号。需要批量改字段时,比如:
Order.objects.filter(status='pending').update(status='canceled')这才是高效的更新方式。
删除时,delete()会返回(总删除数, {每类删除明细})。要注意外键的on_delete行为,CASCADE会把关联数据一起删掉,PROTECT则会阻止删除。生产环境我倾向于用PROTECT或自定义逻辑,避免误删连带数据。
3.2 过滤与聚合:filter、exclude、annotate、aggregate
filter是AND条件,多个filter等价于一个filter里写多个条件。例如:
orders = Order.objects.filter(user=user, status='paid')双下划线语法是Django查询的精髓:
created_at__date='2024-01-01':按日期部分匹配。price__gte=100:大于等于。user__name__icontains='张':关联表字段模糊匹配。id__in=[1, 2, 3]:范围查询。
exclude不是简单的“排除”,它的语义是“不满足整个条件集合”。比如exclude(status='paid', amount=100),排除的是同时满足两个条件的行,而不是把所有status为paid或amount为100的都排除。这一点非常容易踩坑,如果你需要的是“任一条件不满足”,记得拆成多个exclude。
annotate和aggregate是分组统计的两把刀。aggregate返回一个汇总值,比如总数、平均值、求和;annotate给每一行增加新的统计字段。看一个实际场景:统计每个用户的订单总额。
from django.db.models import Sum user_stats = ( User.objects .annotate(total_amount=Sum('order__amount')) .values('id', 'name', 'total_amount') )这段代码会用GROUP BY按用户分组,并在每行新增total_amount字段。配合values可以控制输出字段。注意annotate的顺序会影响最终结果,如果你先filter再annotate,统计维度会变。
3.3 懒加载和缓存机制:为什么你的查询在重复跑
QuerySet是惰性的,创建它并不会立刻执行SQL,只有真正需要数据时才查库。这个特性节省了不少无谓查询,但也带来一个坑:同一个QuerySet重复迭代会重复查库。
qs = Order.objects.filter(status='paid') print(len(qs)) # 第一次查询 for order in qs: # 第二次查询 ...上面这段代码看似只用了一个qs,实际上数据库查了两遍。解决办法是强制求值并缓存:
qs = list(Order.objects.filter(status='paid'))或者利用len(qs)之后再用同一个qs,因为Django对切片和len的结果会缓存。但要记住,all()本身不缓存,只有执行了具体的取值操作后才缓存查询结果。
平时排查性能时,先看一眼SQL执行次数。如果一个页面SQL执行了几百次,不是N+1就是重复迭代。
4. 关系模型:一对多、多对多、一对一怎么设计才顺手
4.1 正向和反向关系的访问
外键关系在Django里用起来很顺手,但正反向访问很多人分不清。比如订单属于用户:
class User(models.Model): name = models.CharField(max_length=50) class Order(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')从订单查用户是正向访问:order.user;从用户查订单是反向访问:user.orders.all()。如果没有设置related_name,Django默认生成user.order_set,可读性差而且容易和多个外键冲突。所以我的习惯是:每个外键都显式设置related_name,并取一个能说明关系的复数名。
related_name还有一个用处:它支持查询条件。比如user.orders.filter(status='paid'),可以在反向关联上直接过滤。更重要的是,你可以在filter里跨关系查询:
Order.objects.filter(user__name='张三')这就是Django ORM方便的地方,不需要手动写JOIN,但理解底层是JOIN才能优化查询。
4.2 多对多关系的through模型
ManyToManyField能自动生成一张中间表,但如果你需要在关系上存储额外字段,必须用through手动管理中间表。举一个常见例子:用户收藏商品,同时要记录收藏时间。
class User(models.Model): name = models.CharField(max_length=50) class Product(models.Model): title = models.CharField(max_length=100) fans = models.ManyToManyField(User, through='Favorite', related_name='favorite_products') class Favorite(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint(fields=['user', 'product'], name='uniq_user_product') ]使用through之后,add()、remove()这些快捷方法就不能用了,必须通过中间表创建记录:
Favorite.objects.create(user=user, product=product)这样做的好处是,你可以随时在中间表上扩展业务字段,比如加一个source字段表示收藏来源。没有through的自动中间表,后期想加字段会很被动。
4.3 性能核心:select_related和prefetch_related的选择
关系查询最经典的问题是N+1查询。比如遍历100个订单,每次访问order.user都会查一次用户表,整体就是1次订单查询加100次用户查询,性能惨不忍睹。
解决办法是预加载关联对象。select_related用于一对一和多对一关系,它通过JOIN在一条SQL里把关联数据查出来;prefetch_related用于多对多和一对多关系,它会额外发几条SQL,然后在Python层面组装。
orders = Order.objects.select_related('user').all() for order in orders: print(order.user.name) # 不再产生额外查询users = User.objects.prefetch_related('orders').all() for user in users: print(user.orders.count())选哪个不是看心情,而是看关系的方向。多对一用select_related,一对多和多对多用prefetch_related。prefetch_related还有一个进阶用法:Prefetch对象可以带过滤条件,比如只预加载已支付的订单:
from django.db.models import Prefetch users = User.objects.prefetch_related( Prefetch('orders', queryset=Order.objects.filter(status='paid')) ).all()注意,这里不能直接用user.orders.filter(status='paid'),那会触发新的查询,抵消预加载的效果。
5. 数据完整性与并发控制
5.1 事务:atomic用了就一定要知道回滚规则
事务保证一组数据库操作要么全部成功,要么全部失败。Django里最简单的方式是:
from django.db import transaction try: with transaction.atomic(): order.save() item.save() except Exception: # 事务已经回滚,这里做错误处理 pass只要with atomic块内抛出异常,事务就会回滚,块内所有数据库变更都不生效。要注意的是,atomic支持嵌套,内层块出现问题,可以只回滚内层保存点,外层不受影响。
实际开发中遇到过一个问题:在atomic块内调用了外部接口,外部接口失败后抛异常触发了回滚,但外部接口的副作用(比如发短信)没法回滚。这提醒我们,事务应该只包裹数据库操作,不要长时间占用事务执行RPC调用。
另外,如果atomic块里捕获了异常但没有往外抛,事务不会回滚。Django默认在退出atomic块时发现异常才处理,所以捕获异常前要考虑清楚:是继续提交,还是主动回滚。
5.2 F表达式:让运算发生在数据库而不是Python
并发控制最容易出问题的地方是数字字段的更新。比如库存扣减:
product = Product.objects.get(pk=1) product.stock -= 1 product.save()这段代码在高并发下会丢更新。两个请求同时读到stock=10,各自减一后都写成9,但实际上应该是8。原因是读取和写入中间有间隔,两个事务互不可见。
Django提供F表达式,把运算直接推给数据库:
from django.db.models import F Product.objects.filter(pk=1).update(stock=F('stock') - 1)这句SQL会变成UPDATE product SET stock = stock - 1,数据库在行级加锁,天然避免并发覆盖。只要涉及计数器、余额、库存这类字段,我都推荐用F表达式,不要先把值读到Python里算。
F表达式还能用在filter里做同一条记录的字段比较,比如:
Order.objects.filter(paid_amount=F('total_amount'))这会查出已付金额等于总金额的订单。
5.3 自定义校验与clean函数
模型层校验往往被忽略,因为很多人依赖表单校验或序列化器校验。但其实模型自带一套校验机制,full_clean()会依次执行字段校验、唯一性校验、自定义clean()方法。不过在save()时,Django默认不会自动调用full_clean(),需要你手动触发。
在clean()里可以写跨字段校验逻辑。比如一个促销活动,结束时间必须晚于开始时间:
from django.core.exceptions import ValidationError class Campaign(models.Model): start_time = models.DateTimeField() end_time = models.DateTimeField() def clean(self): super().clean() if self.end_time and self.start_time and self.end_time <= self.start_time: raise ValidationError('结束时间必须晚于开始时间')如果你希望所有入口都强制校验,可以统一在save()方法里调用full_clean()。但要注意,这会增加一次查询和校验开销。业务约束严格的场景值得,性能敏感的批量操作就不要这么做了。
6. 数据性能排查清单与实用经验
6.1 慢查询追踪:django-debug-toolbar和connection.queries
定位Django性能问题,第一步永远是看SQL。最常用的是django-debug-toolbar,它能在页面侧边栏展示SQL数量、耗时和重复查询。如果你在写脚本或调试API,可以用:
from django.db import connection # 打印当前请求/脚本执行的SQL print(connection.queries)connection.queries记录每一次SQL语句和耗时,直接看就能发现哪些查询在循环里反复执行。
还有一个轻量级方式,在配置里开启慢查询日志,把执行时间超过阈值的SQL记录下来。这比前端调试更适合生产环境。
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', ... 'OPTIONS': { 'slow_query_log': True, 'long_query_time': 0.5, } } }6.2 索引与表结构优化
索引是查询加速的核心。字段上有db_index=True或unique=True就会创建索引。Django还支持组合索引,比如:
class Order(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) status = models.CharField(max_length=16) created_at = models.DateTimeField() class Meta: indexes = [ models.Index(fields=['user', 'status', '-created_at']), ]组合索引的字段顺序非常关键。原则是:等值查询的字段放前面,范围查询和排序字段放后面。把user放前面,可以同时加速“查某个用户的所有订单”和“查某个用户某种状态的订单”。
要避免在查询条件里对字段做函数运算。比如:
User.objects.filter(created_at__year=2024)虽然Django能翻译成SQL,但这通常会让索引失效,导致全表扫描。更合适的做法是查范围:
start = datetime(2024, 1, 1) end = datetime(2025, 1, 1) User.objects.filter(created_at__gte=start, created_at__lt=end)大数据量分页也建议用键集分页,而不是LIMIT offset。偏移量越大,数据库要扫的无效行越多。
6.3 一个真实的订单系统优化案例
这里分享一个我实际参与过的优化案例。当时某个订单列表页打开要2秒,随着数据量增长越来越慢。排查时发现,页面循环里访问了order.user.name,还做了order.items.count(),一次列表20个订单,SQL数量直接飙到80多条。
优化思路分三步:
第一步,把Order.objects.all()改成Order.objects.select_related('user'),用户信息通过JOIN一次性查出,少了20条额外查询。
第二步,商品数量不能用order.items.count(),因为每次都会发COUNT语句。改成:
orders = orders.annotate(item_count=Count('items'))这样在一条SQL里用LEFT JOIN和GROUP BY把每单的商品数量算好。
第三步,给经常筛选的status字段加索引,并在页面筛选逻辑里避免二次全表查询。
优化后SQL从80多条降到3条,接口响应时间从2秒降到40毫秒。这个案例给我的启发是:性能优化要靠工具定位,不要凭感觉乱加prefetch_related。
写在最后的经验之谈
做了这么多年Django项目,我的体会是:模型和数据这块没有太多玄学,重点是把ORM的心智模型建立起来。你越清楚每个操作对应什么样的SQL,写出来的代码就越可靠。遇到问题不要急着改代码,先看SQL,再想对象关系,最后动手优化。如果能在建模阶段就把字段类型、关系方向、索引需求想清楚,后面真的能少踩很多坑。Django的ORM设计得已经足够友好,但友好不意味着你可以不懂底层,恰恰相反,懂得底层的人才能真正用好这层封装。