GORM 的 AutoMigrate 只会加表/加列/加索引,从不改列名/删列/改约束。此前这类 破坏性变更靠「HasColumn 判断 + 幂等补丁」硬撑,既没有账本记录「跑过什么」, 也把 5 个一次性回填(backfillAnsweredAt / backfillFeatureControls / reconcileLegacyChatEnabled / normalizeChatQuota / normalizeHarassmentSetting) 留成了每次启动都全表扫一遍。 新增 schema_migrations 账本表 + runMigrations: - 迁移按版本号升序执行,每个迁移在事务里「跑 up + 记版本」,任一失败阻断启动 - 那 5 个幂等补丁收编为 v1~v5,收编后只跑一次,之后启动直接跳过 - AutoMigrate 仍负责声明式的加表/加列/加索引,版本迁移负责它做不了的破坏性变更 与一次性回填,两者分工互不替代 测试覆盖:幂等(跑两遍不重复)、失败阻断后续、清单严格升序。实测真实库首次 启动建账本 + v1~v5 落库,二次重启账本不变。
143 lines
5.3 KiB
Go
143 lines
5.3 KiB
Go
package db
|
|
|
|
import (
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SchemaMigration 记录一次已应用的数据库迁移。一行 = 一个已跑过的版本。
|
|
// 这是迁移机制的账本:启动时按版本号升序执行「账本里还没有」的迁移,已应用的跳过。
|
|
//
|
|
// 为什么需要它:GORM 的 AutoMigrate 只会「加表 / 加列 / 加索引」,从不「改列名 /
|
|
// 删列 / 改约束」。任何破坏性 schema 变更若靠 AutoMigrate 的 HasColumn 判断或幂等补丁
|
|
// 硬撑,都会在「改列名 → 新旧两列并存、新列恒空」这种形态下静默丢数据,而且没有任何
|
|
// 地方记录「这个库已经跑过哪些变更」。版本表把「跑过什么、顺序如何」固化下来,
|
|
// 让迁移可解释、可回看、可审计。
|
|
type SchemaMigration struct {
|
|
// Version 单调递增的版本号,迁移按它升序执行。
|
|
Version int `gorm:"primaryKey"`
|
|
// Name 迁移的简短描述,用于日志与人工排查。
|
|
Name string `gorm:"type:varchar(128)"`
|
|
// AppliedAt 应用时间,仅供审计。
|
|
AppliedAt time.Time
|
|
}
|
|
|
|
// Migration 一个数据库迁移步骤。
|
|
type Migration struct {
|
|
Version int
|
|
Name string
|
|
// Up 执行迁移。应当尽量幂等,因为早期版本在收编进版本表之前可能已在旧流程里
|
|
// 跑过一次;靠版本账本保证「收编后只跑一次」。
|
|
Up func(db *gorm.DB) error
|
|
}
|
|
|
|
// migrations 按版本号升序排列的迁移清单。新增迁移时追加到末尾并分配一个更大的版本号。
|
|
//
|
|
// 收编说明:下面这些迁移对应的函数原本散落在 db.Init 里,靠 HasColumn 判断或幂等硬撑,
|
|
// 每次启动都会重新执行一遍。收编进版本表后,它们只在「该版本号尚未记录」时执行一次,
|
|
// 之后启动直接跳过 —— 既省掉无谓的全表扫描,也消除了「幂等补丁某天不小心变得不幂等」
|
|
// 时对历史数据的重复改写风险。
|
|
var migrations = []Migration{
|
|
{
|
|
Version: 1,
|
|
Name: "backfill questions.answered_at",
|
|
Up: backfillAnsweredAt,
|
|
},
|
|
{
|
|
Version: 2,
|
|
Name: "backfill feature_controls",
|
|
Up: backfillFeatureControls,
|
|
},
|
|
{
|
|
Version: 3,
|
|
Name: "reconcile legacy chat enabled",
|
|
Up: reconcileLegacyChatEnabled,
|
|
},
|
|
{
|
|
Version: 4,
|
|
Name: "normalize chat quota",
|
|
Up: normalizeChatQuota,
|
|
},
|
|
{
|
|
Version: 5,
|
|
Name: "normalize harassment setting",
|
|
Up: normalizeHarassmentSetting,
|
|
},
|
|
}
|
|
|
|
// runMigrations 按版本号升序执行尚未应用的迁移。
|
|
//
|
|
// 执行模型:先建账本表(若无),读取已应用版本集合;对每个未应用的迁移,
|
|
// 在事务里跑 Up 并写入账本行。任一步失败即返回错误并终止后续迁移 —— 迁移是有序依赖的,
|
|
// 半途而废比硬着头皮跳过更安全(调用方会阻断启动,让运维看到明确的失败版本)。
|
|
//
|
|
// 与 AutoMigrate 的分工:AutoMigrate 仍负责「新增表 / 加列 / 加索引」这类声明式、
|
|
// 幂等、无数据丢失风险的变更(它本来就擅长且安全);版本迁移负责 AutoMigrate
|
|
// 做不了的破坏性变更与一次性数据回填。两者在 db.Init 里顺序执行,互不替代。
|
|
func runMigrations(db *gorm.DB) error {
|
|
if err := db.AutoMigrate(&SchemaMigration{}); err != nil {
|
|
return errors.Wrap(err, "create schema_migrations table")
|
|
}
|
|
|
|
var applied []SchemaMigration
|
|
if err := db.Order("version ASC").Find(&applied).Error; err != nil {
|
|
return errors.Wrap(err, "load applied migrations")
|
|
}
|
|
appliedSet := make(map[int]bool, len(applied))
|
|
for _, m := range applied {
|
|
appliedSet[m.Version] = true
|
|
}
|
|
|
|
// 防御:清单必须按版本号严格升序且无重复,否则顺序执行会出错、账本会混乱。
|
|
ordered := make([]Migration, len(migrations))
|
|
copy(ordered, migrations)
|
|
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Version < ordered[j].Version })
|
|
for i := 1; i < len(ordered); i++ {
|
|
if ordered[i].Version == ordered[i-1].Version {
|
|
return errors.Errorf("duplicate migration version %d", ordered[i].Version)
|
|
}
|
|
}
|
|
|
|
for _, m := range ordered {
|
|
if appliedSet[m.Version] {
|
|
continue
|
|
}
|
|
logrus.WithField("version", m.Version).WithField("name", m.Name).
|
|
Info("应用数据库迁移")
|
|
|
|
// 迁移必须在一个事务里完成:Up 成功 + 记版本要么同时生效,要么都不生效。
|
|
// 否则进程在「Up 成功、账本未记」之间崩溃,重启后会重复执行一次迁移。
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
if err := m.Up(tx); err != nil {
|
|
return errors.Wrapf(err, "run migration %d (%s)", m.Version, m.Name)
|
|
}
|
|
rec := SchemaMigration{Version: m.Version, Name: m.Name, AppliedAt: time.Now()}
|
|
if err := tx.Create(&rec).Error; err != nil {
|
|
return errors.Wrapf(err, "record migration %d", m.Version)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppliedMigrations 返回已应用的迁移版本号集合,供测试与运维查询。
|
|
func AppliedMigrations(db *gorm.DB) (map[int]bool, error) {
|
|
var rows []SchemaMigration
|
|
if err := db.Order("version ASC").Find(&rows).Error; err != nil {
|
|
return nil, errors.Wrap(err, "load applied migrations")
|
|
}
|
|
set := make(map[int]bool, len(rows))
|
|
for _, r := range rows {
|
|
set[r.Version] = true
|
|
}
|
|
return set, nil
|
|
}
|