数据库迁移工具:Schema版本控制、增量迁移、回滚策略、C语言迁移框架

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

那时候项目上线,改个表结构全靠口头通知——"小王,你那边加个字段啊",结果生产环境炸了,数据对不上,回滚?不存在的,只能手动改。嗯,从那以后,我就开始琢磨怎么用代码来管理数据库的版本变化。

今天聊的这套东西,说白了就是给数据库做"版本管理",跟Git管理代码一个道理。你想想看,代码能回滚,数据库凭啥不能?

为什么需要Schema版本控制?

我见过太多团队,数据库结构靠"口口相传"。新同事入职,先跑一遍建表脚本,跑完发现跟线上对不上——这种坑,我踩过不止一次。

Schema版本控制要解决三个核心问题:

  • 一致性:开发、测试、生产环境的表结构完全一致
  • 可追溯:谁、在什么时间、改了什么东西,一清二楚
  • 可回滚:改错了能退回去,不用删库跑路

核心思路:每个数据库变更都对应一个版本号,按顺序执行。版本号通常用时间戳或递增数字,比如 V001、V002。

增量迁移:小步快跑

增量迁移的意思,就是每次只改一点点。别想着一个脚本搞定所有变更——我见过有人把三个月的需求攒成一个迁移脚本,结果跑了一小时还没完,中间还报错了。

增量迁移的典型流程:

  1. 创建新的迁移脚本,比如 V003_add_user_age.sql
  2. 脚本里只包含本次变更:ALTER TABLE、CREATE INDEX 等
  3. 执行后,在版本表中记录当前版本号
  4. 下次启动时,检查版本号,自动执行未跑过的脚本

我的习惯:每个迁移脚本都包含"向上迁移"和"向下回滚"两部分。向上是改结构,向下是撤销改动。这样万一出问题,一条命令就能回去。

回滚策略:给自己留条后路

回滚不是"删了重来",而是有策略地撤销变更。我总结了几种常见场景:

变更类型 回滚操作 注意事项
新增表 DROP TABLE 确认表中无重要数据
新增列 ALTER TABLE DROP COLUMN 列不能有依赖关系
修改列类型 ALTER TABLE MODIFY 回原类型 数据可能截断,提前备份
新增索引 DROP INDEX 不影响数据,安全
删除表 从备份恢复 必须提前备份!

我曾经踩过的坑:有一次回滚时直接 DROP 了一个表,结果发现下游服务还在往里面写数据。从那以后,我规定回滚操作必须先在测试环境跑一遍,确认没有依赖关系再上线。

C语言迁移框架:自己动手造轮子

市面上有很多迁移工具,但如果你用的是嵌入式数据库或者自研的存储引擎,那就得自己写框架了。C语言做迁移框架,核心就三件事:

  • 读取迁移脚本文件
  • 解析版本号
  • 按顺序执行 SQL

下面是我常用的一个简化版框架,你可以直接拿去改:

#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <sqlite3.h>

// 版本表结构
// CREATE TABLE schema_version (
//     version INTEGER PRIMARY KEY,
//     applied_at TEXT DEFAULT (datetime('now'))
// );

int get_current_version(sqlite3 *db) {
    sqlite3_stmt *stmt;
    int version = 0;
    const char *sql = "SELECT MAX(version) FROM schema_version";
    
    if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
        if (sqlite3_step(stmt) == SQLITE_ROW) {
            version = sqlite3_column_int(stmt, 0);
        }
        sqlite3_finalize(stmt);
    }
    return version;
}

int apply_migration(sqlite3 *db, const char *filename, int version) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return -1;
    
    // 读取整个文件内容
    fseek(fp, 0, SEEK_END);
    long size = ftell(fp);
    rewind(fp);
    
    char *sql = malloc(size + 1);
    fread(sql, 1, size, fp);
    sql[size] = '\0';
    fclose(fp);
    
    // 执行迁移脚本
    char *err_msg = NULL;
    int rc = sqlite3_exec(db, sql, NULL, NULL, &err_msg);
    free(sql);
    
    if (rc != SQLITE_OK) {
        fprintf(stderr, "迁移失败: %s\n", err_msg);
        sqlite3_free(err_msg);
        return -1;
    }
    
    // 记录版本号
    char insert_sql[256];
    snprintf(insert_sql, sizeof(insert_sql),
             "INSERT INTO schema_version(version) VALUES(%d)", version);
    sqlite3_exec(db, insert_sql, NULL, NULL, NULL);
    
    return 0;
}

void run_migrations(sqlite3 *db, const char *migration_dir) {
    int current = get_current_version(db);
    DIR *dir = opendir(migration_dir);
    if (!dir) return;
    
    struct dirent *entry;
    while ((entry = readdir(dir)) != NULL) {
        // 文件名格式: V001_add_column.sql
        if (strncmp(entry->d_name, "V", 1) != 0) continue;
        
        int version = atoi(entry->d_name + 1);
        if (version <= current) continue;
        
        char path[512];
        snprintf(path, sizeof(path), "%s/%s", migration_dir, entry->d_name);
        
        printf("正在执行迁移: %s\n", entry->d_name);
        if (apply_migration(db, path, version) != 0) {
            fprintf(stderr, "迁移 %s 失败,停止执行\n", entry->d_name);
            break;
        }
    }
    closedir(dir);
}

int main() {
    sqlite3 *db;
    sqlite3_open("mydb.db", &db);
    
    // 确保版本表存在
    const char *init_sql = 
        "CREATE TABLE IF NOT EXISTS schema_version ("
        "    version INTEGER PRIMARY KEY,"
        "    applied_at TEXT DEFAULT (datetime('now'))"
        ")";
    sqlite3_exec(db, init_sql, NULL, NULL, NULL);
    
    run_migrations(db, "./migrations");
    
    sqlite3_close(db);
    return 0;
}

个人建议:迁移脚本最好用纯 SQL 文件,不要在里面写 C 代码。这样 DBA 也能看懂,而且方便手动执行。我见过有人把业务逻辑写进迁移脚本里,结果回滚时还得改代码——太折腾了。

迁移框架的核心流程图

下面这张图展示了整个迁移流程,从启动到执行完毕:

C语言迁移框架执行流程 程序启动 检查 schema_version 表 获取当前版本号 扫描 migrations 目录 有未执行的 迁移脚本? 执行迁移脚本 记录版本号 迁移完成 处理节点 判断 流程方向

回滚的实现思路

回滚比迁移复杂一些,因为你要知道"当前版本"和"目标版本"之间的所有变更。我的做法是:

  • 每个迁移脚本对应一个回滚脚本,命名如 V001_rollback.sql
  • 回滚时,从当前版本开始,倒序执行回滚脚本
  • 每次回滚后,更新版本表

关键点:回滚脚本必须经过充分测试。我见过有人写的回滚脚本直接把表删了,结果数据全没了——这种回滚比不回滚还可怕。

避坑指南

做数据库迁移这些年,我总结了几条铁律:

  1. 永远不要手动修改线上数据库——哪怕只是加个索引,也要走迁移流程
  2. 迁移脚本要幂等——同一个脚本跑两次,结果应该一样
  3. 回滚脚本要提前写好——别等出问题了再写,那时候手忙脚乱容易出错
  4. 版本号要严格递增——别跳号,也别重复
  5. 迁移前备份数据——这是最后一道防线

我曾经犯过的错:有一次在生产环境跑迁移,脚本里有个语法错误,结果表结构改了一半就停了。从那以后,我规定所有迁移脚本必须先在一个"影子数据库"上跑一遍,确认没问题再上生产。

好了,关于数据库迁移工具的核心内容就这些。你想想看,有了这套框架,数据库变更就不再是"玄学"了——版本可控、可回滚、可追溯,这才是正经的工程化做法。


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