5.4 审查并执行Alembic迁移

2026-09-05 15:36 更新

审查并执行Alembic迁移

模型文件描述“现在希望有什么结构”,迁移文件描述“数据库怎样从旧结构走到新结构”。两者不能互相替代:应用模型会继续变化,而已经执行过的迁移必须保留当时的字段、约束和数据处理语义。Alembic 的 --autogenerate 只能根据元数据和数据库状态生成候选差异,生成后必须人工审查,不能把候选文件直接当成正确迁移。

本节演示给 posts 增加可为空的 excerpt 字段。示例代码是静态迁移文件,可以在没有数据库的环境中做结构检查;真正的 upgrade、已有数据回填、downgrade 和表结构验证必须在独立的 PostgreSQL 练习数据库中执行。本节没有连接真实数据库,因此不把数据库迁移描述成已验证。

先分清四个名字

一份 Alembic revision 至少包含四类容易混淆的信息:

名称 作用 是否决定执行顺序
revision 当前节点在迁移图中的唯一标识 是,配合 down_revision
down_revision 当前节点的父节点
文件名中的 slug 对人可读的变更描述
文件名前的日期或时间 便于目录浏览

迁移执行顺序由 revision 图决定,不由文件名的日期排序决定。团队可以在 alembic.ini 中设置可读的文件名模板,例如:

[alembic]
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s

在 ConfigParser 配置中需要把 % 写成 %%;Alembic 实际使用时会把它还原成模板标记。文件名中的 slug 应说明变更,例如 2026_09_05_8b17f0c4e2a1_add_posts_excerpt.py,但不要根据文件名猜执行顺序。

一份静态 revision

下面的迁移只做一件事:增加可为空的文本字段。它没有导入应用的 Post 模型,也没有在运行时读取当前模型定义,因此未来模型变化不会改变历史迁移的含义。

<!-- file: ch05_migration/versions/20260905_8b17f0c4e2a1_add_posts_excerpt.py -->

"""add posts excerpt

Revision ID: 8b17f0c4e2a1
Revises: 4f2d9a11c0e7
Create Date: 2026-09-05 10:00:00
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa


revision = "8b17f0c4e2a1"
down_revision = "4f2d9a11c0e7"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.add_column(
        "posts",
        sa.Column("excerpt", sa.Text(), nullable=True),
    )


def downgrade() -> None:
    # 删除列会丢失已经写入的 excerpt 内容,只能在确认可接受时执行。
    op.drop_column("posts", "excerpt")

生成候选 revision 的命令通常是:

alembic revision --autogenerate -m "add posts excerpt"

审查时至少确认:生成的表名和列名是否正确、nullable 是否符合已有数据、唯一和外键约束是否有明确名字、是否误删或误改了字段、upgradedowngrade 是否覆盖同一变更,以及迁移是否误用了当前应用模型。审查完成后,才在练习数据库中执行:

alembic upgrade head
alembic current
alembic downgrade -1

这些命令需要正确配置 alembic.inienv.py 和 PostgreSQL URL。本地静态检查不会执行它们。

现有数据决定迁移步骤

给已有表增加非空字段不能只写 nullable=False。旧行没有这个值,数据库在执行 ADD COLUMN 时就可能无法满足约束。常见的分阶段方案是:

  1. 先增加允许 NULL 的字段,或者提供一个明确的 server_default
  2. 使用静态、可审查的 SQL 为已有行回填,例如从 content 截取摘要。
  3. 查询并确认没有剩余 NULL,再在另一份迁移中设置 NOT NULL
  4. 如果默认值只为历史回填服务,确认是否应该移除它,避免未来写入得到意外默认值。

例如,回填逻辑可以是迁移文件中的明确语句:

op.execute(
    sa.text(
        "UPDATE posts "
        "SET excerpt = left(content, 160) "
        "WHERE excerpt IS NULL"
    )
)

这段 SQL 依赖 PostgreSQL 的 left() 函数;如果项目要求跨数据库,应改用目标数据库都支持的表达式,或在迁移中明确数据库方言。数据回填不是“自动生成”的安全结果,必须结合数据量、锁、执行时间和失败恢复方案审查。

字段重命名也不能简单地删除旧列再创建新列,否则数据会丢失。应使用 op.alter_column() 或目标数据库支持的重命名语句,并检查索引、外键、触发器、应用代码和回滚是否同步。旧列名、旧类型和转换规则应固定写在迁移文件里,不要从当前 ORM 模型动态推导。

用无数据库检查验证 revision 结构

下面的检查脚本读取上面的 revision 文件,用 AST 验证 revision 图和 upgrade/downgrade 入口,再用 Alembic 的 PostgreSQL 离线操作生成 SQL。它没有连接数据库,也不会修改任何表;因此只能验证文件结构和操作语句,不等价于 PostgreSQL 集成测试。

<!-- file: ch05_migration/check_revision.py -->

from __future__ import annotations

import ast
import io
import re
from pathlib import Path

import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations


REVISION_FILE = (
    Path(__file__).parent
    / "versions"
    / "20260905_8b17f0c4e2a1_add_posts_excerpt.py"
)


def assignment_value(tree: ast.Module, name: str) -> str | None:
    for node in tree.body:
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name) and target.id == name:
                    value = ast.literal_eval(node.value)
                    return value if isinstance(value, str) else None
    return None


def self_check() -> None:
    source = REVISION_FILE.read_text(encoding="utf-8")
    tree = ast.parse(source, filename=str(REVISION_FILE))
    revision = assignment_value(tree, "revision")
    down_revision = assignment_value(tree, "down_revision")
    assert revision is not None and re.fullmatch(r"[0-9a-f]{12}", revision)
    assert down_revision is not None
    assert "from app.models" not in source
    assert any(
        isinstance(node, ast.FunctionDef) and node.name == "upgrade"
        for node in tree.body
    )
    assert any(
        isinstance(node, ast.FunctionDef) and node.name == "downgrade"
        for node in tree.body
    )
    assert "op.add_column(" in source
    assert 'op.drop_column("posts", "excerpt")' in source

    output = io.StringIO()
    context = MigrationContext.configure(
        dialect_name="postgresql",
        opts={"as_sql": True, "output_buffer": output},
    )
    operations = Operations(context)
    operations.add_column("posts", sa.Column("excerpt", sa.Text(), nullable=True))
    operations.drop_column("posts", "excerpt")
    generated = output.getvalue().lower()
    assert "alter table posts add column excerpt text" in generated
    assert "alter table posts drop column excerpt" in generated
    print("check_revision self-check passed; no database connection used")


if __name__ == "__main__":
    self_check()

在包含 ch05_migration/ 的工作目录运行:

python ch05_migration/check_revision.py

--autogenerate 不是最终审查

Alembic 需要在 env.py 中得到目标 MetaData,然后将数据库当前结构和应用元数据做比较。它擅长生成明显的新增列、删除列或类型差异,但不能可靠推断所有重命名、数据回填、业务语义和数据库特有对象。生成文件后必须逐行检查,尤其是自动生成的删除操作和非空字段变更。

在本节锁定的 Alembic 1.19.2 中,具名 CHECK 约束的自动检测默认关闭;需要显式启用 alembic.ext.checkconstraint_byname 扩展后才能参与比较。即使启用,生成的约束变更仍然只是候选迁移,必须人工核对名称、已有数据和目标数据库行为。

迁移文件应保持静态:结构由代码固定,数据值可以在明确的回填步骤中读取数据库当前内容。不要在迁移中导入会持续变化的业务模型、请求配置或外部 API;否则同一份历史迁移在未来运行时可能得到不同结果。多个分支产生 head 时,还要先检查迁移图和合并策略,不要用文件名排序掩盖分支问题。

升级、降级和数据损失

upgrade 应把数据库从父 revision 推进到当前 revision,downgrade 应在可接受的范围内撤销结构变化。删除 excerpt 会丢失已经填写的摘要,因此这个 downgrade 不是无损操作;执行前必须确认备份、数据保留和回退计划。只在独立练习数据库中演示 downgrade -1,不要对生产数据未经审查地执行降级。

真实 PostgreSQL 验收至少包括:迁移前后的 alembic currentalembic upgrade head、关键表结构检查、已有数据回填数量、重复值和无效引用的失败结果,以及在可接受时执行的 downgrade。当前本节只运行了无数据库静态和离线 SQL 检查,以上 PostgreSQL 验收仍待在专用环境完成。

资料来源

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号