详细摘要 摘要

生成:2025-06-21 18:47

摘要详情

音频文件
2025-06-05 | DjangoCon Europe 2025 | How to get Foreign Keys horribly wrong in Django
摘要类型
详细摘要
LLM 提供商
openai
LLM 模型
gemini-2.5-pro
温度
0.3
已创建
2025-06-21 18:47:59

概览/核心摘要 (Executive Summary)

本次演讲由软件开发者Haki Benita主讲,主题为“如何在Django中极其错误地使用外键”。演讲通过对一个简单的产品目录应用进行“代码审查”,深入剖析了Django中与外键相关的常见陷阱和高级优化技巧。核心观点是,Django的ORM虽然强大便捷,但其默认行为(如为外键自动创建索引)在复杂或高负载场景下可能导致性能低下、数据库锁争用和不安全的迁移。

演讲强调了开发者必须超越ORM的抽象,理解并审查其生成的SQL。主要议题包括:
1. 索引管理:识别并移除因UniqueConstraint和外键同时存在而产生的冗余索引。
2. 安全迁移:揭示了默认迁移(AlterField)在修改索引时会重建外键,导致长时间锁表。演讲者推荐使用SeparateDatabaseAndStateRunSQL进行手动、精细化的迁移控制。
3. 高级策略:介绍了CREATE INDEX CONCURRENTLY以避免在生产环境中锁表,并探讨了其在非原子性迁移中的风险与应对策略(如调整操作顺序)。此外,还演示了如何使用局部索引(Partial Index)为可空字段大幅节省存储空间。
4. 并发控制:指出了select_for_updateselect_related结合使用时会意外锁定关联表,并提供了解决方案select_for_update(of=['self'])

最终结论是,构建健壮、高效的Django应用需要开发者具备批判性思维,主动审查并优化数据库交互,通过明确的声明(如db_index)和高级工具来弥补框架默认行为的不足。


引言与示例模型介绍

演讲者Haki Benita以一个简单的Django产品目录应用作为贯穿全场的示例。该应用包含两个核心模型:CategoryProduct

  • Product模型的核心字段:
    • name, description: 基本产品信息。
    • category: 指向Category的外键。
    • sort_order: 产品在分类内的排序。
    • created_by, last_edited_by: 指向User模型的外键,用于审计。
    • unique_together = ('category', 'sort_order'): 确保同一分类下排序值唯一。

演讲的目标是通过对这个看似简单的模型进行逐步审查,揭示其中隐藏的与外键和数据库交互相关的深层次问题。

迁移与索引优化:从unique_togetherUniqueConstraint

  1. 弃用unique_together:

    • 演讲者首先指出,unique_together是一个即将被废弃的元选项。
    • 建议: 应使用models.UniqueConstraint,因为它提供了更强的控制力,并且是官方推荐的做法。
  2. 发现冗余索引:

    • unique_together替换为UniqueConstraint后,通过检查PostgreSQL的表结构(\d product),发现category_id字段上存在两个索引。
    • 原因:
      • 一个索引由新建的UniqueConstraint(category_id, sort_order)上创建。
      • 另一个索引是Django在创建category外键时 隐式自动创建 的。
    • 问题: 这两个索引功能重叠,其中一个(由外键创建的单独索引)是多余的,可以移除以节省资源。

深入剖析Django迁移:风险与手动干预

  1. 默认迁移的陷阱:

    • 为了移除外键上的多余索引,开发者可能会直接在模型字段上设置db_index=False
    • 然而,生成的迁移文件看似无害(AlterField),其背后的SQL却暗藏风险。
    • 关键问题: > Django在执行此操作时,并不仅仅是删除索引,而是会 “重新创建外键约束” (recreating the foreign key)。
    • 后果:
      • 性能问题: 重新验证外键约束是一个非常缓慢且 阻塞 的操作,尤其是在大表上。
      • 操作不符: 开发者只想删除一个索引,却触发了代价高昂的约束重建。
  2. 解决方案:SeparateDatabaseAndState

    • 为了精确控制数据库操作,演讲者推荐使用migrations.SeparateDatabaseAndState
    • 工作原理: 它允许将对Django模型状态的更改(State Operations)与实际在数据库上执行的SQL(Database Operations)分离开。
    • 实施步骤:
      1. State Operations: 使用Django自动生成的AlterField操作,以确保Django的模型状态保持同步。
      2. Database Operations: 使用migrations.RunSQL,手动编写DROP INDEX语句,只执行真正需要的操作。
    • 核心建议: > “在执行任何迁移之前,务必检查其生成的SQL” (always check the sql generated by a migration before you execute it)。

构建健壮的迁移:可逆性与并发操作

  1. 可逆迁移:

    • 手动编写的RunSQL迁移默认是不可逆的。
    • 建议: 为RunSQL提供第二个参数(reverse_sql),即反向操作的SQL语句(如CREATE INDEX),以确保迁移可以安全回滚。
  2. 并发操作 (CONCURRENTLY):

    • 在繁忙的生产系统上,即便是DROP INDEX也可能因获取排他锁而阻塞业务。
    • 解决方案: PostgreSQL支持并发创建/删除索引 (CREATE/DROP INDEX CONCURRENTLY),它不会阻塞进行中的读写操作。
    • 重要前提:
      • 并发操作 不能在事务块(atomic block)中执行
      • 因此,包含并发操作的迁移必须设置atomic = False
    • 风险: 非原子性迁移一旦中途失败,可能导致数据库处于不一致状态。建议将这类操作拆分到独立的迁移文件中。

外键索引的隐性用途:DELETE操作的性能陷阱

  1. 问题: created_by字段主要用于审计,很少用于查询,那么它的索引可以删除吗?
  2. “血的教训” (written in blood):
    • 演讲者分享了一个真实案例:一个定期删除未验证用户的后台进程,在删除用户时频繁超时。
    • 根本原因: 当删除一个被外键引用的对象时(如User),数据库需要检查所有可能引用它的表(如Product),以确保完整性约束。
    • 如果没有在引用字段(如product.created_by)上建立索引,这个检查过程将演变为全表扫描,极其缓慢。
  3. 结论: > 外键上的索引不仅用于SELECT查询,也间接用于关联对象的DELETE操作。
  4. 最佳实践:
    • 总是显式地为外键设置db_index (db_index=TrueFalse)。
    • 附上注释,解释保留或移除该索引的原因,使决策过程透明化。

高级索引策略:针对可空字段的局部索引 (Partial Indexes)

  1. 问题: last_edited_by字段是可空的(null=True),在示例中,只有0.1%的产品被编辑过,但其索引大小却和非空的created_by索引一样大。
  2. 原因: PostgreSQL默认会为NULL值创建索引条目,对于稀疏数据(大量NULL值),这会造成巨大的空间浪费。
  3. 解决方案:局部索引 (Partial Index)
    • 通过在models.Index中添加condition,可以创建一个只覆盖表中部分行的索引。
    • 示例: models.Index(fields=['last_edited_by'], name='...', condition=Q(last_edited_by__isnull=False))
    • 效果: 索引大小从 6 MB 骤降至 32 KB,极大地节省了存储空间并提高了效率。

迁移操作的顺序与安全性

在创建一个新的局部索引并删除旧的完整索引时,操作顺序至关重要,尤其是在非原子性迁移(atomic = False)中。

  • 错误顺序: 先DROP旧索引,再CREATE新索引。
    • 风险: 在这两个操作之间,该字段上没有任何索引。如果此时CREATE操作耗时较长或失败,系统将处于无索引的脆弱状态,相关查询性能会急剧下降。
  • 正确顺序: 先CREATE新索引,再DROP旧索引。
    • 优势: 在整个迁移过程中,该字段始终至少有一个索引可用,从而将对正在运行的系统的影响降至最低。

应用层并发控制:select_for_update的正确使用

  1. 问题: 在并发场景下,直接在实例上修改数据(instance method)是不安全的,因为内存中的对象可能已过时。
  2. 解决方案: 使用悲观锁,即select_for_update(),在更新前锁定数据行。
  3. select_for_update的隐藏陷阱:
    • select_for_update()select_related()结合使用时,默认会锁定查询中涉及的 所有表 的行。
    • 示例: 在更新Product时,如果select_related('created_by'),那么不仅Product行被锁定,关联的User行也会被锁定。这可能导致意想不到的锁争用和性能瓶颈。
  4. 精确锁定:
    • 建议: 使用select_for_update(of=['self'])来明确告知Django只锁定主查询模型(即Product)的行,而不要锁定JOIN过来的其他表。
    • 这是一个重要的最佳实践,即使当前没有使用select_related,也应添加of=['self']以防未来有人修改代码引入问题。

问答环节 (Q&A) 摘要

  • Django操作 vs. 原生SQL: 演讲者倾向于在可能的情况下使用Django内置的操作(如AddIndex),因为它们具有跨数据库的可移植性。只有在内置功能无法满足需求时,才使用RunSQL
  • 非原子性迁移失败: 观众指出,CREATE INDEX CONCURRENTLY若被中断,会留下一个无效的索引,再次运行时会失败。解决方案是在执行前检查是否存在无效索引,如果存在则先删除它。这凸显了非原子性迁移的复杂性和风险。
  • 迁移测试: 演讲者团队的实践是,通过GitHub Action自动将迁移生成的SQL作为评论发布到PR中,强制要求代码审查者查看实际的SQL,以此作为一种审查机制,而非编写自动化测试。
  • 学习SQL的必要性: 演讲者认为,尽管ORM很方便,但所有开发者都应该学习并熟悉SQL。了解ORM的默认行为和局限性,才能在需要时进行有效干预。
  • 对Django Core的改进建议: 演讲者和观众认为,Django可以在某些方面进行改进,例如:让AlterField在仅修改索引时不要重建外键;让并发索引操作(add/remove index concurrently)能支持由外键隐式创建的索引。

核心结论

演讲的最终结论是,编写高质量、高性能的Django应用,开发者不能盲目信任ORM的默认行为。必须投入精力去理解底层数据库的工作原理,并采取明确、审慎的策略来控制数据库交互。

"There is no AI that can [write] this model. Only we can write this model... There's a lot of thought that went into this model."

这强调了人类开发者基于深入理解和经验进行细致设计与优化的不可替代性。