ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Flask-Migrate实战:数据库迁移与版本控制全指南

Flask-Migrate实战:数据库迁移与版本控制全指南 你有没有经历过这种时刻一个跑了一年多的Flask项目数据库表结构靠脑子记新同事入职问“users 表里现在有哪些字段”你只能打开数据库管理工具一个个看某次上线前手动改了一张表结果测试环境没同步前端页面直接 500。我早期做 Flask 项目时确实被数据库结构维护折磨过后来把 Flask-Migrate 引入项目这类问题基本绝迹了。Flask-Migrate 本质上是 Alembic 在 Flask 环境下的封装它把数据库结构的一次次调整变成类似 Git 提交那样的版本记录。每次模型变更自动生成一份迁移脚本可以随时升级、回滚也能在一台全新的机器上通过一条命令让数据库结构复原到最新状态。这篇文章我会从原理、完整配置、实战工作流、生产环境避坑这几个层面把它讲透适合已经用 Flask SQLAlchemy 写项目、但还没有建立数据库版本管理流程的开发者。1. Flask-Migrate 解决的是什么问题1.1 没有版本管理时改表结构有多痛大部分 Flask 新手最早接触的表结构初始化方式是db.create_all()。这个函数确实方便它的逻辑简单粗暴查询数据库里已存在的表缺哪张建哪张。但这里的“缺哪张”是整表级别不是字段级别。也就是说如果你在模型里给User加了nickname字段db.create_all()根本不会理你它只负责创建不存在的表压根不管已有表要不要加列。于是很多人退而求其次开始手动维护 SQL 脚本。我在一个外包项目里见过这种维护方式项目根目录放一个sql/文件夹里面按日期命名比如20240115_add_nickname.sql。听起来好像也能用但实际执行起来全是坑脚本执行顺序靠人肉记换台电脑、换个人顺序就乱了。某个脚本在生产环境执行过测试环境忘了执行两边结构不一致。最要命的是没有回滚机制。今天加了个字段明天想撤不好意思你得自己写反向 SQL而且写反向 SQL 的时候很可能某个表已经被后续脚本改过了。我自己就踩过这个坑。有一回在测试库执行了一个删字段脚本结果误删了线上库的同一张表结构里的关键列等我反应过来数据已经被 NULL 覆盖了。那种想砸键盘的冲动经历过的人都懂。1.2 Flask-Migrate、Alembic、SQLAlchemy 三者到底是什么关系理清三者关系很多困惑会迎刃而解。SQLAlchemy 是 ORM 框架负责让你用 Python 类定义表结构它本身不提供自动修改数据库表结构的能力。Alembic 是 SQLAlchemy 作者维护的数据库迁移工具它的设计目标是针对整个数据库结构进行版本化管理每次变更生成一个脚本支持升级和降级。Flask-Migrate 则是把 Alembic 适配进 Flask 生态的一层胶水让 Flaks 开发者不用直接操作 Alembic API而是通过熟悉的flask db命令行完成所有迁移操作。用生活里的事情打比方SQLAlchemy 是画图纸的你定义好房子长什么样Alembic 是施工队按图纸拆墙砌墙同时记录每次施工的档案Flask-Migrate 是施工监理的翻译官让在 Flask 环境里的你能直接用最顺手的工具指挥施工队。你可能想问为什么不直接裸用 Alembic当然可以但 Flask-Migrate 做了几件简化工作它把迁移命令挂到了 Flask 的 CLI 体系下你不需要关心 Alembic 的环境配置和上下文加载它自动把 Flask-SQLAlchemy 的实例与 SQLAlchemy 的元数据绑定好模型变更能被直接感知它还帮你生成了migrations/目录结构和env.py不需要手动写 Alembic 环境配置。1.3 “智能管家”到底智能在哪所谓智能主要体现在三个维度。第一自动感知模型变更。你在models.py里加一个字段、删一张表、改一个索引通过flask db migrate就能自动生成对应的迁移脚本初稿不需要手工写 SQL。第二建立可追溯的版本链。每次生成的迁移脚本都有唯一版本号和上一个版本建立父子关系数据库结构的前世今生一目了然。第三可升级、可回滚。线上结构出问题一条flask db downgrade就能回到上一个版本像时光机一样。但这里我必须泼一盆冷水它的“智能”是有边界的。自动生成的脚本只是“初稿”不是“终稿”。列重命名、默认值变化、复杂约束调整这些操作自动识别经常出错必须人工校对。很多生产事故不是迁移工具导致的而是开发者太信任自动生成的脚本没检查就升级了。2. 核心机制拆解2.1 迁移脚本的真面目初始化flask db init之后项目里会出现一个migrations/文件夹里面最重要的一块是versions/目录每次生成的迁移脚本就放在这里。打开任意一份脚本核心结构是一个 Python 类继承自 Alembic 的Migration主要包含五个关键属性revision当前版本号、down_revision父版本号、upgrade()升级操作、downgrade()降级操作。一个典型脚本长这样add nickname column Revision ID: 2a3f9c1d4e Revises: 1b7e8a3f0c Create Date: 2024-06-15 10:24:00.123456 from alembic import op import sqlalchemy as sa revision 2a3f9c1d4e down_revision 1b7e8a3f0c branch_labels None depends_on None def upgrade(): op.add_column(users, sa.Column(nickname, sa.String(length50), nullableTrue)) def downgrade(): op.drop_column(users, nickname)upgrade()和downgrade()里的操作是一对互逆操作。你加列反向就是删列你建表反向就是删表。这保证了任何一个版本都能从它的上一个版本升级而来也能回退到上一个版本。2.2 版本链的运作逻辑Alembic 的版本管理机制核心就是这张由revision和down_revision构成的单向链表。revision是这个版本的身份证down_revision指向它爸爸。整条链从最早的初始版本指向最新的 head 版本。平时用到的命令背后都是在操作这条链flask db upgrade默认走向head也就是把链上所有未执行的迁移脚本按顺序执行一遍。flask db upgrade 1表示只前进一个版本。有时候我只想小步验证就会用这个。flask db upgrade revision_id表示直接迁移到指定版本。flask db downgrade -1表示回退一个版本相当于执行当前版本的downgrade()。这里顺便说说flask db current和flask db history这两个查询命令。current看的是数据库当前处于哪个版本history看的是整个迁移脚本的历史线。每当我接手一个老项目第一件事就是跑这两个命令摸清结构基线比任何文档都靠谱。2.3 autogenerate 的原理与边界flask db migrate的自动生成功能内部做的事情可以拆成三步连接数据库通过 SQLAlchemy 的inspect读取当前真实表结构扫描项目里所有 SQLAlchemy 模型定义把两者逐一对比找出差异生成增删改操作的脚本。这个思路本身很巧妙但对比逻辑决定了它有几个天生的盲区列重命名识别不了。你把name改成full_nameautogenerate 通常认为你删了name新建了full_name。如果数据库里老列有数据这样执行会导致数据丢失。默认值变化经常被忽略。alter 一个列的 default 值autogenerate 往往不产生任何脚本。约束级别的修改不一定能识别全。部分索引、复合主键的调整它可能会漏掉或误判。所以我的原则是自动生成的脚本必须逐行校对特别是涉及删除列、修改约束、变更类型这三类操作宁可多花十分钟手动改写脚本也不要拿生产环境赌博。3. 完整实操从项目接入到日常迭代3.1 安装与初始化配置先安装依赖pip install flask-migrate单文件应用的接入方式非常简单以最常见的 Flask Flask-SQLAlchemy 结构为例from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] postgresql://user:passlocalhost/mydb app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False db SQLAlchemy(app) migrate Migrate(app, db)如果你用的是工厂模式创建应用接入方式稍有不同from flask_migrate import Migrate migrate Migrate() def create_app(config_nameNone): app Flask(__name__) # ... 配置加载 ... db.init_app(app) migrate.init_app(app, db) return app这里有一个很多人第一天就会踩的坑用了工厂模式却忘了调用migrate.init_app()。结果运行flask db init时报错提示找不到数据库实例。初始化两次的本质是Flask-Migrate 需要显式地把 Alembic 和数据库绑定起来这个动作不能省。初始化完成之后在项目根目录运行export FLASK_APPapp.py # Windows 用 set FLASK_APPapp.py flask db init执行完项目里会多出一个migrations/文件夹里面包含alembic.ini、env.py、versions/等目录。到这里第一阶段的接入工作就完成了。3.2 核心工作流模型变更的四步走日常开发中凡是改了模型结构都遵循这条固定流程修改模型定义。比如在User模型里加一个字段class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(80), uniqueTrue, nullableFalse) nickname db.Column(db.String(50), nullableTrue) # 新增生成迁移脚本flask db migrate -m add nickname to users table这个命令不会真正修改数据库它只负责生成脚本文件。执行完之后必须打开migrations/versions/里新生成的文件从upgrade()的头看到尾确认自动生成的变更符合预期。确认无误后应用迁移flask db upgrade这条命令才是真正把变更落到数据库的动作。执行后可以再跑flask db current验证当前版本号已经前进到新脚本的版本号。这套流程跑顺之后每天的迭代就会变得非常顺畅改模型生成脚本检查脚本升级。就像代码提交的四部曲形成肌肉记忆。3.3 部署新环境和回滚操作一个新同事入职需要本地起一套开发环境。老办法是发给他一个巨大的 SQL 初始化文件然后祈祷他别漏执行。有了 Flask-Migrate整个过程只有两步pip install -r requirements.txt flask db upgrade只要迁移脚本完整入库任何环境都能一键得到和线上一致的表结构包括索引、约束、自增主键的起始状态。这才是真正的可复制性。回滚操作则是这样flask db downgrade -1回滚前一定要确认一件事当前版本的downgrade()函数是不是正确的逆操作。很多团队写的迁移脚本upgrade()写得认真downgrade()就敷衍了事填了个pass。这样的迁移脚本上线后一旦结构出了变故想回退都回退不了。我见过一个项目因为始终没写过downgrade()最后只能在线上手动写 SQL 修结构代价惨重。3.4 数据迁移在版本脚本里搬运数据结构迁移只解决“表长什么样”的问题但很多场景需要同时改数据。比如你给订单表加了一个order_no字段但历史数据需要回填生成唯一的订单号用 SQLAlchemy 模型直接写定时任务不是不行但迁移脚本里一起做更优雅def upgrade(): op.add_column(orders, sa.Column(order_no, sa.String(32), nullableTrue)) # 回填历史数据 conn op.get_bind() orders sa.table( orders, sa.column(id, sa.Integer), sa.column(order_no, sa.String), ) result conn.execute(sa.select(orders.c.id)) for row in result: conn.execute( orders.update() .where(orders.c.id row.id) .values(order_nogenerate_order_no()) ) # 回填完成后把字段改成非空 op.alter_column(orders, order_no, nullableFalse) def downgrade(): op.drop_column(orders, order_no)注意op.get_bind()这个操作它拿到的是当前迁移事务的数据库连接而不是新建一个独立连接这样整个迁移处于同一个事务里要么全部成功要么全部失败不会出现回填到一半数据库崩了、结构变了但数据没补完的情况。4. 进阶技巧与生产环境经验4.1 多数据库绑定的处理方式有些项目不止一个数据库比如一个主业务库、一个日志库都用 SQLAlchemy 管理。Flask-SQLAlchemy 里通过__bind_key__区分Flask-Migrate 也支持多 bind 的迁移。配置方式是在Create Date之外给迁移脚本增加bind_key信息。实际操作中flask db migrate会检测模型的__bind_key__自动把变更归入对应数据库的迁移流程。如果你遇到自动分配不准的情况可以手动在生成的脚本里给upgrade()和downgrade()加上 bind 参数def upgrade(): op.add_column(logs, sa.Column(trace_id, sa.String(64), nullableTrue), schemapublic)但说实话多数据库迁移是 Flask-Migrate 里坑比较多的一个领域。自动识别偶尔会混乱生成脚本张冠李戴把本属于 A 库的表变更写进 B 库的迁移脚本。我建议多库项目在生成迁移脚本后认真检查脚本里的表名和 bind 归属最好在 CI 里加一步自动化检查。4.2 生产环境升级策略先迁移还是先发代码这是部署顺序的灵魂拷问。先升级数据库再发布新代码存在一个风险窗口新表结构已经生效但线上跑的还是旧代码如果旧代码执行了与新结构冲突的 SQL可能报错。先发布新代码再升级数据库风险反过来新代码已经上线但数据库还是老结构只要访问了新字段同样爆炸。结合我的实战经验比较稳的策略是这样的新增可空字段、新增表、新增独立索引这类兼容性变更无脑先升级数据库。旧代码不会访问新字段完全无感。删除字段、修改字段约束、重命名列这类破坏性变更一定要分两步走。先用一个发布周期让代码兼容新旧结构比如代码里同时兼容字段存在和不存在的情况再升级数据库最后再清理代码里的兼容逻辑。大批量数据迁移务必避开业务高峰期。一次 UPDATE 锁住整张表业务直接排队卡顿这种事情我在线上见过不止一次。迁移之前留后手。PostgreSQL 用pg_dump、MySQL 用mysqldump做一次备份成本低、收益大出了事有后悔药吃。4.3 分支合并与版本膨胀治理多人协作时最常见的迁移冲突场景是这样的你和同事各自从同一个 master 拉分支都改了模型都生成了迁移脚本等到合并代码时Alembic 发现自己有了两个 head也就是版本链分叉了。此时flask db upgrade会提示你有多个 head不处理不让你升级。解决办法是合并分支。Alembic 提供了 merge 功能flask db merge命令执行后会生成一个合并脚本down_revision同时指向两个分支的 head。注意合并脚本只负责把版本链合并本身不包含任何结构变更它只是告诉 Alembic“这两个 head 之后统一到这里来。”除此之外还要警惕版本膨胀。项目跑了两三年versions/目录里几百个脚本每次升级都要顺序执行几百个迁移慢不说风险也累积了。业界常用的做法是定期做基线压缩手动创建一个空表结构的全新基线脚本用flask db stamp把已有环境的版本号标记到新基线然后删除老的迁移脚本。这个操作有风险只推荐在团队对数据库结构有较高掌控力的前提下进行而且压缩前必须全员确认当前所有环境的版本一致性。5. 实战中的坑与排查速查表5.1 高频问题实录autogenerate 没识别出列重命名导致重建列丢数据。这是最常见的坑。现象是跑完flask db migrate后生成的脚本里出现drop_column和add_column成对出现而不是alter_column。原因就是 autogenerate 不知道name和full_name是同一个字段它当作重组处理。解决办法是手动把这两个操作改成op.alter_column(users, name, new_column_namefull_name)改完再升级。down_revision 冲突拒绝升级。我在团队里遇到过一例同事手动编辑了迁移脚本的down_revision导致版本链断节。排查方法是先跑flask db history查看完整历史线再用flask db current对比数据库当前版本找到断掉的位置手动修正down_revision指向正确的父版本。模型没有全量导入autogenerate 以为某些表要删除。这是个大坑。你只在一处模块里定义了部分模型其他模型分散在其他文件没有导入Flask-Migrate 扫描不到它们就会认为这些表“应该被删除”。如果不加检查直接升级数据表直接被删。解决方法是确保所有模型都被导入到 Flask-Migrate 能感知的模块里比如在models/__init__.py中统一导入或者在migrations/env.py里把项目的模型模块全部 import 进来。我还会在 CI 里加一个检查脚本任何自动生成的迁移脚本如果包含drop_table必须人工二次确认才能合入。生产环境 upgrade 执行到一半失败。迁移默认是在事务里执行的大多数数据库支持 DDL 事务失败会回滚。但 MySQL 这类数据库在部分 DDL 操作上不支持事务一旦中途断电或报错数据库可能处于半迁移状态。应对方法是 upgrade 之前先备份并且把一次上线涉及的所有迁移脚本先在测试环境完整演练一遍确认没有隐患再上生产。5.2 命令与排查速查表flask db init初始化迁移环境生成migrations/目录flask db migrate -m 描述根据模型变更生成迁移脚本flask db upgrade升级到最新版本flask db upgrade 1升级一个版本flask db downgrade -1回退一个版本flask db downgrade revision回退到指定版本flask db current查看当前版本flask db history查看迁移历史flask db stamp revision把当前数据库标记为指定版本不实际变更结构stamp这个命令值得单独说。它适用于一种常见场景项目刚开始接 Flask-Migrate但数据库已经有一堆表了不想从头生成基线脚本只想让工具认账。这时候可以在migrations/versions/里建立初始基线脚本然后对现有数据库执行flask db stamp head告诉工具“当前库状态就等价于最新版本”后续增量变更从这里开始走正常流程。5.3 我踩过坑之后形成的几个好习惯迁移脚本必须像对待业务代码一样对待提交 MR 时要单独列出来让同事评审。我见过太多人把注意力放在业务逻辑上对迁移脚本草草看一两眼就合入最后问题全出在数据库上。所有迁移脚本都必须包含可用的downgrade()。哪怕你觉得这个字段永远不可能回退也请写上对应的逆操作。不写downgrade()的脚本就是埋雷短期内看不出问题等你要回退的时候就难受了。每次部署前在测试环境完整跑一遍flask db upgrade确认耗时和数据影响。有些迁移脚本在小数据量的测试库上毫秒级完成一到生产几亿行的表上就是分钟级甚至小时级锁表、磁盘 IO、事务膨胀都是连锁反应。早发现早处理。迁移脚本里严禁直连线上数据库做临时改动。我见过有人在生产库上手动执行 SQL 后忘记更新迁移脚本结果开发环境一跑upgrade就报错因为迁移脚本和数据库实际结构对不上。数据库结构的一切变更都必须走迁移脚本没有例外。版本号尽量一次提交只动一个版本。多人并发改模型时尽量避免在同一时间点各自生成多个迁移脚本再合并那是给自己找罪受。更好的方式是让一个人基于最新 master 统一生成其他人只改模型不生成脚本。另外说一个小技巧如果你发现自己经常需要手动修正 autogenerate 生成的脚本可以在migrations/env.py里通过include_object或include_name回调过滤掉某些不需要关注的表比如日志表、临时表。这样自动生成脚本时干扰会少很多校对压力也小。我在实际项目中体会最深的一点是Flask-Migrate 真正改变的不是技术实现而是团队协作的路径。当数据库结构变更可以像代码版本一样被审阅、回溯、部署团队里很多原来的“线上事故”就变成了普通的 MR 评审问题。这个工具不难学难的是从一开始就守住“一切变更走迁移脚本”这条底线。希望这篇内容能帮你少走一些我当年走过的弯路。
返回列表