mcpField
D
django-migrations

django-migrations

github.com/posthog数据与数据库40.5k242 次安装今天更新
代码仓库

关于 django-migrations

面向修改 Django 迁移的工程师的项目专用技能,强调安全发布、保留迁移历史,并使用项目辅助工具降低架构变更风险。

主要功能

  1. 01防止删除迁移文件
  2. 02分阶段废弃表和列
  3. 03用可调用选项处理增长中的枚举
  4. 04迁移生成与验证指引
  5. 05项目专用安全辅助工具

适用场景

  1. 01审查架构变更方案
  2. 02废弃模型、表或列
  3. 03解决迁移冲突
  4. 04添加枚举选项以避免反复生成迁移

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/posthog/posthog --skill django-migrations

安装后在 Claude Code 中输入/django-migrations确认已加载。

详细介绍

Django 迁移

在提议更改迁移历史或迁移运行方式之前,请先查看 已经尝试过的事项。其中记录了压缩历史以及 Person 表切换的已关闭的尝试。

在编写或编辑迁移之前,请先阅读以下文件:

  • docs/published/handbook/engineering/developing-locally.md(## Django migrations、### Non-blocking migrations、### Resolving merge conflicts)
  • docs/published/handbook/engineering/safe-django-migrations.md
  • docs/published/handbook/engineering/databases/schema-changes.md
  • products/README.md(## Adding or moving backend models and migrations),当在 products/* 中工作时

如果任务是 ClickHouse 迁移,请改用 clickhouse-migrations。

绝不删除迁移文件

添加迁移没问题。删除历史上的迁移文件——任何已经在 master 上的 */migrations/NNNN_*.py,哪怕是某个应用的 0001_initial.py,哪怕是为了“撤销”一次 schema 变更——都不行。 删除该文件并不能撤销任何东西:表和它的约束仍然存在于每个运行过该迁移的数据库中,全新的数据库永远不会重新创建它们,而且“Migration Risk Analysis” CI 任务会在每个早于该删除操作的打开的 PR 上,将该文件重新标记为幽灵新迁移。repo-checks CI 任务中的已删除迁移检查(hogli lint:migration-deletions 命令)会阻止这种做法。确实有意为之、经过审查的删除——产品/应用迁移、回滚、压缩——会在 .github/scripts/migration-deletion-allowlist.txt 中被确认,绝不能通过禁用该防护来实现。

如果某个任务要求你删除迁移文件,请停下来并标记它。

要退役一个模型/表:

  1. 移除所有用法和模型类。运行 makemigrations,然后将生成的 DeleteModel 包装在 migrations.SeparateDatabaseAndState(state_operations=[...]) 中(仅状态,无数据库变更)。保留此文件。将应用保留在 INSTALLED_APPS 中。 如果该模型有指向 posthog_team、posthog_user、posthog_organization 或 posthog_project 的 ForeignKey,请在同一迁移的 database_operations 中用 DropForeignKey 删除该约束。 这是必须的,而不是清理工作。Django 不再级联到它无法看到的表,因此子行能在父级删除后保留下来。这些约束是 DEFERRABLE INITIALLY DEFERRED,所以父级删除会运行其整个级联,然后在 COMMIT 时失败,团队和组织删除会一直处于损坏状态,直到有人删除该表。
  2. 部署,等待至少一个完整的部署周期。
  3. 稍后在一个新迁移中使用 SafeDropTable——绝不能通过删除旧文件来实现。只要第 1 步留下了指向热父表的外键,就把这视为应完成的工作,而不是可选项。 DROP TABLE 会依次对它的外键引用的每个表获取 ACCESS EXCLUSIVE,一次一个关系,因此它的加锁顺序会与任何活跃的多表读取的加锁顺序交叉,两者就会死锁。lock_timeout 阻止不了这一点,因为死锁检测器会先解决循环,并终止等待最久的那个会话:一个活跃读取(一次 500)或迁移(一次被阻塞的部署)。SafeDropTable 会根据 deadlock_timeout 得出的预算,提前获取所有锁,因此输掉竞争的是迁移。绝不要手写 RunSQL。参见 热表风险。 python manage.py audit_orphan_hot_table_fks 会列出已处于此状态的表。请针对长期存在的数据库运行它:压缩后的历史对于在压缩之前就离开 Django 状态的表没有 CreateModel,因此全新的数据库永远不会创建它,迁移文件中也不留任何痕迹。

完整指南:safe-django-migrations.md(## Dropping Tables、### Removing a whole product or app)。删除你的分支添加但从未合并到 master 的迁移是允许的(重新生成)。

下线一列

默认:不要删除它。 从 ORM 中移除该字段,但保留该列。这样数据得以保留,也无需协调部署。

删除字段并运行 makemigrations 并非如此。Django 会在它生成的每一条 SELECT 和 INSERT 中列出每个具体字段,因此生成的 RemoveField 会在停止代码请求该字段的同一次部署中删除该列,而仍运行旧版本的每个 pod 都会查询失败。# deprecated 注释也无济于事,因为该字段仍在模型上。

posthog.migration_helpers 中的两个辅助函数可以正确地做到这一点:

  • deprecate_field(models.IntegerField(null=True)) 就地包装该字段。不生成迁移。读写时都会发出警告,该列会从所有查询中移除。该字段必须已经是 null=True,因为一旦被隐藏就再没有任何写入该列的操作。使用 raise_on_access=True 来证明已无任何调用方。
  • untrack_field("mymodel", "myfield") 用仅状态的迁移替换生成的 RemoveField,这样该字段会从模型类中移除,而列得以保留。

外键列必须在同一次迁移中使用 untrack_field 加 DropForeignKey。 一旦 Django 看不到该字段,父级删除就不再级联到它,而延迟约束会导致此后的删除在 COMMIT 时永远失败。deprecate_field 完全不能用于外键,因为它不生成迁移,也就无处放置约束的删除。

只有在上述方法之一部署满一个完整部署周期后,才删除该列,并在状态移除之后的迁移中用 RunSQL ... DROP COLUMN IF EXISTS 来删除它。如果是来自 deprecate_field,先删除被包装的那一行,并用 untrack_field 替换生成的 RemoveField;两个迁移可以放在一个 PR 中。永远不要直接发布 makemigrations 生成的普通 RemoveField,因为它会在一次操作中同时移除状态并删除列,而分析器无法将其与未暂存的删除区分开。

先检查非 ORM 的读取方(nodejs/、rust/、Temporal workers、Metabase)。对 Django 隐藏字段并不能说明它们的情况。

完整指南:safe-django-migrations.md(## Dropping Columns)。

下线专用迁移测试

数据迁移测试保护的是发布过程,而不是产品的永久行为。在所有受支持环境都已应用该迁移、回滚窗口已关闭、且没有任何受支持的升级仍依赖旧数据状态之后,移除该专用测试。

删除已过期的测试,而不是将其标记为跳过。保留迁移文件。全新 schema 的 CI 仍会检查完整迁移链是否可应用。

不要将本规则应用于迁移工具、迁移安全检查、可复用的回填系统,或人们仍可运行的任何回填。

不断增长的枚举需要可调用选项

一个随时间不断增长的 choices= 列表,每次新增都会在使用该枚举的每个模型上生成一个 AlterField。这些迁移不产生任何 SQL —— choices 属于 Django 的 Field.non_db_attrs,因此 schema editor 会跳过它们 —— 但它们仍会进入迁移状态、CI 的按分片迁移重放以及 max_migration.txt,并在那里与合并队列中等待的每一个其他迁移发生冲突。

改为传入一个可调用对象。Django 会惰性地解析它,因此迁移只记录一次函数引用,之后再也不变:

def external_data_source_type_choices() -> list[tuple[str, str]]:
    return ExternalDataSourceType.choices


source_type = models.CharField(max_length=128, choices=external_data_source_type_choices)

运行时行为不变:DRF 仍会构建一个 ChoiceField,因此 OpenAPI 枚举和生成的前端类型保持完整;full_clean() 仍会拒绝未知值;admin 仍会渲染下拉框;get_FOO_display() 仍会解析出标签。真正的 schema 变更(max_length、null、新字段)仍会生成迁移。

当枚举是一个随组织新增内容而增长的注册表 —— 来源、集成、产品、提供商、模型 id —— 时就该用可调用对象。对于领域固有的封闭词汇表,比如状态或优先级,则保留普通列表,因为新增成员是值得记录的真正领域变更。

有两件事可调用对象不会为你做:

  • 迁移会永远通过导入路径引用它,因此重命名或移动它需要单独的迁移。把它放在它所包装的枚举旁边。
  • 它必须是模块级的具名函数。lambda 或由共享工厂返回的闭包没有可导入路径,因此 Django 无法序列化它 —— 每个枚举一个小函数才是预期形态,而不是要抽取出来的重复代码。
  • 它从迁移状态中隐藏了选项的增长,但没有从列中隐藏。比字段 max_length 更长的新成员仍是真正的 schema 变更,因此当注册表增长时要检查余量。

工作流

  1. 分类:将变更归类为增量式(新增可空列、新表)或有风险(删除/重命名、NOT NULL、索引、约束、大量数据更新、模型移动)。如果变更涉及热点表,那么无论它看起来多么增量式,也属于有风险。另见下文跨语言的 NOT NULL 隐患。
  2. 生成:DEBUG=1 ./manage.py makemigrations [app_label]。 对于合并冲突:python manage.py rebase_migration <app> && git add <app>/migrations(posthog 或 ee)。
  3. 应用安全规则:来自 safe-django-migrations.md —— 该文档详细涵盖多阶段发布、SeparateDatabaseAndState、并发操作、幂等性以及所有有风险的模式。
  4. 验证:./manage.py sqlmigrate <app> <migration_number>,运行测试,确认迁移序列是线性的。

使用迁移辅助函数

posthog.migration_helpers 为那些高风险但常见的场景提供了可直接替换的操作。优先使用这些;它们会跟踪 Django 状态、禁用超时,并且在 bin/migrate 重试时保持幂等:

  • 添加/删除索引 → SafeAddIndexConcurrently / SafeRemoveIndexConcurrently(model_name + models.Index)。永远不要使用 Django 的 AddIndexConcurrently —— CI 会阻止它。
  • 添加 CHECK 约束 → AddConstraintNotValid,然后在后续迁移中执行 ValidateConstraint(或在同一个迁移中设置 atomic = False)。
  • 向 热点表 添加 ForeignKey → 在模型上以 db_constraint=False 声明该外键(这样 CreateModel / AddField 不会发出父表锁),然后用 AddForeignKeyNotValid 把数据库约束加回来,并在后续迁移中接着执行 ValidateForeignKey。参见 指向热点表的外键。
  • 仅以原始 SQL 表示的索引(没有 Django Index)→ 用 SeparateDatabaseAndState 包裹 CreateIndexConcurrently / DropIndexConcurrently。
  • 删除 Django 为某个字段创建的索引(ForeignKey 或其他 db_index=True 索引,或 varchar 或 text 字段的 _like 配套索引)→ DropFieldIndexesConcurrently(model_name=..., name=...),在 db_index 关闭时替代自动生成的 AlterField。它会推导出 Django 的名称,因此风险分析器对手工输入的带哈希后缀名称的阻止规则不适用。对于外键,它拒绝留下一个父表删除仍会读取、却没有以该列开头的 btree 索引的键。
  • 停用某一列 → 在模型上使用 deprecate_field,或用 untrack_field 替代自动生成的 RemoveField。参见 停用某一列。
  • 删除已停用的表 → SafeDropTable("posthog_oldfeature")。它会在低于 deadlock_timeout 的预算下预先锁定被引用的父表,因此锁循环往往以迁移被重试而告终,而不是杀死一个正在进行的读取,并且它在重试时保持幂等。它拒绝分区表或继承表,因为它的锁列表不覆盖层级中的成员。
  • 删除正在停用列上的 check 和 unique 规则 → DropColumnConstraints(table, columns=[...]),在停止写入该列的发布之前执行。它会从目录中找出这些列上的每一个 check、unique 和 exclusion 约束以及每一个唯一索引,包括早先的替换遗留、没有任何迁移命名的那些,并在一次有界锁下删除它们。永远不要把 Django 生成的名称键入 DROP CONSTRAINT IF EXISTS:错误的名称会成功且什么都不删除。风险分析器会阻止对带 Django 哈希后缀的名称进行前向删除。
  • 删除停用遗留的外键 → DropForeignKey(table, column=...) 或 DropForeignKey(table, to_table=...)。它从 pg_constraint 读取名称,因此没有任何硬编码,重试也是无操作。它在一个有界阶段内先锁定每个父表,然后锁定子表,与 SafeDropTable 相同。事务会持有这些锁直到 COMMIT,因此请把表上的每个键交给一个操作(column=["a_id", "b_id"]),并让它单独留在其迁移中,最多与仅状态操作相邻。风险分析器会阻止在一个原子迁移中进行两次删除,或在其他数据库操作旁边进行删除。对于指向多个繁忙父表的键,改用 atomic = False 并为每个键一个操作,这样每次删除单独提交,并在 atomic_false_acknowledged_migrations.txt 中确认该迁移。分析器会阻止一个其键触及两个热点表的操作,即使在 atomic = False 下也是如此。

所有并发索引操作都要求 atomic = False。

当你遇到一个没有辅助函数的高风险但常见的模式时的元原则:不要根据文档手写安全 DDL —— 在 posthog/migration_helpers 中提供一个可直接替换的辅助函数,并让 CI 策略指向它。一个只需 import 的辅助函数每次都胜过一大堵带有各种注意事项的 RunSQL。

针对风险分析器编写脚本

posthog/management/migration_analysis/models.py 中定义了两个风险数据类。OperationRisk 为单个操作评分;MigrationRisk 为整个迁移文件评分,并暴露 max_score、level 和 category。两者都支持 .score,因此在 MigrationRisk 上既可以读取 score 也可以读取 max_score —— 它们返回相同的数值。

热点表风险

posthog_team、posthog_user、posthog_organization 和 posthog_project 几乎在每个请求中都会被读取。对它们执行任何 ALTER TABLE —— 包括其他地方都视为“安全”的普通可空 AddField —— 都需要 ACCESS EXCLUSIVE 锁,而当该锁请求在等待进行中的查询时,该表上之后的所有查询都会排队等待。即使是仅修改元数据的 ADD COLUMN,也可能一波一波地(每次 bin/migrate 重试一波)拖垮全站流量,直到 ALTER 赢得锁竞争。这已经造成过生产环境的 5xx 事故。

在编写涉及这些模型的迁移之前:

  • 对于 Team:将领域特定字段放到 Team 扩展模型上 —— posthog/models/team/README.md。那是一个 CREATE TABLE,不会锁 posthog_team。
  • CREATE INDEX CONCURRENTLY(通过 SafeAddIndexConcurrently)是可以的 —— SHARE UPDATE EXCLUSIVE 不会阻塞读或写。
  • 如果该字段确实属于热点表(核心身份、跨产品设置、SDK 配置),HotTableAlterPolicy 分析器会在 CI 中阻止该迁移,直到将 <app_label>.<migration_name> 添加到 posthog/management/migration_analysis/hot_table_acknowledged_migrations.txt。该确认也意味着需要与基础设施团队协调部署,选择低流量窗口。
指向热点表的外键

一个 指向 热点表的 ForeignKey 是从另一侧产生的相同风险,并且会从 任何 app 触发 —— 一个普通的产品 app CreateModel 或 AddField,带有 to="posthog.team"(或 settings.AUTH_USER_MODEL,即 posthog_user)。创建 FK 约束会对被引用的父表加 SHARE ROW EXCLUSIVE 锁,这与父表上每个 INSERT/UPDATE/DELETE 持有的 ROW EXCLUSIVE 冲突;在写入流量下,锁会排队,并且 lock_timeout 会在每次 bin/migrate 重试时取消它。HotTableAlterPolicy 现在会标记这种情况。有两种解决方式:

  • 在 ForeignKey 上使用 db_constraint=False —— 不发出 FK 约束,并且完全 不 对父表加锁(仅由应用层强制执行)。这是唯一真正无锁的路径。
  • 真实的数据库约束,两阶段 —— 将 FK 声明为 db_constraint=False,然后用 AddForeignKeyNotValid 将其作为数据库约束加回,并在后续迁移中 ValidateForeignKey。坦白说:ADD CONSTRAINT ... NOT VALID 在添加元数据时仍然会对父表加一个 短暂的 SHARE ROW EXCLUSIVE 锁 —— 它跳过了行扫描,因此缩短了加锁窗口,但并未消除它。随后的 VALIDATE 在父表上无锁运行。

产品数据库边界

products/db_routing.yaml 中列出的 app 只会在它们自己的数据库上迁移,不会在其他任何地方迁移。一个迁移只能依赖于应用于它自身所应用数据库的迁移,因此绝不要添加从其他 app 到这些 app 之一的依赖,反之亦然。外键或索引都无法跨数据库,因此这条边毫无价值,而 CI 的 schema 恢复依赖于它的缺失:它会忘记被路由 app 的 django_migrations 行,以便每个任务在自己的路由下应用它们,而如果被遗忘行存在依赖者,Django 会拒绝迁移。posthog/test/repo_invariants/test_migration_dependencies_share_a_database.py 会阻止这条边。

跨语言的 NOT NULL 风险

主 Postgres 数据库中的 posthog_user、posthog_team 以及其他核心表会同时被 Django 和 nodejs/(plugin-server 测试通过 insertRow)、rust/ 服务以及 Temporal worker 写入。那些非 Django 的写入方会发出只列出它们关心的列的原始 INSERT,因此任何新增的、没有 Postgres 级 DEFAULT 的 NOT NULL 列都会让它们报错:null value in column "<col>" violates not-null constraint。

仅仅使用 Django 的 default= 不会创建 Postgres 级默认值——按照设计,Django 将其视为仅在 Python 侧、在 Model.__init__ 时应用的属性:

  • 可调用默认值(default=list、default=dict、default=uuid.uuid4)根本不会被写入 SQL。
  • 标量默认值(default=False、default=0、default="")会以 ADD COLUMN ... DEFAULT X NOT NULL 的形式写入,随后立即被后续的 ALTER COLUMN ... DROP DEFAULT 删除——用 ./manage.py sqlmigrate 验证。

合并之前,先 grep 查找该表的外部写入方:

rg -n "INSERT INTO <table>|insertRow\(.*'<table>'" nodejs rust products services

如果有任何匹配,就在模型字段上同时添加 default= 和 db_default=。db_default= 会落地一个真正的 Postgres DEFAULT;default= 则为 ORM 创建保留 Python 侧的值:

class User(models.Model):
    hide_mcp_hints = models.BooleanField(default=False, db_default=False, null=False)

makemigrations 会生成一个普通的 AddField(..., db_default=False, default=False, ...),而 sqlmigrate 只显示 ADD COLUMN ... DEFAULT false NOT NULL——没有后续的 DROP DEFAULT。

db_default= 对 nodejs / rust 测试套件同样至关重要。posthog/management/commands/setup_test_environment.py 调用 disable_migrations(),并直接从模型定义构建测试 schema,完全跳过迁移。普通的 default= 对该路径不可见;Django 写入生成的 CREATE TABLE 的是 db_default=。没有它,.github/workflows/ci-nodejs.yml 中的 postgres-parity 和 Jest 任务会在原始 INSERT 上失败,即使单独运行 ./manage.py migrate 看起来是正确的。

若要修改现有列的默认值(不涉及 ADD COLUMN),请改用普通的 RunSQL:

migrations.RunSQL(
    sql="ALTER TABLE <table> ALTER COLUMN <col> SET DEFAULT '[]'::jsonb;",
    reverse_sql="ALTER TABLE <table> ALTER COLUMN <col> DROP DEFAULT;",
)

始终使用 ./manage.py sqlmigrate <app> <number> 验证没有多余的 DROP DEFAULT 混入,并确认 ./manage.py makemigrations --dry-run 报告没有状态漂移。

评论

支持 Markdown