ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Alembic数据库迁移实战:从初始化到生产环境的完整指南

Alembic数据库迁移实战:从初始化到生产环境的完整指南

Alembic数据库迁移实战:从初始化到生产环境踩坑全记录

数据库迁移这件事,我最初是没当回事的。当时手里跑着一个小项目,表结构改了,就直接在线上环境执行几条ALTER TABLE完事,数据量不大,出问题的概率微乎其微。直到后来某次改动涉及三张表的关联字段,我在推生产前把四条 SQL 的执行顺序搞错了,结果外键约束在数据清理之前就生效,线上直接报错半小时。那次之后我彻底明白了,只要是会进代码仓库的表结构变更,就必须有一套和代码版本管理配套的数据库版本管理手段。在我看来,Alembic 就是干这个事的标准工具,它和 SQLAlchemy 深度绑定,用 Python 写迁移脚本,支持自动生成大部分 DDL,本质上就是给数据库结构做的 Git。

这篇文章按我实际的使用路径来写,从安装初始化讲起,重点放在自动生成迁移脚本的边界、SQLite 场景下的批处理限制、多分支合并冲突的解法,还有生产发布时最容易踩的几个坑。适合正在用或准备用 SQLAlchemy 的 Python 团队,也适合想从手工改表过度到规范化迁移流程的开发者。

1. 为什么需要一套显式的数据库迁移机制

先不说 Alembic 本身怎么用,我们得先把为什么需要这件事掰扯清楚。很多团队项目的表结构变更,靠的是"某个人在某个环境手动执行了一条 SQL",这种习惯在一个人的个人项目里没问题,一旦多人协作、有多套环境,问题就全冒出来了。

1.1 没有版本控制的数据库结构就是一场灾难

我曾经在某团队见到过这样的状况:开发库里的表字段被某位同事加了索引,但没人记录这条操作。等另一个人拉最新的代码跑单元测试,发现测试环境表结构和代码对不上,链路里到处是诡异的报错。排查到最后发现,是新功能依赖的字段在测试环境还不存在。而真正的元凶,是那个"手动执行 SQL 并以为大家都会同步操作"的同事。

手工维护学号、日期、备注这类说明文档也好不到哪儿去。文档里写着"执行 001_fix.sql",但没人记得这个脚本是在哪个基线之上做的,也没人知道它的反向操作是什么。一旦升级失败想回滚,翻遍团队聊天记录也找不到原始变更内容。

Alembic 解决的就是这套连带问题。每个迁移脚本文件里包含两个函数,upgrade()负责执行变更,downgrade()负责回滚对应的那一次变更。数据库里维护一张alembic_version表,记录当前基线版本号,升级或回滚都按版本链一个个执行。这套逻辑和 Git 把代码从一个 commit 切到另一个 commit 的机制几乎一模一样,区别在于迁移脚本只能按版本号顺序推进,不搞分支切换。

1.2 Alembic 和 SQLAlchemy 的关系

Alembic 是 SQLAlchemy 的作者在 SQLAlchemy 生态上构建的迁移工具,它不排斥原生 SQL,模型定义也能被它自动读取。但它本身依赖 SQLAlchemy 做数据库连接、类型映射和元数据解析,也就是说,如果你的项目不是基于 SQLAlchemy 的,用 Alembic 会平白增加一层依赖成本。反过来,项目里已经用了 SQLAlchemy 模型,Alembic 几乎是天然配套的,因为它的自动生成功能就是照着 SQLAlchemy 模型的元数据和当前数据库结构之间的差异来产出脚本的。

2. 安装、初始化与环境配置中的关键细节

2.1 初始化命令与目录结构

安装没什么说的,pip install alembic就行。初始化一次项目目录:

cd /path/to/your/project alembic init alembic

执行完之后,根目录下会多出一个alembic.ini配置文件和一个alembic文件夹,里面包含了env.py、script.py.mako模板和versions目录。

有一个细节容易被忽略:alembic init生成的env.py默认是离线运行模式,它不自动导入你的模型定义,也不会去连你的数据库读取目标 schema,所以在这里必须显式把target_metadata指向你的 SQLAlchemyBase.metadata。

我用一个最小化的例子来说明。假设项目里有一个models.py,内容大致如下:

from sqlalchemy import Column, Integer, String from sqlalchemy.ext.declarative import declarative_base Base = declarative_base() class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) name = Column(String(64), nullable=False) email = Column(String(128), unique=True)

那alembic/env.py里至少要做两处改动。一处是把from models import Base加进来,另一处是把target_metadata = SomeBaseClass.metadata改成target_metadata = Base.metadata。如果你有多个模型文件分散在不同包里,建议建立一个models/__init__.py,在这个统一入口导入所有模型类,然后在env.py里只导入Base就行。我在项目里还会顺手把sys.path的当前目录加进去,避免相对导入出问题。

这一段配置是所有自动生成的前提。如果你连这步都没有做正确,后面跑alembic revision --autogenerate时生成的迁移文件会是一堆空的升级/降级函数,对着空壳一脸懵。

2.2 配置数据库连接地址

alembic.ini里有一个sqlalchemy.url选项,默认是driver://user:pass@localhost/dbname。个人项目直接填没问题,但团队项目里每个人都有独立的开发库,把这个地址写死在配置文件里会产生一堆本机冲突。

我常用的做法是在alembic.ini里保留一个占位:

sqlalchemy.url = driver://user:pass@localhost/dbname

然后在env.py里用环境变量覆盖:

import os from sqlalchemy import create_engine config.set_main_option( "sqlalchemy.url", os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url")) )

这样做的好处是,CI 环境、预发环境和开发环境共用同一份迁移脚本,只是通过环境变量切换目标库。团队里如果有人需要连测试环境,只需要在 shell 里设置一下DATABASE_URL再执行迁移命令即可,不需要改任何文件。

3. 自动生成迁移脚本:它能识别什么,不能识别什么

alembic revision --autogenerate -m "add user email"是使用频率最高的命令。它会在执行时连一次数据库,读取当前真实表结构与 SQLAlchemy 模型的元数据做对比,然后生成一个带op.create_table、op.add_column这类调用的脚本。

看起来非常方便,但你说它能监控一切吗?不是的。我见过太多人把自动生成当成万能工具,结果生成的脚本和预期不符,最后在线上执行时报错,或是执行成功但没达到预期效果。

3.1 它能稳定识别的变更类型

经过我反复实践,下面这几类变更可以完全信任自动生成,产出的脚本基本是符合预期的:

  • 新增表
  • 删除表
  • 新增列
  • 删除列
  • 修改列的可空性(nullable)
  • 修改列长度(比如String(64)改成String(128))
  • 新增独立约束(unique constraint、foreign key)
  • 创建索引

这些操作有一个共同特点:都能通过数据库的元数据直接比对出来,不需要推断业务意图。所以自动生成的可靠性在这一块很高。

3.2 它识别不了或容易搞错的场景

从我的踩坑经验来看,下面这些场景自动生成脚本基本会"瞎"或者给出错误结果。

默认值变更

假设之前给users.status设置了default=0,后来业务上想改成default=1,你只需要在模型里改一行default,跑--autogenerate,生成的迁移文件大概率是空的,或者只包含一个无关紧要的 index 调整。原因是数据库层面的列默认值(server_default)通常不会因为 Python 模型里的default变更而自动改变,Alembic 对比的是数据库 schema 和模型 schema,不是 Python 代码逻辑。

如果确实要变更数据库默认值,得手动补迁移脚本,写成:

from alembic import op def upgrade(): op.alter_column("users", "status", server_default="1") def downgrade(): op.alter_column("users", "status", server_default="0")

数据库视图、函数、触发器的变化

Alembic 不会自动感知到你在模型外创建的视图和触发器,它们也不在Base.metadata里。每次重建视图你都得手写op.execute,并且建议在迁移脚本里记录视图的版本。我们团队的做法是,把视图的创建 SQL 放在固定目录下维护,迁移脚本里引用其内容再执行。

列类型的精度变化

比如NUMERIC(10,2)改成NUMERIC(12,2),DateTime改成DateTime(timezone=True),这类类型参数调整,Alembic 有时会忽略,有时会生成成drop column+add column的操作,后者在绝大多数数据库上都会毁掉整张表的数据。我在某次生产发布里就遇到过一次,自动生成脚本把一列String(100)改为String(200),正常生成的是ALTER COLUMN。但一旦遇到带精度变化的类型,最好人工产看产出的脚本再决定执行。

3.3 当自动生成脚本与你预期不相符时的排查路径

有一种情况很典型:你改了模型,跑--autogenerate,结果发现迁移文件里多出了一堆莫名其妙的操作,比如 drop 掉某个约束、改掉某列顺序,等等。这种多半是开发环境数据库质量和模型元数据不一致造成的。

我的建议是,不要在改完模型的第一次自动生成时直接提交,先逐个查看生成的迁移脚本,对每个op指令问一句:这个操作是否对应了我这次的需求?如果多出来的操作不是本次预期的,先确认本地库是不是之前手工执行过一些未纳入迁移的变更。如果是,那就把本地库重新建库,灌入迁移基线,再执行一次 autogenerate,这次生成的脚本才是干净且可控的。

4. 处理数据迁移场景的批处理模式与时机问题

自动生成通常只处理 schema 层面的变更,而真实的业务变更往往还伴随数据转换。比如把users.phone里存的座机号码统一格式化为国际区号,或者把orders.status的字符串状态值映射为数字状态码。这类迁移必须由开发者在迁移脚本里手工书写。

4.1 batch_alter_table 的正确打开方式

SQLite 对ALTER TABLE的支持极度有限,只支持改表名、加列、加索引,不支持删除列、修改列约束,更不支持把一列的类型从Text改成Integer。如果目标数据库是 SQLite(开发环境很常见),Alembic 的普通op.drop_column会直接报错。

这时候必须用批处理模式。它的原理是:生成一张满足新结构的新表,把旧表数据按你指定的逻辑拷贝进去,删除旧表,再把新表重命名为旧表名。看起来繁琐,但这是 SQLite 上唯一可行的路子,而且 Alembic 会把整段操作包在事务里,尽可能保证数据一致性。

一个实际例子:我要把users表里的full_name拆成first_name和last_name。模型改动完,迁移脚本大概是这么写的:

def upgrade(): with op.batch_alter_table("users") as batch_op: batch_op.add_column(sa.Column("first_name", sa.String(50), nullable=True)) batch_op.add_column(sa.Column("last_name", sa.String(50), nullable=True)) def downgrade(): with op.batch_alter_table("users") as batch_op: batch_op.drop_column("first_name") batch_op.drop_column("last_name")

但这里面还有一个值得注意的问题:拆分之前,旧表里可能已经有几千条数据,full_name里的空格分隔得并不规律。这个数据填充逻辑如果只写在upgrade()里op.execute,那么新列最终是空的,因为batch_alter_table并不会替你执行数据迁移。

所以在批量模式的同一个脚本里,需要在加列之后,立即用op.execute执行数据写入:

from alembic import op import sqlalchemy as sa def upgrade(): with op.batch_alter_table("users") as batch_op: batch_op.add_column(sa.Column("first_name", sa.String(50), nullable=True)) batch_op.add_column(sa.Column("last_name", sa.String(50), nullable=True)) conn = op.get_bind() conn.execute( sa.text( "UPDATE users SET first_name = TRIM(SUBSTR(full_name, 1, INSTR(full_name, ' ') - 1)), last_name = TRIM(SUBSTR(full_name, INSTR(full_name, ' ') + 1))" ) )

当然,上面 SQL 只适用于所有记录都有空格的情况。更严谨的做法是把这种逻辑写成 Python 脚本遍历处理,但那样迁移耗时就会更长。原则是,宁可多写几行可读性强的代码,也不要试图写那种"一行 SQL 走天下"的高风险语句。

4.2 数据迁移应该在 upgrade 里做还是单独脚本里做

我见过不少团队喜欢把数据迁移写进独立的 Python 脚本,在升级之后手工运行。短期看方便,长期看全是坑。因为如果这个迁移脚本没跑,而代码已经全部上线,那么老数据就永远处于不可用的状态。最好的做法是让 schema 变更和数据迁移捆绑在一起,放在同一个 Alembic 迁移文件里,这样数据库版本号和业务数据水平才能严格同步。

有个例外是超大批量的历史数据清洗。一次性跑完可能要几十分钟甚至几小时,这种不适合放在upgrade()里导致整个迁移长时间阻塞。我们当时的处理是:迁移脚本里只做 schema 变更和增量数据的兼容,历史数据的清理部分以幂等脚本的形式独立运行,但这个独立脚本会在 README 里被显著标注,而且每次执行都会记录基线。这个方案本身不算完美,但确实比把重型清洗放进迁移链路里对生产更友好。

5. 多分支迁移冲突与合并策略

迁移脚本本质上是一个线性历史记录,从版本 A 到版本 B 再到版本 C,数据库只认一个当前版本号。只要两个人对同一个数据库链路的迁移是在不同分支同时进行的,当第二个分支合入主干后,就会出现两个迁移文件的down_revision指向同一个父版本,也就是分叉。此时运行alembic upgrade head,Alembic 会直接报错提示 multiple heads,它不知道你要走哪条迁移链。

5.1 分叉发生的典型场景和解决方案

在多人协作的项目中,我经常看到这样的场景:A 同学在 feature/a 分支上新增了一个users表的外键约束,生成了版本abc1;B 同学在 feature/b 分支上给orders表增加了一个索引,生成了版本abc2。两个分支都基于主干的版本abc0,合并时 git 不冲突,但 Alembic 发现数据库版本的迁移历史从abc0分出两个头,即abc1和abc2。

解决方式是用alembic merge创建一个合并迁移节点。

alembic merge -m "merge abc1 and abc2" abc1 abc2

这个命令会生成一个新迁移,它的down_revision同时包含abc1和abc2,它的upgrade()里默认什么都不用干,只需要空过,Alembic 会把两条分支的变更都当作已经应用,并把当前版本推进到合并点。

有一点要留意:merge命令生成的脚本里,upgrade()方法是空的,但它的使命是为了记录合并事实。如果你在合并迁移文件里什么都不写也能跑,但如果你希望合并点也执行一些收尾逻辑,比如清理字段、修正数据,也可以在这里补。

5.2 迁移链的依赖顺序比想象中更重要

如果说合并解决的是"多个头"的问题,那down_revision顺序就是决定迁移能否顺利回滚的关键。新手写迁移时经常把一个迁移的down_revision挂错,结果升级到某个版本后再想回滚,Alembic 会蹦出一堆错误。

我的建议是为项目制定一个简单的命名约定:down_revision永远指向当前主干 HEAD。生成迁移脚本后,第一时间查看该文件的down_revision是不是上一版本。如果因为合并没有及时更新主干,合并后你再生成新迁移,一定要再次确认这条链的完整性。跑一遍alembic history能快速看出全貌:

alembic history

输出里可以清晰看到每个版本的 revision 和 down_revision,哪条链断了、哪个节点是多头,一目了然。

6. 生产发布时的迁移执行策略与回滚边界

在开发和测试环境玩得再花,生产执行的策略也是另一回事。下面这些是我踩过坑之后整理出来的发布细节,对正在准备上生产的团队应该特别有用。

6.1 发布窗口的迁移顺序问题

经常会遇到的一种情况是:新代码已经部署,但数据库迁移还没执行。这会造成新旧代码和库结构不匹配,线上错误堆成山。因此我习惯把每个版本迭代分成两个阶段:

  • 第一阶段:先执行数据库迁移,再部署代码
  • 第二阶段:只有在你确认迁移成功且不需要立即回滚时,才继续部署

有些团队反过来,先发代码再跑迁移,这是何等的风险。代码里的模型如果引用了尚不存在的列,线上接口会直接 500。而先跑迁移的代价是,如果迁移失败,你还没发布代码,对用户的影响面小得多,可以立刻回滚迁移。

我有一个更安全的做法:如果迁移本身是破坏性的(比如删除列、改动字段类型),但代码兼容新旧结构,那可以先发代码、再跑迁移,否则先跑迁移、再发代码。

6.2 回滚不是万能的

Alembic 的downgrade()能做的回滚,仅限于你写进迁移脚本里的反向操作。如果你在upgrade()里删了一张表,但在downgrade()里只写了删除一个索引,那数据是找不回来的。

我在多个项目里见过一个普遍的坏习惯:只写upgrade(),downgrade()就是一个pass或者写个无关痛痒的操作。一旦线上迁移执行成功后发现业务逻辑有严重 bug,想回退数据库,回滚脚本直接失效,只能靠从备份恢复。

所以,真实生产经验是:所有迁移脚本必须同时提供可靠的downgrade()。这条不是可选项,是发布前的铁律。一个简单的检查清单包括:

  • 新增的表在downgrade()里要对应drop_table
  • 新增的列在downgrade()里要对应drop_column
  • 数据迁移必须设计幂等的反向操作
  • 如果反向操作不可行(比如数据一旦被清理就无法恢复),那这个迁移就不要计划回滚,只能靠备份

6.3 一个典型的取消发布反向迁移场景

某次我负责的模拟项目里,某个版本上线后运营后台发现用户列表页的排序字段出了问题,需要把这次迁移整体回滚。这四个版本链的迁移脚本分别是加字段、加索引、更新数据、删除一个冗余列。前三步都能顺利回滚,但那个"删除冗余列"的迁移,因为它删掉了大量冗余数据,而我已经明确知道这个冗余数据后期不再需要,所以downgrade()无法直接恢复那一堆历史内容。

当时我们的决定是:不执行自动回滚,改为在修复代码之后继续向下推进。也就是说,在最新版本之上再新增一个迁移,把代码和数据结构调整回正确方向。这种策略比强制回滚更安全,因为你不会因为回滚触发预期外的旧代码兼容问题。

7. 一次迁移执行失败的完整排查链路

最后分享一段我亲历过的排查过程,这个过程帮助你理解 Alembic 的失败模式和应对思路。

7.1 现象与初步判断

正常执行alembic upgrade head,跑了几秒后报错,具体错误信息我已经不记得原文,大意是某条 SQL 在 PostgreSQL 里违反了一个约束。生产环境立刻被锁定在中间版本,应用还在用代码版本 7,但数据库版本是 6.3 和 6.4 的混合状态。

我第一步做的是看当前库实际版本:

alembic current

输出显示当前版本停在迁移链的某一个中间点,说明失败的那个迁移虽然在执行时报错,但可能只执行了一半。Alembic 的事务机制在一些数据库上是基于 DDL 事务的,PostgreSQL 支持迁移脚本整体包在事务里,但某些操作如果已经自动提交,那失败迁移所留下的半成品状态就必须手工处理。

7.2 定位问题所在

我打开报错的那个迁移文件,检查upgrade()里的执行顺序。发现问题聚焦在一块batch_alter_table操作上:我把nullable=True的列改成nullable=False,但表里已经存在 NULL 值。Alembic 在数据库层尝试把列改成非空,数据库直接拒绝。

为了解决它,我在该迁移脚本中,把修改列约束的操作挪到UPDATE之后再执行,确保所有 NULL 都被替换成了合法值:

def upgrade(): op.execute("UPDATE users SET phone='' WHERE phone IS NULL") with op.batch_alter_table("users") as batch_op: batch_op.alter_column("phone", existing_type=sa.String(20), nullable=False)

7.3 失败后的恢复步骤

此时数据库已经处于半迁移状态,但 Alembic 的版本号并未推进。为了不再次触发同样的错误,我选择先把版本号手工修正到失败迁移的前一个版本,然后修复迁移脚本内容,最后再次执行alembic upgrade head。

如果一个数据库根本不支持 DDL 事务,半成品状态会更复杂。此时需要你根据迁移脚本中已执行的操作,用反向 SQL 手动还原。这也是为什么我一直强调downgrade()要写得完整,没有它,恢复只能靠人肉,而人肉恢复通常会在最紧张的时候出错。

8. 团队协作约定与最终建议

聊到这儿,该把经验收敛成一套制度了。Alembic 本身是工具,但真正让迁移可持续的是一套团队约定。

以下几点是我在多个团队项目中总结了很久才固定下来的公约,直接套用即可:

  • 任何表结构变更,必须通过迁移脚本合入主干,禁止在开发库、测试库手动执行 DDL
  • 迁移脚本文件名必须包含 ticket 号或需求号,比如add_order_tracking_columns_to_orders,方便追溯
  • downgrade()不能为空,除非它确实无法回退
  • 每周至少一次从最新主干跑alembic upgrade head和downgrade base,验证迁移脚本的可执行性
  • 执行迁移时数据库连接账号需具有 DDL 权限,但核心数据表的DELETE和TRUNCATE权限应按最小化原则收敛
  • 所有迁移脚本在提交之前,必须由另一名团队成员 review,重点看batch_alter_table的时机和数据迁移的幂等性

这些规则看起来繁琐,但真正为项目省下的时间是不可估量的。特别是那句"每周跑一次downgrade base",我第一次听到这个建议时也觉得太折腾,但实际执行后,确实在两周内就抓到过一个迁移脚本把索引名写错的问题——在测试库回滚时索引没删掉,导致后续 schema 状态乱掉。这类问题如果等生产上线再暴露,代价会大得多。

最后再分享一个小技巧:当你准备给生产执行迁移时,先在本地用真实数据的脱敏副本跑一遍整个迁移链。不是为了测试语法,而是为了观察耗时和数据转换逻辑对行数的影响。如果迁移需要十几分钟,你最好提前规划停服窗口,不要指望发布流程里的迁移步骤能无限超时。迁移这件事,慢不可怕,乱和悄无声息的脏数据才可怕。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进