ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Atlas自动管理数据库Schema变更,告别手写迁移脚本

用Atlas自动管理数据库Schema变更,告别手写迁移脚本

接手这个项目的时候,我最怕的不是业务逻辑,是数据库里那张users表。三个环境各长各的样子,本地比测试多一列,测试比生产多一个索引,问谁都说"应该是我改的吧"。后来我把数据库schema变更的管理从"靠人自觉"变成"靠工具强制",用的就是一个叫atlas的开源项目。如果你也在为手写迁移脚本没人维护、各环境schema漂移、上线前才发现表结构对不上而头疼,这篇文章应该能帮到你。我会从零梳理我接入atlas的完整过程:它到底解决了什么问题、命令怎么用、以及使用半年后我觉得最需要注意的地方。

1. 手写迁移脚本的烂账,atlas是怎么算清楚的

1.1 传统迁移脚本最痛的不是写,而是没人维护

以前我维护过一个用golang-migrate管理schema的项目,迁移文件按编号堆在migrations目录里:001_create_users.sql、002_add_posts.sql,每个文件还配套一个down脚本。刚开始大家热情高涨,规定"改表必须先写迁移文件,再在本地执行"。但几个月后就变味了——有人在Navicat里直接改表,改完就忘;有人提交了迁移文件但没在本地跑过;有人down文件干脆不写,问就说"回滚用不上"。

这些问题的根源不是某个人偷懒,而是手写迁移脚本这套模式本身太依赖人的纪律。迁移文件是团队对schema演进的唯一记录,可它既没有校验机制,也没有自动对比能力。更糟糕的是,线上出过几次hotfix,运维直接在生产库手敲了几条ALTER TABLE,从此迁移历史与真实结构出现了分叉。一旦记录和现实对不上,后面每一步都是在错误地基上盖楼。

最典型的翻车场景是:A开发在004号迁移里加了某列,B开发没拉最新代码,又在005号迁移里重复加了一遍。上线时004执行失败,005也跟着失败,发布卡在中间,大家开始手工比对数据库、翻聊天记录,整个晚上都在救火。这套机制太依赖人,而人恰恰是最不可靠的环节。

1.2 atlas的核心思路:不写步骤,只写状态

atlas的做法和我之前用的工具完全不同:你只需要把"数据库最终应该长什么样"描述出来,atlas会自己连接当前数据库、读取真实结构、计算两者差异,然后生成迁移SQL。打个比方,用Git协作时,你不需要给同事发一个"把第3行改成xxx"的补丁包,你直接推送最终文件,Git帮你算diff。atlas把数据库schema管理也变成了这个模式。

它有两个核心概念:

  • Desired State(期望状态):也就是你要维护的schema.hcl文件,描述目标表结构。
  • Current State(当前状态):通过inspect指令从数据库里实时读出来的实际结构。

atlas的核心是一个diff引擎,它会根据当前库真实结构、目标状态和目标数据库方言,算出最小变更。这个设计有个很大的优点:不管数据库被手动改成什么样,只要有schema.hcl在,一条diff命令就能暴露所有偏离,不再存在"没人记得最后一次手改是什么时候"的问题。

1.3 该用什么不用什么:别把atlas当银弹

诚实说,atlas不是每个项目都需要。如果你的项目只有一个人、库里就三四张表、没有任何协作和发布流程,那你手写SQL可能更快。但如果项目有多人协作、schema在持续演进、线上环境需要审计和追溯,atlas就能发挥很大作用。

我判断一个项目是否适合接入atlas,就看三个条件:第一,表结构是否还在频繁变化;第二,是否有两个以上环境需要保持结构一致;第三,团队有没有代码评审和发布流程。前两者决定了你是否需要工具来兜底,第三者决定了流程能不能真正落地。工具能兜住的是流程,兜不住的是混乱。一个连代码评审都没有的团队,上了atlas也只会多一层"看起来在管理"的假象。

2. 第一次连库:安装、权限与inspect实操

2.1 安装atlas:四种方式,我推荐这样选

atlas的安装方式比较多,我整理成一张表,方便你按自己的环境选:

安装方式命令适合场景
官方脚本curl -sSf https://atlasgo.sh | shLinux/macOS 快速安装
Homebrewbrew install ariga/tap/atlasmacOS 用户日常使用
Dockerdocker pull ariga/atlas不想污染环境,或CI里使用
go installgo install ariga.io/atlas/cmd/atlas@latest已有Go开发环境

我本机用的是Homebrew,CI里用Docker镜像。有个小建议:atlas的迭代速度很快,Homebrew的tap有时候不是最新版,如果你依赖某个新特性,直接用Docker镜像拉latest更稳妥。安装完先跑一下atlas version确认版本号,同时可以顺便看看帮助信息:atlas --help。

2.2 连接URL和数据库账号权限:别一上来就用root

atlas连接数据库统一走URL字符串,不同数据库的格式大概是这样:

  • MySQL:mysql://用户名:密码@主机:端口/库名
  • PostgreSQL:postgres://用户名:密码@主机:端口/库名?sslmode=disable
  • SQLite:sqlite://file.db

很多人图省事直接拿root账号用,我强烈不建议。atlas是自动化工具,账号密码会出现在命令行和CI配置里,一旦泄露就是整个库被拿捏。按最小权限原则,单独给它建一个账号是正确做法。

我在MySQL上用的账号授权大概长这样:

CREATE USER 'atlas'@'%' IDENTIFIED BY 'change-me'; GRANT SELECT, SHOW VIEW, TRIGGER ON *.* TO 'atlas'@'%'; GRANT ALTER, CREATE, DROP, REFERENCES, INDEX ON myapp.* TO 'atlas'@'%';

第一组权限保证atlas能正常读取库结构,做inspect和diff;第二组授权是执行apply时用的。如果你只想先做只读的检查和迁移计划,第二组DDL权限可以不建。这里埋一个伏笔:权限不全会让inspect结果缺东西,具体表现放到第6章详细说。

2.3 第一次inspect:把数据库从黑盒变成HCL

初始化一个项目时,我习惯先把现有的真实库结构导出来,让它成为后续维护的基线。这个动作在atlas里叫inspect:

atlas schema inspect \ -u "mysql://atlas:pass@localhost:3306/myapp" > schema.hcl

假设myapp库里有一张users表,生成的schema.hcl大概长下面这样:

schema "myapp" { charset = "utf8mb4" collate = "utf8mb4_0900_ai_ci" } table "users" { schema = schema.myapp column "id" { type = bigint auto_increment = true null = false } column "email" { type = varchar(255) null = false } column "created_at" { type = timestamp null = false default = sql("CURRENT_TIMESTAMP") } primary_key { columns = [column.id] } index "uniq_users_email" { unique = true columns = [column.email] } }

HCL是HashiCorp的配置语言,第一次看到这个格式可能会不习惯,但它其实是把数据库结构"声明化"了。和一堆CREATE TABLE语句相比,HCL更容易对比、更可读,而且是数据库方言中立的——不管底层是MySQL还是PostgreSQL,描述方式都统一。

inspect还支持用--schema指定只导出某个schema,用--exclude排除某些表。比如日志表、临时表,不想纳入版本管理的可以在导出时就过滤掉。每次数据库结构有变化,重新跑一次inspect和原来的schema.hcl做对比,就能马上看到差异。

2.4 dev-url参数:为什么每次diff都离不开它

atlas在做diff的时候,需要在一个空的"开发数据库"里临时执行DDL来验证迁移计划,这个库叫dev database。最方便的方式是直接让atlas帮你拉一个docker容器:

--dev-url "docker://mysql/8"

atlas会自动拉MySQL 8的镜像,在容器里模拟目标结构,运行完就销毁。也可以指向你自己本地的一个空库。

为什么需要它?因为diff引擎要真实执行DDL来探测数据库行为,而不同版本的MySQL、PostgreSQL对同样一条DDL的响应细节可能有差异,工具不可能用一套内置规则覆盖所有情况。如果没有dev-url,很多类型差异、默认值差异、索引差异都没法准确判断,diff结果要么漏报,要么误报。我的经验是:不管inspect、diff还是migrate diff,都习惯性地把dev-url带上,省得遇到莫名其妙的差异再回头排查。

3. 声明式迁移:只描述"最终表结构",剩下交给diff

3.1 理解期望状态:一张schema.hcl管所有

声明式工作流的核心是:schema.hcl是唯一需要维护的东西。要加表、删列、改索引,都去改这个文件,然后让atlas去同步数据库。

举个例子,现在的需求是给users表加一个nickname列,同时新增一张posts表。我在schema.hcl里做两处改动。先给users表补上nickname列:

column "nickname" { type = varchar(50) null = true }

再新增posts表:

table "posts" { schema = schema.myapp column "id" { type = bigint auto_increment = true null = false } column "title" { type = varchar(200) null = false } column "user_id" { type = bigint null = false } column "created_at" { type = timestamp null = false default = sql("CURRENT_TIMESTAMP") } primary_key { columns = [column.id] } foreign_key "fk_posts_user" { columns = [column.user_id] ref_columns = [table.users.column.id] on_delete = CASCADE } }

注意,HCL文件描述的是"最终状态",不是"这一次要做什么"。所以如果要删一列,操作方式就是把对应的column块从文件里删掉。atlas检查到期望状态里没有这个字段了,会觉得"这是需要执行的变更",从而生成一个DROP COLUMN。对于从声明式工作流入门的人来说,这个思维转变需要一点时间,但熟悉之后是真的很省心。

3.2 先diff预览,再apply执行:两步走

目标状态改好了,先不要急着让atlas直接改库。第一步,用schema diff看看它准备执行什么SQL:

atlas schema diff \ --from "mysql://atlas:pass@localhost:3306/myapp" \ --to file://schema.hcl \ --dev-url "docker://mysql/8"

这条命令会把期望状态和当前实际结构做对比,输出一段完整的ALTER TABLE语句。注意,它只是输出,不会执行任何东西。确认输出符合预期后,第二步才是真正执行:

atlas schema apply \ -u "mysql://atlas:pass@localhost:3306/myapp" \ --to file://schema.hcl \ --dev-url "docker://mysql/8" \ --dry-run

我在这个环节有个固定习惯:无论diff看过多放心,dry-run一定会再跑一遍。因为diff引擎在你改动幅度比较大、或者dev-url指向的数据库版本和线上不一致时,生成的SQL可能跟预期有出入。dry-run输出没有问题后,把--dry-run去掉,或者加上--auto-approve,atlas才会真正执行变更。

3.3 自动生成的DDL不是免检产品:数据迁移要自己接手

这是新手最容易忽略的一点。atlas擅长计算结构差异并生成DDL,但它不会替你处理数据迁移。举两个实际例子。

第一个例子,把users表的nickname列从varchar(100)改成varchar(50)。如果线上已经存在长度超过50的值,ALTER TABLE会直接报错。atlas能生成这条ALTER语句,但没有办法替你决定那些超出长度的数据该怎么办。

第二个例子,把某个可空列改成NOT NULL。如果表里已经有NULL值,这步变更也会失败。正确流程是先写一段数据清理或者回填脚本,把历史数据处理好,再让atlas执行结构变更。结构diff算得再准,也没办法理解你的业务语义,所以"先修数据、再改结构"这个顺序要刻在脑子里。

3.4 声明式迁移的适用场景:开发环境是真香

声明式工作流最适合的场景是开发、测试和演示环境。我们团队的日常流程是:开发拉完最新代码,发现schema.hcl有新增内容,直接跑一条atlas schema apply,本地库就变成最新状态。相比以前逐个执行SQL文件的方式,这一步省掉了大量口口相传的"你记得跑一下003号迁移啊"的沟通成本。

声明式工作流也有明显短板:它没有版本历史的概念,库表结构的变化过程无法追溯。开发环境出问题,大不了清库重来。但生产环境一旦需要审计、需要回查"这个索引是什么时候加的、对应哪个发版批次",声明式就无能为力了。所以生产环境我强烈推荐用版本化迁移,也就是下一节的内容。

4. 版本化迁移:让数据库变更像代码提交一样可追溯

4.1 为什么生产环境不能直接schema apply

生产环境的任何变更都要经得起审计。声明式apply虽然方便,但它只关心"结构对不对",不关心这个变更对应代码的哪个版本。假设某个线上问题是在一次例行发布后出现的,你想快速定位是当前发布引入的,还是前几天一次手滑apply引入的,声明式工作流给不了答案。

版本化迁移的思路是:每个变更都是一个有编号的文件,像Git提交一样串成一条线。执行过的版本会被记录到数据库里专门的revision表,下次再执行时只跑新增那些。这样一来,数据库结构的历史就是一条清晰的时间线,出了事能顺着时间线倒查。对生产环境来说,这种可追溯性比省那几分钟操作时间重要得多。

4.2 用migrate diff生成迁移文件

版本化迁移的日常操作也很简单。项目里建一个migrations目录,修改schema.hcl之后,执行:

atlas migrate diff add_posts_table \ --dir "file://migrations" \ --to "file://schema.hcl" \ --dev-url "docker://mysql/8"

这条命令会读取当前期望状态和现有迁移历史的最新版本状态,计算差异,然后生成一个新的迁移文件。生成的目录看起来是这样:

migrations/ 20241012103000_add_posts_table.sql atlas.sum

SQL文件内容就是刚才diff出来的DDL,文件名的前缀是时间戳,保证迁移版本唯一且有序。

有一点需要适应:atlas默认只生成正向迁移文件,不强制生成down迁移文件。它的哲学是意外出错时,用一个新的修复迁移来纠正,而不是依赖回滚。这个思路我在实践里是越来越认同的,因为数据库回滚经常因数据已经变化而变得不可靠。你以为回退了表结构,但数据已经被新逻辑改过了,回滚反而会造成更严重的不一致。

4.3 atlas.sum:迁移历史的防篡改指纹

每次生成迁移文件时,migrations目录里都会生成或更新一个叫atlas.sum的文件。它是整个迁移目录内容校验和,相当于一份目录指纹。任何人手工改动、删除某个迁移文件,哪怕只改了一个字符,atlas.sum就对不上了。

在CI里跑一句校验命令,可以确保迁移历史没有被私下篡改:

atlas migrate validate --dir "file://migrations"

这个机制在多团队协作时尤其有价值。以前总有人图省事,直接修改已经合并到主干的迁移文件,导致其他开发者的环境迁移顺序全乱。有了校验和,任何对迁移历史的未授权改动,在CI阶段直接就会暴露。让流程去约束人,比反复强调纪律有效得多。

4.4 生产环境的apply流程

版本化迁移在生产的执行命令非常直观:

atlas migrate apply \ --url "mysql://atlas:pass@prod-host:3306/myapp" \ --dir "file://migrations"

atlas会连接生产库,读取revision记录,跟migrations目录里的文件做对比,只应用那些还没有执行过的新迁移。建议用--revisions-schema参数把revision表放到独立的schema里,跟业务表隔离,避免业务库结构里混入迁移状态表。

我的落地建议是:不要在业务的空窗期手动去生产环境敲apply命令,把迁移执行放进发布流程的固定步骤里。而且执行之前,让atlas把即将执行的迁移文件列表完整打出来,人工扫一眼有没有意外内容。这个过程虽然多花一分钟,但能挡住太多因为"我以为改动只有这一个"造成的生产事故。

4.5 团队协作流程:从分支到上线

跑顺版本化迁移之后,我所在的团队形成了这样一套协作流程:

  1. 开发者在分支里修改schema.hcl,同时提交业务代码。
  2. 在PR描述里附上atlas migrate diff生成的SQL预览。
  3. 评审通过后,CI里跑atlas migrate lint检查迁移是否有风险。
  4. 合并到主干后,发布系统在部署时自动执行atlas migrate apply。
  5. 本地开发环境还是用atlas schema apply做同步,保持开发效率。

这套流程跑起来以后,最大的感受是:数据库变更终于不再是"某个人记得要执行的额外步骤",而是和代码一样被评审、被测试、被记录的正规流程。团队里再也不会出现"改表的人忘了告诉别人"这种事了。

5. 在CI里拦截破坏性变更:atlas lint的配置与实战

5.1 atlas能查出哪些"危险动作"

迁移文件生成之后、合并进主干之前,建议跑一遍lint检查。atlas的lint能识别一批常见的破坏性变更,比如:

  • DROP TABLE、DROP COLUMN
  • 把非空列改成可空(make_column_nullable)
  • 修改列类型可能导致数据截断,比如把varchar(255)改成varchar(50)
  • 修改或删除主键
  • 删除索引

命令格式大概是:

atlas migrate lint \ --dir "file://migrations" \ --dev-url "docker://mysql/8" \ --latest 1

--latest 1表示只检查最近这一个迁移文件,提交多个迁移时可以调整这个数字。lint执行时会在dev库里把整个迁移历史跑一遍,模拟出最终结构,然后逐项比对变更,发现破坏性动作后按策略报error或warning。

在atlas.hcl项目配置里可以定义lint的严格程度,把"哪些属于不能通过的破坏性变更"配置error,其余的降级为warning。具体配置项每个版本略有差异,接入时以官方文档为准,但整体思路是:让工具先兜住最危险的那批操作。

5.2 接入GitHub Actions的示例

CI接入并不复杂,官方提供了现成的action,也可以自己用命令行执行。这里给一个最简单的GitHub Actions工作流示例:

name: db-migration-check on: pull_request: paths: - 'schema.hcl' - 'migrations/**' jobs: atlas-lint: runs-on: ubuntu-latest services: mysql: image: mysql:8 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: dev ports: - 3306:3306 steps: - uses: actions/checkout@v4 - uses: ariga/atlas-action/migrate/lint@v1 with: dir: 'file://migrations' dev-url: 'mysql://root:root@localhost:3306/dev'

这个workflow里用GitHub的services功能起了一个MySQL 8容器当作dev数据库,然后让atlas lint在这个库里模拟整个迁移历史。跑一次大概十几秒,效率很高。如果你的CI平台不是GitHub Actions,思路完全一样:准备好一个可以被反复清空的数据库连接,然后执行atlas migrate lint命令。

把"数据库变更检查"放进门禁之后的最大收益是:危险操作不再依赖某个有经验的reviewer恰好发现,而是一旦出现就直接在CI阶段亮红牌。

5.3 lint的边界:结构风险好查,业务风险得靠人

lint能拦住很多低级错误,但别把它当银弹。它知道"删了一列"是破坏性变更,但它判断不了"这个字段从订单表挪到用户表"是不是业务上正确的决定。它也不知道你那张表在生产环境有2000万行数据,ALTER TABLE执行期间可能需要锁表10分钟。

所以在我的流程里,lint永远配人肉review。CI只是兜底,真正要对变更负责的,还是那个review SQL diff的人。这个人需要对业务和数据模型都足够熟悉,能判断出"这列索引去掉之后,会不会有慢查询突然冒出来"。自动化检查和人工评审是配合关系,不是替代关系。

6. 三个月的实战记录:这些坑值得你避开

6.1 权限不足时,inspect出来的HCL缺外键

刚接入atlas时,我用一个只读账号做inspect,生成的schema.hcl看起来一切正常,但外键约束全部消失了。一开始我还以为是atlas解析不了我的库文件,后来拿root账号对比着inspect了一份,才发现是权限问题。MySQL的某些元数据查询,需要额外的引用权限才能看到外键关系。

这个问题排查起来不难,但难在意识。很多人默认"我能SELECT就能看到表结构",实际上数据库元数据的信息可见性和你的账号权限是强相关的。如果你发现inspect出来的HCL少了外键、少了索引,先别怀疑工具,先用高权限账号对比一遍。为安全起见,这种对比可以在临时库做,不要直接拿生产库的高权限账号到处试。

6.2 大表DDL会锁表,atlas不会替你说"不"

atlas生成的ALTER TABLE语句,在MySQL上执行时可能对表加元数据锁,尤其是变更涉及索引重建或列类型修改时。如果是一张几千万行的表,整个过程可能阻塞线上读写。atlas本身没有"这张表太大、不建议直接执行"的智能判断,它只是一个生成和执行DDL的工具。

我们团队的做法是:对超过某个行数阈值的表,结构变更不直接走atlas apply,而是把DDL交给gh-ost在低峰期执行,再把结果同步回schema.hcl。这里要提醒:atlas负责让你知道结构需要变成什么样,至于怎么安全地把大表结构改过去,是你的DBA和运维体系要回答的问题。

6.3 老项目接入atlas:先有baseline再谈管理

如果你要接入atlas的数据库已经存在很久,迁移目录还是空的,这时候直接跑migrate apply会出大问题。atlas会认为所有迁移都没执行过,然后尝试从零开始把所有表都建一遍,这显然不是你想要的。

正确做法是先做baseline。第一步,用atlas schema inspect导出当前真实结构,并用atlas migrate diff生成一个初始迁移文件,把这个文件作为你的起点基线。第二步,在已存在的库上执行apply时,用baseline相关参数告诉atlas:这条初始迁移已经应用过了,只需要把版本记录写进revision表,不要真的再执行一遍。这样老库就无缝纳入了版本管理,后续的每一次变更都能沿着基线往后叠加。具体参数写法每个atlas版本略有差异,我当时也是翻官方文档确认的,但思路就是这个思路。

6.4 索引和约束命名不统一,diff会反复横跳

atlas的diff引擎是拿期望状态和当前状态逐项对比的,索引名、外键名、约束名全都是对比项。如果你在schema.hcl里定义索引时没起名,或者起了名字但和数据库里自动生成的名字不一致,每次diff都会产生一个"改名"操作。今天diff提示要把索引从idx_name改成uniq_users_email,明天重新inspect一下又发现名字对不上,来回横跳,非常干扰。

治本的方法是:所有索引、外键、主键,在HCL里都显式命名,而且要有一致的命名规范。比如主键统一叫pk_表名,外键叫fk_来源表_目标表,索引叫idx_表名_列名。命名稳定了,diff输出才稳定。很多让atlas"看起来不准"的情况,排查到最后都是命名问题。

6.5 dev-url容器和本机网络的坑

最后说一个环境相关的坑。通过docker://协议拉起的dev数据库实际上运行在Docker容器里,它和你的目标数据库之间如果不在同一个网络,连接就会出问题。我在CI里遇到过:atlas任务在容器里跑,同时用services里的MySQL当dev-url,因为网络配置不对,导致atlas连接被拒。

后来我把dev-url统一指向CI services里声明的MySQL服务名,问题就消失了。所以我的建议是:dev-url不一定非要用docker://协议,只要指向任何你可以随意建表、可以被反复清空的MySQL或PostgreSQL实例都可以。本地实验时,也可以起一个常驻的MySQL容器专门当dev库,这样比每次现拉镜像更稳定,也更方便排查问题。

最后分享一点个人体会。atlas不是把数据库迁移变成了"零思考",恰恰相反,它逼着我把思考从"怎么写ALTER语句"转移到了"表结构到底应该长什么样"上面来。这个转变带来的好处是长远的:schema.hcl成为唯一的真相来源之后,所有环境的差异都变成了可审查、可修复的东西,而不是靠几个核心开发记在脑子里。如果你也想引入atlas,我的建议是先拿一个非核心项目练手,把inspect、schema diff、migrate diff、migrate apply这几条命令彻底跑顺,再逐步推广到核心业务。等整套流程稳定下来,你会明显感觉到,数据库结构变更终于不再是一件靠"记得"才能做对的事情了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进