资讯详情

资讯详情

Django多对多关系深度解析:从自动中间表到through自定义实践

1. 从一次选课系统的建模翻车说起多对多关系大概是Django ORM里最让人又爱又恨的一块。爱的是它足够方便一个ManyToManyField声明下去中间表自动生成正向反向查询都能跑恨的是一旦业务稍微复杂一点比如需要记录选课时间成绩是否退课这类附加信息很多人第一反应还是硬塞一个ManyToManyField结果写到一半发现字段没地方放只能推倒重来。我在早期做一个课程管理模块时就踩过这个坑。当时的需求很朴素一个学生可以选多门课一门课可以被多个学生选。我顺手写了students models.ManyToManyField(Student)跑得挺顺。过了两周产品加需求——要记录每个学生选这门课的时间还要记录期末成绩。我盯着那个自动生成的中间表发呆因为它根本不允许我加字段。最后只能删掉字段、重建模型、写数据迁移脚本折腾了大半天。这篇内容就围绕Django 2.2这个版本把多对多关系从能用讲到用对。我会拆开讲清楚Django在背后到底建了什么表、through参数什么时候必须上、正向反向查询怎么写才不绕、add/remove/set/clear这些方法各自的行为边界在哪、以及批量操作时怎么避免N1查询。适合已经写过基础Django模型、但在多对多上还经常犯迷糊的朋友。如果你正在做选课、标签、关注、权限这类典型的多对多场景这篇应该能帮你少走点弯路。2. Django在数据库里到底替你建了什么2.1 自动中间表的真实结构很多人用多对多用了很久却从没打开过数据库看一眼中间表长什么样。这其实挺危险的因为不理解底层结构后面遇到性能问题或者迁移冲突时就会完全懵。假设我们有两个模型class Student(models.Model): name models.CharField(max_length50) class Course(models.Model): title models.CharField(max_length100) students models.ManyToManyField(Student)Django在makemigrations之后除了建student和course两张表还会额外建一张名为course_students的表。命名规则是源模型名_字段名注意这里用的是定义字段的那个模型作为前缀而不是被指向的模型。这一点很容易记反尤其是反向查询的时候。这张中间表的结构非常精简只有三个字段字段名类型说明idAutoField自增主键course_idForeignKey指向Course带索引student_idForeignKey指向Student带索引注意它没有联合唯一约束之外的额外字段也没有时间戳。Django还会在这两个外键上建立联合唯一索引保证同一对(course, student)不会重复插入。这个约束是数据库层面的不是ORM层面的所以哪怕你绕过ORM直接写SQL重复数据也插不进去。理解这一点很关键自动中间表是一个纯粹的关系表它只表达谁和谁有关系不表达这个关系有什么属性。一旦你需要属性就必须自己接管中间表。2.2 为什么默认不给你加字段有人会问Django为什么不干脆在中间表里预留几个通用字段比如created_at、extra之类的答案其实很务实绝大多数多对多关系确实不需要附加信息预留字段只会让表变宽、索引变重、语义变模糊。Django的设计哲学是默认简单需要时再复杂所以它把选择权交给你。这个取舍在Django 2.2里体现得很明显。2.2是一个LTS版本官方对它的定位是稳定、长期维护所以这一块API从1.x到2.2基本没怎么变你在这版学到的写法迁移到更高版本也基本通用。这也是我建议新手从2.2入手理解多对多的原因——它足够经典没有被后续版本的异步特性、db_comment之类的新东西干扰。2.3 中间表的索引策略再补一个容易被忽略的细节。自动中间表上course_id和student_id各自都有单列索引加上联合唯一索引实际上索引数量是三个。这意味着按课程查学生走course_id索引快。按学生查课程走student_id索引快。判断某对关系是否存在走联合唯一索引快。但如果你在中间表上加了大量自定义字段又频繁按这些字段过滤就要考虑补索引了。我见过一个项目在中间表上加了status字段然后天天filter(statusactive)结果全表扫描慢得离谱。加个索引就解决了但前提是你得知道中间表长什么样。3. through参数什么时候必须自己接管中间表3.1 一个判断标准到底该用自动中间表还是自定义中间表我给一个特别简单的判断标准只要这个关系本身需要被单独查询、单独更新、或者携带任何业务字段就必须用through。回到开头的选课场景。需求是记录选课时间和成绩那么选课这个动作本身就是一个实体它有属性。这时候正确做法是class Student(models.Model): name models.CharField(max_length50) class Course(models.Model): title models.CharField(max_length100) students models.ManyToManyField( Student, throughEnrollment, related_namecourses ) class Enrollment(models.Model): student models.ForeignKey(Student, on_deletemodels.CASCADE) course models.ForeignKey(Course, on_deletemodels.CASCADE) enrolled_at models.DateTimeField(auto_now_addTrue) score models.DecimalField(max_digits5, decimal_places2, nullTrue, blankTrue) class Meta: unique_together (student, course)这里有几个点值得展开说。第一throughEnrollment告诉Django别自动建表了用我指定的这个模型当中间表。注意through接收的是模型名字符串不是模型类本身这样能避免模型定义顺序导致的循环引用问题。第二Enrollment里的两个外键是必须的Django靠它们识别关系的两端。字段名可以随便起但类型必须是ForeignKey指向关系两端的模型。第三unique_together要自己加。用了through之后Django不再自动帮你建联合唯一约束你得手动补上否则同一个学生选同一门课可以插多条记录。这是很多人从自动表切到自定义表时最容易漏的一步。3.2 through带来的行为变化用了through之后add()、remove()、set()、clear()这些便捷方法会部分失效。具体来说add()在Django 2.2里如果through模型除了两个外键之外还有其他必填字段add()会直接报错因为它不知道怎么填那些字段。如果中间模型只有两个外键没有额外必填字段add()还能用但不会触发中间模型的save()方法也就不会走你自定义的逻辑。remove()同样受限Django 2.2对through模型的remove()支持很有限。clear()直接不可用。set()不可用。所以一旦用了through创建关系就得老老实实写Enrollment.objects.create(students, coursec, scoreNone)删除关系就是Enrollment.objects.filter(students, coursec).delete()这看起来麻烦但换来的是完全的控制权。你可以在这两个操作里加业务逻辑比如选课时检查人数上限、退课时记录日志。自动中间表给不了你这些。3.3 迁移时的坑从自动中间表迁移到自定义中间表是另一个高频翻车点。假设你原来有students models.ManyToManyField(Student)现在想改成through直接改代码然后makemigrationsDjango会生成一堆删除和新建操作数据大概率丢失。稳妥的做法分三步走先新建Enrollment模型但暂时不改ManyToManyField跑一次迁移让新表建出来。写一个数据迁移脚本把自动中间表course_students里的数据搬到Enrollment里同时补上默认的enrolled_at。再修改ManyToManyField加上throughEnrollment跑第二次迁移。这个过程我在两个项目里都做过慢是慢了点但数据一条没丢。如果你数据量不大也可以直接导出成JSON再导入但数据迁移脚本更可控尤其是生产环境。4. 正向反向查询别被related_name绕晕4.1 正向查询的两种写法正向就是从定义字段的模型出发。Course上定义了students字段所以从Course查Student是正向course Course.objects.get(id1) # 写法一直接当管理器用 students course.students.all() # 写法二走查询集 students Student.objects.filter(course__id1)写法一更直观适合已经拿到单个对象的情况。写法二适合在查询集上做链式过滤比如找出所有选了标题含Python课程的学生Student.objects.filter(course__title__containsPython)注意这里的course是Django根据模型名自动生成的反向查询名小写模型名不是字段名。这个区别很多人搞混。字段名是students但反向查询用的是course。4.2 反向查询与related_name反向就是从Student查Course。默认情况下Django用小写的源模型名作为反向查询名所以是student.course_setstudent Student.objects.get(id1) courses student.course_set.all()_set这个后缀是Django的约定表示这是一个反向关系集合。但course_set这个名字在很多场景下语义不清比如一个学生查自己的课程叫course_set就很别扭。这时候用related_name改名class Course(models.Model): students models.ManyToManyField( Student, related_nameenrolled_courses )改完之后student.enrolled_courses.all()语义立刻清晰了。我的经验是只要反向查询会在业务代码里频繁出现就一定要设related_name。不设的话半年后回来看xxx_set你自己都想不起来这是啥。还有一个细节如果设置了related_name原来的course_set就不能用了Django会直接报错。所以改名要趁早别等代码写了一大堆再改。4.3 用through时的查询路径用了through之后查询多了一层。想拿到某个学生的所有选课记录含成绩得走中间模型enrollments Enrollment.objects.filter(studentstudent).select_related(course) for e in enrollments: print(e.course.title, e.score)想直接拿课程列表可以courses Course.objects.filter(enrollment__studentstudent)注意这里的enrollment是中间模型的小写名。如果你给中间模型的外键设了related_name这里就要用related_name。我一般会给中间模型的两个外键都设related_name比如student外键设related_nameenrollments这样student.enrollments.all()就能直接拿到所有选课记录比enrollment_set顺眼多了。4.4 一个容易踩的查询陷阱有个坑我踩过不止一次在filter()里跨多对多关系做条件时如果条件涉及多个关系可能产生重复结果。比如Course.objects.filter(students__name张三, students__name李四)这个查询的语义是存在一个学生叫张三且存在一个学生叫李四但这两个学生可以是同一个人吗不是Django会生成两个JOIN要求同时满足。但如果写成Course.objects.filter(students__name__in[张三, 李四])语义就变成存在至少一个学生名字是张三或李四结果可能只匹配到一个学生。这两个查询结果完全不同用的时候一定要想清楚业务语义。需要精确匹配多个关系时往往要配合annotate和Count来做。5. add、remove、set、clear的行为边界5.1 四个方法的语义对比这四个方法在自动中间表下都能用但行为差异不小我整理成一张表方法作用是否触发中间模型save是否去重适用场景add添加关系否是单个或批量添加remove删除指定关系否不适用精确删除某几个set重置为指定集合否是全量覆盖clear清空所有关系否不适用清空重点说set。set的语义是把关系集合设置成我给的这些它会先算出当前集合和目标集合的差集然后删掉多的、加上少的。它不会先clear再add所以性能上比手动清空再添加要好。但要注意set接收的是可迭代对象传列表、查询集都行。course.students.set([s1, s2, s3])这行代码执行后course的学生就精确是s1、s2、s3这三个多出来的会被删掉。5.2 批量操作的性能差异add支持一次传多个对象course.students.add(s1, s2, s3)这比循环调用三次add要快因为Django会尽量合并成批量插入。但即便如此add在Django 2.2里对多对多的批量插入优化有限数据量大时还是慢。我实测过给一个对象添加1000个关系add(*objs)大概要几秒而直接用bulk_create写中间表能压到几百毫秒。如果你确实要批量建关系且用的是自动中间表可以拿到中间模型直接操作ThroughModel Course.students.through ThroughModel.objects.bulk_create([ ThroughModel(course_idcourse.id, student_ids.id) for s in student_list ])注意bulk_create会绕过save()和信号也不会自动去重重复数据会直接触发数据库唯一约束报错。所以用之前要么确保数据干净要么用ignore_conflictsTrue这个参数在Django 2.2里已经支持。5.3 一个关于clear的注意事项clear()会删除该对象的所有关系但它不会删除对象本身也不会删除关系另一端的对象。这个听起来像废话但真有人以为course.students.clear()会把学生也删了。不会的它只清空中间表里course_id等于当前课程的那些行。另外clear()在用了through之后不可用前面提过。如果你非要在through场景下清空只能手动Enrollment.objects.filter(coursecourse).delete()6. 批量场景下的N1问题与优化6.1 N1是怎么产生的多对多最容易引发N1查询。看这段代码courses Course.objects.all() for course in courses: print(course.title, course.students.count())如果数据库里有100门课这段代码会执行1次查课程 100次查学生数量总共101次查询。这就是典型的N1。数据量小的时候感觉不出来一旦课程上千页面直接卡死。6.2 prefetch_related的正确用法多对多关系不能用select_related那是给外键和一对一用的要用prefetch_relatedcourses Course.objects.prefetch_related(students).all() for course in courses: print(course.title, len(course.students.all()))这样Django会先查所有课程再用一条IN查询把所有相关学生一次性捞出来然后在Python层面做匹配。查询次数从101降到2。但有个细节要注意prefetch_related之后course.students.all()返回的是已经缓存的结果不会再去数据库查。但如果你调用course.students.count()Django可能会忽略缓存重新查。所以拿到预取结果后用len()而不是count()能真正利用上缓存。6.3 用through时的预取用了through之后预取要指定中间模型courses Course.objects.prefetch_related( Prefetch(enrollment_set, querysetEnrollment.objects.select_related(student)) ).all()这里Prefetch对象允许你自定义预取时用的查询集配合select_related把学生信息也一起捞出来避免在循环里再查学生。这个组合在报表类页面里特别有用我做过一个成绩单页面优化前加载要七八秒加上Prefetch和select_related之后降到一秒以内。6.4 统计场景的annotate方案如果只是要数量根本不需要预取对象直接用annotate在数据库层面算from django.db.models import Count courses Course.objects.annotate(student_countCount(students)) for course in courses: print(course.title, course.student_count)这样只有一次查询数据库帮你把计数算好。这是最高效的方案能用annotate就别用prefetch_related。判断标准很简单要对象就用prefetch只要数字就用annotate。7. 几个真实项目里攒下的经验7.1 中间表命名要趁早自动中间表的默认名字是源模型名_字段名比如course_students。这个名字一旦建表就固定了后面想改得走迁移。我建议在项目初期就通过db_table显式指定中间表名比如class Course(models.Model): students models.ManyToManyField( Student, db_tablecourse_student_relation )这样命名更规范也避免以后模型改名导致表名对不上。虽然Django能处理改名但显式指定总是更省心。7.2 自关联多对多要小心多对多还能指向自己比如用户关注用户class User(models.Model): name models.CharField(max_length50) following models.ManyToManyField( self, symmetricalFalse, related_namefollowers )这里symmetricalFalse是关键。默认情况下自关联多对多是对称的A关注B就意味着B也关注A这显然不符合关注场景。设成False之后关注才是单向的。这个参数不设逻辑直接错而且错得很隐蔽因为数据能存进去只是语义反了。7.3 删除策略要想清楚多对多关系里删除一端对象时中间表里的关系怎么处理默认是级联删除也就是删掉课程中间表里所有该课程的关系行都没了。这通常是对的。但如果你的中间模型有额外业务含义比如订单和商品的关系删商品可能不该删订单记录这时候就要在中间模型的外键上设on_deletemodels.PROTECT或者SET_NULL具体看业务。我见过一个项目中间表存的是用户收藏商品结果商品下架时把收藏记录全删了用户回来发现收藏夹空了投诉一堆。后来改成SET_NULL加软删除才解决。所以中间表的外键删除策略一定要结合业务想清楚别用默认值糊弄过去。7.4 测试里怎么造多对多数据写单元测试时造多对多数据有个小技巧用bulk_create造中间表数据比循环add快得多。另外测试数据库每次重建不用担心脏数据可以放心用set做全量覆盖。如果测试里要验证关系数量记得用count()而不是len()因为测试环境没有预取缓存两者结果一样但count()更符合语义。多对多这块说到底就是一句话关系简单就用自动表关系有属性就上through查询记得预取删除想清楚策略。把这四点吃透Django 2.2里的多对多基本就不会再给你添堵了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →