两周前,我接手了一个遗留项目的数据库维护工作。那套系统上线快三年,迁移脚本散落在三个不同的目录里,有的叫v1.0.3_fix.sql,有的叫add_column_xxx_temp.sql,版本号对不上,本地库和测试库的结构也早就漂移得不像同一个项目。我花了两天才理清楚"线上到底有哪些表",那一刻我就决定,必须换一套数据库 Schema 管理方案。
后来我把项目里的迁移流程整个切到了Atlas。它不是又一个让程序员手写 SQL 的老式迁移工具,而是反了过来:你先描述"我最终想要什么结构",Atlas 帮你算出来"现在需要变哪些"。对我这种被历史包袱捆住手脚的人,这套思路几乎是一剂解药。这篇文章就把我换工具的过程中踩过的坑、梳理清楚的概念、以及目前跑在 CI 里的完整用法都写出来,给同样在项目里挣扎的人一个参考。
1. 从手写迁移脚本到声明式 Schema:我为什么换到 Atlas
1.1 旧方案的问题在哪
过去团队用的是经典的版本化迁移脚本:每改一次表结构,就加一个20240118_xxx.sql。听起来很规整,实际用起来有几个致命伤。
第一个问题是脚本可执行性没保证。A 环境执行到第 12 个脚本时报错,B 环境可能早就执行到第 15 个了。数据库不像应用代码,没有一个git status能告诉你哪一步没跑。很多团队靠维护一张schema_version表来自欺欺人,而这张表本身也可能在某个环境里被人手工跳过。
第二个问题是评审难。一个加了索引的脚本,一个删了字段的脚本,都只是一堆 SQL 文本。你很难一眼看出来"这次改动到底会不会锁表""是不是会在生产环境丢掉数据"。等老代码里某个查询还在引用被你删掉的列时,爆炸的就是线上。
第三个问题是数据库真实的 schema 和你想的不一样。ORM 的模型、迁移脚本、测试环境,三方经常对不上。这也是我这次接手项目的直接痛点。
1.2 Atlas 提供了一条不同的路径
Atlas 的核心思路叫做Desired State(期望状态)。你不再关心"第几步改了什么",而是维护一个完整的 schema 声明文件,告诉它你最终要什么样的表结构。Atlas 对比目标数据库的当前状态,生成一份迁移计划,帮你把数据库变成期望状态。
这套思路有两个很舒服的地方:
- 对已经存在的数据库,可以让 Atlas 直接反向解析,把线上结构导出成 HCL 声明文件,作为起点。
- 以后每次改动,只需要在声明文件里加表、加字段,Atlas 负责算 diff、生成可阅读的 SQL 迁移文件。
它不是不要迁移脚本了,而是把迁移脚本从"人肉维护"变成了"机器生成"。团队 review 的对象也从"SQL 文本"变成了"你想要的最终结构 + 生成的 diff"。这个转变对日常开发和数据库维护来说,完全不一样。
如果你现在管理的项目只有几张表、也不怎么变更,那继续手写 SQL 问题不大。但如果你维护的是中大型项目,跨多个环境发布,数据结构每周都在变,Atlas 这套声明式工作流绝对值得你花一个下午试试。
2. 先理解 Atlas 的底牌:Desired State、HCL 与迁移计划
2.1 声明式 vs 版本化,两边不是对立的
很多人一上来就纠结 Atlas 到底是"声明式工具"还是"版本化工具"。实际上 Atlas 两套都支持:
atlas schema apply:走声明式流程,直接对比目标库和声明文件,立即执行变更。atlas migrate diff+atlas migrate apply:走版本化流程,先生成一份增量迁移文件,再按版本顺序执行。
我更推荐把 Atlas 当作"用声明式来生成版本化迁移"的组合用法。这样做的好处是,你的迁移文件是确定性的、可回溯的,可以走 review 和 CI;而生成它的时候,又不需要人肉去写每一段 DDL。这个组合是它在生产环境里最好使的形态。
2.2 HCL 到底长什么样
Atlas 自己定义了一套 schema 语法,用 HCL 书写。它比 SQL DDL 更紧凑,也更方便程序解析。
下面是一段最基础的schema.hcl:
schema "public" { comment = "application schema" } table "users" { schema = schema.public column "id" { type = bigint auto_increment = true unsigned = true } column "email" { type = varchar(255) null = false } column "created_at" { type = timestamp null = false default = sql("CURRENT_TIMESTAMP") } primary_key { columns = [column.id] } index "idx_users_email" { unique = true columns = [column.email] } }第一次看到这段声明的人可能有点不适应,它的可读性其实不差:"有一张 users 表,有 id、email、created_at 三个字段,主键是 id,email 上有唯一索引"——非常直白。
如果你的项目已经有数据库,不需要自己默写这份文件,直接用 Atlas 从线上库里反解出来:
atlas schema inspect \ --url "mysql://root:pass@tcp(localhost:3306)/mydb" \ --schema mydb \ --format hcl > schema.hcl这条命令会把整个库的表结构、索引、外键、触发器都变成 HCL。生成后我习惯打开文件扫一遍,确认没有把奇怪的历史残留带进"期望状态"。
2.3 迁移计划是怎么算出来的
Atlas 生成迁移计划的基本逻辑是:期望状态(HCL 文件)减去当前状态(数据库实际的表结构),得到变化集合。听起来简单,实际执行时有个关键点:不同的数据库对 DDL 的理解不一样,同一个 ALTER TABLE,在 MySQL 8 和 PostgreSQL 15 上产生的行为可能不同。
所以 Atlas 需要你提供一个--dev-url,也就是一个临时的、干净的开发数据库。它会在这个空库上模拟执行 DDL,把可能产生的隐式行为(比如 MySQL 对整个表的重建)也考虑进去。简单说,dev-url 就是它的"沙盒计算器",没有它,很多 diff 结果会失真。
我第一次用的时候偷懒,只给了--url和--to,结果生成的 SQL 里多了一堆奇怪的 rename。加上 dev-url 之后,一切都正常了。
3. 把 Atlas 接进真实项目:安装、初始化到第一次迁移
3.1 安装与基础命令
安装方式我试过几种,最省事的是用 Homebrew:
brew install ariga/tap/atlas如果团队统一用 Docker,也可以直接拉镜像:
docker pull arigaio/atlas安装完先看一下版本:
atlas version需要注意,Atlas 的 CLI 更新很快,命令参数在不同大版本之间可能有差异。我写这篇文章时用的主版本是 v0.14 左右,如果你执行下面命令发现某个参数不存在,第一反应应该是去看atlas --help,而不是质疑文章写错了。这也是所有快速演进的工具都有的通病。
3.2 用 atlas.hcl 管理环境配置
每次敲一长串参数非常容易出错,Atlas 提供了atlas.hcl配置文件来帮你管理不同环境。
在项目根目录创建一个atlas.hcl:
env "local" { src = "file://schema.hcl" url = "mysql://root:pass@localhost:3306/mydb" dev = "docker://mysql/8" migration { dir = "file://migrations" format = atlas } } env "ci" { src = "file://schema.hcl" url = "${CI_DB_URL}" dev = "docker://mysql/8" migration { dir = "file://migrations" format = atlas } }我来解释一下每个字段的含义:
src:期望状态的来源,可以是一个 HCL 文件,也可以是数据库连接。url:目标数据库连接串,就是你要改的那个库。dev:dev database 连接,用于计算迁移计划和做 lint。migration.dir:迁移文件存放目录。migration.format:迁移文件的格式,用默认atlas即可。
配置好之后,后续命令都很短。比如直接应用期望状态到本地库:
atlas schema apply --env localAtlas 会先连上本地库,和schema.hcl对比,然后把计划变更打印出来,确认后执行。
3.3 生成第一个版本化迁移文件
我更推荐把它纳入版本控制,所以第一个步骤是生成迁移文件:
atlas migrate diff init_migration \ --env local \ --dev-url "docker://mysql/8"这里init_migration是迁移文件的名字,你可以按自己的习惯命名。执行成功之后,migrations/目录下会出现类似下面这样的结构:
migrations/ 202501100001_init_migration.sql atlas.sumatlas.sum是用于校验迁移文件完整性的 checksum 文件,必须提交到 Git,不然 CI 里没法验证历史文件有没有被篡改。
打开生成出来的 SQL,你会看到类似这样的内容:
-- create "users" table CREATE TABLE `users` (`id` bigint unsigned NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE INDEX `idx_users_email` (`email`)) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;到这里,你已经为项目建立起了基线。
3.4 在已有环境上回放迁移
如果你已经用atlas schema apply把本地库改了,那迁移文件的状态和数据库当前状态会不一致。更规范的做法是:从迁移文件出发,用atlas migrate apply把历史回放:
atlas migrate apply --env local这个命令会检查migrations/目录下所有迁移脚本,只执行没有在当前数据库记录中出现的那些。这样你的数据库演进过程就变成了"一条完整的时间线",不会出现本地和线上各长各的的情况。
我的经验是:日常开发可以用atlas migrate diff生成新迁移,但在真正改动共享环境前,优先用atlas migrate apply回放,而不是直接用schema apply跳过历史。前者更接近生产环境的发布路径,容易暴露累积问题。
4. 让迁移进入工作流:本地开发、测试环境与 CI 的配合
4.1 多人协作时,迁移文件谁来生成
团队里多个人同时改 schema 是冲突高发区。我的建议是:
- 每个人在自己分支上改
schema.hcl,用atlas migrate diff生成迁移文件。 - 如果多人同时生成同号迁移文件,Git 合并时大概率会在
atlas.sum上冲突。 - 解决方式是保留一个版本号更大的迁移文件,删掉另一个同号的,再重新生成一个。
这个流程其实和代码合并没本质区别,但这套设计让冲突从"线上数据库结构不一致"提前到了"代码 review 阶段",成本低很多。
4.2 用 migrate lint 拦下破坏性变更
最让我满意的一个命令是atlas migrate lint。它能在迁移文件 review 阶段就发现危险操作。
假设你在分支里删掉了users表的email字段,然后执行:
atlas migrate lint \ --env local \ --dev-url "docker://mysql/8" \ --git-base mainAtlas 会比较migrations/目录从 main 分支以来的变化,并对每条迁移语句做风险分析。遇到删除列、删除表、收紧字段长度这类操作时,会输出一个 "destructive" 或 "dangerous" 级别的风险提示。
我团队现在的规则是:CI 里执行 lint,如果风险级别超过 warning,就阻止合并。注意,lint 不是万能的,它只能从 DDL 语义上判断风险,判断不了你的业务是否真的还需要那张表。但它至少能拦住一些"手一抖就把线上列删了"的低级事故。
4.3 在 GitHub Actions 里跑一套最小 CI
下面是我现在用在项目里的一个极简 GitHub Actions 片段:
name: db-migration-check on: pull_request: jobs: lint: runs-on: ubuntu-latest services: mysql: image: mysql:8 env: MYSQL_ROOT_PASSWORD: pass MYSQL_DATABASE: ci_db ports: - 3306:3306 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install Atlas run: | curl -sSf https://atlasgo.sh | sh - name: Run migrate lint env: CI_DB_URL: mysql://root:pass@localhost:3306/ci_db run: | atlas migrate lint \ --env ci \ --dev-url "docker://mysql/8" \ --git-base origin/main注意这里多了一个--git-base origin/main,它要求 checkout 时要有完整历史,所以fetch-depth不能设为 1。这个细节我踩过一次,写成 fetch-depth: 0 才能让 Atlas 拿到 main 分支和当前分支的迁移文件对比基础。
如果你所在团队不用 GitHub,也没关系,因为atlas migrate lint就是一个 CLI 命令,任何 CI 平台都能跑,关键是让"数据库迁移的变更检查"变成自动化的一环。
5. 我踩过的坑:类型转换、外键约束、锁表与权限
5.1 timestamp 和 datetime 的类型漂移
第一次用 Atlas 生成 MySQL 迁移时,我发现它总是给时间字段生成datetime(6),而我的线上库是timestamp(6)。反复 diff 之后才意识到,MySQL 的CURRENT_TIMESTAMP在两种类型下行为不完全一致,Atlas 为了避免误判,默认会倾向更精确的类型。
后来我把所有时间字段统一成了timestamp并显式在 HCL 里写了type = timestamp,迁移文件才干净。所以说,声明式工具的 diff 结果和你的历史类型越一致,生成的迁移就越没有"意外惊喜"。如果你接手的项目里类型本身已经很不规范,先在声明文件里把规范定下来,再逐步用一个个迁移去纠正。
5.2 重命名字段会被识别成 drop + add
在 HCL 里给列改名,实际上就是对schema.hcl删掉旧字段、加上新字段。Atlas 并不能读心知道你其实是"rename",它看到的是一次 destructive 的删除操作。如果你确实要重命名列,建议先手动在迁移文件里写成RENAME COLUMN,再同步更新 HCL,避免自动生成的 drop/add 造成数据丢失。
同样的道理适用于修改约束类型、交换索引顺序等操作。生成迁移文件后,务必人工扫一遍那条 ALTER TABLE,特别是DROP关键字出现的地方。
5.3 大表加列,该锁还是要锁
Atlas 不会自动帮你用 pt-osc 或者 gh-ost 去做在线 DDL。在表数据量达到几百万甚至千万行时,一个看似普通的ADD COLUMN在 MySQL 的 InnoDB 引擎下也可能导致长时间锁表。
我现在的团队会这样做:把 Atlas 生成的 SQL 提取出来,交给专门的 DBA 脚本去执行,或者把它拆进低峰期窗口。Atlas 生成迁移文件的意义在于"让变更可预测、可 review",它不负责替你规避数据库本身的执行机制。这一点必须提前想清楚,别以为声明式工具能解决所有物理局限。
5.4 数据库账号权限名不副实
Atlas 需要相当高的权限才能完成 diff 和 apply。如果你用的是一个只拥有DML权限的账号去跑atlas schema apply,会直接报权限不足,而且报错信息不够直观,得看完整日志才知道是哪一句 DDL 触发的。我的建议是单独为 Atlas 建一个专用账号:
GRANT ALL ON mydb.* TO 'atlas_user'@'%' IDENTIFIED BY 'your_password';如果你用 dev-url 连接的是 Docker 里临时启动的 MySQL,用 root 问题不大,但在生产环境或共享测试环境,一定要给最小但足够的 DDL 权限,保证它能做CREATE、ALTER、DROP、INDEX这些操作。
5.5 dev 库版本必须和目标库一致
Atlas 在生成迁移计划时会把 DDL 放到 dev 库里跑一遍。如果 dev 库是 MySQL 8.0,线上库是 MySQL 5.7,那么生成出来的 DDL 很可能是 8.0 的语法,拿到 5.7 上执行就会失败。
--dev-url不只是一个"随便什么临时库"这么简单,它的版本、字符集、collation 最好和目标环境对齐。项目里我用的是docker://mysql/8和docker://postgres/15这种模板,简单可靠。如果你们是自建机房的数据库,就在 dev-url 里指向一台固定的开发实例,保证版本一致。
6. 选型参考:Atlas、Flyway、Alembic、Sqitch 该怎么挑
6.1 几种工具的思维方式对比
| 工具 | 核心方式 | 迁移文件生成 | 数据库漂移检测 | 适用场景 |
|---|---|---|---|---|
| Atlas | 声明式期望状态 + diff | 自动生成 HCL/SQL | 强 | 跨环境项目、CI 要求高的团队 |
| Flyway | 版本化手写 SQL | 手写 + 代码生成工具 | 弱 | Java 生态、较稳定的结构 |
| Alembic | 从 ORM 模型生成迁移 | 自动生成 Python/SQL | 中 | Python/SQLAlchemy 项目 |
| Sqitch | 依赖式 SQL 变更集 | 手写 SQL | 弱 | 对单个数据库 SQL 有强依赖的团队 |
Flyway 的核心抽象是"第几个版本执行了什么 SQL",它很成熟,但没法回答"线上库是不是真的和迁移脚本一致"这个问题。Alembic 和 ORM 天然绑得紧,模型即结构,很方便,但如果你的项目里存在大量手写的复杂 SQL、视图、存储过程,用 Alembic 来管反而痛苦。Sqitch 更像是一个 SQL 管理框架,依赖关系清晰,但一切都得自己写。
Atlas 的差异化优势在于它把"数据库的真实状态"当成了头等公民。你可以先 inspect 一个已有的旧库,再让它成为你迁移史的唯一真源,这是很实用的能力。
6.2 什么情况下我会首选 Atlas
我的判断标准很简单:
- 项目里表的数量超过了二十张;
- 每次发布都需要带着数据库变更一起走;
- 团队里没有专门的 DBA,后端同学要自己维护结构;
- 你受够了手工写 DDL 和"环境之间结构不一致"的反复拉扯。
满足其中两条以上,就值得切到 Atlas。
如果你只用单机 SQLite 做小工具开发,或者项目从创建到现在数据库一直没变过,那换不换都无所谓。工具是拿来解决问题的,不是拿来赶时髦的。
6.3 对选型的一点提醒
Atlas 还在快速迭代,有些高级特性(比如权限模型、多云平台的 Terraform provider)在不同版本里的 API 变化比较大。如果你决定在生产环境使用,我的建议:
- 锁定 CLI 或 Docker 镜像的版本,不要在发版前一天升级。
- 把
atlas.sum里的校验和视为安全敏感文件,绝不允许其他人手工修改。 - 在 CI 之外,每周至少做一次"在全新库上回放所有迁移"的验证,确保迁移文件本身是自洽的。
最后再分享一个我自己的习惯:无论 Atlas 生成的迁移计划看起来多合理,我在合入前都会把生成出来的 SQL 从头到尾读一遍。lint 能帮我挡掉明显的高风险操作,但业务含义只有人才能判断。数据库结构里藏着很多隐性的业务契约,自动化工具再强,也只是助手,不是决策者。这套工作流目前在我手上维护的项目里跑了半年多,最大的感受是从此没人再来问我"哎,测试库怎么少了张表"了,这大概就是数据库工程该有的状态。