Django单表查询详解:从基础QuerySet到性能优化
写Django的查询绕不开ORM的说话方式。单表查询几乎是每个项目的第一课也是大多数人觉得“会用”但一深究就露怯的部分。标题上的“Django中单表基础查询”看起来简单但实际上这里面的门道远不只是凑出几个filter调用那么简单。我见过不少项目前期单表查询写得很随意等数据量一上来几条链式调用就把数据库压垮了回头排查才知道是当初对QuerySet的理解不够。这篇东西不打算按官方文档的顺序给你复述一遍而是从实际写代码的角度把单表查询里最常用、最容易出错、最值得深挖的地方拆开讲清楚。1. 先搞清楚ORM到底替我们做了什么1.1 一张表和一个模型类之间的映射关系很多新手写查询的时候脑子里是把Django模型当成一张表的“替身”在用这种理解方向没有错但不够细。模型类里定义的每一个字段其实不只是数据库表里的一个列它还携带了类型约束、默认值、索引信息、关联关系等一堆元数据。你在代码里写User.objects.filter(status1)的时候Django做的事情远不止翻译成一句SQL。它会先检查模型定义里是否存在status这个字段接着把Python层面的值1转换成数据库对应的类型再对照字段的类型决定怎么生成SQL条件。如果你定义的是CharField那么filter(status1)会把1转成字符串再匹配如果是IntegerField那就直接用数值。这个过程中的类型转换和校验既是方便也是约束——方便在于你不用手写转换约束在于一旦类型不对查询结果可能和你的预期完全不同。实际项目里我建议你把模型定义当成数据库设计的“第一份文档”来对待。字段类型、db_index、null、blank这些参数都直接影响后续所有查询语句的写法。比如一个明明需要经常按条件筛选的字段建表的时候没加索引等到数据量上去了每次查询都全表扫描这时候再回头补索引代价比一开始就设计好要大得多。我在一个后台管理项目里就吃过这种亏一个订单表的status字段一直没加索引运营后台按状态筛选订单时几百万行的表查询要好几秒最后通过迁移文件补上了db_indexTrue查询时间才降下来。1.2 查询集QuerySet的惰性特性惰性求值这句话看文档的人基本都见过但真正体感过的人并不多。QuerySet的惰性意味着你写qs Order.objects.filter(status2)这条语句的时候数据库一条SQL都不会执行。真正的查询发生在你开始迭代这个QuerySet、对它做切片、调用list()、或者访问它的某个元素的时候。这个特性用得好是性能利器用得不好就是坑。举一个我印象很深的例子有次我在一个视图函数里这样写orders Order.objects.filter(userrequest.user) if orders: print(orders.count()) total len(orders)看起来只是随手写了三行代码但实际执行的SQL远不止一条。if orders会触发一次查询判断是否存在orders.count()是一条COUNT语句len(orders)又会把整表数据加载进内存再求长度。三次访问三次数据库往返。更隐蔽的是如果中间有一次对orders做了进一步过滤那么前面已经“部分求值”的QuerySet可能会被缓存导致后面的结果不是最新的。我后来给自己立了一条规矩凡是需要多次使用同一个QuerySet的场景要么立刻评估成列表保存要么就在同一个链式调用里完成所有过滤再求值。千万不要在一个视图里把同一个QuerySet翻来覆去地访问既不优雅也不高效。1.3 常用字段类型对应的SQL映射速览Django的字段类型和数据库类型之间的关系我整理了一张经常用的对应表理解这个对应关系比死记SQL语句有用得多。Django字段常见数据库对应类型要点AutoFieldint/bigint自增主键ORM自动维护CharFieldvarchar必须指定max_lengthTextFieldtext长文本不能加索引IntegerFieldint常规整数注意范围FloatFieldfloat浮点注意精度问题DecimalFielddecimal金额等精确数值用这个BooleanFieldtinyint/bool用法简单但注意默认值DateTimeFielddatetime查询时区容易踩坑DateFielddate只存日期ForeignKeyint存关联表主键默认带索引这些对应关系决定了你在写查询条件时Python传值的类型应该是什么。比如用DateTimeField做范围查询时传入字符串日期经常出错Django会要求你传入datetime对象或者使用__date这种查询表达式。理解了映射关系很多报错信息就不会让你一头雾水了。2. 单表查询的基本姿势2.1 全量查询和单条查询谁更常用全量查询的写法再简单不过Model.objects.all()返回的是所有记录组成的QuerySet但真正到业务代码里直接拿all()去用的场景反而很少。因为所谓的“全量”经常只是后续过滤的起点。单条查询则有讲究了。get()方法用于获取唯一的一条记录但它的行为比很多人以为的要严格得多。如果查询结果有多条它直接抛出MultipleObjectsReturned异常如果一条都没有抛出DoesNotExist。这两个异常都需要你显式处理否则视图层就直接500了。我在项目中实际更常用的是filter().first()或者filter().last()。它们返回的是对象或者None不需要处理异常更贴近“取一条记录”的业务语义。用first()的时候它默认会按主键升序取第一条如果你希望取最新的一条得配合order_by(-id)使用。这里有个容易忽视的细节first()和last()在内部其实都做了LIMIT 1所以性能上没有差别差别只在于排序方向是否明确。提示不要指望get()在“取不到数据”时返回None它一定会抛异常。如果你不希望写try...except那就用filter()配合first()。2.2 过滤条件的通用写法与查询表达式过滤条件最基础的形态是fieldvalue但真正让ORM查询灵活起来的是双下划线查询表达式。Model.objects.filter(status__gt2)、filter(create_time__date2024-01-01)、filter(title__icontainsdjango)这些都是用双下划线把字段名和查询类型连接起来的语法。我按实际使用频率给你列一下最常用的查询类型__exact精确匹配默认行为可以不写__iexact不区分大小写的精确匹配__contains/__icontains模糊匹配后者不区分大小写__in匹配列表中的任意值注意空列表会返回空集__gt/__gte大于 / 大于等于__lt/__lte小于 / 小于等于__range范围查询常用于日期区间__isnull判断是否为NULL__startswith/__endswith前缀 / 后缀匹配__date/__year/__month/__day日期部分提取匹配用这些表达式时有个原则能用ORM表达式做筛选的就不要先取出所有数据再在Python里过滤。我见过有人为了省事把表里所有记录load到内存里然后用列表推导式筛选状态值。数据量小的时候看不出问题等表里有几十万行的时候这种写法会让服务器内存直接爆掉。2.3 排序、切片与去重的组合技巧order_by(field)按升序排order_by(-field)按降序排多个字段排序时用逗号分隔。由于SQL执行顺序的原因order_by在过滤条件之后、分页之前生效。如果你要按多个字段排序比如先按状态升序、再按创建时间降序写法是order_by(status, -create_time)。切片操作用来限制返回记录数相当于SQL里的LIMIT。QuerySet[0:10]取前十条QuerySet[-5:]取最后五条。但注意切片不能传负数步长比如QuerySet[::-1]这种写法是不支持的。distinct()用来去重在单表查询里有时会和values()配合使用。比如你想知道表里一共有几种不同的状态值可以写成Model.objects.values_list(status, flatTrue).distinct()。这里values_list返回的是字段值组成的列表flatTrue表示直接把值平铺出来而不是返回元组。我在整理统计数据时经常会遇到去重需求这里分享一个经验单表内去重distinct()通常好用但是如果查询里带了order_byDjango会按照排序字段一并做SELECT这时候distinct可能不会按照你预想的方式去重。如果发现去重结果不对优先检查order_by是不是包含了未被values选中的字段。3. 聚合、分组与统计3.1 聚合函数的几种用法Django的聚合函数放在django.db.models里常用的有Count、Sum、Avg、Max、Min。聚合函数本身不会单独使用而是配合aggregate()或annotate()发挥作用。aggregate()返回的是一个字典它的作用是对整个查询集做一次汇总结果是单行的统计值。比如统计订单总金额from django.db.models import Sum result Order.objects.aggregate(total_amountSum(amount))这里的result形如{total_amount: Decimal(1234.50)}键名是你在aggregate里指定的那个别名。Sum遇到空表会返回None而不是0这是一个很容易被忽略的细节。如果你需要展示0最好在拿到结果后做一次空值处理。annotate()则是给QuerySet里的每一行增加一个统计字段它不改变返回的行数而是“按行附加信息”。比如给用户表附加每个用户的订单数量from django.db.models import Count users User.objects.annotate(order_countCount(order))这里的order_count就是每个用户对应的订单数量你可以像访问普通字段一样访问它user.order_count。注意annotate在有分组需求时的行为会造成行数变化如果你关联查询的是多行子表Count的行数会被放大这时候要用Count(order, distinctTrue)来避免重复计数。3.2 分组统计的实战经验annotate和values组合在一起就实现了SQL里GROUP BY的效果。标准写法是先用values(field)指定分组字段再用annotate附加统计值。举个例子按订单状态统计数量from django.db.models import Count status_counts ( Order.objects .values(status) .annotate(totalCount(id)) .order_by(status) )这段代码生成的SQL大致等价于SELECT status, COUNT(id) AS total FROM order GROUP BY status ORDER BY status。关键点在于values(status)必须在annotate之前。一旦调整顺序查询行为的差异会很大我在帮别人排查问题时发现许多人把annotate写在values前面结果统计维度完全不对。分组后过滤用filter还是having在SQL里HAVING用于对分组后的结果做条件过滤Django用annotate之后接着写filter即可实现这个效果。比如只统计订单数大于5的用户from django.db.models import Count active_users ( User.objects .annotate(order_countCount(order)) .filter(order_count__gt5) )这个filter就会被Django翻译成HAVING而不是WHERE因为它的条件是建立在聚合结果之上的。注意聚合函数加distinctTrue并不总是能解决问题当分组行本身来自多表连接时需要先厘清是哪一层产生了重复行再决定在哪一层去重。4. 不常见但很实用的查询技巧4.1 F表达式和Q表达式救了不少场景先说F表达式。它用来引用数据库中的字段本身而不是Python变量。一个经典场景是商品库存扣减from django.db.models import F Product.objects.filter(id1).update(stockF(stock) - 1)如果不使用F你得先把商品的stock查出来减一后再保存回去。这中间有一个“先查后改”的时间窗口在并发场景下极容易产生超卖问题。F表达式的优势在于它把“读取旧值并计算”这个操作直接下推到数据库端执行从根源上消除了并发冲突。再说Q表达式。当一个查询条件里同时存在与和或的逻辑时filter里传多个关键字参数是AND但想要表达OR就必须用Q。例如想要查标题包含“Django”或作者名字叫“张三”的文章from django.db.models import Q articles Article.objects.filter( Q(title__icontainsDjango) | Q(author__name张三) )Q对象可以用|表示或、用表示与、用~表示非自由度非常高。复杂的筛选逻辑建议先用Q对象拼装最后再一次性传给filter可读性远好于多次调用filter或exclude的组合。我写复杂筛选条件时通常先把各个条件封装成独立的Q对象再到最后一行组合q1 Q(status1) q2 Q(create_time__date__gtestart_date) q3 Q(create_time__date__lteend_date) qs Model.objects.filter(q1 q2 q3)这样做的好处是后续加需求时只需要改动对应的小对象不会扰动主体查询链。4.2 查询性能排查与N1问题单表查询本身不涉及关联但如果你在查询之后逐个访问关联字段N1问题照样会出现。比如你在一个页面里展示用户列表然后循环里访问每个用户的订单数量即使你的“单表查询”写出了花也架不住循环里每次都发一条SQL。一个典型的反面例子users User.objects.all() for user in users: orders_count user.order_set.count()这段代码会为每一个用户生成一条额外的查询语句。用户有500个就是500次查询。解决办法是用annotate一次性把统计值带出来from django.db.models import Count users User.objects.annotate(order_countCount(order)) for user in users: print(user.order_count)这时候请求数据库的次数从501次降到了1次。排查这类问题最简单的办法是打开Django的SQL日志把所有查询语句打印出来直接数一数一次页面渲染发了几条SQL。如果SQL数量和列表行数有明显的线性关系那么N1基本可以坐实了。性能排查时explain也是一个值得养成的习惯。QuerySet有explain()方法能输出数据库的查询计划。我在排查慢查询时经常这样用qs Order.objects.filter(status3).order_by(-create_time) print(qs.explain())从输出里能看到是否走了索引、预估扫描行数是多少。这一步操作成本很低但对判断“查询慢是不是因为缺索引”非常有效。5. 常见问题与排查实录5.1 查询结果为空时的处理策略filter查询结果为空不会报错只会返回一个空的QuerySet。但很多人会在空QuerySet上调用first()拿到None后直接访问属性这时候会抛出AttributeError。处理办法是显式判断obj Model.objects.filter(id1).first() if obj: print(obj.name) else: print(没找到)5.2 惰性求值导致的时间点坑如果一个QuerySet在请求开始的时候被创建但一直到请求快要结束的时候才真正执行SQL期间数据可能已经发生了变化。在统计类操作里这种“查询时间点漂移”偶尔会造成结果不一致。我的习惯是如果一段代码里需要基于同一份数据做多次判断就在第一次访问时把QuerySet转化成列表后续操作都基于这个固定快照。5.3 时区问题DateTimeField配合USE_TZ True时Django会把所有时间都按UTC存储。如果你在代码里用本地时间字符串直接过滤日期范围可能出现查不到数据的情况。处理办法是先用timezone模块把本地时间转成UTC或者用make_aware确保传入的是带时区的时间对象。5.4 字段名拼写错误Django在filter里写错字段名会在启动时报FieldError这比SQL里的静默错误要好处理得多。但有一种隐蔽情况你在filter里写了关联表的字段比如filter(author__name张三)如果author这个关联名在模型里不存在Django也会抛出FieldError。这种报错信息会明确告诉你无效的字段名顺着提示改就好不用慌。我把实际开发中经常踩到的查询问题整理成了一张速查表现象可能原因排查方向查不到数据时区问题 / 类型不匹配 / 条件写错打印最终SQL检查传入值的类型查询特别慢缺索引 / 查询条件在函数内explain()看执行计划结果重复多表关联后产生的重复行给Count加distinctTrue内存占用高把所有行load进内存再过滤改用ORM过滤条件某字段一直为Noneget()返回空对象 / 聚合空表确认查询结果做空值兜底数据量翻倍annotate与多行子表关联检查是否需要distinctTrue6. 操作过程中的额外心得最后分享三个我自己的小习惯不一定会在官方教程里出现但实际用下来确实能少踩很多坑。第一写完查询之后打开Django的shell_plus用print(queryset.query)看一眼生成的SQL。很多时候你以为自己写的条件是对的但翻译成SQL后才知道问题出在哪。这个方法对调试任何可疑查询都有效成本几乎为零。第二养成给查询链加注释的习惯。单表查询写多了以后链式调用的方法会越来越长如果没有注释说明这一段查询的业务目的两个星期后回来看代码你可能自己都忘了为什么这么写。我一般会在QuerySet对象赋值那行上方写一行注释说明这是“查询某个时间段内有效状态的订单”。第三对复杂查询保持“拆分优先”的心态。如果一个查询条件超过三个我会先考虑用Q对象组装或者在模型管理器Manager里定义可复用的方法。这样主查询保持干净后续维护时也只需要改动管理器的内部逻辑。单表查询虽然叫“基础”但它真正考验的是你对ORM机制的理解程度。把这些看似基础的知识点想明白写出来的代码不仅更稳性能也会好很多。