☰
数据库迁移工具选型:用caveman纯SQL管理数据库结构变更
2026/10/15 9:47:03 网站建设 项目流程

做后端的人应该都遇到过这种尴尬:本地跑得好好的代码,拉到测试环境一跑直接崩,原因多半是数据库结构对不上。以前我的处理方式是把建表 SQL 存在一个 docs/schema.sql 里,谁改表就手动改这个文件,再靠群消息同步给别人。直到某天线上报警,才发现有一行 ALTER TABLE 根本没落到生产库。后来我把 caveman 这类简单的数据库迁移工具塞进所有项目,状况才终于稳定下来。

caveman 是一个用纯 SQL 文件管理数据库结构变更的命令行工具,核心设计思路就是“简单到原始人也能用”。你只需要维护 up 和 down 两种 SQL 文件,它负责记住哪些已经跑过、按顺序执行剩下的、必要时帮你回滚。这篇文章主要聊它的核心原理、完整实操以及我在真实项目里的踩坑记录,也会对比 Alembic、Flyway 这类大而全的方案该怎么选。无论你用的是 Flask、Django 还是裸 SQLAlchemy,只要还在靠人工同步建表语句,这篇文章都能帮上忙。

1. 为什么要用迁移工具:caveman 想解决的核心痛点

1.1 从 schema.sql 手工时代讲起

先复盘一下没有迁移工具时的典型场景。项目初期,几个人一起开发,数据库结构变化很频繁。A 同学在本地给 users 表加了一列,B 同学拉代码之后并不知道这回事,写完功能才发现查询报错。稍微正规一点的项目会维护一个 schema.sql 作为最终结构参考,但它只能表达“数据库最终长什么样”,表达不了“每一步是怎么从上一个版本变成现在的”,更没法解决“哪个实例、哪台机器执行过哪一步”。

于是出现了各种野路子:手动在测试库执行一遍所有人的改动,谁部署谁自己记得跑;或者写一个 migrate.py,在里面维护一串 SQL 字符串,每次启动应用时自动执行。前者的问题在于依赖人脑记忆和口头沟通,环境一多必出漏;后者的问题在于把 SQL 硬编码进业务代码,既没法单独 review,也没法单独回滚某一次变更。

数据库迁移工具做的事情,就是把“结构变更”这件事工程化:每一次变更是一个有独立编号的迁移文件,工具负责记录和按序执行。caveman 做的就是这件事,而且把文件格式和命令数量压到了最低限度。

1.2 命名里的哲学:简单到原始人也能用

这个工具的名字起得很直白,caveman 就是穴居人,意思是“简单到原始人也能用”。实际用下来,它确实保持了这种克制:没有自动生成迁移的模型对比机制,没有复杂的钩子和插件系统,也不需要记一堆配置项。你给它一个迁移目录和一个数据库连接串,它给你的是 up、down 等几个基本动作。

这里有个值得思考的点:Alembic 这类工具功能更全,为什么还有人愿意用一个“原始人”级别的东西?我的体会有两个。第一,autogenerate 的自动化经常掩盖真实意图。模型字段从 String(50) 改成 String(100),自动生成的迁移就是一条 ALTER COLUMN,但如果你手写 SQL,就会顺便想清楚:要不要加 NOT NULL、存量数据要不要处理、索引需不需要调整。第二,工具越简单,团队的认知负担越小。新人看一眼迁移目录就明白机制,不需要先学一整套 Python 迁移 API。

1.3 为什么坚持用纯 SQL 而不是 Python 代码

主流迁移工具大致分两派。一派是“代码定义迁移”,比如 Alembic 用 Python 写 op.add_column 这类操作;另一派是“纯 SQL 迁移”,caveman 属于这一派。代码定义迁移的优势是跨数据库、可编程,但隐藏成本是要理解那套 Python API 的语义,而且 API 一旦升级,历史迁移脚本可能会跟着调整。

纯 SQL 的优势有三点很实打实。第一,学习成本几乎为零,会写 SQL 就会做迁移,DBA 也能直接参与 review。第二,SQL 能表达数据库特有能力,比如 PostgreSQL 的 CREATE EXTENSION、MySQL 的 ALTER TABLE 在线算法,这比抽象 API 更贴近底层,能调用的能力更全。第三,迁移文件的 diff 就是 SQL diff,review 时一眼能看出数据定义的变化。caveman 走这条路,说白了就是把复杂性留给数据库引擎,而不是自己在上面再造一层抽象。

2. 核心原理拆解:迁移文件、顺序与状态记录

2.1 一个编号一对文件:up 与 down 的约定

caveman 的约定非常朴素。项目里维护一个迁移目录,目录里是成对的 .up.sql 和 .down.sql 文件,文件名前缀是数字编号。up 文件表示“应用这次变更要执行什么”,down 文件表示“撤销这次变更要执行什么”。

-- 0001_create_users.up.sql CREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP NOT NULL DEFAULT NOW() );
-- 0001_create_users.down.sql DROP TABLE users;

这套约定有几个细节值得展开。第一,编号要全局唯一且递增,工具才能排序并判断哪些没执行过。第二,up 和 down 必须成对出现,否则没法回滚。第三,文件名里 create_users 这种可读后缀是给人看的,工具真正依赖的只有数字前缀。很多人第一次接触时会以为文件名是什么无所谓,实际上编号规则直接决定了后面的排序正确性,这一点到第 4 部分我会专门讲一个踩坑案例。

2.2 状态记录表:工具怎么知道哪些迁移跑过了

执行过哪些迁移、现在推进到哪一步,caveman 的答案是在目标数据库里建一张元数据表。每次执行 up 时,工具扫描迁移目录里的所有编号,和元数据表里已记录的编号做差集,得到待执行清单。

这张表可以理解成图书馆的借阅记录:书架上有什么书是一回事,你借走并登记过哪些是另一回事。没有这张表,工具就只能靠猜,而猜恰恰是手工维护 schema 时最容易出错的地方。由于不同版本对元数据表的字段设计可能不同,具体表名和结构我不在这里凭记忆给结论。你安装之后连一次数据库,看看工具到底建了哪张表、里面存了哪些编号,比看任何文档都直观。

2.3 up 的执行流程:扫描、比对、排序、执行

一次正常的 up 流程大致是:命令读取数据库连接串和迁移目录,连接数据库并确保元数据表存在,然后扫描目录里的迁移文件,读取已应用编号,按编号排序做差集,最后逐个执行未应用的 up 文件并登记编号。

这里有个容易忽略的点:排序。文件名如果是纯数字前缀,工具按数字大小排序,而不是按字符串排序。10_up.sql 在字典序里排在 2_up.sql 前面,但按数字大小它应该排在后面。好的工具会做编号解析,但如果你自己手工创建文件时没有统一补零,就很可能在团队协作里制造出顺序错乱的现场。这个问题我在 4.2 会细讲。

2.4 关于事务:一个文件尽量只做一件事

很多迁移工具会尽量保证“一个迁移文件一个事务”,caveman 的具体行为同样建议以你使用的数据库和版本为准,但实操中真正有价值的建议是:不要在一个迁移文件里塞入多个不相关的变更。PostgreSQL 的 DDL 是事务性的,一个文件包在事务里失败可以整体回滚;MySQL 的 DDL 不支持事务回滚,一半成功一半失败的话,只能人工修复。

所以我的习惯是,一个文件只完成一个逻辑变更,并单独写清楚它的 up 和 down。这样无论失败定位、修复、重放还是回滚,边界都清晰可控。

3. 实操全流程:从安装到第一次 up 与 down

3.1 安装与环境准备

caveman 在 PyPI 上,直接用 pip 装:

pip install caveman

装完先跑一下帮助命令,确认当前版本的参数:

caveman --help

这个习惯很重要。轻量工具为了保持简单,CLI 参数在不同版本之间可能调整,以你本机实际输出为准,别盲信博客里的旧命令。

3.2 建目录、配连接串

在项目根目录创建迁移文件夹:

mkdir migrations

数据库连接串采用标准 URL 格式。PostgreSQL 示例:

postgresql://user:password@localhost:5432/myapp

SQLite 则更简单:

sqlite:////absolute/path/to/app.db

建议把连接串放到环境变量里,比如 DATABASE_URL,而不是写死在命令中。后面我写 Makefile 时会再次用到这个变量。

3.3 编写第一组迁移文件

假设要给空库建 users 表,并且加唯一索引:

-- 0001_create_users.up.sql CREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX idx_users_email ON users(email);
-- 0001_create_users.down.sql DROP TABLE users;

注意,up 里建表和建索引属于“一次变更”,down 只需要 DROP TABLE,因为索引随表一起消失。这个设计保证 down 之后还能再次 up,迁移可以反复重放。

3.4 执行迁移并验证结果

执行 up:

caveman -d migrations --db "postgresql://user:password@localhost:5432/myapp" up

执行成功后,去数据库里确认两件事:一是业务表确实建出来了,二是元数据表里登记了 0001。你可以用客户端连上去看,也可以用 SQL 查表清单。检查元数据表这一步容易被跳过,但它恰恰能帮你理解工具的行为。

3.5 回滚:把 up 换成 down

回滚命令就是把动作换成 down:

caveman -d migrations --db "postgresql://user:password@localhost:5432/myapp" down

注意 down 回滚的是最近一次已应用的迁移,不是把整个数据库重置一遍。生产环境执行 down 前,一定先备份数据,因为你写的 down SQL 通常是破坏性的。

3.6 接入现有工程的方式

我不太建议把迁移逻辑直接塞进应用启动的代码路径,多实例部署时容易遇到并发执行同一批迁移的竞争。更稳的做法是:本地开发时手动执行;部署和 CI 阶段,把迁移作为独立步骤先于服务启动执行。

比如部署流水线分成三步:先备份,再迁移,后发布。迁移失败就中止发布,应用代码停留在旧版本,数据库也不会被半新的结构污染。这套流程无论用不用 caveman 都值得贯彻。

4. 常见问题与排查实录:那些年踩过的坑

4.1 常见问题速查表

症状可能原因排查方向
up 时连接不上数据库连接串格式错误、端口不通、驱动缺失先用原生客户端连一遍,确认连接串本身可用再排查工具层
执行 up 却没有变化元数据表已记录全部编号,或 -d 路径不对检查目录路径,查看元数据表记录
迁移执行顺序不符合预期文件名编号未补零,触发了字符串排序统一使用 0001 这种固定位数编号
某个迁移反复失败文件里有非幂等语句,半途失败留下了残留对象手动清理残留,修正文件后重试
down 之后数据丢失这是 down 的本意,但很多人没备份生产环境 down 前先备份,多做演练

4.2 版本号排序的坑:补零要从第一天做起

真实案例:团队早期有人创建了 9_add_age.sql,过段时间另一个人创建了 10_add_phone.sql。如果工具的编号解析做得不够稳,或者某个环节用了字符串排序,10 就会跑到 9 前面执行。两个迁移互不依赖的时候不会立刻出事,但随着编号变多,目录里会出现 1、11、2、3 这样混乱的展示顺序,review 也会变得困难。

解决办法很简单:从第一天起就固定编号位数。0001、0002 一直延续到当前编号,新文件先看一眼目录里最大编号再加一。不要在编号位数上偷懒,这是免费换取的可维护性。

4.3 连接串与数据库驱动的坑

轻量迁移工具一般不自带数据库驱动,它依赖你环境里已有的数据库客户端库。我遇到过的典型报错是安装完工具后一执行就提示缺模块,原因就是驱动没装。遇到类似报错,先看提示缺哪个包,用 pip 装上再试。

另外要提醒一句:开发环境用 SQLite、生产用 PostgreSQL 的项目,迁移行为差异很大。SQLite 对 ALTER TABLE 的支持很有限,很多在 PostgreSQL 里写得很顺的 DDL 在 SQLite 上直接失败。尽量让本地开发环境贴近生产,比如用 docker compose 起一个 PostgreSQL 实例,再做迁移。

4.4 多人并发执行迁移的竞争问题

团队多人同时在本地跑迁移,或者 CI 和手动部署同时触发,就可能出现竞争:两个进程同时扫描到同一个待执行编号,都去执行,第二个进程因为表已存在直接报错。这种情况不算工具 bug,本质是执行入口没有收敛。

我的建议是约定“同一时刻只有一个入口负责跑迁移”。CI 和发布流水线只保留一条路径,本地开发时如果多人共用一个数据库,先互相确认一声,或者干脆每人一个本地实例,互不干扰。

4.5 我给自己定的几条硬规则

踩过几轮坑之后,我现在遵守这么几条:已提交到共享分支的迁移文件永不修改,只新增;一个迁移文件只包含一个逻辑变更;每个 up 必须有对应的 down,哪怕觉得永远用不到回滚;生产环境执行 down 前先备份再演练;把迁移文件当代码 review,逐行看 SQL 而不是只看最终 schema。

5. 与 Alembic、Flyway 的横向对比和选型建议

5.1 一张对比表看清差异

维度cavemanAlembicFlyway
迁移文件形态纯 SQLPython 代码纯 SQL
自动生成迁移无有 autogenerate无
学习成本极低中等低
跨数据库能力取决于你的 SQL高,API 做了抽象取决于 SQL
运行环境PythonPythonJVM
适合项目中小项目、SQL 主导SQLAlchemy 生态多语言团队、Java 生态

5.2 什么场景下选 caveman 更合适

我的判断标准很简单:团队主要用 Python,开发成员对 SQL 足够熟悉,愿意直接掌控 DDL;项目规模还没到需要上百个带编号依赖的复杂迁移;不想为了数据库迁移引入一整套框架。满足这几条,用 caveman 是性价比很高的选择。

反过来,如果项目大量依赖 SQLAlchemy 的模型定义,希望迁移初稿能自动生成,那 Alembic 和模型的联动更顺。如果团队以 Java 为主、多个服务共享数据库规范,Flyway 的生态和它的锁机制会更合适。工具没有绝对的好坏,只有和团队上下文合不合适。

5.3 存量项目平滑接入:反向生成基线

已有项目想接迁移工具,很多人担心历史 schema 怎么处理。通用解法是反向生成基线:用导出工具把数据库当前所有对象生成一份基线 SQL,编号定位 0001,然后想办法让元数据表直接标记 0001 为已应用,后续的新变更从 0002 开始。

在 caveman 上实践时,核心要点是理解元数据表的读写约定。你可以先创建一个空的 0001 迁移并执行,让工具登记编号,再用真实基线替换文件内容;或者在测试库上验证工具支持直接插入元数据记录。具体方式因版本而异,但思路都是“让工具以为基线已经跑过”。

6. 团队协作中的落地细节:让迁移真正成为流程闭环

6.1 把命令固化成 Makefile 脚本

命令行工具只在个人机器上跑是远远不够的,要让它成为团队习惯,最好把命令写进 Makefile:

migrate-up: caveman -d migrations --db "$$DATABASE_URL" up migrate-down: caveman -d migrations --db "$$DATABASE_URL" down migrate-status: caveman -d migrations --db "$$DATABASE_URL" status

连接串统一走 DATABASE_URL 环境变量,不写死在仓库里。这样无论谁在什么环境执行,命令都是一样的,减少“我跟你命令不一样”的沟通成本。

6.2 CI 里加一个从零迁移检查

在 CI 里加一个“从零构建数据库”的 job:创建临时数据库,执行全部迁移,再跑测试。这能暴露两类问题:一是迁移文件在干净库上能不能完整执行,二是测试代码是不是依赖了迁移之外的 schema 变更。

这一步很值得投入。很多迁移问题在增量环境里很难发现,但干净库会把问题放大得很明显。让每个 MR 都过一遍这个检查,比上线当天凌晨再发现 DDL 错误要舒服得多。

6.3 编号冲突与沟通约定

多人协作时,迁移编号冲突很常见。缓解办法是靠约定:创建新迁移前先拉取最新的 main,确认最大编号是多少,再递增生成。如果 review 时发现两个 MR 用了同一个编号,直接打回去,让后合并的人改编号。

还有一个细节是迁移文件的归属:谁创建、谁负责,review 时必须有人对 SQL 语义负责。数据库结构变更会影响所有人,所以它应该是团队里被认真对待的对象,而不是某个人顺手提交的一个文件。

最后说一点我个人的体会。用 caveman 这几年,它没有带来什么“酷炫”的开发体验,但它帮我避免了最基础也最尴尬的线上事故:表结构不一致、漏执行迁移、回滚无门。它的存在感极低,除了写迁移文件的时候,几乎感觉不到它的存在,但恰恰是这种“感觉不到”让我很放心。如果你也在找一个愿意把复杂性留在 SQL 层而不是框架层的迁移工具,不妨从一个小项目开始试,跑通一次 up 和 down,再决定要不要让它成为项目的标配。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询