← 返回首页

Alembic 迁移实测:autogenerate 检测不到的四件事

AlembicSQLAlchemy数据库迁移

TL;DR:我把 Alembic 1.20.1 装在本机跑了一遍,结论是 --autogenerate 只能当草稿生成器,不能当差异比对工具。改列名会被识别成「加一列 + 删一列」,1000 行数据在升级后 100% 变成 NULL,退出码还是 0;compare_server_default 默认关闭,改了列的默认值,生成的迁移体是 pass,版本号却照常往前走;SQLite 上的 batch_alter_table 是整表重建,只要块里有一条操作不是 add_column,同一个 add_column 就从 0.0106 秒变成 0.5477 秒(50 万行,51.9 倍)。下面每个数字都是本机跑出来的,脚本原样贴在文里。

本文不含任何联盟推广链接。

实验环境:全部可复现

我实际测试的组合是 Windows 11 + Python 3.13.14 + Alembic 1.20.1 + SQLAlchemy 2.1.6 + SQLite 3.53.1。Alembic 的版本号取自官方仓库 Releases API(截至发稿最新为 rel_1_20_1,发布于 2026-10-22),SQLite 的版本号用 sqlite3.sqlite_version 读出,不是安装包的版本号。

每次实验都是独立的临时目录:alembic init 之后写一版 models.py,revision --autogenerate 生成迁移,upgrade head 执行,再用 sqlite3 直接查库核对。为了避免读到缓存的模型,每次改 models.py 都会清掉 __pycache__。

一、改列名:官方明说检测不到,实测是 1000 行数据全没

Alembic 官方文档在「What does Autogenerate Detect and What Does it Not Detect?」一节里把话说得很清楚:

Changes of column name. Like table name changes, these are detected as a column add/drop pair, which is not at all the same as a name change.

同一节还有一句更该背下来的:

> It is critical to note that autogenerate is not intended to be perfect. It is *always* necessary to manually review and correct the candidate migrations that autogenerate produces.

我把这句话落到实测上。第一版模型是 Account(id, email),建表后插入 1000 行,SELECT COUNT(*), SUM(email IS NOT NULL) 返回 (1000, 1000)。然后把模型里的 email 改名为 email_address,再跑一次 revision --autogenerate。

日志只有两行,语气平静得像在报告天气:

INFO  [alembic.autogenerate.compare.tables] Detected added column 'account.email_address'
INFO  [alembic.autogenerate.compare.tables] Detected removed column 'account.email'

生成的迁移是这样:

def upgrade() -> None:
    op.add_column('account', sa.Column('email_address', sa.String(length=100), nullable=True))
    op.drop_column('account', 'email')


def downgrade() -> None:
    op.add_column('account', sa.Column('email', sa.VARCHAR(length=100), nullable=True))
    op.drop_column('account', 'email_address')

upgrade head 执行完,退出码 0,没有任何警告。查库结果是 (1000, 0) —— 行数还在,email_address 一列全是 NULL。1000 行的邮箱地址,一条不剩。注意 downgrade 也不是逆操作,它是另一个 add/drop 对,回滚同样会把数据清掉。

同一份改动如果目标列带 nullable=False,Alembic 连执行都执行不下去:

sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) Cannot add a NOT NULL column with default value NULL
[SQL: ALTER TABLE account ADD COLUMN email_address VARCHAR(100) NOT NULL]

退出码 1。好在版本号没有跟着跑偏:失败后 alembic_version 仍停在旧版本,表结构也保持原样(只有 id 和 email 两列)。这一点比很多迁移工具诚实。

二、`compare_server_default` 默认关闭:改了默认值,迁移是空的

官方文档把「列默认值变更」放在「optionally detect」而不是「can detect」里:

> Change of server default. This will occur if you set the EnvironmentContext.configure.compare_server_default parameter to True ... The feature is off by default so that it can be tested on the target schema first.

实测:模型里把 server_default="anon" 改成 "guest",跑 revision --autogenerate,生成的迁移体是:

def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    pass
    # ### end Alembic commands ###

没有警告,没有提示,退出码 0。把 compare_server_default=True 传进 context.configure() 再跑一次,立刻就检测出来了,生成的是 op.alter_column('t', 'name', existing_type=sa.VARCHAR(length=50), server_default='guest', existing_nullable=False)。

默认值这种改动不会报错,只会假装改过了。如果你不知道这个开关存在,你会拿着一条空迁移上线,然后奇怪为什么新插入的行还是老默认值。

三、`compare_type` 从 1.12.0 起默认打开,但只对共享参数生效

这条和很多旧教程写的不一样。官方文档的变更说明原文是:

> Changed in version 1.12.0: The default value of EnvironmentContext.configure.compare_type has been changed to True.

实测 String(50) 改成 String(200),日志打出 Detected type change from VARCHAR(length=50) to String(length=200) on 't.name',迁移里生成了对应的 op.alter_column(..., type_=sa.String(length=200), ...)。

但文档紧接着就划了边界:如果两侧的额外参数不对等,Alembic 会放弃比较 ——

> If either the metadata type or database type has additional arguments beyond that of the other type, these are not compared.

所以「类型变了一定会被检测到」是错误预期,正确预期是「共享的参数(长度、精度)变了才会被检测到」。

四、SQLite 上的 batch 是整表重建,一个操作连累整块

这是本文最贵的一条数字。Alembic 的 batch 模式文档把机制写得很直白:

the existing table structure is reflected from the database, a new version of this table is created with the given changes, data is copied from the old table to the new table using "INSERT from SELECT", and finally the old table is dropped and the new one renamed to the original name.

对应到 SQL 就是 CREATE TABLE _alembic_batch_temp → INSERT INTO _alembic_batch_temp ... SELECT → DROP TABLE → ALTER TABLE _alembic_batch_temp RENAME TO。

关键在触发条件,文档原文:

> the batch context by default only does the "move and copy" process if SQLite is in use, and if there are migration directives other than Operations.add_column() present, which is the one kind of column-level ALTER statement that SQLite supports.

也就是说:块里只要有一条操作不是 add_column,整块就走重建,包括那条本来 O(1) 就能完成的 add_column。我在同一条迁移里测了三件事,用 time.perf_counter() 卡在每一段前后:

表行数直接 add_columnbatch 块里只有 add_columnbatch 块里 add_column + 改约束放大倍数
1,0000.0128 s0.0112 s0.0215 s1.9x
10,0000.0110 s0.0109 s0.0309 s2.8x
100,0000.0195 s0.0108 s0.1132 s10.4x
500,0000.0199 s0.0106 s0.5477 s51.9x

两条曲线一眼就能分开:直接 add_column 恒定在 0.011–0.020 秒(与行数无关,是元数据级操作);batch 重建的时间随行数线性涨。50 万行时,同一个 add_column 因为同一块里多了一条 alter_column,从 0.0106 秒变成 0.5477 秒。

顺带一个容易忽略的副作用:1000 行的表做完一次 batch 重建后,数据库文件从 36,864 字节涨到 61,440 字节(涨了约 66.7%),SQLite 不会把旧表的空间还给你。

五、batch 重建会静默丢掉未命名 CHECK 约束

这条是官方文档自己承认的,而且版本分界线很明确:

Unnamed CHECK constraints continue to be omitted from the table recreate operation; up until version 1.20, this omission was silent. As of 1.20, it emits a warning.

我建了一张带两条 CHECK 的表,一条有名一条无名:

CREATE TABLE t (id INTEGER NOT NULL, n INTEGER,
  CONSTRAINT ck_named CHECK (n > 0), CHECK (n < 1000), PRIMARY KEY (id))

走完一次 batch 重建,表变成:

CREATE TABLE "t" (id INTEGER NOT NULL, n INTEGER NOT NULL, m INTEGER,
  PRIMARY KEY (id), CONSTRAINT ck_named CHECK (n > 0))

ck_named 还在,CHECK (n < 1000) 没了。1.20 版会在 alembic/operations/batch.py:298 抛一条 UserWarning,原文是「Unnamed CHECK constraint on reflected table 't' is being omitted from the table recreate; pass the constraint explicitly using batch_alter_table.table_args.」—— 在 1.20 之前,这件事是完全静默的。

六、SQLite 3.53 之后,NOT NULL 已经不用重建了

SQLite 官方 changelog 在 3.53.0(2026-04-09)的「New SQL language features」里有一条:

Enhance ALTER TABLE to permit adding and removing NOT NULL and CHECK constraints.

本机 SQLite 3.53.1 上实测四种写法,SET NOT NULL 与 DROP NOT NULL 都能执行成功,SET DATA TYPE 与 SET DEFAULT 仍然是 syntax error。也就是说,Alembic 的 SQLiteImpl 里那条「非 add_column 一律重建」的启发式(源码 alembic/ddl/sqlite.py 的 requires_recreate_in_batch(),docstring 原文「Normally, only returns True on SQLite when operations other than add_column are present」)在 3.53+ 上已经过于保守了。

我把两种路径放在同一条迁移里对拍:

表行数直接 alter_column(nullable=False)batch 重建同一改动倍数
10,0000.0201 s0.0314 s1.6x
100,0000.0298 s0.0879 s2.9x
500,0000.0708 s0.4429 s6.3x

直接执行那条路径,Alembic 生成的是 ALTER TABLE big ALTER COLUMN b SET NOT NULL —— 一条 O(1) 的语句。走 batch 则是全表重建。在 3.53+ 上,改 NOT NULL 已经不需要付出重建代价了。

需要说明的是:Alembic 仍然建议用 batch 来保证跨版本兼容,本文只是在报告实测到的时间差,不构成「关掉 batch」的建议。低于 3.53 的 SQLite 上,那条 ALTER COLUMN 会直接语法错误。

踩坑录:五个真实报错与两处静默失效

报错一:Cannot add a NOT NULL column with default value NULL。autogenerate 给已有数据的表加了一个非空列。原因是它只会照着模型写 nullable=False,不会替你想默认值。解法是手工拆成三步:先加可空列 → 用 op.execute() 回填历史行 → 再改约束。

报错二:NotImplementedError: No support for ALTER of constraints in SQLite dialect. 在 SQLite 上直接跑 op.create_unique_constraint()。源码在 alembic/ddl/sqlite.py:78,原文紧接着一句「Please refer to the batch mode feature which allows for SQLite migrations using a copy-and-move strategy.」解法是把这条操作挪进 op.batch_alter_table() 块里。

报错三:UserWarning: Unnamed CHECK constraint ... is being omitted。batch 重建时未命名 CHECK 被丢弃。解法是给约束起名字(naming_convention 是官方推荐做法),或者在 batch_alter_table(table_args=...) 里显式带上。

报错四:Multiple head revisions are present for given argument 'head'。两个人各从同一个 base 拉了一条分支,upgrade head 就炸了。源码在 alembic/script/base.py:223,完整原文是「please specify a specific target revision, '@head' to narrow to a specific head, or 'heads' for all heads」。解法是 alembic merge heads -m "merge",别手工改 down_revision。

报错五:Can't locate revision identified by '',退出码 -1。删掉了一个版本文件,或数据库里的版本号指向了本地不存在的 revision。源码在 alembic/script/base.py:236。解法是 alembic stamp 把版本号对齐到实际状态,或者把丢失的版本文件从备份里找回来。

静默失效一:空迁移照样推进版本号。alembic revision -m "xxx" 用默认模板生成的文件,upgrade() 和 downgrade() 的函数体都是 pass(官方模板原文就是 """Upgrade schema.""" 加一个 pass)。实测版本号从 d22dd80cf08e 推进到 7ec59bca7385,库里什么都没变;alembic downgrade -1 同样退出码 0,版本号退回去了,也什么都没变。

静默失效二:autogenerate 会把模型里没有的表判成「多余的」。官方原文:「If a target database has many tables that are not part of the MetaData object, the autogenerate process will normally assume these are extraneous tables in the database to be dropped」。所以 Base 里少 import 一个模型,生成的就是一条 drop_table。

本文的边界

本机没有 PostgreSQL 与 MySQL 实例,全文结论只在 SQLite 上实测过;batch 在 PostgreSQL 上的表现(官方提到会先 DROP CONSTRAINT)我没有验证。除第六节的四组耗时外,所有时间数字都是单次测量,没有做多轮取中位数,同机复跑会有波动。全文不涉及任何商品推荐。

FAQ

Q:autogenerate 生成的迁移能直接上线吗?

不能。官方原话是「It is always necessary to manually review and correct the candidate migrations」。本文第一节已经实测了不审的代价:1000 行数据全丢,退出码 0。

Q:为什么改默认值检测不到?

compare_server_default 默认关闭。在 env.py 的 context.configure() 里传 compare_server_default=True 即可,文档同时提醒这个特性「cannot always produce accurate results」。

Q:SQLite 上是不是所有迁移都要套 batch_alter_table?

不需要。add_column 是 SQLite 支持的列级 ALTER,套了 batch 也不会触发重建(本文第四节实测 50 万行仍是 0.0106 秒)。真正的触发条件是块里出现了 add_column 以外的操作。

Q:怎么让「改列名」不丢数据?

不要用 autogenerate 的结果。手工写成 op.alter_column('account', 'email', new_column_name='email_address'),一条语句,数据与约束都保留。

Q:多个分支怎么合?

用 alembic merge heads,它会生成一条带两个 down_revision 的合并版本。手工改 down_revision 只会把版本图改坏。

Q:版本号和库的实际结构对不上怎么办?

先 alembic current 看 Alembic 认为自己在哪,再 alembic history 看版本图。确认后 alembic stamp 把版本号改成真实状态,而不是去删 alembic_version 表。

相关阅读

一句话

autogenerate 是草稿生成器,不是差异比对器;它输出的每一个 pass、每一对 add/drop,都得你自己看一遍再上线。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页