Golang 项目启动时维护数据库变更

2023-02-01 17:05:32 浏览数 (2)

前言

数据库变更管理是软件发布必不可少的环节,理想状态是只需发布一个镜像,就能更新应用和数据库。我们项目使用gorm来操作数据库,gorm是具有数据库迁移功能的,但是没有SQL脚本直观。另外我们的应用是同库多服务的微服务,还有些服务存在多个实例的情况,这就需要考虑数据竞争问题了。经过调研,最终选择了Github 10k star 的golang-migrate。

使用

准备SQL脚本

将初始化脚本、升级脚本放在项目里的init/postgres/sql目录下。

代码语言:javascript复制
init
└── postgres
    ├── init.go
    └── sql
        ├── 20230113084913_init.down.sql
        ├── 20230113084913_init.up.sql
        ├── 20230114084930_1.1.0.down.sql
        └── 20230114084930_1.1.0.up.sql

脚本命名

  • 前面部分是一个整数,体现version的大小关系,这里用时间表示,你也可以用001002
  • 后面部分是描述信息,仅仅是给程序员看。

一次数据库变更包含一个升级脚本和一个回退脚本,考虑到我们没有数据库变更回退的需求,down.sql内容为空。

MySQL和Oracle不支持DDL回滚,但PG是可以的。

整个脚本用事务包裹,保证原子性。

使用if not exits,支持重复执行。

20230113084913_init.up.sql

代码语言:javascript复制
BEGIN;
CREATE TABLE IF NOT EXISTS users(
 xxx
);
CREATE TABLE IF NOT EXISTS users_1(
 xxx
);
COMMIT;

写代码

代码语言:javascript复制
package postgres

import (
    "context"
    "embed"
    "strings"
    "time"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    "github.com/golang-migrate/migrate/v4/source/iofs"

    "github.com/pkg/errors"
)

var (
    // `go embed` 仅能嵌入当前目录及其子目录,无法嵌入上层目录。同时也不支持软链接。
    //go:embed sql/*.sql
    fs embed.FS
    // 由于 go:embed 可以配置多个目录,这里还需要指定下
    initSqlPath = "sql"
)

// InitDb 用于项目启动时初始化数据库,使用参考 xxx
func InitDb(databaseUrl string, timeout time.Duration) (err error) {
    sourceInstance, err := iofs.New(fs, initSqlPath)
    if err != nil {
        return errors.Wrapf(err, "could not open initSqlPath: %s", initSqlPath)
    }

    url := dsn2Url(databaseUrl)
    m, err := migrate.NewWithSourceInstance("iofs", sourceInstance, url)
    if err != nil {
        return errors.Wrap(err, "could not init db migrate")
    }

    // 超时控制
    timeoutCtx, cancelFunc := context.WithDeadline(context.Background(), time.Now().Add(timeout))
    defer cancelFunc()

    for {
        done := make(chan struct{})
        go func() {
            err = m.Up()
            done <- struct{}{}
        }()

        select {
        case <-timeoutCtx.Done():
            return errors.New("init db timeout")
        case <-done:
            if err != nil {
                if err == migrate.ErrNoChange {
                    _, _ = m.Close()
                    return nil
                }
                if err == migrate.ErrLocked {
                    time.Sleep(1 * time.Second)
                    continue
                }
                return errors.Wrap(err, "init db failed")
            }
            _, _ = m.Close()
            return nil
        }
    }
}

func dsn2Url(databaseUrl string) string {
    arr := strings.Split(databaseUrl, " ")
    params := make(map[string]string, len(arr))
    for _, kv := range arr {
        pair := strings.Split(kv, "=")
        if len(pair) == 2 {
            params[pair[0]] = pair[1]
        }
    }

    url := "postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode=disable&TimeZone=Asia/Shanghai"
    for k, v := range params {
        url = strings.Replace(url, "{" k "}", v, -1)
    }
    return url
}
  • databaseUrl:gorm.io/driver/postgres里的dsn"host=xxxx port=xxx user=postgres password=xxx dbname=xx sslmode=disable TimeZone=Asia/Shanghai"
  • 数据竞争问题:最开始我通过数据库唯一索引去实现一个分布式锁,写完后调试代码时发现,golang-migrate具备锁功能,它是通过pg的咨询锁实现的数据库级别的锁。如果获取锁失败会返回migrate.ErrLocked错误,于是我就通过它加了个轮询。

参考

  1. Golang migrate 做数据库变更管理

Post Views: 5

0 人点赞