
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_keyTrue) name Column(String(64), nullableFalse) email Column(String(128), uniqueTrue)那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:passlocalhost/dbname。个人项目直接填没问题但团队项目里每个人都有独立的开发库把这个地址写死在配置文件里会产生一堆本机冲突。我常用的做法是在alembic.ini里保留一个占位sqlalchemy.url driver://user:passlocalhost/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设置了default0后来业务上想改成default1你只需要在模型里改一行default跑--autogenerate生成的迁移文件大概率是空的或者只包含一个无关紧要的 index 调整。原因是数据库层面的列默认值server_default通常不会因为 Python 模型里的default变更而自动改变Alembic 对比的是数据库 schema 和模型 schema不是 Python 代码逻辑。如果确实要变更数据库默认值得手动补迁移脚本写成from alembic import op def upgrade(): op.alter_column(users, status, server_default1) def downgrade(): op.alter_column(users, status, server_default0)数据库视图、函数、触发器的变化Alembic 不会自动感知到你在模型外创建的视图和触发器它们也不在Base.metadata里。每次重建视图你都得手写op.execute并且建议在迁移脚本里记录视图的版本。我们团队的做法是把视图的创建 SQL 放在固定目录下维护迁移脚本里引用其内容再执行。列类型的精度变化比如NUMERIC(10,2)改成NUMERIC(12,2)DateTime改成DateTime(timezoneTrue)这类类型参数调整Alembic 有时会忽略有时会生成成drop columnadd 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), nullableTrue)) batch_op.add_column(sa.Column(last_name, sa.String(50), nullableTrue)) 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), nullableTrue)) batch_op.add_column(sa.Column(last_name, sa.String(50), nullableTrue)) 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 headAlembic 会直接报错提示 multiple heads它不知道你要走哪条迁移链。5.1 分叉发生的典型场景和解决方案在多人协作的项目中我经常看到这样的场景A 同学在 feature/a 分支上新增了一个users表的外键约束生成了版本abc1B 同学在 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操作上我把nullableTrue的列改成nullableFalse但表里已经存在 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_typesa.String(20), nullableFalse)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 状态乱掉。这类问题如果等生产上线再暴露代价会大得多。最后再分享一个小技巧当你准备给生产执行迁移时先在本地用真实数据的脱敏副本跑一遍整个迁移链。不是为了测试语法而是为了观察耗时和数据转换逻辑对行数的影响。如果迁移需要十几分钟你最好提前规划停服窗口不要指望发布流程里的迁移步骤能无限超时。迁移这件事慢不可怕乱和悄无声息的脏数据才可怕。