数据库迁移与版本管理:版本表设计、增量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 来防止并发写入。
整体流程:一张图说清楚
下面这张图,是我自己项目里用的迁移流程。你看一眼就能明白:
这个流程看着简单,但每个环节都有坑。比如扫描脚本时,文件名排序要按自然序,别按字典序——V10 应该在 V2 后面,而不是前面。
实战中的几个要点
最后,分享几个我在项目中积累的经验:
- 脚本要幂等:同一个脚本跑两次,结果应该一样。用 IF NOT EXISTS、CREATE OR REPLACE 这类语法。
- 版本号别跳:V001 之后直接 V003,中间缺了 V002,迁移工具会报警。我建议版本号连续,哪怕 V002 是个空脚本。
- 生产环境先备份:不管迁移工具多可靠,备份是最后的防线。我曾经在测试环境跑了一百次都没问题,生产环境一次就挂了——因为数据量不一样。
- 日志要详细:每个脚本的执行时间、影响行数、checksum,全记下来。出问题的时候,这些日志就是破案线索。
一个小技巧:
我习惯在迁移工具里加一个 --dry-run 参数。加上这个参数,工具只检查版本、扫描脚本、计算 checksum,但不执行任何 SQL。这样可以在正式迁移前先「预览」一遍,看看哪些脚本会被执行。
数据库迁移这件事,说白了就是给数据库上个保险。版本表是保险单,增量脚本是保费,回滚机制是理赔流程。C 语言迁移工具,就是那个帮你打理这一切的保险经纪人。
嗯,今天就聊到这儿。记住:没有回滚方案的迁移,都是裸奔。
公众号:蓝海资料掘金营,微信deep3321