关于 django-safe-migration
django-safe-migration 是一项面向 Django 开发者的技能,用于审查、编写和改写 PostgreSQL 迁移。它结合生成的 SQL、锁行为、部署策略和项目运行时防护,识别滚动部署中的风险并指导安全改写。
主要功能
- 01审查迁移生成的 SQL
- 02识别 PostgreSQL 锁与停机风险
- 03按项目配置评估运行时防护
- 04编写或改写安全迁移
- 05指导拆分迁移及部署顺序
适用场景
- 01检查迁移是否适合滚动部署
- 02添加 NOT NULL 列或外键
- 03降低索引创建对写入的阻塞
- 04改写可能引发停机的迁移
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vintasoftware/ django-ai-plugins --skill django-safe-migration安装后在 Claude Code 中输入/django-safe-migration确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/examples.mdoperation-guide.mdpostgres-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 URL | none | 如果已设置,在审核输出中标记问题时附加 #<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 EXCLUSIVE | ADD FOREIGN KEY(同时在子表和被引用表上获取) | 仅写操作(针对两张表) |
SHARE | CREATE INDEX | 仅写操作 |
SHARE UPDATE EXCLUSIVE | CREATE 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 技术:
ADD CONSTRAINT … NOT VALID— 创建约束并立即对新的写入操作强制执行,但跳过对现有行的扫描。短暂持锁且不扫描表:FK 约束会在两张表上获取SHARE ROW EXCLUSIVE,CHECK 约束会获取ACCESS EXCLUSIVE。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 滚动部署中所有不安全或有风险的操作。
步骤
- 通读整个迁移文件。
- 运行
<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并执行全表扫描)——仅从迁移文件无法看出这两项操作。 - 加载
references/operation-guide.md。解释某个被标记的操作为何不安全时,加载references/postgres-locks.md。 - 对照下方的检测清单,检查生成的每条 SQL 语句。
- 如果任何操作需要
ACCESS EXCLUSIVE(被标记为错误或警告),请在输出报告前询问用户:“此迁移包含
ACCESS EXCLUSIVE操作。应添加lock_timeout,以便快速失败,避免排队并引发连锁影响。默认值为2s——请确认,或提供自定义值。” 如果迁移中已包含SET LOCAL lock_timeout,请说明现有值,并询问用户确认该值是否合适。 在修复说明中使用确认后的值。 - 输出结构化报告:
迁移审核:
错误 — 会导致停机或被运行时保护机制阻止
- [ERROR]
<OperationClass>用于<app>.<Model>.<field>:<说明其作用及不安全的原因> 修复:<用一句话说明安全的替代方案> 此重写做了哪些改变:明确安全版本是否仍需要ACCESS EXCLUSIVE;如果需要,说明实际改进之处——锁持续时间(完整表扫描 → 几毫秒)、故障模式(静默的队列级联 → 快速超时错误),或两者兼有。绝不能让读者误以为重写完全消除了锁。 <如果已配置,则提供 wiki/文档链接>
警告 — 可能导致锁持续时间过长或部署问题
- [WARNING]
<OperationClass>:<问题> 修复:<安全的替代方案>
结构性问题
- [ERROR|WARNING] <问题>(例如,缺少 atomic=False、RunPython 没有反向操作)
安全
- [OK] <通过所有检查的操作>
- 如果存在错误或警告,提供重写选项(模式 3)。
- 如果全部通过,确认迁移可以安全部署。
模式 2:编写
目标
为描述的变更生成正确的零停机迁移。
步骤
- 加载
references/operation-guide.md和references/examples.md。 - 如果操作含义不明确,先澄清:
- 涉及哪个模型和字段?
- 是新增列还是更改现有列?
- 对于 FK:引用哪个表?是新增列还是现有列?
- 对于类型变更:从什么类型变为什么类型?
- Django 版本是什么(影响
db_default是否可用)?
- 如果操作需要
ACCESS EXCLUSIVE,询问用户:“此迁移将使用
ACCESS EXCLUSIVE。如果无法获取锁,将通过lock_timeout快速失败。默认值为2s——请确认或提供自定义值。” - 确定需要多少个迁移文件——许多模式需要在单独的 PR 中使用两个文件。
- 使用
references/examples.md中的模式生成迁移文件。在每条ACCESS EXCLUSIVE语句之前添加SET LOCAL lock_timeout = '<confirmed_value>'。在常规的atomic = True迁移中,可以使用前置的RunSQL;在atomic = False迁移中,应将超时设置与 DDL 合并到同一个RunSQL操作中,以确保运行 DDL 时SET LOCAL仍然有效。 - 需要两个文件时,务必输出部署说明:
## 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:重写
目标
将现有的不安全迁移转换为一个或多个安全迁移。
步骤
- 阅读迁移文件。
- 使用检测清单识别所有不安全操作。
- 加载
references/operation-guide.md和references/examples.md。 - 如果某项操作需要
ACCESS EXCLUSIVE,请询问用户:“此迁移包含一项
ACCESS EXCLUSIVE操作。重写时会添加lock_timeout,以便在无法获取锁时快速失败。默认值为2s——请确认或提供自定义值。” - 对每项不安全操作应用正确的安全模式。
- 在重写后的迁移中,为每条
ACCESS EXCLUSIVE语句添加SET LOCAL lock_timeout = '<confirmed_value>'。在常规的atomic = True迁移中,可以在前面添加一个RunSQL;在atomic = False迁移中,将超时设置与 DDL 合并到同一项RunSQL操作中,确保 DDL 执行时SET LOCAL仍然有效。 - 如果重写需要拆分成两个文件,则生成这两个文件并附上部署说明。
- 保留:迁移编号前缀、
dependencies以及任何安全操作,且保持不变。 - 重写后,验证结构规则(见下文)。
- 在迁移代码之后,始终输出一个 “此重写的变更内容” 区块,说明:
- 是否仍需要
ACCESS EXCLUSIVE(通常需要——请明确说明)。 - 实际改进之处:锁定时长(全表扫描 → 仅元数据操作只需毫秒级)、失败模式(静默排队 → 由
lock_timeout触发快速超时错误),或两者都有。 - 引入的任何数据完整性窗口(例如,
NOT VALID表示现有行在 Migration 2 执行前尚未验证),以及考虑到该表先前的状态,这是否会造成影响。
- 是否仍需要
检测清单
错误 — 无论运行时防护条件如何都不安全
| Django 操作 | 检测内容 | 不安全原因 |
|---|---|---|
AddField | null=False 且没有 db_default(Django 5.0+) | 旧代码插入数据时会省略该列——没有数据库级默认值可供回退 |
RemoveField | 不在 SeparateDatabaseAndState 中 | 旧代码按名称查询已删除的列——会立即崩溃 |
DeleteModel | 不在 SeparateDatabaseAndState 中 | 旧代码查询已删除的表——会立即崩溃 |
AddIndex | 未使用 AddIndexConcurrently | CREATE INDEX 会获取 SHARE 锁——构建期间会阻塞写入 |
RemoveIndex | 未使用 RemoveIndexConcurrently | DROP 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 KEY | Django 会删除并重新添加 FK:DROP CONSTRAINT 会在子表和被引用表上获取 ACCESS EXCLUSIVE 锁;不使用 NOT VALID 重新添加会在两张表上获取 SHARE ROW EXCLUSIVE 锁并进行全表扫描。使用 SeparateDatabaseAndState 和 RunSQL 添加 NOT VALID,并包含 lock_timeout。 |
AddIndexConcurrently 或 RemoveIndexConcurrently | Migration 类中的 atomic 不为 False | CONCURRENTLY 不能在事务中运行——会报错 |
RunPython | 函数直接导入模型(from app.models import X) | 使用的是当前模型类,而不是历史快照——会导致旧迁移失效 |
如果项目配置了运行时防护,还需检查以下错误
如果项目配置了运行时防护(参见 Project Configuration),也要将以下情况标记为错误(它们会在迁移时引发异常):
| Django 操作 | 检查内容 |
|---|---|
RenameField | 任何重命名 |
RenameModel | 任何重命名 |
AlterField | 列类型变更 |
AddConstraint | ExclusionConstraint |
如果未配置运行时防护,也要将这些情况标记为错误——它们需要安全地重写。
警告
| 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 ...; """)