首页/Python 框架指南/Alembic 数据迁移
Python 框架指南 · 第 07 章

Alembic 数据迁移

本章你将学会 - 说清 createall 的三个硬伤:只建不改、不记版本、多环境不同步 - alembic init 生成的四大件各管什么:alembic.ini / env.py / script.py.mak…

约 34 分钟13,497 字28 个代码示例

本章你将学会

  • 说清 create_all 的三个硬伤:只建不改、不记版本、多环境不同步
  • alembic init 生成的四大件各管什么:alembic.ini / env.py / script.py.mako / versions/
  • 在 env.py 里接入自己的模型:target_metadata 一行定乾坤
  • revision --autogenerate 生成迁移,revision / down_revision 怎么串成一条链
  • 手写迁移:op.create_table / op.add_column / op.drop_column / op.execute
  • 一套命令走天下:upgrade / downgrade / history / current / heads
  • alembic_version 表怎么记「数据库干到第几页」
  • --autogenerate 的盲区,以及四条迁移纪律

第 05 章结尾留了个扣子:create_all 是活动板房,Alembic 才是正规施工队。这一章施工队正式进场——墨集 inkhub 要从「自己机器上跑得欢」走向「多人协作、有测试环境有生产环境的正经项目」,表结构的每一次演进都得有据可查、有路可退。


1. 先算旧账:create_all 到底缺什么

把 create_all 的账算全,它有三个硬伤:

硬伤一:只建不改。checkfirst=True 的真实含义是「表存在就跳过」。你给 Article 加了一列,重跑 create_all,它看一眼:表在,跳过——新列永远不会出现。开发期的土办法是删库重建,可库里的文章数据呢?陪着结构一起火化。

硬伤二:不记版本。数据库被改成了什么样、什么时候改的、为什么改——一概不知。出了问题想退回上个版本?没有历史,退个寂寞。

硬伤三:多环境无法同步。你本机改了模型,测试环境的库怎么跟上?生产的库呢?挨个环境手工执行 SQL?手工操作必出错,出了错还没法追溯是谁、在哪儿、改了什么。

一句话:create_all 解决「从 0 到 1」,解决不了「从 1 到 1.1、1.2、2.0」。

Alembic 干的就是这件事:把每一次表结构变更写成一个带版本号的脚本,脚本进版本库;任何环境执行同一串脚本,就能到达同一个版本。升级、回滚、多环境同步,全有据可查。

比喻再升级一下:create_all 像街边刷墙的游击队,刷完就走不留痕迹;Alembic 是带施工日志的正规施工队——每次改造登记一页日志(迁移脚本),工地门口再挂一块进度牌(alembic_version 表),干到第几页,牌子上写得清清楚楚。本章要搞明白的就是:日志本怎么写、进度牌怎么翻。


2. 施工队进场:安装与 alembic init

作用:安装 Alembic,在项目里生成它的工作目录。

最小示例:

uv add alembic psycopg2-binary
alembic init migrations

第二个命令的参数 migrations 是目录名(想叫 alembic 也行),输出大致是:

Creating directory '.../migrations' ...  done
Creating directory '.../migrations/versions' ...  done
Generating '.../alembic.ini' ...  done
Generating '.../migrations/env.py' ...  done
Generating '.../migrations/script.py.mako' ...  done
Please edit configuration/connection/logging settings in '.../alembic.ini' before proceeding.

落地后的目录结构(墨集的项目骨架):

inkhub/
├── alembic.ini            # 队里的制度手册(总配置)
└── migrations/            # 施工队现场
    ├── env.py            # 现场指挥部:连哪个库、照哪套图纸
    ├── script.py.mako    # 空白日志页的模板
    ├── README            # 无关紧要的占位说明,可删
    └── versions/         # 施工日志本(此刻还是空的)

生成物还会包含一个叫 README 的占位文件,内容就一行「Generic single-database configuration」,没实际用途。真正的四大件是 alembic.ini、env.py、script.py.mako、versions/,接下来逐个拆。

常见坑:

  • alembic init 要在项目根目录执行——它和你项目的包结构是平级关系,跑错目录后面 import 会乱。
  • 用了异步驱动(asyncpg)的应用,Alembic 这边照样装同步驱动(psycopg2-binary),原因见下一节。

3. alembic.ini:队里的制度手册

作用:Alembic 的总配置文件,ini 格式。管「日志放哪、连哪个库、文件叫什么名、输出什么日志」。多数项一辈子不用动。

值得认识的三项:

[alembic]
# 迁移脚本目录在哪(%(here)s 指本文件所在目录)
script_location = %(here)s/migrations

# 生成迁移文件名:版本号_说明文字(%%(rev)s_%%(slug)s)
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s

# 把哪个目录加进 sys.path(让 env.py 能 import 到你的项目代码)
prepend_sys_path = .

# 数据库地址(占位写法,真实 URL 由 env.py 覆盖)
sqlalchemy.url = driver://user:pass@localhost/dbname

文件后半段的 [loggers] / [handlers] 是 Python 标准 logging 配置,管 alembic 命令自己的日志输出,不用动。

常见坑:

  • 别把真实密码留在 sqlalchemy.url 里——这个文件是要进版本库的。留占位符,真实 URL 在 env.py 里从项目配置(第 03 章的 Settings)读出来覆盖。
  • prepend_sys_path = . 决定 env.py 里能不能 import 到你的包。命令在别的目录跑、报 ModuleNotFoundError,先怀疑它。
  • 团队想统一迁移文件名带日期(好排序):把 file_template 那行注释打开,新生成的文件就带年月日。

4. env.py:现场指挥部(本章唯一要大改的文件)

作用:每次执行任何 alembic 命令,这个脚本都会先跑一遍。它回答两个问题:连哪个数据库?照哪套图纸施工?图纸就是第 05 章说的 Base.metadata 档案柜。

要改的就两处:导入你的模型、覆盖数据库 URL。

# migrations/env.py 关键改动(完整版见本章 demo)
from app.core.config import db_settings   # 第 03 章 Settings 的数据库配置
from app.model import Base                 # __init__. 里 import 了全部模型

config = context.config

# 用项目配置覆盖 alembic.ini 里的占位 URL
# 注意:同步驱动 postgresql:// —— 不带 +asyncpg
config.set_main_option(
    "sqlalchemy.url",
    f"postgresql://{db_settings.user}:{db_settings.password}"
    f"@{db_settings.host}:{db_settings.port}/{db_settings.name}",
)

# 关键一行:把模型档案柜交给 Alembic
target_metadata = Base.metadata

target_metadata 是 autogenerate 的输入之一:没有它,--autogenerate 直接罢工(报 Can't locate revision / target_metadata 未配置之类的错)。模型包的 __init__.py 要把所有模型文件 import 一遍,套路和第 05 章 create_all 完全一致——图纸没进档案柜,谁来施工都白搭。

env.py 里还有两个函数不用动,认识即可:

  • run_migrations_online():默认模式,真连数据库执行迁移;
  • run_migrations_offline():离线模式,只把迁移渲染成 SQL 文本(--sql 时走这条路,交给 DBA 人工执行的场景用)。

常见坑:

  • URL 必须是同步驱动。应用跑 postgresql+asyncpg://,但 Alembic 默认用同步引擎干活,URL 写成 postgresql:// 配 psycopg2 即可。新手最常撞的错:直接把 asyncpg 的 URL 塞进来,报 asyncpg 相关的 driver 错误。
  • 全异步项目想让 env.py 也 async?官方有异步模板:alembic init -t async migrations。能用,但同步驱动更省心,除非有特殊理由,建议保持同步。
  • 模型没被 import = autogenerate 生成的迁移缺表且不报错,跑到升级执行时才发现,甚至更晚。排查第一反应永远是 app/model/__init__.py。
  • 路径按你自己项目调整:示例是 app.core.config / app.model,你可能是 inkhub.config / 别的,道理不变。

5. script.py.mako:空白日志页的模板

作用:每次生成新的迁移脚本,Alembic 按这个 Mako 模板渲染出一个 py 文件。${...} 是模板占位符。

几个关键占位符:

占位符 渲染成什么
${up_revision} 本页页码(随机 12 位十六进制,如 a1b2c3d4e5f6)
${down_revision} 上一页的页码(第一页渲染成 None)
${message} 你 -m 传的说明文字
${upgrades} / ${downgrades} autogenerate 对比出的差异代码(手写模式渲染成 pass)

什么时候改它?想让全团队的迁移脚本统一风格——比如统一加 from __future__ import annotations、统一注释格式——改模板一处,之后所有生成页都变。不改也完全能用,本章不动它。

常见坑:别把它当业务文件改坏——${} 语法一旦残缺,之后每次 revision 生成脚本都会报模板渲染错误。


6. versions/:日志本与页码链

作用:存放所有迁移脚本。每个脚本是一页施工日志,页脚的两个字段把整本日志串成一条链:

revision = "b2c3d4e5f6a7"        # 本页页码
down_revision = "a1b2c3d4e5f6"   # 上一页页码(第一页是 None)

墨集的日志本(本章 demo 的三个脚本):

versions/
├── a1b2c3d4e5f6_init.py              revision: a1b2c3d4e5f6   down_revision: None
├── b2c3d4e5f6a7_add_review_score.py   revision: b2c3d4e5f6a7   down_revision: a1b2c3d4e5f6
└── c3d4e5f6a7b8_drop_review_score.py revision: c3d4e5f6a7b8   down_revision: b2c3d4e5f6a7

把 down_revision 想成「上一站站名」:每一站只记得自己的上一站,但顺着站名一路往回数,能从任意一站数到起点。Alembic 的所有导航(升级、回滚、历史)都是沿着这条链走的。

两个术语:

  • head:链的最后一页,即当前最新版本;
  • base:链之前的「虚无」,什么结构都还没有。

常见坑:

  • 页码是身份标识,生成后别改。数据库 alembic_version 表里记的就是页码(第 10 节),你手痒改了页码,库里的记录就对不上了。
  • 别复制文件伪造新迁移。新迁移必须用 alembic revision 生成——页码有随机性和唯一性要求,手抄文件改个 revision 看着能用,实际是埋雷。
  • 两个人同时生成分支会出现两个 head(链分叉),合并用 alembic merge。团队按顺序提交迁移、及时拉代码,一般碰不到;见到了知道是怎么回事即可。

7. 生成迁移:revision --autogenerate

作用:对比「图纸」(Base.metadata)和「工地现状」(数据库的实际结构),把差异写成一个新的迁移脚本。这是日常用得最频繁的命令。

签名:

alembic revision --autogenerate -m "说明文字"   # 对比差异,生成脚本(不执行)
alembic revision -m "说明文字"                   # 只给一页空白日志,内容自己写(第 8 节)

最小示例——墨集编辑部提需求:给文章版本记录审稿分。

# 第一步:改模型(第 05 章的姿势)
class ArticleVersion(Base, IDMixin, TimestampMixin):
    """文章历史版本。"""
    ...
    # 新增:审稿分 0.0 ~ 10.0,老行由数据库端补 0
    review_score: Mapped[Decimal] = mapped_column(
        Numeric(3, 1), server_default=text("0")
    )
# 第二步:生成迁移
alembic revision --autogenerate -m "add review_score"
# Generating '.../versions/b2c3d4e5f6a7_add_review_score.py' ...  done
# 第三步:打开生成的脚本审查(demo 同款,略有精简)
def upgrade() -> None:
    op.add_column(
        "article_version",
        sa.Column(
            "review_score",
            sa.Numeric(precision=3, scale=1),
            server_default=sa.text("0"),
            nullable=False,
        ),
    )


def downgrade() -> None:
    op.drop_column("article_version", "review_score")
# 第四步:确认无误,执行
alembic upgrade head

真实生成的脚本里,op 调用被夹在 # ### commands auto generated by Alembic - please adjust! ### 两行注释之间——please adjust 的言外之意就是「请审查我」。

它怎么知道要写什么:连上数据库反射出实际结构 → 和 target_metadata 里的图纸逐表逐列对比 → 差异翻译成 op 调用。所以第一次跑(数据库还是空的)生成的就是全量建表脚本——demo 里那页 init 就是这么来的。

存量库怎么接入:墨集开发库里已经用 create_all 建过表了,两条路——

  • 干净路线(开发环境):删掉 schema,autogenerate 生成 init 建表迁移,upgrade head 重新建;
  • 存量路线(有数据的环境):先在空库上生成好 init 迁移,再对已有库执行 alembic stamp head——只把进度牌翻到最新页,不真执行脚本(第 9 节)。

常见坑:

  • 生成 ≠ 正确。autogenerate 是初稿不是答案:漏检(第 11 节)和误检都存在,生成后必须逐行审查再执行。
  • revision 只是「写日志」,没施工;忘了 upgrade,数据库还是老样子。模型、迁移脚本、数据库三样东西要两两对齐。
  • 改完模型忘了生成迁移,应用却在跑——新列在代码里存在、在库里不存在,一查询就炸。改模型和生成迁移,一个动作拆不开。

8. 手写迁移:op 三板斧

作用:autogenerate 搞不定(改名、改类型)或不放心(数据回填)的场景,自己写。所有结构操作都通过 op 模块——Alembic 的「操作代理」,它负责把你的意图翻译成当前方言的 SQL。

op.add_column / op.drop_column

签名:

op.add_column("表名", sa.Column("列名", 类型, ...))
op.drop_column("表名", "列名")

最小示例——给已有数据的表加非空列(上一节的真实代码):

def upgrade() -> None:
    # 表里已有历史版本行:加非空列必须给 server_default 兜底,
    # 否则老行的新列填不上,迁移直接炸
    op.add_column(
        "article_version",
        sa.Column(
            "review_score",
            sa.Numeric(precision=3, scale=1),
            server_default=sa.text("0"),
            nullable=False,
        ),
    )


def downgrade() -> None:
    op.drop_column("article_version", "review_score")

op.create_table / op.drop_table

签名(高频形态):

op.create_table(
    "表名",
    sa.Column("id", sa.Integer(), nullable=False),
    sa.Column("name", sa.String(length=50), nullable=False),
    sa.PrimaryKeyConstraint("id"),
    sa.UniqueConstraint("name"),
    sa.ForeignKeyConstraint(["外键列"], ["目标表.目标列"], ondelete="CASCADE"),
)
op.drop_table("表名")

最小示例:墨集五张表的完整建表代码在 demo 的 a1b2c3d4e5f6_init.py——就是这套 API 的堆叠,建表顺序(channel/tag → article → article_tag/article_version)由外键依赖决定。照着读一遍,胜过看十遍文档。

op.execute:跑裸 SQL

签名:

op.execute("任意 SQL 语句")   # 建表删表之外的活,全靠它

最小示例——加列之后回填历史数据:

def upgrade() -> None:
    # 先以可空形式加列 → 回填 → 再收紧成非空,三步走最稳
    op.add_column("article", sa.Column("word_count", sa.Integer(), nullable=True))
    op.execute("UPDATE article SET word_count = 0 WHERE word_count IS NULL")
    op.alter_column("article", "word_count", nullable=False)


def downgrade() -> None:
    op.drop_column("article", "word_count")

顺手认识 op.alter_column:改列类型、可空性、列名(new_column_name=)都归它管,第 11 节还会出场。

手写迁移的固定流程:

alembic revision -m "backfill word_count"   # 拿一页带页码的空白日志
# 打开生成的文件,把 upgrade / downgrade 补齐
alembic upgrade head                        # 执行

常见坑:

  • upgrade 和 downgrade 尽量互逆。真不可逆的(如删列),downgrade 里写 pass 也行,但要注释清楚「回不来了」——demo 第三页就是这么做的。
  • 迁移脚本是历史快照,不是活代码:别回头改老脚本去配合新模型,理由见第 12 节军规二。
  • 新页永远用 alembic revision 生成,别手抄文件(第 6 节的雷)。

9. 干活与回退:upgrade / downgrade / 查台账

alembic upgrade:往后翻日志干活

作用:沿版本链向新版本执行迁移。

签名:

alembic upgrade head                # 一口气干到最新
alembic upgrade +1                  # 只往前一页
alembic upgrade a1b2c3d4e5f6        # 干到指定页码
alembic upgrade head --sql           # 只渲染 SQL 打印,不连库(离线模式)

alembic downgrade:倒着翻

作用:沿版本链向老版本回退,逐页执行每个脚本的 downgrade()。

签名:

alembic downgrade -1                # 回退一步
alembic downgrade a1b2c3d4e5f6      # 回退到指定页码
alembic downgrade base              # 全部退光(表删光,慎之又慎)

history / current / heads:查台账

alembic history    # 列出日志本每一页(从最新到最老)
alembic current   # 数据库现在停在第几页(读 alembic_version 表)
alembic heads     # 日志本的最新一页是哪页

history 的输出示意(格式从简,重点是每页的页码和说明,最新页标 head):

c3d4e5f6a7b8 (head)  drop_review_score
b2c3d4e5f6a7         add_review_score
a1b2c3d4e5f6         init

日常排查的固定反射:current 和 heads 对不对得上——对不上,就是库落后于脚本,该 upgrade 了。

alembic stamp:只翻牌,不施工

签名:

alembic stamp head     # 把进度牌直接翻到最新页,不执行任何脚本

存量库接入(第 7 节)用它;误删 alembic_version 表后对账也能用它。除此之外别碰——伪造进度等于跳过施工。

常见坑:

  • downgrade 是真删。downgrade base 会把整条链的表全删掉,回退前看清楚自己站在哪页、要退几页。
  • 回退之后,数据库和模型就不一致了(模型比库新)。这只能是暂时状态——要么重新 upgrade 回去,要么按当前模型生成新迁移对齐,绝不能长期「带伤运行」。
  • 生产环境执行前,先 alembic current 看清现状,再 alembic history 确认要跨过哪几页,心里有数再动手。

10. alembic_version 表:门口的进度牌

作用:Alembic 在数据库里自动创建的一张表(默认名 alembic_version),只有一列 version_num。它是工地门口的进度牌,回答唯一的问题:这个库干到第几页了。

SELECT * FROM alembic_version;

--  version_num
-- --------------
--  b2c3d4e5f6a7     ← 这个库停在 add_review_score 这页

upgrade 的完整动作,就三步:

  1. 读进度牌,找到当前页码;
  2. 沿版本链从当前页走到目标页,逐页执行脚本的 upgrade();
  3. 每执行完一页,把牌子上的页码更新成这一页。

downgrade 反着来:从当前页往回走,逐页执行 downgrade(),牌子跟着往回翻。

多环境同步的本质就在这块牌子上:开发、测试、生产各有一块自己的进度牌,代码(迁移脚本)却只有一套。测试环境先 upgrade 验证,没问题再让生产 upgrade——同一个版本库,各自前进,永远知道彼此差几页。

常见坑:

  • 别手动改、删这张表。改了等于伪造施工进度:跳页会漏建结构,倒着改会让 Alembic 重复执行已做过的操作。
  • 重建数据库后忘了跑迁移,应用一启动就报「表不存在」——这是好事,比静默错强多了。
  • 多库场景(多租户)可以给每个库配不同的 version_table 名字,进阶话题,知道有这回事即可。

11. --autogenerate 的盲区:它只是初稿

autogenerate 对比的是「模型定义」和「数据库反射结果」,这个对比天生有盲区。按杀伤力排序:

盲区一:改列类型,常常检测不到。String(50) 改 String(200)、Integer 改 BigInteger,旧版 Alembic 的 compare_type 默认是关的,一声不吭;新版(1.12 起)把它默认打开了,多数类型变化能检出,但「等价类型」的宽容比较、个别方言下的边角差异仍可能漏。稳妥结论:改类型别赌 autogenerate,审查时重点盯,必要时手写:

op.alter_column(
    "article", "title",
    existing_type=sa.String(length=200),
    type_=sa.String(length=500),
    existing_nullable=False,
)

盲区二:改列名 = 删一列加一列。把 title_snapshot 改名 snapshot_title,autogenerate 生成的是 drop_column + add_column——照着执行,数据全没。正确姿势是手写改名:

op.alter_column(
    "article_version", "title_snapshot",
    new_column_name="snapshot_title",
    existing_type=sa.String(length=200),
)

盲区三:server_default 的变化默认不比。compare_server_default 默认关(开了也容易误报),改默认值基本靠手写或人工核对。

盲区四:只管结构,不管数据。列加上了,老行的值怎么填?autogenerate 不管——op.execute 回填是自己的活(第 8 节)。

盲区五:改名表、部分索引、注释等边角。表改名同样识别为删表建表;一些方言特有的索引变化也未必检出。

统一成一句话:autogenerate 给的是「待审查的草稿」,不是「可直接执行的答案」。生成之后逐行读一遍,是纪律,不是可选项。


12. 迁移纪律:四条军规

军规一:迁移脚本进版本库。它和业务代码同等地位——队友拉下代码,alembic upgrade head 一跑,环境就同步了。脚本只躺在某台机器上,等于没有。

军规二:已执行的脚本是不可变的常量。上线过的迁移脚本,永远不要回头改。原因:alembic_version 已经记下那页的页码——已执行过的环境(生产)和还没执行的环境(新同事的机器)手里拿着不同内容的同一页,链就裂了。发现老脚本有 bug?写一个新的迁移去修正它。上一章说模型是图纸,这一章补一条:迁移脚本是历史档案,档案只增不改。

军规三:生产 upgrade 之前,先备份。pg_dump 一分钟,出事有退路。迁移是改结构的操作,再自信的脚本也值得一次备份;改列类型、批量回填这类大动作,先在测试环境完整演练一遍。

军规四:不可逆操作,三思后行。drop_column、drop_table、缩窄类型,都是「数据跟着走」的操作——downgrade 只能恢复结构,恢复不了数据。执行前问自己三遍:这列的数据真没用了吗?备份导出了吗?能不能先软删除观察一阵?

日常开发的节奏固定成四拍:

改模型 → revision --autogenerate → 人肉审查脚本 → upgrade head

两个高频追问:

  • 上线后发现某页迁移有 bug?不改老脚本(军规二),写新迁移修正:让新迁移把结构对回正确的样子,审查后照常 upgrade。
  • 什么时候该 downgrade?尽量别。回滚后库和模型不一致,可能按下葫芦浮起瓢。真要回滚,记得那只是暂时状态,尽快用新的 upgrade 或新迁移对齐。

13. 本章 demo:migrations_reference

这一章不搭可运行的迁移工程,demo 换成一套「抄得走」的样例:

demo/migrations_reference/
├── README.md                             # 起步命令 + 使用说明
├── env.py                                # 接入墨集模型的 env.py 完整示例
└── versions/
    ├── a1b2c3d4e5f6_init.py               # 第一页:建五张表
    ├── b2c3d4e5f6a7_add_review_score.py    # 第二页:article_version 加审稿分
    └── c3d4e5f6a7b8_drop_review_score.py   # 第三页:删审稿分(downgrade 演示恢复写法)

建议的读法:先读 env.py 对照第 4 节;再按 init → add → drop 的顺序把三个脚本读一遍,配合第 6 节的链图,down_revision 的指向一看就懂。三个文件是真实迁移脚本的完整长相(docstring、revision 字段、upgrade/downgrade 一个不少),照着写自己的不会错。


14. 小结速查

需求 命令 / 写法
初始化 alembic init migrations
接入模型 env.py:target_metadata = Base.metadata + 覆盖 URL(同步驱动)
自动生成迁移 alembic revision --autogenerate -m "说明"
手写迁移 alembic revision -m "说明",自己补 upgrade / downgrade
加列 / 删列 op.add_column("t", sa.Column(...)) / op.drop_column("t", "col")
建表 / 删表 op.create_table("t", ...) / op.drop_table("t")
改列 op.alter_column("t", "col", type_=..., new_column_name=...)
跑裸 SQL op.execute("UPDATE ...")
升级到最新 alembic upgrade head
回退一步 alembic downgrade -1
查历史 / 当前版本 / 最新页 alembic history / alembic current / alembic heads
只翻牌不施工 alembic stamp head
只生成 SQL alembic upgrade head --sql

自测三问:

  1. create_all 的三个硬伤分别是什么?哪一条让你在生产环境完全没法用?
  2. alembic_version 表里存的是什么?执行 upgrade head 的过程中它是怎么被更新的?
  3. 把模型里的列 title_snapshot 改名 snapshot_title,直接跑 --autogenerate 会生成什么?有什么后果?正确的做法是什么?

数据库这条线到此收口:连接、会话、建模、查询、迁移都齐了。下一章回到 FastAPI 本身——依赖注入:路由函数之外,怎么把「每个接口都要用的东西」优雅地送进去。


下一章:08 · 依赖注入