提到 Atlas,搞数据方向的同事第一反应多半是 MongoDB Atlas,或者人体颈椎的第一节椎骨。我这里要聊的是 atlasgo.io 那个开源的数据库 Schema 管理工具。第一次把它引入团队,是因为我们受够了一套业务三套库、三副面孔的场面:预发环境没有开发环境建过的索引;DBA 半夜手动执行过一条 ALTER 临时救业务,这个改动却没有任何迁移记录;两个特性分支同时往 migrations 目录里塞文件,合完代码谁都说不清下一步该跑哪个脚本。2023 年我花了一个下午把手写迁移流程换成了以 Atlas 为核心的声明式 Schema 管理链路,这篇文章算是对这套折腾的完整复盘。内容覆盖它解决的痛点、差异计算原理、从零接入到 CI 门禁的落地步骤,以及我实际踩过的几个坑,适合后端开发、DBA 和所有被数据库迁移折磨过的人直接抄作业。
1. 从手写迁移 SQL 到声明式 Schema:Atlas 到底解决了什么
1.1 迁移脚本流的三个裂缝
传统上我们用迁移工具管理数据库演进,思路是把每一次结构变更写成一份 SQL 文件,按顺序回放。这个模式本身没问题,但团队一放大、环境一多,裂缝就出来了。
第一个裂缝是顺序冲突。两个分支各自加了迁移文件,合并时编号可能撞车,也可能语义上互相依赖却按错误顺序执行。Flyway 这类工具遇到编号缺失会罢工,需要人工去判断哪个目录是权威,这个判断成本在多人协作里会反复出现。Atlas 的版本化迁移目录用的是时间戳文件名,配合 checksum 文件,冲突在 git 层就暴露了,不需要迁移工具来仲裁。
第二个裂缝是环境漂移。开发、预发、生产不是同一时刻诞生的,各自的基线和手改历史不同。你在一台机器上测得好好的迁移脚本,推到另一台库上可能因为"这个列早就手动加过了"而失败。漂移最可怕的地方在于它发生时没有任何告警,等你发现的时候,库的实际状态和迁移脚本能推导出的状态已经差了好几个版本。
第三个裂缝是"改 Schema 不等于改代码"。应用代码改了可以立刻部署,但数据库结构改了不跑迁移就没有任何效果。代码有 CI 卡着,库结构没有。于是经常出现代码里引用了新列、生产库上这个列不存在的经典事故。Atlas 的声明式思路之所以值得换,就是因为它把"数据库最终应该长什么样"变成了和代码一样可审查、可版本化的资产,而不是散落在几十个 ALTER 脚本里的模糊记忆。
1.2 声明式期望状态:这跟"自动生成 ALTER"不是一回事
很多人第一次听说 Atlas,以为它就是一个根据前后两个版本自动写 ALTER 语句的生成器。这么理解会错过它最关键的设计。
声明式的核心是"期望状态"。你只需要维护一份完整的 Schema 描述文件,告诉 Atlas 数据库最终应该长什么样子。Atlas 会主动连上目标数据库,检查当前真实状态,计算两者之间的差异,然后生成一份最小变更计划。它不是从"上一个版本"推导"下一个版本",而是从"当前库里的实际样子"推导"你想要的样子"。这两个出发点完全不同:基于版本推导,中间任何一步被手动篡改过,结果就全错了;基于真实状态推导,无论之前发生过什么,它永远往目标状态收敛。
可以类比装修:命令式是你告诉施工队"这里砸墙、那里加插座",施工队凭记忆施工,最后和设计图对不上你都不知道是哪一步出的问题;声明式是你把完整设计图给施工队,他们先量房复核现状,只做和图纸有出入的部分,做完拿图纸逐项验收。Atlas 干的就是量房和画改动清单这两件事。
| 维度 | 手写迁移 SQL | Atlas 声明式 |
|---|---|---|
| 事实源 | 散落在 N 个 ALTER 脚本里 | 一份完整的 schema.hcl |
| 变更计算 | 人肉评估所有环境差异 | diff 引擎按当前库状态计算 |
| 审查对象 | 每个 SQL 文件本身 | 期望状态文件 + 生成的迁移计划 |
| 环境漂移感知 | 无 | diff 一跑就知道 |
1.3 什么场景下别急着上 Atlas
工具再好也有适用边界。单库单环境、个人项目、表就二三十张,手写迁移已经很稳定,没必要引入额外的学习成本。另外一个容易误解的地方:Atlas 管的是 Schema 结构,不是数据。往已有表里加列之后要回填几百万行的历史数据,这种数据迁移必须自己做,别指望声明式工具替你兜底。还有一类场景要谨慎,就是团队里没有任何数据库基础知识的成员直接对生产库跑 --auto-approve,这跟给新手发一把手术刀没有区别。声明式降低了写 DDL 的门槛,但没有降低审核的责任。
2. 差异计算引擎是怎么运作的:期望状态、真实状态和影子库
2.1 HCL/SQL 双模描述与只看不动手的 inspect
Atlas 支持两种方式描述期望状态:一种是它原生的 HCL 格式,另一种是纯 SQL 格式。HCL 是典型的声明式描述,可读性很强,而且天然支持注释和策略属性。比如一张普通的用户表,schema.hcl 长这样:
schema "public" { comment = "业务主库" } table "users" { schema = schema.public column "id" { type = bigint null = false identity { generation = "ALWAYS" } } column "email" { type = varchar(255) null = false } column "created_at" { type = timestamptz null = false default = sql("now()") } primary_key { columns = [table.users.column.id] } index "users_email_key" { unique = true columns = [table.users.column.email] } }第一次看到这个文件的人通常会问:字段类型、主键、索引这些我懂,但怎么知道我现在线上的库长什么样?这就是atlas schema inspect干的事。它只做读取,连接现有数据库,把整个 schema 反向转换成 HCL 或 SQL 输出到文件:
atlas schema inspect \ --url "postgres://user:pass@host:5432/mydb?search_path=public" \ > schema.hcl这个命令是我日常用得最多的一个,因为它是所有改造的起点。inspect 出来的文件就是当前库的"体检报告",先拿到这份报告,再在上面做修改,比凭空写一份新 schema 靠谱得多。对于偏好 SQL 的团队,Atlas 也支持用纯 SQL 文件描述期望状态,命令里把--to file://schema.sql即可。两种格式用哪个取决于团队习惯,HCL 的优势在于可以被代码生成器程序化操作,SQL 的优势在于 DBA 看起来更亲切。
2.2 dev database:为什么计算 diff 需要一台"影子数据库"
这一节是 Atlas 和其他工具相比最反直觉的地方:算一个 diff,它居然要求你额外提供一个开发库。很多新手第一次跑atlas schema diff被提示--dev-url缺失,第一反应是"这工具怎么这么麻烦"。
麻烦是有道理的。数据库的 schema 差异不是纯文本比对,而是语义比对。同一个"给 name 列加唯一索引"的意图,在 MySQL、PostgreSQL、SQLite 里生成的 DDL 完全不同,甚至同一个 PostgreSQL 的 13 和 15 版本之间,默认值表达式的语义都有细微差别。要准确判断一段 DDL 是不是真的等效,必须让真实的数据库内核去"试跑"一下。
Atlas 的做法是维护一台 dev database,我习惯叫它影子库。你在命令里通过 dev-url 指定它,Atlas 会在影子上执行所有用于校验的语句,把计算结果映射回你的目标库。比较常用的写法是用 Docker 协议直接拉一个一次性容器:
atlas schema diff \ --from "postgres://prod-ip:5432/mydb" \ --to "file://schema.hcl" \ --dev-url "docker://postgres/15"这里有个红线必须划清楚:dev-url 绝不能指向你的生产库,也不能指向任何你珍惜的库,因为它会被拿来执行 DDL。影子库的特点就是"用完即焚、脏了随便折腾"。实际使用中我发现,没有影子库时 Atlas 的 diff 会退化为启发式推断,对类型长度、默认值表达式这类细节容易误判。所以别图省事省掉这个参数,它恰恰是准确性最关键的保障。
2.3 差异规划器的安全行为:destructive 变更怎么拦
diff 引擎算出差异之后,会生成一个规划好的变更计划。这一步最值得说的,是它对待破坏性操作的态度。默认情况下,atlas schema apply对真实库执行时处于交互确认模式,drop 表、drop 列、drop 索引这类操作会被单独标记出来,要求人工确认,甚至要求你显式追加--dangerous参数才允许执行。这意味着一个原本可能在代码评审里被忽略的DROP COLUMN,现在会以刺眼的红色块出现在终端里,逼着你停下来想清楚。
这个设计解决了一个真实痛点:迁移文件在提交前的评审阶段,reviewer 往往只关注业务逻辑,对 DDL 的破坏性缺乏敏感度。当工具把破坏性操作单独挑出来,评审的关注点就自然聚集到了最危险的地方。lint 阶段还有另一层拦截,后面会有专门的小节展开。
3. 从零接入:首次建表、版本化迁移到上线的三连操作
3.1 安装与项目初始化
Atlas 是 Go 写的单二进制工具,安装非常干净。macOS 上用 Homebrew:
brew install ariga/tap/atlasLinux 或 CI 机器上用官方脚本:
curl -sSf https://atlasgo.sh | sh注意 curl 管道到 sh 这个操作,执行前建议先下载脚本看一眼内容,这是基本的安全习惯。不想用脚本也可以拉 Docker 镜像运行,镜像名是arigaio/atlas。装完跑一个atlas version确认环境没问题。我写这篇时的版本在 0.2x 附近,命令如果和你的版本有细微出入,以官方文档最新为准。
项目初始化其实不需要什么特别的 init 步骤,核心就是三样东西:一个 schema 描述文件、一个 migrations 目录、以及一份约定的 dev-url。从一开始就把这三样放进 git 仓库,后面所有事情都围绕它们展开。
3.2 用 HCL 定义第一张业务表
沿用上面的 users 表,再补一张 orders 表,加上外键关联,看完整定义:
table "orders" { schema = schema.public column "id" { type = bigint null = false identity { generation = "ALWAYS" } } column "user_id" { type = bigint null = false } column "amount" { type = numeric(10, 2) null = false default = sql("0.00") } primary_key { columns = [table.orders.column.id] } foreign_key "orders_user_id_fkey" { columns = [table.orders.column.user_id] ref_columns = [table.users.column.id] on_delete = CASCADE } index "orders_user_id_idx" { columns = [table.orders.column.user_id] } }写完这份文件,在本地空白库上直接执行一次 apply,验证整份声明是可落地的:
atlas schema apply \ --url "postgres://localhost:5432/mydb?search_path=public" \ --to "file://schema.hcl" \ --dev-url "docker://postgres/15" \ --auto-approve--auto-approve在这里是安全的,因为目标是我本地随意折腾的库。但请记住,这个参数一旦对准生产库就危险了。我的习惯是:本地和 CI 上可以用 auto-approve,人工操作真实环境时永远保留交互确认。
不知道 HCL 怎么写怎么办?记不住语法就别硬写,用 inspect 打底。先建一个草稿库,手动建好表,再atlas schema inspect导出 HCL 作为起点,这比自己对照文档写靠谱得多。
3.3 从声明的 Schema 产出可回放的迁移文件
对生产环境,我们不建议直接 apply。生产库需要审计、需要回滚路径、需要记录谁在什么时候改了什么。所以常规流程是用atlas migrate diff从语义变化生成版本化迁移文件:
atlas migrate diff add_orders_table \ --dir "file://migrations" \ --to "file://schema.hcl" \ --dev-url "docker://postgres/15"执行后 migrations 目录下会多出一个时间戳命名的 SQL 文件,内容是 Atlas 算出的所有 DDL 语句,同时生成的还有一个atlas.sum文件,它是整个目录的 checksum 清单。任何人对迁移文件做了手工编辑,checksum 都会对不上,这在多环境部署时是非常有用的防呆机制。
迁移文件生成后,用 migrate apply 回放到目标库:
atlas migrate apply \ --dir "file://migrations" \ --url "postgres://prod-ip:5432/mydb"Atlas 会在目标库维护一张 revisions 表(默认叫 atlas_schema_revisions),记录每个已执行文件的名称、checksum、执行时间。以后每次 apply 只跑未执行过的文件,重复执行同一个文件会被直接跳过。这套机制解决了手写迁移时代最头疼的问题:没人记得哪个脚本在哪个环境跑过了。
3.4 生产 apply 前最后一遍检查
apply 到生产前,我的固定动作是先在预发环境完整跑一遍迁移,然后打开生成的 SQL 文件逐条读一遍。重点看三类内容:有没有涉及 drop 操作的语句;有没有对大表的重建性操作,比如改列类型;有没有生成出的语句和业务预期不符,比如明明只是加了一个索引,diff 却生成了整表重建。都不是的话,再执行 apply。
还有一个小技巧:生成迁移文件并检查无误后,把这个 SQL 文件提交进 MR,让团队里的人都能看到"这次上线要跑哪些 DDL"。"上线前的一遍人工读 SQL"这个习惯,让我拦截过不止一次误生成的 drop 语句。工具负责计算,人负责判断,两者缺一不可。
4. 上线之后才需要的东西:Lint 门禁、漂移检测和团队协作
4.1 migrate lint:在 PR 阶段拦下高风险变更
Atlas 的 lint 功能是它区别于绝大多数迁移工具的地方。atlas migrate lint会分析迁移文件,把它与基线版本对比,识别出有风险的变更模式,然后根据你的策略决定是告警还是报错。
常用的规则有这么几条:
| 规则 | 触发场景 | 我的建议 |
|---|---|---|
| Destructive | drop 表、drop 列、drop 索引 | error |
| Parallel | 多个迁移文件可能并行执行导致外键顺序错误 | error |
| DataLoss | 改列类型或删除列导致数据丢失风险 | 人工确认 |
配合策略文件.atlas.hcl,可以把规则固化成团队约定:
lint { destructive { error = true } parallel { error = true } }实际用法是放在 PR 阶段,对改动点整个 migrations 目录做一次扫描:
atlas migrate lint \ --dir "file://migrations" \ --dev-url "docker://postgres/15" \ --git-base "origin/main"--git-base告诉 lint 以主分支的迁移目录为基线,只审查新增的迁移文件。跑完自动把结果挂在 PR 检查上,destructive 变更直接标红,开发在合并前就被迫处理,而不是上线当天临时打电话给 DBA。
这里要说明一点:lint 拦截的是"模式风险",不是"业务风险"。比如给千万行的大表加一个 NOT NULL 列,lint 可能判断为需要人工确认,但它无法判断你的业务是不是能承担锁表代价。所以 lint 是底线,不是全部。
4.2 漂移检测:库与声明不一致时怎么发现
漂移是数据库管理的头号隐藏问题。常见来源包括:DBA 手动热修;某次故障后有人直接在库里改了结构;某个自动化脚本执行了不在迁移目录里的 DDL。漂移不会主动报警,等你发现时通常已经伴随事故。
检测漂移的本质就是反复跑 diff,然后把结果当错误处理。我会在 CI 里放一个定时任务或手工触发的 job,对预发库执行:
output=$(atlas schema diff \ --from "$STAGING_URL" \ --to "file://schema.hcl" \ --dev-url "docker://postgres/15" 2>&1) if [ -n "$output" ]; then echo "Schema drift detected:" echo "$output" exit 1 fi echo "No drift"这个 job 只做只读 diff,绝不 apply,它存在的意义是让"库和声明不一致"这件事从不可见变成可见。漂移检测跑起来之后,我明显感觉到团队手改数据库的频率降低了,因为大家都知道改了会被抓出来。
4.3 GitHub Actions 的最小 CI 闭环
lint 加漂移检测可以合成一个最小的 CI 闭环,让迁移这件事在没有人工干预的情况下先完成第一道质检。示例配置如下:
name: atlas-migrate-lint on: pull_request: paths: - "schema.hcl" - "migrations/**" - ".atlas.hcl" jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: ariga/atlas-action/migrate/lint@v1 with: dir: "file://migrations" dev-url: "docker://postgres/15" git-base: "origin/main"需要注意 CI 里的 DSN 一律通过 GitHub Secrets 注入,不要在 workflow 文件里写明文密码。CI 跑起来之后,开发提交改 Schema 的 PR 会在几分钟内得到反馈:要么通过,要么因为破坏性变更被拦下。这个体验比过去"上线当天被 DBA 拦住"好太多了。
5. 我用 Atlas 踩过的坑:顺序、外键、枚举与存量库
5.1 MySQL 的隐式提交让文件级事务保护失效
很多迁移工具会把单个迁移文件包装在一个事务里执行,Atlas 也是这样设计的。但这个保护在 MySQL 上有一个大坑:MySQL 的 DDL 语句会触发隐式提交,事务包裹对它基本无效。也就是说,一个 SQL 文件里前半段成功、后半段失败时,前面已经执行的 DDL 不会回滚,文件会被标记为失败,但实际库已经被改了一半。
这个坑的教训是:MySQL 环境下,迁移文件要尽量拆小,一个文件只做一个逻辑变更。把"加列、加默认值、加索引"拆成三个文件虽然繁琐,但每个文件的失败点独立,排查和恢复都要容易得多。PostgreSQL 的 DDL 可以回滚,团队是 PG 的话这个问题的严重程度会低一些。
5.2 给大表加 NOT NULL 列的正确姿势
这是 Atlas 生成的 diff 和实际运行结果差距最大的场景。对一张几千万行的表,diff 生成的可能是:
ALTER TABLE "orders" ADD COLUMN "status" bigint NOT NULL DEFAULT 0;在 MySQL 上这条语句会锁住整张表进行全量重写,业务基本停摆。Atlas 能算出结构差异,但它不知道你的表有多大,也不知道你能不能承受锁表。正确做法是把一次结构变更拆成三步:
-- 第一步:加可空列 ALTER TABLE "orders" ADD COLUMN "status" bigint; -- 第二步:业务低峰期回填数据 UPDATE "orders" SET "status" = 0 WHERE "status" IS NULL; -- 第三步:收紧约束 ALTER TABLE "orders" ALTER COLUMN "status" SET NOT NULL;第一步可以直接用atlas migrate diff生成,第二步是手写 SQL 迁移,第三步继续交给 diff。Atlas 允许你在 migrations 目录里手动创建 SQL 文件,它会统一纳入 checksum 和版本管理。这是工具不会替你兜底的地方,恰恰也是使用声明式工具时最需要工程判断力的地方。
5.3 枚举与默认值造成的伪差异
diff 跑多了,会遇到一种让人抓狂的情况:diff 明明显示有差异,生成的语句也执行了,但下次再跑 diff 又出现同样的差异。这就是伪差异,通常出在枚举类型和默认值表达式上。
PostgreSQL 的枚举类型修改就是一个例子。改枚举值时,diff 在某些版本组合下会生成创建新类型、切换列、删除旧类型的完整流程,一旦某个环节不被当前版本支持,就会反复产生无效差异。默认值表达式的问题更隐蔽,CURRENT_TIMESTAMP、now()、'now()'::text这些写法在数据库内部可能被规范化,但 Atlas 记录的和数据库实际存的字符串格式不完全一致,于是 diff 每次都认为这里变了,实际上什么都没变。
应对手段有两个:一是统一默认值写法,团队约定一律使用某一种标准形式,从源头消灭差异;二是对确认无变化的表,在 diff 时用--exclude排除相关 schema 对象,避免噪音干扰。关键是别在伪差异上反复做无用功,先怀疑是不是表达式格式问题,再怀疑是不是真的漂移了。
5.4 存量库改造的起点不是写 Schema,而是 inspect
最后这个坑操作上最严重。很多人上手 Atlas 时,公司已经有一堆生产库了,于是想当然地准备从头写一份 schema.hcl 去描述这些库。这个方向是错的,因为存量库的表结构里埋着大量历史包袱:孤儿外键、重复索引、不一致的默认值。凭记忆写出来的这份文件,和数据库真实状态肯定有差距,差距越大,apply 时生成的破坏性语句就越多。
正确流程是反过来的:先用atlas schema inspect把存量库完整地导出成 HCL 基线,把这个基线作为 schema.hcl 的起点提交进仓库。然后所有的演进都基于这个基线来做,migrate diff 生成的增量迁移文件从第一步就建立在真实状态之上,而不是空中楼阁。
外键依赖顺序也是存量库改造的高频事故点:新建表 A 引用了还不存在的表 B,diff 生成的单个 SQL 文件里 A 在 B 之前创建,直接被数据库拒绝。解决办法是拆成多个迁移文件,先建 B 再建 A,或者对 MySQL 在文件头临时关闭外键检查,但不建议把关闭外键检查当成常规操作,它容易掩盖真实的依赖顺序错误。
最后说一句我自己的体会。从手写迁移切到 Atlas,最值钱的不是少写了几条 ALTER,而是团队的数据库变更第一次有了"期望状态可审查、迁移文件可校验、破坏性操作有门禁"这三条底线。schema.hcl 是唯一事实源,migrations 目录是多人协作的产物,lint 和漂移检测是最后两道防线。如果你正在为多环境库不一致发愁,建议先用 inspect 把现状摸清楚,再决定怎么把声明式流程引进来。有一点别忘了:所有命令里的 dev-url 固定一个数据库镜像版本,别今天 postgres/15、明天 postgres/16 混着用,不然协作者之间的 diff 结果会因为镜像语义差异而对不上,那种问题排查起来非常磨人。