mcpField
D
django-safe-migration

django-safe-migration

github.com/vintasoftware数据与数据库166785 次安装14 周前更新
代码仓库

关于 django-safe-migration

django-safe-migration 是一项面向 Django 开发者的技能,用于审查、编写和改写 PostgreSQL 迁移。它结合生成的 SQL、锁行为、部署策略和项目运行时防护,识别滚动部署中的风险并指导安全改写。

主要功能

  1. 01审查迁移生成的 SQL
  2. 02识别 PostgreSQL 锁与停机风险
  3. 03按项目配置评估运行时防护
  4. 04编写或改写安全迁移
  5. 05指导拆分迁移及部署顺序

适用场景

  1. 01检查迁移是否适合滚动部署
  2. 02添加 NOT NULL 列或外键
  3. 03降低索引创建对写入的阻塞
  4. 04改写可能引发停机的迁移

安装

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

$npx skills add https://github.com/vintasoftware/django-ai-plugins --skill django-safe-migration

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

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
examples.md
operation-guide.md
postgres-locks.md

详细介绍

Django 迁移 — 零停机

项目配置

请读取当前主机的项目指令文件(例如 AGENTS.md 或 CLAUDE.md),获取以下值。如果未设置,请使用显示的默认值。

键默认值说明
Django 版本unknown影响 db_default 的可用性——仅 Django 5.0+ 支持该功能
部署策略rolling deploy滚动部署限制最严格;蓝绿部署或维护窗口部署允许执行更多操作
运行时防护none如果配置了防护(例如 django-pg-zero-downtime-migrations),其阻止的操作属于错误;未覆盖的操作属于警告
迁移命令python manage.py sqlmigrate例如 make sqlmigrate 或 docker compose run web python manage.py sqlmigrate
文档 / wiki URLnone如果已设置,在审核输出中标记问题时附加 #<anchor> 链接

要为项目配置此技能,请在当前主机的项目指令文件中添加如下部分:

## django-safe-migration
- Django version: 5.1
- Deploy strategy: rolling deploy
- Runtime guard: none
- Migration command: docker compose run web python manage.py sqlmigrate
- Docs URL: https://github.com/your-org/repo/wiki/migrations

适用场景

  • “审核此迁移”
  • “此迁移安全吗?”
  • “为……编写迁移”
  • “重写此迁移以确保安全”
  • “如何添加 NOT NULL 列 / 删除列 / 添加索引 / 重命名列 / 添加 FK……”

零停机迁移为何重要

在滚动部署中,首先应用新的数据库架构,然后逐个重启应用实例。在部署期间的任何时刻,新旧代码都会同时访问同一个数据库。

每次迁移都必须能够在应用的上一版本仍在提供服务时安全运行。在大型表上获取 ACCESS EXCLUSIVE 锁的迁移会阻塞所有读写操作——对于繁忙的表,即使只持续几秒也会造成停机。

问题不只是锁本身,还有等待队列:一个耗时 50ms 的快速 ALTER TABLE 会排在任何长时间运行的事务后面,后续所有查询也都会排在该迁移后面。在繁忙的表上,这会连锁导致连接池耗尽。


PostgreSQL 锁的工作方式

锁获取方式阻塞内容
ACCESS EXCLUSIVE大多数 ALTER TABLE、DROP INDEX、DROP CONSTRAINT(FK)所有读写操作
SHARE ROW EXCLUSIVEADD FOREIGN KEY(同时在子表和被引用表上获取)仅写操作(针对两张表)
SHARECREATE INDEX仅写操作
SHARE UPDATE EXCLUSIVECREATE INDEX CONCURRENTLY、VALIDATE CONSTRAINT无实质影响——可在有流量时安全执行

有关完整的冲突矩阵(表级锁 × 业务逻辑操作 × 行级锁)和 FIFO 等待队列的说明,请加载 references/postgres-locks.md。以下情况需要加载:

  • 解释某项具体操作为何不安全
  • 开发者询问某种锁类型会阻塞或冲突什么
  • 判断两个并发操作是否会相互影响

lock_timeout

任何需要 ACCESS EXCLUSIVE 的操作之前都应设置 SET LOCAL lock_timeout。这样迁移会快速失败(并给出清晰的错误),而不是无限期等待锁,从而避免等待队列连锁导致连接池耗尽。

对于普通事务迁移:

migrations.RunSQL("SET LOCAL lock_timeout = '2s'"),
migrations.AlterField(...),  # or any ACCESS EXCLUSIVE operation

SET LOCAL 会将超时时间限定在当前事务中,因此不会影响其他会话,也不会在迁移完成后继续生效。

默认值:2s——如果已知表上有确实需要更多时间的长时间运行事务,可适当调高;对要求更严格的环境则可调低。

对于 atomic = False 迁移,请将 SET LOCAL 和 DDL 放在同一个 RunSQL 操作中;参见下文的结构规则。


关键模式

NOT VALID + VALIDATE(用于 FK 和 CHECK 约束)

一种分两步添加约束、避免长时间锁定的 PostgreSQL 技术:

  1. ADD CONSTRAINT … NOT VALID — 创建约束并立即对新的写入操作强制执行,但跳过对现有行的扫描。短暂持锁且不扫描表:FK 约束会在两张表上获取 SHARE ROW EXCLUSIVE,CHECK 约束会获取 ACCESS EXCLUSIVE。
  2. VALIDATE CONSTRAINT — 扫描现有行,确认它们符合约束。会获取 SHARE UPDATE EXCLUSIVE(FK 约束还会在被引用的表上获取 ROW SHARE),不会阻塞读写操作。

危险之处在于全表扫描,而不是约束创建本身。将其拆分后,阻塞写入的锁定窗口可缩短到毫秒级,并将耗时较长的扫描移至不会造成阻塞的步骤。

PostgreSQL 文档:NOT VALID · VALIDATE CONSTRAINT


运行时保护机制

某些项目会配置自定义数据库后端或代码检查工具,在迁移时针对不安全操作报错(例如 zero_downtime_migrations、django-pg-zero-downtime-migrations、django-migration-linter)。

审核迁移时:

  • 项目的运行时保护机制会阻止的操作 → 归类为错误(迁移无法运行)
  • 保护机制未涵盖的操作 → 归类为警告(迁移会运行,但可能导致停机)

如果未配置运行时保护机制,则将所有不安全操作视为需要在部署到生产环境前安全改写的错误。

请检查上方的 Project Configuration 区块(或 AGENTS.md),了解此项目的保护机制涵盖哪些内容。


模式 1:审核

目标

识别 PostgreSQL 滚动部署中所有不安全或有风险的操作。

步骤
  1. 通读整个迁移文件。
  2. 运行 <migration_command> <app_label> <migration_name>,获取 Django 实际执行的 SQL。**务必执行此操作——生成的 SQL 才是最终依据。**使用当前主机项目说明文件中的 Migration command 值;默认值为 python manage.py sqlmigrate。如果未设置此项,且项目根目录下存在 Makefile 或 docker-compose.yml,请询问用户:“在此项目中如何运行 sqlmigrate?”并建议他们将答案保存到该说明文件中。仅查看 ORM 操作类并不足够:例如,对会添加 null=True 的 FK 字段使用 AlterField,也会删除并重新添加 FK 约束,生成 DROP CONSTRAINT(在子表和被引用表上获取 ACCESS EXCLUSIVE),随后生成不带 NOT VALID 的 ADD CONSTRAINT FOREIGN KEY(在两张表上获取 SHARE ROW EXCLUSIVE 并执行全表扫描)——仅从迁移文件无法看出这两项操作。
  3. 加载 references/operation-guide.md。解释某个被标记的操作为何不安全时,加载 references/postgres-locks.md。
  4. 对照下方的检测清单,检查生成的每条 SQL 语句。
  5. 如果任何操作需要 ACCESS EXCLUSIVE(被标记为错误或警告),请在输出报告前询问用户:

    “此迁移包含 ACCESS EXCLUSIVE 操作。应添加 lock_timeout,以便快速失败,避免排队并引发连锁影响。默认值为 2s——请确认,或提供自定义值。” 如果迁移中已包含 SET LOCAL lock_timeout,请说明现有值,并询问用户确认该值是否合适。 在修复说明中使用确认后的值。

  6. 输出结构化报告:

迁移审核:

错误 — 会导致停机或被运行时保护机制阻止
  • [ERROR] <OperationClass> 用于 <app>.<Model>.<field>:<说明其作用及不安全的原因> 修复:<用一句话说明安全的替代方案> 此重写做了哪些改变:明确安全版本是否仍需要 ACCESS EXCLUSIVE;如果需要,说明实际改进之处——锁持续时间(完整表扫描 → 几毫秒)、故障模式(静默的队列级联 → 快速超时错误),或两者兼有。绝不能让读者误以为重写完全消除了锁。 <如果已配置,则提供 wiki/文档链接>
警告 — 可能导致锁持续时间过长或部署问题
  • [WARNING] <OperationClass>:<问题> 修复:<安全的替代方案>
结构性问题
  • [ERROR|WARNING] <问题>(例如,缺少 atomic=False、RunPython 没有反向操作)
安全
  • [OK] <通过所有检查的操作>
  1. 如果存在错误或警告,提供重写选项(模式 3)。
  2. 如果全部通过,确认迁移可以安全部署。

模式 2:编写

目标

为描述的变更生成正确的零停机迁移。

步骤
  1. 加载 references/operation-guide.md 和 references/examples.md。
  2. 如果操作含义不明确,先澄清:
    • 涉及哪个模型和字段?
    • 是新增列还是更改现有列?
    • 对于 FK:引用哪个表?是新增列还是现有列?
    • 对于类型变更:从什么类型变为什么类型?
    • Django 版本是什么(影响 db_default 是否可用)?
  3. 如果操作需要 ACCESS EXCLUSIVE,询问用户:

    “此迁移将使用 ACCESS EXCLUSIVE。如果无法获取锁,将通过 lock_timeout 快速失败。默认值为 2s——请确认或提供自定义值。”

  4. 确定需要多少个迁移文件——许多模式需要在单独的 PR 中使用两个文件。
  5. 使用 references/examples.md 中的模式生成迁移文件。在每条 ACCESS EXCLUSIVE 语句之前添加 SET LOCAL lock_timeout = '<confirmed_value>'。在常规的 atomic = True 迁移中,可以使用前置的 RunSQL;在 atomic = False 迁移中,应将超时设置与 DDL 合并到同一个 RunSQL 操作中,以确保运行 DDL 时 SET LOCAL 仍然有效。
  6. 需要两个文件时,务必输出部署说明:
## Deployment Order
- Migration 1: ships in the same PR as the model/code change
- Migration 2: ships in a follow-up PR after the deploy is confirmed stable

模式 3:重写

目标

将现有的不安全迁移转换为一个或多个安全迁移。

步骤
  1. 阅读迁移文件。
  2. 使用检测清单识别所有不安全操作。
  3. 加载 references/operation-guide.md 和 references/examples.md。
  4. 如果某项操作需要 ACCESS EXCLUSIVE,请询问用户:

    “此迁移包含一项 ACCESS EXCLUSIVE 操作。重写时会添加 lock_timeout,以便在无法获取锁时快速失败。默认值为 2s——请确认或提供自定义值。”

  5. 对每项不安全操作应用正确的安全模式。
  6. 在重写后的迁移中,为每条 ACCESS EXCLUSIVE 语句添加 SET LOCAL lock_timeout = '<confirmed_value>'。在常规的 atomic = True 迁移中,可以在前面添加一个 RunSQL;在 atomic = False 迁移中,将超时设置与 DDL 合并到同一项 RunSQL 操作中,确保 DDL 执行时 SET LOCAL 仍然有效。
  7. 如果重写需要拆分成两个文件,则生成这两个文件并附上部署说明。
  8. 保留:迁移编号前缀、dependencies 以及任何安全操作,且保持不变。
  9. 重写后,验证结构规则(见下文)。
  10. 在迁移代码之后,始终输出一个 “此重写的变更内容” 区块,说明:
    • 是否仍需要 ACCESS EXCLUSIVE(通常需要——请明确说明)。
    • 实际改进之处:锁定时长(全表扫描 → 仅元数据操作只需毫秒级)、失败模式(静默排队 → 由 lock_timeout 触发快速超时错误),或两者都有。
    • 引入的任何数据完整性窗口(例如,NOT VALID 表示现有行在 Migration 2 执行前尚未验证),以及考虑到该表先前的状态,这是否会造成影响。

检测清单

错误 — 无论运行时防护条件如何都不安全
Django 操作检测内容不安全原因
AddFieldnull=False 且没有 db_default(Django 5.0+)旧代码插入数据时会省略该列——没有数据库级默认值可供回退
RemoveField不在 SeparateDatabaseAndState 中旧代码按名称查询已删除的列——会立即崩溃
DeleteModel不在 SeparateDatabaseAndState 中旧代码查询已删除的表——会立即崩溃
AddIndex未使用 AddIndexConcurrentlyCREATE INDEX 会获取 SHARE 锁——构建期间会阻塞写入
RemoveIndex未使用 RemoveIndexConcurrentlyDROP INDEX 会获取 ACCESS EXCLUSIVE——会阻塞读取和写入
AddConstraint(FK)未使用 NOT VALID + VALIDATE 模式在子表和被引用表上持有 SHARE ROW EXCLUSIVE 锁进行全表扫描——扫描期间会阻塞写入(但不会阻塞读取)
AddConstraint(CHECK)未使用 NOT VALID + VALIDATE 模式在持有 ACCESS EXCLUSIVE 锁的情况下进行全表扫描
AlterField移除现有列的 null=True,且未采用 CHECK 路径在持有 ACCESS EXCLUSIVE 锁的情况下进行全表扫描
AlterField(FK 字段)sqlmigrate 输出包含 DROP CONSTRAINT,后接 ADD CONSTRAINT FOREIGN KEYDjango 会删除并重新添加 FK:DROP CONSTRAINT 会在子表和被引用表上获取 ACCESS EXCLUSIVE 锁;不使用 NOT VALID 重新添加会在两张表上获取 SHARE ROW EXCLUSIVE 锁并进行全表扫描。使用 SeparateDatabaseAndState 和 RunSQL 添加 NOT VALID,并包含 lock_timeout。
AddIndexConcurrently 或 RemoveIndexConcurrentlyMigration 类中的 atomic 不为 FalseCONCURRENTLY 不能在事务中运行——会报错
RunPython函数直接导入模型(from app.models import X)使用的是当前模型类,而不是历史快照——会导致旧迁移失效
如果项目配置了运行时防护,还需检查以下错误

如果项目配置了运行时防护(参见 Project Configuration),也要将以下情况标记为错误(它们会在迁移时引发异常):

Django 操作检查内容
RenameField任何重命名
RenameModel任何重命名
AlterField列类型变更
AddConstraintExclusionConstraint

如果未配置运行时防护,也要将这些情况标记为错误——它们需要安全地重写。

警告
Django 操作检查内容问题
AddConstraint (UNIQUE)未采用先建索引模式在 SHARE 锁下创建内联索引——构建期间会阻止写入
AlterField为现有列添加 null=True(DROP NOT NULL)会获取 ACCESS EXCLUSIVE 锁——目录更新很快,但会排在任何长时间运行的事务之后;所有后续查询都将排在迁移之后,可能耗尽连接池
RunPython缺少 reverse_code不可逆
RunSQL缺少 reverse_sql不可逆

结构规则(始终检查)

  • 只要任何操作使用了 CONCURRENTLY 或 VALIDATE CONSTRAINT,就必须在 Migration 类上设置 atomic = False。具体对于 VALIDATE CONSTRAINT:(1)SHARE UPDATE EXCLUSIVE 锁会与自身冲突——参见 references/postgres-locks.md 中的冲突矩阵;(2)atomic = True 会使包装事务在整个扫描期间保持打开状态,持有之前所有语句的锁并增加死锁风险;(3)atomic = False 可让 VALIDATE 在自己的事务中运行,并在完成后立即释放锁。
  • 对 RemoveField / DeleteModel 使用 SeparateDatabaseAndState 拆分:始终使用两个单独的文件。
  • RunPython:至少始终提供 reverse_code=migrations.RunPython.noop。
  • RunSQL:始终提供 reverse_sql。如果确实不可逆,请使用 migrations.RunSQL.noop 并说明原因。
  • 在 RunPython 中访问模型:始终使用 apps.get_model("app", "ModelName"),绝不直接导入。
  • 每个 ACCESS EXCLUSIVE 操作之前都必须设置 SET LOCAL lock_timeout。默认值为 2s;编写或重写之前始终与用户确认。使用 SET LOCAL(而非 SET),以便将超时时间限定在当前事务中。
    • atomic = False 的例外情况:SET LOCAL 仅在事务结束时重置。在 atomic = False 的迁移中,每个操作都在包装事务之外运行,因此单独的 migrations.RunSQL("SET LOCAL lock_timeout = '2s'") 会在下一个操作执行前重置,无法生效。当迁移设置了 atomic = False 时,始终将超时设置和 DDL 合并到同一个 RunSQL 调用中:
      migrations.RunSQL("""
          SET LOCAL lock_timeout = '2s';
          ALTER TABLE app_model ADD COLUMN ...;
      """)
      

评论

支持 Markdown