刚开始用Django的人,十有八九是从“照着教程写个单表CRUD”入门的。可真正到了自己上手做项目,我见过不少人反而在单表上栽跟头——要么天天对着数据库敲原生SQL,把ORM当摆设;要么被QuerySet的惰性求值搞得一头雾水,重复查库还不知道慢在哪里;要么一个简单的update操作写出了两步SQL,并发一来就出问题。这篇我把Django ORM的单表实例完整过一遍,从模型定义、增删改查,到filter链式查询、分组聚合和性能细节,该给代码的给代码,该讲原理的讲原理,争取让刚接触ORM的人也能照着写,让已经写过一段时间的人重新审视自己的习惯。
1. 从一张文章表说起:ORM单表操作能替你扛多少事
1.1 为什么单表也要正儿八经用ORM
不少人有个误解:单表那么简单,直接写SQL不就好了?但实际项目里,你的SQL大概率会混杂在业务逻辑里,字符串拼接、条件判断、参数转义全都得自己管,出错的概率并不低。ORM的价值不是省掉SQL,而是把“数据库行”和“Python对象”之间的转换、SQL的生成、参数的绑定、结果集的封装全部标准化,让你只关心业务对象,不关心数据库方言。
Django ORM在单表场景下,能替你扛住的包括:模型到表结构的映射、自增主键的管理、字段值的类型转换、插入/更新语句的自动生成、查询条件的安全拼接、结果集的懒加载与缓存。这些能力如果全部手写SQL,一套下来可能要多写几百行,而且几乎不可能比ORM更稳健。
说句实在话,单表操作恰恰是ORM最能发挥优势的场景,因为你不需要处理复杂的关联关系,所有问题都集中在一张表上,逻辑清晰、边界明确、排查容易。把单表摸透了,后面再碰多表关联,你会发现大部分套路都是单表查询的延伸,不会再有“ORM好难”的隔阂感。
1.2 贯穿全文的article表结构
为了不空谈理论,后面所有代码都用同一个业务模型:内容后台里的文章表。它足够典型,又不会涉及外键,正好契合单表实例这个主题。
from django.db import models class Article(models.Model): STATUS_DRAFT = 0 STATUS_PUBLISHED = 1 STATUS_OFFLINE = 2 STATUS_CHOICES = [ (STATUS_DRAFT, "草稿"), (STATUS_PUBLISHED, "已发布"), (STATUS_OFFLINE, "已下线"), ] title = models.CharField(max_length=200, db_index=True) summary = models.TextField(blank=True) content = models.TextField() status = models.SmallIntegerField(choices=STATUS_CHOICES, default=STATUS_DRAFT) view_count = models.PositiveIntegerField(default=0) published_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "article" ordering = ["-created_at"] def __str__(self): return self.title这张表包含了几类最常见的字段:有长度限制的短文本、不限长度的长文本、状态类的整数、计数类的非负整数、可空时间字段、自动写入的创建和更新时间。后面讲CRUD时,每次操作都会落到这张表上,你能直接看到每条代码最终对应的SQL行为和业务效果。
2. 模型不是写几个字段那么简单:类型、参数与Meta的取舍
2.1 字段类型怎么选:从业务约束倒推
字段类型选择的核心原则是:从业务需求倒推,而不是凭喜好写。比如标题最多200字,就用CharField并指定max_length,数据库里对应的就是varchar(200),这个长度限制既约束了输入,也是在告诉数据库“这一列该怎么存储”。正文可能几千字甚至更长,用TextField,对应MySQL里的longtext,不受长度限制。
状态字段我习惯用SmallIntegerField配合choices,而不是IntegerField或BooleanField。原因很直接:文章状态至少是“草稿/已发布/已下线”三态,BooleanField表达不了三态,IntegerField又有点杀鸡用牛刀,SmallIntegerField在数据库里只占两个字节,后台上限完全够用。至于choices,它不会在数据库层加约束,主要作用是在表单校验和后台、API序列化时给出可选值提示,以及让代码里不出现魔法数字。
# 推荐这样引用状态值,而不是到处写 0/1/2 published = Article.objects.filter(status=Article.STATUS_PUBLISHED)view_count用PositiveIntegerField,天然拒绝负数。published_at允许为空,因为草稿还没发布时间,只有正式发布才会写入这个字段,所以设null=True、blank=True,表示“数据库字段可为空,表单校验时也允许不填”。
2.2 null、blank、db_index这些参数到底管什么
这是单表建模里最容易混淆的一组概念。null是纯数据库层面的,表示这一列在数据库里能不能存NULL;blank是表单层面的,表示表单提交时允不允许为空,它不影响数据库结构。对于CharField和TextField这类字符串字段,我通常只写blank=True而不写null=True,因为需要区分“空字符串”和“NULL”时,业务判断会变得很别扭,过滤条件也要多写一层isnull判断。而DateTimeField这种非字符串字段,要表达“没有值”就必须用NULL,所以null=True、blank=True一起写。
db_index的意思是给这一列建普通索引。索引不是越多越好,写多了一个是占存储,另一个是写入时索引维护有开销。实践中我优先给两类字段加索引:经常出现在filter、order_by里的字段,以及有明显区分度的字段。比如title经常按标题搜索,加db_index;status虽然查询多,但它的区分度太低——大部分行都是已发布状态,数据库即使走了索引也还是要回表读大量行,这类低区分度的单列索引收益有限,需要和其他字段组合使用才更有价值。
unique参数更好理解,它表示该字段的值在表里不能重复,同时也会创建唯一索引。单表业务里如果某个业务编号、别名、标识要求唯一,直接在字段上写unique=True,比在应用层反复先查重再插入要稳妥得多,因为并发下查重必然产生竞态,唯一约束是数据库层最后一道强制防线。
2.3 Meta子句里最常用的三样东西
Meta子句是模型的“整体配置区”,单表实例里最常用的就是db_table、ordering和indexes三个。
- db_table:显式指定表名。不指定时Django会按app名加模型名生成,比如blog_article。显式写db_table的好处是避免app改名或模型改名后表名变化,也方便DBA直接看表名就能定位业务模块。
- ordering:设置默认排序。这里有个我踩过多次的坑——一旦在Meta里写了ordering,后面所有的查询默认都会带上ORDER BY,很多你在测试环境看不出来的问题都是它闹的。比如对一个大表做聚合统计时,默认排序带来的额外排序开销可能非常大。我的建议是:除非业务非常明确“所有查询都按这个顺序返回”,否则不要全局设置ordering,宁可每次查询时显式order_by。这个习惯能帮你避开不少隐性性能问题。
- indexes:定义联合索引。单表场景下,经常一起作为查询条件的字段可以组合成联合索引。比如我的文章表里,status和published_at经常一起出现在筛选条件中,就可以写成下面的形式:
class Meta: db_table = "article" indexes = [ models.Index(fields=["status", "published_at"], name="idx_status_pub"), ]定义完模型,记得执行数据库迁移。这一步其实是把Python类翻译成数据库表结构的落地过程:
python manage.py makemigrations python manage.py migratemakemigrations生成迁移文件,migrate把迁移应用到数据库。改模型字段、加索引、调参数,都要走这两个命令,数据库结构才能和模型保持同步。
3. 增删改查的代码写法与SQL行为:别再只记API不追本质
3.1 创建:create、save与bulk_create的边界
创建单条数据,最直接的写法是:
article = Article.objects.create( title="ORM单表实例", summary="一篇把单表操作讲透的文章", content="正文内容……", status=Article.STATUS_PUBLISHED, )objects.create()的本质是“实例化模型对象 + 调用save()”,它会自动回填自增主键id,也会触发auto_now_add字段写入当前时间。与它等价的手动写法是:
article = Article(title="ORM单表实例", content="正文", status=Article.STATUS_PUBLISHED) article.save()两种写法效果一样,create只是省了一行。但要注意,save()每次都会执行一条INSERT语句,如果循环里创建大量数据,会逐条INSERT,性能很差。这时应该用bulk_create批量插入,比如初始化测试数据、导入旧系统数据时就非常实用:
articles = [ Article(title=f"文章{i}", content=f"正文{i}") for i in range(1000) ] Article.objects.bulk_create(articles, batch_size=500)bulk_create会把多条数据拼成一条INSERT或分批的多条INSERT,大大减少数据库往返次数。代价是它不会触发模型save()方法,也不会自动更新updated_at这类auto_now字段,插入后对象的自增主键也可能不会回填(不同数据库行为不一样,MySQL下通常不回填)。所以生产环境要批量导入并依赖主键做后续操作时,先确认你的数据库行为,或者干脆导入后用id字段重新查一遍。
3.2 读取:get与filter的各自脾气
读取单条记录,最常用的方法是get():
try: article = Article.objects.get(id=1) except Article.DoesNotExist: print("文章不存在") except Article.MultipleObjectsReturned: print("存在多条记录,查询结果不唯一")get()的脾气是:查不到抛DoesNotExist,查到多条抛MultipleObjectsReturned。这两个异常在真实项目里都容易碰到,前者常见于URL传来的id被删除,后者常见于查询条件没有唯一性约束。所以用get的时候,务必确认查询字段在业务上一定唯一,比如主键、unique字段。否则就用filter兜底:
articles = Article.objects.filter(title="ORM单表实例")filter返回的是QuerySet,不是一个对象,哪怕结果只有一条也还是QuerySet。这样设计的意图是让查询条件可以继续往下挂,比如filter之后再filter、order_by、切片。QuerySet不会因为结果是一条就退化成单对象,想取第一条就用.first():
article = Article.objects.filter(title="ORM单表实例").first().first()查不到返回None,不抛异常,在某些场景下比get()更好用,但也更容易让你忽略“为什么会有多条”这个业务问题。
3.3 更新:实例save与QuerySet update是两条路
更新是单表操作里坑最多的地方。先看最常见的写法:
article = Article.objects.get(id=1) article.title = "新的标题" article.save()这段代码对应了三条SQL:SELECT查出对象、UPDATE更新整行数据、再隐式的状态同步。问题在于save()会把所有字段都更新一遍,哪怕只改了title一个字段,也会把content、summary、view_count全部重写,字段多时不仅浪费,还有可能因为并发把别人刚改的内容覆盖掉。
如果没有业务逻辑需要加载对象,更推荐直接用QuerySet的update():
Article.objects.filter(id=1).update(title="新的标题")update()直接翻译成一条UPDATE语句,不会先查一次,也不会把整行所有字段重写一遍,高效且简洁。它返回受影响的行数,0表示没有匹配到数据。
更典型的一个场景是浏览量自增。很多人会写成“读出view_count,加1,再save”,这在并发下一定会丢更新。正确做法是用F表达式,把计算下推到数据库端执行:
from django.db.models import F Article.objects.filter(id=1).update(view_count=F("view_count") + 1)F("view_count") + 1在数据库里执行的是view_count = view_count + 1,这个过程没有“读出来再写回去”的间隔,所以并发安全。用F更新过的对象,如果后续还要读取最新值,记得重新refresh_from_db(),否则对象里的view_count还是旧值。
3.4 删除:批量删除与单条删除的差别
删单条很简单:
article = Article.objects.get(id=1) article.delete()delete()会返回一个元组,(受影响总行数, {每个模型的删除明细}),单表场景下通常用不到第二个返回值。批量删除用QuerySet:
Article.objects.filter(status=Article.STATUS_OFFLINE).delete()如果一张表有大量数据要清空,比如历史日志表,用filter().delete()还是会逐行触发数据库的删除操作。某些数据库下,倒不如用objects.all().delete()配合事务或者直接走数据库的TRUNCATE,后者在超大表场景下快几个数量级。不过Django默认不提供truncate的ORM方法,真碰上亿级表清理,手动执行SQL并不是丢人的事,ORM也要在合适的场景下让位。
4. 查询组合是单表实战的重头戏:filter链式、Q、聚合与分页
4.1 链式filter与exclude:AND逻辑和NULL陷阱
QuerySet最有价值的地方就是链式查询能力。多个filter连写,最终生成的SQL条件是AND关系:
published_recent = Article.objects.filter(status=Article.STATUS_PUBLISHED).filter(published_at__gte="2024-01-01")这两次filter拼起来的条件是status=1 AND published_at >= '2024-01-01',数据库只执行一次查询,不是查两次再合并。
exclude则负责排除条件,注意它也有个经典陷阱。比如你想排除掉没有摘要的文章:
# 注意:这样写不会排除掉 summary 为 NULL 的记录 Article.objects.exclude(summary="")因为我的模型里summary是TextField,写了blank=True但没写null=True,所以理论上不存在NULL问题。但如果你把字段定义成null=True,那exclude(summary="")只会排除掉空字符串的记录,summary IS NULL的记录依然会出现在结果里,因为它们不符合“等于空字符串”这个条件。遇到可空字段时,想彻底排除空值,需要把isnull条件也挂上:
Article.objects.exclude(Q(summary="") | Q(summary__isnull=True))这类NULL语义问题,恰恰是单表查询里最容易产生“本地正常、线上漏数据”的原因之一。
4.2 Q对象组合复杂条件
filter链式写法的条件是天然AND,但如果要表达OR、NOT,或者“A且(B或C)”这类复合条件,就必须用Q对象:
from django.db.models import Q # 已发布 或 已下线 q_visible = Q(status=Article.STATUS_PUBLISHED) | Q(status=Article.STATUS_OFFLINE) # 浏览量大 且 (标题包含某个词 或 摘要包含某个词) q_hot = Q(view_count__gte=1000) & (Q(title__icontains="Django") | Q(summary__icontains="Django")) result = Article.objects.filter(q_visible & q_hot)Q对象之间用&、|、~操作符组合,和Python里的位运算符长得一样但语义完全不同,这里表示的是SQL里的AND、OR、NOT。组合起来后filter可以只传一个Q对象,避免写出一长串filter(filter(filter()))的嵌套。
一个实用的组织方式:把复杂筛选逻辑拆成小Q变量,再在视图里组合。这样单表查询的条件即使再复杂,代码也能保持清晰,更重要的是每个Q都能单独复用,不会出现同一个筛选条件散落在多个视图里的情况。
4.3 排序、切片与深翻页处理
排序直接order_by,字段名加负号表示倒序:
articles = Article.objects.filter(status=Article.STATUS_PUBLISHED).order_by("-published_at", "-id")多个排序字段按从左到右的优先级生效。这里有个小建议:排序字段里最好带一个唯一性字段,比如id,否则两条记录排序值相同时,分页可能出现数据重复或跳变。
切片是QuerySet里最常用的分页手段:
page_size = 20 articles = Article.objects.filter(status=Article.STATUS_PUBLISHED).order_by("-published_at")[:page_size]这个切片在数据库层生成的SQL是LIMIT 20,不会把所有数据全查出来。但要注意一个深翻页问题:page=1000时,如果用常规的OFFSET方式(Django默认的切片分页就是OFFSET),数据库需要先跳过前面19980行,再取20行,数据量一大性能急剧下降。对后台管理这类翻页深的场景,我一般建议改用键集分页:
last_id = 19980 # 上一页最后一条记录的id articles = Article.objects.filter(status=Article.STATUS_PUBLISHED, id__lt=last_id).order_by("-id")[:20]用id做游标,每次查的都是“上一页之后的新数据”,数据库可以直接走主键索引,翻到多深都是稳定的。缺点是页码跳转不方便,但大多数后台和列表页根本不需要随便跳转到第500页。
4.4 values/values_list与分组聚合
有时候你查数据只是为了展示几个字段,并不需要完整的模型对象。这时用values或values_list可以减少对象构造的开销:
# 返回字典列表:[{"title": "...", "view_count": 100}, ...] rows = Article.objects.filter(status=Article.STATUS_PUBLISHED).values("title", "view_count") # 返回元组列表,flat=True时单字段返回普通值列表 titles = Article.objects.order_by("-view_count").values_list("title", flat=True)在列表页只需要几十个字段的场景里,values可以显著减少内存占用和ORM对象构造的时间。但代价是取回来的数据没有模型方法、没有属性访问的便捷性,适合只做只读展示的接口。
单表统计是聚合函数的强项。aggregate做全表聚合,返回字典;annotate配合values做分组统计,返回QuerySet:
from django.db.models import Count, Sum, Avg, Max, Min # 全表统计:一共有多少篇文章、平均浏览量 stats = Article.objects.aggregate( total=Count("id"), avg_views=Avg("view_count"), max_views=Max("view_count"), ) # 输出:{"total": 5000, "avg_views": 234.5, "max_views": 100000} # 按状态分组:每种状态的文章数量和总浏览量 group_stats = ( Article.objects .values("status") .annotate(total=Count("id"), sum_views=Sum("view_count")) .order_by("status") )需要注意顺序:values(“status”)先指定分组依据,annotate再算每个分组的统计量,order_by("status")保证输出稳定。如果只想统计全部数据而不分组,用aggregate,它不会生成GROUP BY;一旦出现annotate,Django就会生成GROUP BY,语义完全不同,写错了性能差距会非常大。
5. 单表场景下最容易翻车的四个细节
5.1 惰性求值与QuerySet缓存的双面性
QuerySet是惰性的,这意味着filter、exclude、order_by这些操作都只是“搭建查询计划”,不会真正访问数据库。只有迭代、切片、bool()、len()、list()、pickle等操作才会触发SQL执行。很多人以为“我filter了两下,就查了两次库”,其实两条filter根本不会碰数据库,只有最后取值时才执行一次查询。
这个特性也带来了缓存行为。同一个QuerySet一旦求值,它会把结果缓存起来,后续再迭代它不会重复执行SQL:
qs = Article.objects.filter(status=Article.STATUS_PUBLISHED) # 第一次迭代:执行SQL for obj in qs: pass # 第二次迭代:直接使用缓存结果,不访问数据库 for obj in qs: pass这在大多数场景下是好事,但有个隐蔽雷区:如果两次迭代之间数据库数据变了,第二次拿到的还是旧缓存。要是业务要求每次渲染都拿最新数据,就不能复用同一个已求值的QuerySet,而是要重新构造条件。另外,对已缓存的QuerySet做切片、相加等操作会产生新的QuerySet,新对象会重新执行SQL,缓存并不会被新切片继承。
5.2 时间字段与时区:差8小时的问题哪来的
Django项目里只要开了USE_TZ=True(新项目默认开),DateTimeField里存的时间一律是UTC,而不是你的本地时间。比如published_at写入“2025-05-01 12:00”,数据库里实际存的是“2025-05-01 04:00”(如果配置的是东八区)。查询时如果用系统本地时间直接比较,就会整整齐齐差8小时。
from django.utils import timezone # 错误的示范:用 datetime.now() 做比较 Article.objects.filter(published_at__lte=datetime.now()) # 正确的示范:用 Django 的 timezone.now() Article.objects.filter(published_at__lte=timezone.now())timezone.now()在USE_TZ=True时返回带时区的UTC时间,和数据库存储的一致。前端展示时再由模板或序列化器转换到本地时区。这个坑我至少见过三次,全是后台过滤“已发布且已到发布时间”的文章时,发现本该出现的文章迟迟不出现。排查方式也很简单:看一眼Django调试面板里这条查询的SQL和传入的参数,timezone不对一眼就能看出来。
5.3 文本模糊查询在大表上的性能陷阱
__icontains和__contains用起来很顺手,但它们翻译成SQL就是LIKE '%关键词%'。这种写法最要命的是前导通配符,数据库的普通B+树索引完全用不上,只能老老实实全表扫描。几千行的表无所谓,几十万行的表往来几次,数据库CPU就能被拖垮。
我遇到过真实案例:按标题搜索文章,条件写title__icontains="Django",用户每次搜索都全表扫一遍。后来改成title__startswith="Django",索引立刻生效,查询耗时从秒级降到毫秒级。但业务上并不是每次都能从前缀搜,所以这类模糊搜索的正确出路是:数据量小随意;数据量大就上数据库全文索引,或者接独立的搜索服务,而不是死磕ORM单表查询。
另外,__icontains与数据库的collation(排序规则)还有关系。MySQL默认排序规则下,字符串比较对大小写不敏感,用了__contains可能就已经不区分大小写了,再叠加__icontains也没有额外作用,不用白费心思考虑哪一层在生效。
5.4 调试ORM到底发了什么SQL
最后分享一个我每次排查ORM问题几乎必用的动作:看一眼QuerySet到底生成什么SQL。很多人调ORM靠猜,猜不准就乱改。其实Django给了一条很朴素的调试路径,不需要额外引第三方工具:
from django.db import connection qs = Article.objects.filter(status=Article.STATUS_PUBLISHED).order_by("-published_at") print(qs.query)query属性会把当前QuerySet对应的SQL语句原样打印出来,包括条件、排序、切片信息。想要看执行时真实传入的参数值,用str(qs.query)可能不含参数,更准确的做法是在DEBUG模式下查看connection.queries:
from django.db import connection Article.objects.filter(status=Article.STATUS_PUBLISHED).count() print(connection.queries[-1])connection.queries记录了当前连接执行的所有SQL,取最后一条就能看到完整的SQL文本和耗时。我把这个习惯当成底线——凡是怀疑ORM行为不对,先看SQL,再改代码,比拍脑袋快得多。
写在最后的小经验
单表ORM看起来简单,但真正写得好的人,都是能随时说清楚“这一行代码背后数据库做了什么”的。我的个人体会是:不要停留在API层面背方法名,多从SQL行为和缓存机制的角度理解ORM,很多看似玄学的性能问题、数据一致性问题都会迎刃而解。
如果以后再遇到需要批量插入、批量更新、复杂筛选条件组合、分组统计的场景,回过头翻一翻这篇的单表实例,应该能找到对应的正确写法。把单表玩明白之后,再去碰外键、多对多、跨表聚合,你会发现自己对QuerySet的理解已经能直接迁移过去,多表关联只是单表查询的延伸而已。