Alembic 数据迁移
本章你将学会 - 说清 createall 的三个硬伤:只建不改、不记版本、多环境不同步 - alembic init 生成的四大件各管什么:alembic.ini / env.py / script.py.mak…
本章你将学会
- 说清
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/headsalembic_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 的完整动作,就三步:
- 读进度牌,找到当前页码;
- 沿版本链从当前页走到目标页,逐页执行脚本的
upgrade(); - 每执行完一页,把牌子上的页码更新成这一页。
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 |
自测三问:
create_all的三个硬伤分别是什么?哪一条让你在生产环境完全没法用?alembic_version表里存的是什么?执行upgrade head的过程中它是怎么被更新的?- 把模型里的列
title_snapshot改名snapshot_title,直接跑--autogenerate会生成什么?有什么后果?正确的做法是什么?
数据库这条线到此收口:连接、会话、建模、查询、迁移都齐了。下一章回到 FastAPI 本身——依赖注入:路由函数之外,怎么把「每个接口都要用的东西」优雅地送进去。
下一章:08 · 依赖注入