数据库迁移与版本管理:版本表设计、增量SQL脚本、回滚机制、C语言迁移工具

说实话,数据库迁移这件事,我早年吃过不少亏。

那时候项目小,改表结构直接在线上跑 ALTER TABLE,改完就完事。直到有一次,我手滑删了一个字段,结果下游三个服务全部挂掉,回滚花了整整一个通宵。嗯,从那以后,我再也不敢裸奔式改库了。

数据库迁移,说白了就是给数据库做版本管理。你想想看,代码有 Git,依赖有 Maven,数据库凭什么裸奔?

版本表设计:给数据库装个里程表

迁移的第一步,是让数据库知道自己当前在哪个版本。我习惯在目标库中建一张专门的版本表,结构很简单:

CREATE TABLE schema_version (
    version_id   INTEGER PRIMARY KEY,
    version_name VARCHAR(64)  NOT NULL,
    description  VARCHAR(256),
    applied_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    checksum     VARCHAR(64),
    status       VARCHAR(16) DEFAULT 'SUCCESS'
);

这张表就是数据库的「里程表」。每次执行迁移脚本前,先查它;执行成功后,插入一条新记录。

核心要点:

  • version_id 用整数,方便排序和比较
  • checksum 存脚本的 MD5,防止脚本被篡改
  • status 字段记录执行状态,失败时可以标记 ROLLBACK

我在项目中遇到过一个问题:有人手动在数据库里执行了 SQL,但没更新版本表。结果迁移工具以为版本没变,又跑了一遍,直接报错。所以后来我加了一条铁律——任何数据库变更,必须通过迁移工具执行

增量SQL脚本:小步快跑,别搞大跃进

增量脚本,就是每个版本对应一个独立的 SQL 文件。命名规则我推荐这样:

V001__create_user_table.sql
V002__add_email_column.sql
V003__create_order_table.sql
V003.1__fix_order_index.sql

为什么用 V 开头?因为好排序,好识别。版本号用整数或浮点数,别用日期——日期排序容易出幺蛾子。

每个脚本只做一件事。比如 V002 只加一个字段,别顺手把索引也改了。为什么?因为回滚的时候,你只想撤销一件事,而不是拆炸弹。

我的个人习惯:

每个增量脚本开头先检查版本表,如果当前版本 >= 目标版本,直接跳过。这样脚本可以重复执行,不会报错。这叫幂等性。

-- V002__add_email_column.sql
-- 检查当前版本,防止重复执行
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM schema_version WHERE version_id = 2) THEN
        ALTER TABLE users ADD COLUMN email VARCHAR(128);
        INSERT INTO schema_version (version_id, version_name, checksum)
        VALUES (2, 'V002', md5(current_query_text()));
    END IF;
END $$;

回滚机制:给自己留条后路

回滚,是迁移工具里最容易被忽视的部分。很多人觉得「我脚本写对了,不需要回滚」。我曾经也这么想,直到有一次生产环境升级,新加的索引导致查询性能暴跌 10 倍……

回滚脚本的命名,我习惯用 UNDO_V002__add_email_column.sql。每个增量脚本,必须配一个对应的回滚脚本。

-- UNDO_V002__add_email_column.sql
DO $$
BEGIN
    IF EXISTS (SELECT 1 FROM schema_version WHERE version_id = 2 AND status = 'SUCCESS') THEN
        ALTER TABLE users DROP COLUMN IF EXISTS email;
        UPDATE schema_version SET status = 'ROLLBACK' WHERE version_id = 2;
    END IF;
END $$;

注意:

回滚不是简单的反向操作。比如你删了一个字段,回滚时要恢复数据。如果数据已经丢了,回滚也没用。所以迁移前一定要备份,或者用事务包裹。

我建议的回滚策略是:

  • 小版本(如 V003.1)直接回滚到上一个版本
  • 大版本(如 V003)保留回滚脚本,但需要人工确认
  • 破坏性操作(如 DROP TABLE)必须走审批流程

C语言迁移工具:轻量、可控、无依赖

市面上有很多迁移工具,Flyway、Liquibase 都很成熟。但为什么我还要用 C 写一个?

原因很简单:嵌入式环境、IoT 设备、边缘计算节点,这些地方跑不了 Java。C 写的迁移工具,一个静态链接的可执行文件,扔过去就能跑。

核心逻辑其实不复杂:

typedef struct {
    int      version_id;
    char     script_path[256];
    char     checksum[64];
    int      applied;  // 0: 未执行, 1: 已执行
} MigrationScript;

int run_migration(const char *conn_str) {
    // 1. 连接数据库
    // 2. 检查 schema_version 表是否存在,不存在则创建
    // 3. 扫描 scripts/ 目录,按版本号排序
    // 4. 对每个脚本,检查是否已执行
    // 5. 未执行的脚本,计算 checksum,执行 SQL
    // 6. 执行成功,写入版本表;失败则回滚
    // 7. 输出迁移报告
    return 0;
}

这里有个细节:事务包裹。每个迁移脚本必须在一个事务里执行。如果脚本中途失败,整个事务回滚,版本表也不更新。这样数据库状态不会「半残」。

避坑指南:

我曾经在迁移工具里忘了处理并发问题。两个进程同时跑迁移,结果版本表插入了两条记录,版本号还一样。后来我加了数据库锁:

-- 迁移开始时加锁
SELECT pg_advisory_lock(12345);
-- 迁移结束后释放
SELECT pg_advisory_unlock(12345);

如果是 SQLite,可以用 BEGIN IMMEDIATE 来防止并发写入。

整体流程:一张图说清楚

下面这张图,是我自己项目里用的迁移流程。你看一眼就能明白:

数据库迁移流程 启动迁移工具 检查 schema_version 表 扫描 scripts/ 目录 有未执行脚本? 执行迁移脚本 成功 更新版本表 回滚并报错 失败 迁移完成

这个流程看着简单,但每个环节都有坑。比如扫描脚本时,文件名排序要按自然序,别按字典序——V10 应该在 V2 后面,而不是前面。

实战中的几个要点

最后,分享几个我在项目中积累的经验:

  • 脚本要幂等:同一个脚本跑两次,结果应该一样。用 IF NOT EXISTS、CREATE OR REPLACE 这类语法。
  • 版本号别跳:V001 之后直接 V003,中间缺了 V002,迁移工具会报警。我建议版本号连续,哪怕 V002 是个空脚本。
  • 生产环境先备份:不管迁移工具多可靠,备份是最后的防线。我曾经在测试环境跑了一百次都没问题,生产环境一次就挂了——因为数据量不一样。
  • 日志要详细:每个脚本的执行时间、影响行数、checksum,全记下来。出问题的时候,这些日志就是破案线索。

一个小技巧:

我习惯在迁移工具里加一个 --dry-run 参数。加上这个参数,工具只检查版本、扫描脚本、计算 checksum,但不执行任何 SQL。这样可以在正式迁移前先「预览」一遍,看看哪些脚本会被执行。

数据库迁移这件事,说白了就是给数据库上个保险。版本表是保险单,增量脚本是保费,回滚机制是理赔流程。C 语言迁移工具,就是那个帮你打理这一切的保险经纪人。

嗯,今天就聊到这儿。记住:没有回滚方案的迁移,都是裸奔


公众号:蓝海资料掘金营,微信deep3321