使用 atlas 进行数据库迁移管理的实践

工作经历虽然不多,且都是开发基建不那么完善的小公司,因此在工作中经常会有各种不爽,其中一家连源代码管理都没有,那更是痛苦至极。

现在这家公司在基建方面虽然不太完善,开发管理也不是那么完美,但总归来说还是比较不错的,至少有使用 Gitea 这种比较成熟的代码托管平台。

我负责的项目在去年一直都没有正式上线,但也是给其中一个意向客户部署了测试版,在这期间项目也在快速迭代,在没有 CICD 的场合下,每一次在开发测试环境(是的,我们开发和测试是同一个环境)完成某一个需求后,在向客户推送更新时,迁移数据库表结构是一件非常痛苦的事情。

在这件事情上,我的同事们使用了一些不那么合规的方法,通过图形化的数据库管理软件进行结构迁移。这对我来说有一些难以接受,另一方面我确实不习惯使用图形软件管理表结构。

尽管我在公司被称为项目经理,但公司同事和团队成员普遍习惯使用图形化工具。因此引入语言绑定的迁移工具并强制统一使用并不现实,只能寻找外部独立工具作为折中方案。

对于这个工具,我有一下几点要求:

  1. 需要可以导出现有数据库的 DDL
  2. 可以比对两个数据库生成 Migration 的 SQL
  3. 可以像常规的迁移工具一样,通过 Migration 管理数据库版本
  4. 最好是命令行工具,且尽可能不需要有额外的运行时要求

实际上,这个需求的 1、3、4 都并不算复杂,市面上也有许多,例如 dbmategoose 都能做到。但 2 这个需求就很难符合的产品,因为“根据对比生成 Migration”和“通过 Migration 管理数据库版本”本身是冲突的,绝大多数产品并不会同时支持两个方法管理。

在我苦苦寻找之后,我找到了同时满足以上四点的产品 —— atlas

迁移工具的不同形式

市面上的迁移工具有两种形式,分别是:“描述、执行”和“声明、对比、应用”。

类型代表软件
描述、应用- phinx (主要用于 PHP 项目)
- goose (通用,可与 golang 项目集成)
- flyway (通用,可与 Java 项目集成)
声明、对比、应用- alembic (与 ORM SQLAlchemy 集成,用于 Python 项目)
- EntityFramework Core (本身就是 ORM,用于 .NET 项目)
- atlas (通用,面向 CICD 设计)

简单来说,“描述、执行”方式的迁移工具,符合 DBA (数据库管理员)的直觉,它的流程通常是:新建一个迁移文件,描述本次迁移执行的指令,然后迁移工具执行迁移文件,维护迁移的执行历史。但这对于开发人员来说比较繁琐,因为我们在设计或者调整表结构时,更多的是去关注整个结构是否满足需求,而不会去在意甚至是记录增删改了哪些字段。

而“声明、对比、应用”更符合开发人员的直觉,它的流程是这样的:

而之所以这种方法能够与 ORM 高度集成,也是因为当开发人员定义 Entity(或模型)时,实际上就已经完成了绝大多数的表结构定义的工作了。这个时候再手动去维护表结构,就显得有一些多余了。但也正如前言所述,这类方法很多时候也都是与 ORM 高度集成的,对于一些编程语言而言,特别是一些弱类型动态类型语言而言,社区里面没有提供这样的 ORM,也就很难享受到这种面向开发者的迁移流程。

不过好在,现在我们有了 atlas 。

工作流

atlas 提供了两种工作流,分别是 Declarative workflow 和 Versioned workflow 。他们分别对应了:数据库优先和代码优先,这两种工作流程。其中所谓数据库优先则是指对数据库进行相应操作,然后使用 atlas 将数据库直接迁移直接应用到目标数据库中, atlas 将比较两个数据库之间的差异,并生成、执行对应的 SQL/DDL 语句。而代码优先,则是通过定义数据库结构,通过生成迁移文件来实现可以管理和追踪的数据库版本。

在本篇,我们将使用 PostgresSQL 进行演示,尽管 atlas 提供了 HCL 这样一个 DSL (领域特定语言)来完成相关工作,但仅为了数据库管理就引入一个 DSL 收益或许并不算太高。

说明式的工作流/数据库优先 (Declarative workflow)

迁移

假设我们获得了一个名为 "original-database" 的数据库,它的 DDL 是这样的:

CREATE TABLE book (
    id SERIAL,
    title VARCHAR(1000) NOT NULL,
    author VARCHAR(1000) NOT NULL,
    isbn VARCHAR(20)   NOT NULL,
    PRIMARY KEY ("id"),
    UNIQUE ("isbn")
);

现在需要将其数据库结构迁移到 "target-database" 数据库中,我们可以直接执行下面这个命令:

> atlas schema apply --url "postgres://postgres:PASSWORD@localhost:5432/target-database?sslmode=disable" --to "postgres://postgres:PASSWORD@localhost:5432/original-database?sslmode=disable"
Planning migration statements (1 in total):

  -- create "book" table:
    -> CREATE TABLE "public"."book" (
         "id" serial NOT NULL,
         "title" character varying(1000) NOT NULL,
         "author" character varying(1000) NOT NULL,
         "isbn" character varying(20) NOT NULL,
         PRIMARY KEY ("id"),
         CONSTRAINT "book_isbn_key" UNIQUE ("isbn")
       );

-------------------------------------------

Applying approved migration (1 statement in total):

  -- create "book" table
    -> CREATE TABLE "public"."book" (
         "id" serial NOT NULL,
         "title" character varying(1000) NOT NULL,
         "author" character varying(1000) NOT NULL,
         "isbn" character varying(20) NOT NULL,
         PRIMARY KEY ("id"),
         CONSTRAINT "book_isbn_key" UNIQUE ("isbn")
       );
  -- ok (3.7036ms)

  -------------------------
  -- 3.7036ms
  -- 1 migration
  -- 1 sql statement

我们可以看见,atlas 向我们展示了计划执行迁移的 SQL 语句,以及执行的语句数量,以及执行的时间。这个时候我们查看数据库,会发现迁移已经成功了:

schema apply 的结果

更新

在未来,"original-database" 发生了变化:(1) "book" 表中新增了 "isbn2" 字段用于表示其第二个 ISBN;(2) "book" 表中的 "author" 字段变更为 "author_id" 字段;(3) 新增了 "author" 数据表,用来存储作者信息。其 DDL 变更为如下形式:

CREATE TABLE IF NOT EXISTS "author" (
    "id" SERIAL,
    "name" VARCHAR(1000) NOT NULL,

    PRIMARY KEY ("id")
);

CREATE TABLE IF NOT EXISTS "book" (
    "id" SERIAL,
    "title" VARCHAR(1000) NOT NULL,
    "author_id" INTEGER NOT NULL,
    "isbn" VARCHAR(20) NOT NULL,
    "isbn2" VARCHAR(20) NOT NULL,

    PRIMARY KEY ("id"),
    UNIQUE ("isbn"),
    FOREIGN KEY ("author_id") REFERENCES "author" ("id")
);

现在当我们执行一下刚才的指令,会发现 atlas 已经将变更为我们处理好了:

> atlas schema apply --url "postgres://postgres:PASSWORD@localhost:5432/target-database?sslmode=disable" --to "postgres://postgres:PASSWORD@localhost:5432/original-database?sslmode=disable"
Planning migration statements (2 in total):

  -- create "author" table:
    -> CREATE TABLE "public"."author" (
         "id" serial NOT NULL,
         "name" character varying(1000) NOT NULL,
         PRIMARY KEY ("id")
       );
  -- modify "book" table:
    -> ALTER TABLE "public"."book" DROP COLUMN "author", ADD COLUMN "author_id" integer NOT NULL, ADD COLUMN "isbn2" character varying(20) NOT NULL, ADD CONSTRAINT "book_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."author" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;

-------------------------------------------

Applying approved migration (2 statements in total):

  -- create "author" table
    -> CREATE TABLE "public"."author" (
         "id" serial NOT NULL,
         "name" character varying(1000) NOT NULL,
         PRIMARY KEY ("id")
       );
  -- ok (2.4938ms)

  -- modify "book" table
    -> ALTER TABLE "public"."book" DROP COLUMN "author", ADD COLUMN "author_id" integer NOT NULL, ADD COLUMN "isbn2" character varying(20) NOT NULL, ADD CONSTRAINT "book_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."author" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;
  -- ok (3.5375ms)

  -------------------------
  -- 6.0313ms
  -- 1 migration
  -- 2 sql statements

这个时候,我们 Apply approved migration,则会自动将变更应用到 "target-database" 中。

不过有时候,我们需要进行更多更复杂的操作,例如在上面的例子中,我们需要执行诸如重新设置 "author_id" 的操作时,我们可能就需要使用其他方法来实现这一点了。

如果仅仅只是想要查看两个数据库之间的差异而不执行迁移,可以使用 schema diff 命令。

基于版本的工作流/代码优先 (Versioned workflow)

起步

让我们新建一个文件,并将其命名为 schema.sql,并在其中写上我们的表结构:

--- schema.sql
CREATE TABLE IF NOT EXISTS "user" (
    "id" SERIAL PRIMARY KEY,
    "username" VARCHAR(255) NOT NULL,
    "password" VARCHAR(500) NOT NULL,

    UNIQUE ("username")
);
COMMENT ON COLUMN "user"."username" IS '用户唯一用户名';

然后让我们生成第一个迁移文件:

> atlas migrate diff initial --to file://schema.sql --dev-url "docker://postgres/17/dev?search_path=public" --format '{{ sql . " " }}'

在上面这个命令中,我们执行了 migrate 命令中的 diff 子命令,并生成一个名字为 "initial" 的迁移文件。其中 --to 参数表示期望的数据库结构,在这里选择的是 "schema.sql" 文件;--dev-url 表示用于处理迁移的数据库连接,在这里使用 docker 中来进行处理,atlas 将新建一个容器并在 dev 数据库中完成迁移内容的生成工作,如果不方便使用容器,也可以使用 DSN 指定一个数据库进行相关操作;而最后的 --format 表示使用何种格式输出,这里我们使用 SQL 格式,并缩进一个空格。

执行完成这个命令后,atlas 将生成一个文件夹,并在其中新增一个迁移文件:

.
├── migrations
│   ├── 20251110124133_initial.sql
│   └── atlas.sum
└── schema.sql

因为这是我们生成的第一个迁移文件,因此其中的内容与我们的 "schema.sql" 文件一致,这里就不再展示了。同一个目录下还有一个 "atlas.sum" 文件,里面有每个迁移文件的哈希值。

接下来让我们应用这个迁移文件:

> atlas migrate apply --url postgres://postgres:PASSWORD@localhost:5432/example?sslmode=disable
Migrating to version 20251110124133 (1 migrations in total):

  -- migrating version 20251110124133
    -> CREATE TABLE "user" (
        "id" serial NOT NULL,
        "username" character varying(255) NOT NULL,
        "password" character varying(500) NOT NULL,
        PRIMARY KEY ("id"),
        CONSTRAINT "user_username_key" UNIQUE ("username")
       );
    -> COMMENT ON COLUMN "user"."username" IS '用户唯一用户名';
  -- ok (5.2507ms)

  -------------------------
  -- 34.5275ms
  -- 1 migration
  -- 2 sql statements

可以看到,迁移文件被正确执行了,并且 atlas 的友好反馈会展示哪些语句被执行了,以及相应的耗时。接下来查看数据库,我们能够看见表也被正确地创建出来了。

第一次执行迁移的结果

变更于起步之后

接下来,由于业务变更,我们需要调整 "user" 数据表:(1) 追加一个 "display" 的字段用户存储用户的显示名称;(2) 将 "username" 字段调整为 500 个字符的长度;(3) 追加 "username" 的索引。之后我们再追加一个表格 "user_favorite_book" 存储用户收藏的图书的 ISBN,并且这里需要追加外键约束和联合唯一键约束。

要实现这些变更,我们只需要修改我们的 "schema.sql" 文件即可:

--- schema.sql
CREATE TABLE IF NOT EXISTS "user" (
    "id" SERIAL PRIMARY KEY,
    "username" VARCHAR(500) NOT NULL,
    "password" VARCHAR(500) NOT NULL,
    "display_name" VARCHAR(500) NOT NULL DEFAULT '',

    UNIQUE ("username")
);
CREATE INDEX IF NOT EXISTS "user_username_idx" ON "user"("username");
COMMENT ON COLUMN "user"."username" IS '用户唯一用户名';

CREATE TABLE IF NOT EXISTS "user_favorite_book" (
    "id" SERIAL PRIMARY KEY,
    "user_id" INTEGER NOT NULL,
    "book_isbn" VARCHAR(13) NOT NULL,

    UNIQUE ("user_id", "book_isbn"),

    FOREIGN KEY ("user_id") REFERENCES "user"("id")
);

接下来让我们执行下面这个命令:

> atlas migrate diff modify_user_and_create_user_favorite_book --to "file://schema.sql" --dev-url "docker://postgres/17/dev?search_path=public" --format '{{ sql . " " }}'

我们会发现 "migrations" 文件夹下出现了一个新的文件:

.
├── migrations
│   ├── 20251110124133_initial.sql
│   ├── 20251110132553_modify_user_and_create_user_favorite_book.sql
│   └── atlas.sum
└── schema.sql

其内容就是本次变更的 SQL 内容:

--- migrations/20251110132553_modify_user_and_create_user_favorite_book.sql
-- Modify "user" table
ALTER TABLE "user" ALTER COLUMN "username" TYPE character varying(500), ADD COLUMN "display_name" character varying(500) NOT NULL DEFAULT '';
-- Create index "user_username_idx" to table: "user"
CREATE INDEX "user_username_idx" ON "user" ("username");
-- Create "user_favorite_book" table
CREATE TABLE "user_favorite_book" (
 "id" serial NOT NULL,
 "user_id" integer NOT NULL,
 "book_isbn" character varying(13) NOT NULL,
 PRIMARY KEY ("id"),
 CONSTRAINT "user_favorite_book_user_id_book_isbn_key" UNIQUE ("user_id", "book_isbn"),
 CONSTRAINT "user_favorite_book_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);

可以注意到我们在 "schema.sql" 中的所有变更,都被 atlas 的 migrate diff 命令得到了正确的 SQL 语句。

接下来,我们执行相应的命令,便可以将这次的修改应用到数据库中:

> atlas migrate apply --url postgres://postgres:PASSWORD@localhost:5432/example?sslmode=disable
Migrating to version 20251110132553 from 20251110124133 (1 migrations in total):

  -- migrating version 20251110132553
    -> ALTER TABLE "user" ALTER COLUMN "username" TYPE character varying(500), ADD COLUMN "display_name" character varying(500) NOT NULL DEFAULT '';
    -> CREATE INDEX "user_username_idx" ON "user" ("username");
    -> CREATE TABLE "user_favorite_book" (
        "id" serial NOT NULL,
        "user_id" integer NOT NULL,
        "book_isbn" character varying(13) NOT NULL,
        PRIMARY KEY ("id"),
        CONSTRAINT "user_favorite_book_user_id_book_isbn_key" UNIQUE ("user_id", "book_isbn"),
        CONSTRAINT "user_favorite_book_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
       );
  -- ok (9.4ms)

  -------------------------
  -- 19.9508ms
  -- 1 migration
  -- 3 sql statements

第二次迁移的执行结果

实践与问题

atlas 提供的两种工作流可以满足绝大多数的场景,但在我们公司和团队中,使用的是 MySQL 数据库和数据库优先开发流程。因而存在一些问题:

MySQL AUTOINCREMENT

在 atlas 的免费版中,在 Postgres 数据库中的 Sequence 并不会被迁移,因此这些自动递增的值并不会随着结构迁移而迁移。但是因为我们公司使用的是 MySQL ,MySQL 的自动递增值是数据表上一个属性,因福得祸,使用 schema diff 或者 schema apply 执行时,AUTOINCREMENT 也会随之迁移。

令人恶心的东西 AUTOINCREMENT

为了解决这个问题,我将迁移时的工作分成了两步:(1) 生成当前开发/测试环境的数据库的 schema 。使用 schema inspect 获得当前数据库的 schema 文件。然后使用 sed 将其中的 AUTOINCREMENT 的设置去掉。 (2) 使用 schema diff 将期望的数据库和这个移除了 AUTOINCREMENT 的 schema 文件做对比,得到用于迁移的 SQL。

尽管操作变得更多了,但是这样做有两个好处:一是每一步的 SQL 语句都可以被审计;二是生成出来的数据库 schema 可以被 AI 读取,AI 可以获取到数据库中的表结构,从而更好的生成代码。

无法迁移视图 (View)

atlas 的免费版虽然功能已经足够了,但是依然缺少一些功能,例如免费版无法迁移 SQL Server 和 Clickhouse 这类数据库。更麻烦的是无法迁移视图和 Postgres 的扩展,尽管这些东西不太经常变更,但是缺少这个功能,在迁移时还是会增加一些工作量。