三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

数据库迁移:goose与migrate工具

数据库迁移:goose与migrate工具

数据库迁移:goose与migrate工具

摘要: 本篇讲解Go项目中数据库迁移工具的使用,包括goose的SQL迁移文件管理与Go代码嵌入迁移、golang-migrate的嵌入式迁移方案、版本回滚与灰度发布策略,分享迁移脚本中使用了新表结构导致回滚失败的踩坑经历,对比goose、golang-migrate与atlas三个工具的优劣。

开篇故事

去年我们团队遇到一次严重的生产事故。同事手动在数据库里加了一个字段,代码还没部署上去。结果线上代码找不到这个字段,接口全部报错。回滚代码也没用,因为数据库结构已经变了。

从那以后我们立了规矩,数据库结构变更必须用迁移工具管理,不能手动改。迁移工具的核心价值是把数据库变更纳入版本控制,每次变更都有记录,能前进也能回滚。这篇讲两个最常用的Go迁移工具:goose和golang-migrate。

一、goose工具:SQL迁移文件管理

goose是Go生态里最流行的迁移工具,支持SQL和Go两种迁移方式。

SQL迁移文件

goose的SQL迁移文件用注释标记up和down。

-- +goose Up-- 创建用户表CREATETABLEusers(id BIGSERIALPRIMARYKEY,usernameVARCHAR(50)NOTNULLUNIQUE,emailVARCHAR(100)NOTNULLUNIQUE,password_hashVARCHAR(255)NOTNULL,created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,updated_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP);CREATEINDEXidx_users_emailONusers(email);-- +goose Down-- 回滚:删除表DROPTABLEIFEXISTSusers;

在Go代码中嵌入迁移

goose v3支持把迁移文件嵌入到二进制里,部署时不需要带SQL文件。

packagemainimport("context""database/sql""embed""fmt""log""github.com/pressly/goose/v3"_"github.com/lib/pq")// 把migrations目录下的SQL文件嵌入到二进制中////go:embed migrations/*.sqlvarembedMigrations embed.FSfuncmain(){// 连接数据库db,err:=sql.Open("postgres","host=localhost port=5432 user=postgres dbname=myapp sslmode=disable")iferr!=nil{log.Fatal("数据库连接失败:",err)}deferdb.Close()// 设置goose使用嵌入的迁移文件goose.SetBaseFS(embedMigrations)goose.SetDialect("postgres")// Up执行所有待执行的迁移iferr:=goose.UpContext(context.Background(),db,"migrations");err!=nil{log.Fatal("迁移失败:",err)}// 查看当前版本version,_:=goose.GetDBVersion(db)fmt.Println("当前迁移版本:",version)}

常用命令在代码里也能调用。

// 迁移控制命令funcmigrationCommands(db*sql.DB){ctx:=context.Background()goose.Up(ctx,db,"migrations")// 执行所有待迁移goose.Down(ctx,db,"migrations")// 回滚最后一个迁移goose.DownTo(ctx,db,"migrations",3)// 回滚到指定版本goose.Reset(ctx,db,"migrations")// 回滚所有再全部执行goose.UpByOne(ctx,db,"migrations")// 只前进一步goose.Status(ctx,db,"migrations")// 查看状态}

二、golang-migrate:嵌入式迁移

golang-migrate是另一个流行工具,迁移文件up和down分开。

-- 000001_add_posts_table.up.sqlCREATETABLEposts(id BIGSERIALPRIMARYKEY,titleVARCHAR(200)NOTNULL,contentTEXTNOTNULL,author_idBIGINTNOTNULLREFERENCESusers(id),statusVARCHAR(20)DEFAULT'draft',created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP);CREATEINDEXidx_posts_authorONposts(author_id);-- 000001_add_posts_table.down.sqlDROPTABLEIFEXISTSposts;

在Go中使用,同样支持嵌入迁移文件:

//go:embed migrations/*.sqlvarmigrationsFS embed.FSfuncrunMigrate(){// 从嵌入文件系统创建迁移源source,_:=iofs.New(migrationsFS,"migrations")m,err:=migrate.NewWithSourceInstance("iofs",source,"postgres://postgres@localhost:5432/myapp?sslmode=disable",)iferr!=nil{log.Fatal("创建migrate失败:",err)}deferm.Close()// 执行迁移,ErrNoChange表示没有待执行的迁移iferr:=m.Up();err!=nil&&err!=migrate.ErrNoChange{log.Fatal("迁移失败:",err)}version,dirty,_:=m.Version()fmt.Printf("当前版本: %d, dirty: %v\n",version,dirty)}// 版本回滚控制funcrollbackControl(m*migrate.Migrate){m.Steps(-1)// 回退一步m.Migrate(2)// 回退到指定版本m.Force(1)// 强制设置版本,修复dirty状态}

三、版本回滚与灰度发布策略

数据库迁移最怕的是改了结构后代码出问题需要回滚。灰度发布的思路是让迁移分步执行,每一步都能独立回滚。

// 灰度策略:每次只前进一步,不一次性全部迁移funcgrayRelease(ctx context.Context,db*sql.DB){// UpByOneContext只执行一个迁移文件,出问题影响范围小err:=goose.UpByOneContext(ctx,db,"migrations")iferr!=nil{iferr==goose.ErrNoNextVersion{fmt.Println("已经是最新版本")}else{log.Fatal("迁移失败:",err)}}// 验证迁移结果verifyMigration(db)}// 验证迁移是否成功funcverifyMigration(db*sql.DB){varexistsbooldb.QueryRow(` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_name = 'users' ) `).Scan(&exists)ifexists{fmt.Println("users表创建成功")}}

灰度发布的核心原则有三条。每次只执行一个迁移文件。每个迁移文件尽量只做一件事。先在预发布环境验证再上生产。扩字段这种兼容性变更可以先迁移再部署代码,缩字段这种破坏性变更要先部署兼容代码再迁移。

四、独家踩坑:迁移脚本中使用了新表结构导致回滚失败

上个月我们加了一个新功能,需要新建退款表再给老表加字段。迁移脚本的up逻辑没问题,但down逻辑出了大问题。

-- 问题迁移文件-- +goose UpCREATETABLErefunds(id BIGSERIALPRIMARYKEY,order_idBIGINTNOTNULL,amountDECIMAL(10,2)NOTNULL,statusVARCHAR(20)DEFAULT'pending',created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP);ALTERTABLEordersADDCOLUMNrefund_statusVARCHAR(20)DEFAULT'none';UPDATEordersSETrefund_status='none'WHERErefund_statusISNULL;-- +goose Down-- 回滚顺序错了DROPTABLErefunds;-- 这里引用了已经不存在的字段,报错UPDATEordersSETrefund_status=NULL;ALTERTABLEordersDROPCOLUMNIFEXISTSrefund_status;

up执行时正常,但回滚时报错column "refund_status" does not exist。UPDATE引用了已经不存在的字段(因为先DROP了表,逻辑混乱)。迁移卡在中间状态,数据库标记为dirty,后续迁移全部被阻塞。

排查时看goose的schema_migrations表,version对应的is_dirty为true,说明迁移执行到一半失败了。dirty状态下goose拒绝执行任何操作。

// 修复:手动清理dirty状态funcfixDirtyState(db*sql.DB){// 方案一:手动修复版本号db.Exec(` UPDATE schema_migrations SET is_dirty = false, version_id = version_id - 1 WHERE is_dirty = true `)// 方案二:用goose的Force强制设置版本// goose.Force(db, previousVersion, "migrations")}

修复完dirty状态后,重新写了迁移脚本,确保down逻辑的执行顺序和up严格相反。

-- 修复后的迁移文件-- +goose Up-- 先建表,再加字段CREATETABLErefunds(id BIGSERIALPRIMARYKEY,order_idBIGINTNOTNULL,amountDECIMAL(10,2)NOTNULL,statusVARCHAR(20)DEFAULT'pending',created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP);ALTERTABLEordersADDCOLUMNrefund_statusVARCHAR(20)DEFAULT'none';-- +goose Down-- 回滚顺序严格和up相反:先删字段,再删表ALTERTABLEordersDROPCOLUMNIFEXISTSrefund_status;DROPTABLEIFEXISTSrefunds;

经验就是down脚本的执行顺序必须和up严格相反。up里先建表再加字段,down里就要先删字段再删表。每次写完迁移文件,一定要在测试环境跑一遍up再跑一遍down,确认两个方向都能正常执行。

五、对比分析

特性goosegolang-migrateatlas
迁移方式SQL和Go函数纯SQL声明式(schema即代码)
嵌入支持embed.FS原生iofs适配器支持嵌入
回滚支持每个迁移有down每个迁移有down声明式自动计算差异
多数据库支持主流数据库支持主流数据库支持主流数据库
学习成本
社区活跃度中,新工具
事务支持默认每个迁移一个事务默认每个迁移一个事务支持

goose适合大多数项目,SQL和Go两种迁移方式灵活,API简洁。golang-migrate更通用,迁移文件格式标准,适合需要跨语言协作的团队。atlas是新兴工具,声明式迁移理念先进,但学习成本高,适合大型项目。日常项目选goose就够了。

总结与预告

数据库迁移工具把schema变更纳入版本控制,避免手动改表导致的事故。goose用SQL文件管理迁移,up和down配对执行,支持嵌入二进制。灰度发布的核心是分步迁移,每次只执行一个文件。down脚本的顺序必须和up严格相反,写完一定要测试回滚。

到这里数据库和缓存部分的内容就讲完了。从MySQL到Redis到MongoDB,从CRUD到迁移工具,你现在具备了Go操作各种数据存储的完整能力。

← 返回列表