GitHub Actions 数据库迁移流水线:Flyway、Liquibase 与 Prisma Migrate 门禁

GitHub Actions 数据库迁移流水线实战:迁移为何必须进 CI、Flyway 的 migrate 与 validate 与 flyway_schema_history、Liquibase changelog 与 updateSQL 预演、Prisma Migrate 的 deploy 与 dev 差异与 shadow database、向前兼容的 expand-contract 写法、dry-run 与 SQL 审查、生产审批门禁与备份前置、失败回滚策略


一、迁移为什么必须进流水线

手工执行迁移的代价集中在五点:无法重复,换个人执行顺序不同结果不同;无法审计,谁在什么时候改了什么只有聊天记录;无法回放,新环境搭起来时不知道要跑哪些脚本;环境漂移,测试库与生产库的 schema 悄悄不一致;无预演,语法错误在上线当刻才暴露。

把迁移纳入流水线后,schema 变更与代码变更走同一条路径:进 Git、过评审、在测试环境验证、在生产环境受审批约束执行。

四条铁律
1) 迁移脚本一旦合并就不可修改,只能追加新脚本
   校验和机制会直接拒绝已执行过的脚本被改动

2) 每次执行前必须能回答「当前版本是多少」
   Flyway 看 flyway_schema_history
   Liquibase 看 DATABASECHANGELOG
   Prisma 看 _prisma_migrations

3) 生产执行前必须有备份或快照
   没有回滚方案的迁移不允许上生产

4) 迁移与应用部署解耦
   先迁移后部署代码,且迁移必须向前兼容(见第五章)
流水线位置
  开发阶段   本地跑迁移,验证脚本可执行
  PR 阶段    CI 起临时数据库,从零执行全部迁移并校验
  预发阶段   对预发库执行迁移,跑集成测试
  生产阶段   审批后执行,执行前备份,执行后校验

二、Flyway 在 Actions 中的用法

2.1 基本工作流

name: Database migration

on:
  push:
    branches: [main]
    paths:
      - "migrations/**"

jobs:
  migrate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      - name: Install Flyway
        run: |
          curl -fsSL https://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/10.17.0/flyway-commandline-10.17.0-linux-x64.tar.gz \
            | tar xz
          echo "$PWD/flyway-10.17.0" >> "$GITHUB_PATH"

      - name: Migrate and validate
        run: |
          flyway -url=jdbc:postgresql://localhost:5432/app \
                 -user=postgres -password=postgres \
                 -locations=filesystem:./migrations \
                 migrate
          flyway -url=jdbc:postgresql://localhost:5432/app \
                 -user=postgres -password=postgres \
                 -locations=filesystem:./migrations \
                 validate

2.2 版本化与可重复迁移

V1__create_users.sql          版本化迁移,只执行一次
V2__add_email_index.sql       版本号递增,禁止跳号
R__refresh_views.sql          可重复迁移,校验和变化时重跑
U2__undo_email_index.sql      undo 迁移(社区版需插件)
-- migrations/V2__add_email_index.sql
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email
  ON users (email);
命名规范要点
  1) 版本号唯一,重复版本号会导致 migrate 直接失败
  2) 双下划线 __ 分隔版本与描述,单下划线会被当成版本的一部分
  3) 生产环境禁止用 U 开头的 undo 脚本回滚,回滚应写新迁移

2.3 flyway_schema_history 与常见故障

SELECT installed_rank, version, description, type,
       script, checksum, installed_on, execution_time, success
FROM flyway_schema_history
ORDER BY installed_rank;
checksum mismatch
  原因:已执行过的脚本被人改了
  处理:还原脚本,或用 flyway repair 重建校验和(慎用)

failed migration
  原因:脚本执行到一半报错
  处理:手工清理残留对象,删除 success=false 的行,再 repair

out of order
  原因:补了一个低版本号脚本
  处理:默认报错,可开启 outOfOrder=true,但会破坏可重现性

baselineOnMigrate 用于把已有数据库纳入 Flyway 管理:首次执行时把当前状态记为基线版本 0,之后的迁移从 1 开始。没有它,第一次 migrate 会因为「库非空且无历史表」而直接报错。

flyway -url="$DB_URL" -user="$DB_USER" -password="$DB_PASS" \
       -baselineOnMigrate=true -baselineVersion=0 migrate

三、Liquibase 在 Actions 中的用法

3.1 changelog 与 changeSet

# changelog/db.changelog-master.yaml
databaseChangeLog:
  - include:
      file: changelog/001-init.yaml
  - include:
      file: changelog/002-add-email-index.yaml
# changelog/002-add-email-index.yaml
databaseChangeLog:
  - changeSet:
      id: 002-add-email-index
      author: leeting
      changes:
        - createIndex:
            indexName: idx_users_email
            tableName: users
            unique: true
            columns:
              - column:
                  name: email
      rollback:
        - dropIndex:
            indexName: idx_users_email
            tableName: users
Liquibase 与 Flyway 的差异
  1) changeSet 有 id + author 组成的唯一标识,天然支持乱序
  2) 每个 changeSet 可声明 rollback,回滚语义内建
  3) 支持 XML / YAML / JSON / SQL 四种格式
  4) 与数据库无关的抽象 change 可跨库

3.2 updateSQL 预演

updateSQL 不执行,只输出将要执行的 SQL,这是 CI 中最有价值的一步:

- name: Liquibase preflight
  run: |
    liquibase \
      --url="$DB_URL" --username="$DB_USER" --password="$DB_PASS" \
      --changelog-file=changelog/db.changelog-master.yaml \
      --output-file=planned.sql \
      updateSQL
    cat planned.sql

- name: Upload planned SQL
  uses: actions/upload-artifact@v4
  with:
    name: planned-migration-sql
    path: planned.sql
预演的价值
  1) PR 里可以直接看到将要执行的原生 SQL
  2) 可以写规则检查危险语句
  3) 可以人工在评审时确认执行计划

3.3 危险语句检查与打标签

set -euo pipefail
fail=0
if grep -Ei '\bDROP[[:space:]]+(TABLE|COLUMN)\b' planned.sql; then
  echo "::error::检测到 DROP 语句,需要人工确认"; fail=1
fi
if grep -Ei '\bUPDATE\b' planned.sql | grep -vEi '\bWHERE\b'; then
  echo "::error::检测到无 WHERE 的 UPDATE"; fail=1
fi
exit $fail
liquibase --url="$DB_URL" --username="$DB_USER" --password="$DB_PASS" \
  --changelog-file=changelog/db.changelog-master.yaml \
  --tag=release-${{ github.run_number }} \
  update

打 tag 之后可以用 rollback --tag=release-41 回滚到指定标签,这是 Liquibase 相对 Flyway 的显著优势。


四、Prisma Migrate 在 Actions 中的用法

4.1 migrate dev 与 migrate deploy

prisma migrate dev
  用途:本地开发
  行为:对比 schema 与数据库差异,生成新迁移文件,执行迁移
  附带:重建 shadow database,触发 prisma generate
  禁止:在 CI 与生产使用(会尝试生成新迁移、可能重置数据库)

prisma migrate deploy
  用途:CI 与生产
  行为:只执行 migrations 目录中尚未应用的迁移
  不生成:任何新迁移文件
  不重置:任何数据
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm

- name: Apply migrations
  run: |
    npm ci
    npx prisma generate
    npx prisma migrate deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

4.2 shadow database

shadow database 是什么
  Prisma 需要一个额外的空库来「重放全部迁移」,
  以推断当前 schema 应该长什么样,从而判断是否有漂移。

本地开发
  Prisma 自动创建与销毁,需要数据库用户有 CREATEDB 权限

CI 中
  1) 用 services 起一个 Postgres,权限足够即可
  2) 或用云提供的 shadow database URL
  3) 托管数据库通常禁止建库,需要显式指定 shadowDatabaseUrl
datasource db {
  provider          = "postgresql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
}

4.3 漂移检测与基线

npx prisma migrate status

npx prisma migrate diff \
  --from-schema-datasource prisma/schema.prisma \
  --to-schema-datamodel prisma/schema.prisma \
  --script > migrations/20261006000000_baseline/migration.sql

npx prisma migrate resolve --applied 20261006000000_baseline
常见错误码
  P3005  数据库非空但无迁移历史 → 用 migrate resolve 打基线
  P3018  迁移执行失败 → 看 error 字段定位语句,修复后 resolve
  P3009  存在失败的迁移 → 必须 resolve 后才能继续
  P1010  权限不足 → 检查 DATABASE_URL 里的用户权限
维度          Flyway          Liquibase        Prisma Migrate
脚本形式      SQL 为主         XML/YAML/SQL     schema.prisma 生成
校验机制      checksum         changeset 哈希    checksum
回滚          undo 脚本        rollback 内建     不内建,靠前滚
预演          dryRun 有限       updateSQL 强       migrate diff
适用          JVM 技术栈       企业多库场景      Node 全栈

五、向前兼容的迁移写法

数据库迁移最大的风险是「代码与 schema 版本不同步的窗口期」。在这个窗口里,旧版本代码仍在运行,新 schema 必须同时兼容两边,这就是 expand-contract 模式要解决的问题。

以「把 users.name 拆成 first_name 与 last_name」为例

阶段 1 expand(只加不删)
  迁移:新增 first_name、last_name 两列,允许 NULL
  代码:双写,读时优先读新列,回退读旧列
  此时新旧代码都能正常工作

阶段 2 migrate(回填数据)
  迁移:UPDATE 把 name 拆分写入新列(分批,避免长事务)
  代码:仍然双写,读走新列

阶段 3 contract(删除旧列)
  确认无任何代码引用 name 后再执行
  迁移:ALTER TABLE users DROP COLUMN name
  代码:移除双写逻辑

三个阶段的部署之间要隔足够时间(通常一个发布周期以上),确保回滚时不会因为列已删除而失败。

-- 好:加列带默认值,Postgres 11+ 不会重写全表
ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'pending';

-- 危险:先加可空列再补默认值,第二步会重写全表
ALTER TABLE orders ADD COLUMN status text;
UPDATE orders SET status = 'pending';
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
大表分步执行建议
  1) 先加可空列,代码双写
  2) 分批回填:UPDATE ... WHERE id BETWEEN x AND y LIMIT 10000,循环
  3) 加 CHECK 约束时用 NOT VALID,再 VALIDATE CONSTRAINT 避免长锁
  4) 确认无 NULL 后再 SET NOT NULL
-- 好:并发建索引,不阻塞写
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user ON orders (user_id);
锁风险速查
  操作                          锁级别              风险
  ADD COLUMN(有默认值,PG11+)  ACCESS EXCLUSIVE    瞬时
  CREATE INDEX                   SHARE               阻塞写
  CREATE INDEX CONCURRENTLY      无长锁              推荐,失败会留无效索引
  SET NOT NULL                   ACCESS EXCLUSIVE    全表扫描
  ADD FOREIGN KEY                SHARE ROW EXCLUSIVE 需验证全表
  ALTER COLUMN TYPE              ACCESS EXCLUSIVE    常需重写全表

并发建索引失败会留下 INVALID 状态的索引,必须手动 DROP 后重建,否则后续同名创建会报已存在。顺序上永远先迁移后部署代码,且迁移必须向后兼容。


六、CI 中的 dry-run 与 SQL 审查

6.1 从零执行全部迁移

- name: Apply all migrations from scratch
  run: |
    for f in migrations/V*.sql; do
      echo "applying $f"
      psql -h localhost -U postgres -d app -v ON_ERROR_STOP=1 -f "$f"
    done
  env:
    PGPASSWORD: postgres

- name: Verify schema shape
  run: psql -h localhost -U postgres -d app -c '\d+ users'
  env:
    PGPASSWORD: postgres

从零重放能暴露「依赖了某个手工创建的对象」这类隐藏问题,这是迁移流水线最有价值的检查之一。

6.2 危险操作审查门禁

- name: Block destructive statements
  run: |
    set -euo pipefail
    if grep -rEn 'DROP[[:space:]]+(DATABASE|SCHEMA)' migrations/; then
      echo "::error::迁移中禁止出现 DROP DATABASE / DROP SCHEMA"; exit 1
    fi
    if grep -rEn 'TRUNCATE' migrations/; then
      echo "::error::迁移中出现 TRUNCATE,请改用带条件的 DELETE"; exit 1
    fi
建议纳入门禁的规则
  1) 禁止 DROP DATABASE / DROP SCHEMA
  2) 禁止无 WHERE 的 UPDATE / DELETE
  3) 大表 DROP COLUMN 必须带 review 标签
  4) ALTER COLUMN TYPE 必须单独一个 PR
  5) 所有迁移文件必须能通过 SQL 解析(sqlfluff / pg_format)

七、生产迁移的审批与备份

7.1 环境门禁

  migrate-prod:
    needs: verify-migrations
    runs-on: ubuntu-latest
    environment:
      name: production-db
    steps:
      - uses: actions/checkout@v4

      - name: Snapshot before migration
        run: |
          aws rds create-db-snapshot \
            --db-instance-identifier app-prod \
            --db-snapshot-identifier pre-migrate-${{ github.run_number }}
          aws rds wait db-snapshot-completed \
            --db-snapshot-identifier pre-migrate-${{ github.run_number }}

      - name: Apply migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}

environment: production-db 上的 required reviewers 让 job 进入等待状态,只有审批人批准后才继续,这比「靠人记得点确认」可靠得多。

7.2 备份策略与迁移后校验

备份策略选择
  快照(RDS/Aurora)  秒级到分钟级创建,恢复需数分钟到数十分钟
  逻辑备份(pg_dump) 耗时与库大小成正比,恢复慢但可单表恢复
  时间点恢复(PITR)  持续归档,可恢复到任意秒

生产迁移的最低要求
  1) 迁移前创建快照并等待完成,不能只发起不等待
  2) 快照 ID 写进 job summary,便于事后定位
  3) 记录迁移前的 schema 版本与历史表快照
  4) 大表变更额外做一次逻辑备份
- name: Record pre-migration state
  run: |
    psql "$PROD_DATABASE_URL" -c '\dt' > pre-tables.txt
    psql "$PROD_DATABASE_URL" -c 'SELECT * FROM flyway_schema_history' > pre-history.txt
    {
      echo "### 迁移前状态"
      echo "snapshot: pre-migrate-${{ github.run_number }}"
    } >> "$GITHUB_STEP_SUMMARY"
  env:
    PROD_DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
迁移后校验清单
  [ ] 迁移状态为 up to date
  [ ] 关键表结构与预期一致
  [ ] 关键表行数没有异常减少
  [ ] 应用健康检查通过,错误率没有上升

八、失败回滚策略

8.1 回滚的三个层次

层次 1:迁移未完成就失败
  处理:修复脚本,手工清理残留对象,repair 后重跑
  注意:DDL 在多数数据库里不支持事务回滚(MySQL 尤其),
        部分成功是常态,必须人工确认残留状态

层次 2:迁移成功但应用出问题
  处理:优先前滚修复(再写一个迁移),而非回滚数据库
  原因:回滚数据库可能丢数据,且旧版本代码未必兼容

层次 3:必须回滚数据库
  处理:从快照恢复,或执行预先写好的反向迁移
  代价:恢复期间服务不可用,且会丢失快照之后的数据

8.2 前滚优先原则

为什么优先前滚
  1) 迁移已应用的列可能已被新数据写入,回滚会丢数据
  2) 快照恢复需要停机,且时间与库大小成正比
  3) 反向迁移本身也可能出错,风险叠加

前滚的做法
  1) 写一个修复迁移(V_next__fix_xxx.sql)
  2) 走同一条流水线,同样受门禁约束
  3) 如果问题是性能而非正确性,考虑加索引或改查询

8.3 回滚预案模板

迁移编号:V42__add_orders_region.sql
变更内容:orders 表新增 region 列(可空)
影响范围:orders 表约 800 万行
锁风险:ADD COLUMN 有默认值,PG16 瞬时完成
执行时长预估:小于 1 秒
备份:pre-migrate-1042 快照已创建
回滚方案:ALTER TABLE orders DROP COLUMN region(无数据依赖,可安全回滚)
观察指标:orders 写入延迟、应用错误率
超时阈值:5 分钟内错误率超过 1% 则触发回滚

自动回滚只应覆盖「明确安全」的操作,删除列、恢复快照这类动作必须留人工确认,否则一次误判会把小故障变成数据事故。


总结

数据库迁移流水线的价值不在于自动化执行,而在于把不可逆的 schema 变更纳入可重复、可审计、可预演的流程。Flyway 用版本化脚本与 flyway_schema_history 提供最朴素的保证,baselineOnMigrate 解决存量库接入;Liquibase 用 changeSet 的 id 与 author 支持乱序,用 updateSQL 把将要执行的 SQL 摊开在 PR 里供人审查,用 tag 支持按发布批次回滚;Prisma Migrate 则要牢记 migrate dev 只属于本地、CI 与生产一律 migrate deploy,并用 shadow database 与 migrate diff 处理漂移与基线。写法上遵循 expand-contract,加列不删列、双写过渡、分步加约束,才能让新旧代码在切换窗口里共存。最后用危险语句门禁、审批环境、快照前置与前滚优先的回滚策略兜底,数据库这条最容易出事故的链路才算真正被管住。

延伸阅读:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 多云部署编排与基础设施漂移检测
  2. 文档站与静态站点发布流水线
  3. AI 代码审查与 PR 助手集成