更改数据库模式中列的类型乍一看可能很简单,但实际上这是一项有风险的操作,可能导致服务器与数据库之间的兼容性问题。在这篇博客中,我将探讨开发者如何在不导致应用停机的情况下执行此类更改。
最近,在为 Ariga 云 开发功能时,我需要将一个 Ent 字段的类型从无结构 blob 更改为结构化 JSON 字段。更改列类型是必要的,以便使用更高效的查询的 JSON 预测。
原始模式在我们的云产品模式可视化图表中如下所示:

在我们的案例中,我们无法随意把数据复制到新列,因为数据与新列类型不兼容(blob 数据可能无法转换为 JSON)。
过去,人们会认为停止服务器、将数据库模式迁移到下一个版本,然后使用与新数据库模式兼容的新版本启动服务器是可以接受的做法。如今,业务需求往往要求应用提供更高的可用性,这给工程团队带来了在零停机时间内执行此类更改的挑战。
满足此类需求的常见模式,在 Martin Fowler 的 "演进型数据库设计" 中被定义为“过渡阶段”。
过渡阶段是“数据库同时支持旧访问模式和新访问模式的时间段”。这让旧系统有时间按自己的节奏迁移到新结构”,如下面的图所示:
Credit: martinfowler.com
我们规划了一个包含 5 个简单步骤的更改,全部向后兼容:
- 创建一个名为
meta_json的 JSON 列。 - 部署一个执行双写的应用版本。每个新的记录或更新都写入新列和旧列,而读取仍然发生在旧列。
- 将旧列的数据回填到新列。
- 部署一个从新列读取的应用版本。
- 删除旧列。
版本化迁移
在我们的项目中,我们使用 Ent 的 版本化迁移 工作流来管理数据库模式。版本化迁移为团队提供了对如何更改应用数据库模式的细粒度控制。此级别的控制对于实现我们的计划非常有用。 如果您的项目使用 自动迁移 并且想要跟随,请先将您的项目升级为使用版本化迁移。
同样可以使用自动迁移加上 数据迁移 功能完成,但本贴聚焦于版本化迁移
使用 Ent 创建 JSON 列
首先,我们将向用户模式添加一个新的 JSON Ent 类型。
type Meta struct {
CreateTime time.Time `json:"create_time"`
UpdateTime time.Time `json:"update_time"`
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.Bytes("meta"),
field.JSON("meta_json", &types.Meta{}).Optional(),
}
}
接下来,运行代码生成以更新应用模式:
go generate ./...
然后,运行我们的 自动迁移规划 脚本,生成一组包含迁移数据库所需 SQL 语句的迁移文件。
go run -mod=mod ent/migrate/main.go add_json_meta_column
生成的迁移文件描述了更改:
-- modify "users" table
ALTER TABLE `users` ADD COLUMN `meta_json` json NULL;
接下来,使用 Atlas 应用创建的迁移文件:
atlas migrate apply \
--dir "file://ent/migrate/migrations"
--url mysql://root:pass@localhost:3306/ent
结果,我们在数据库中得到了以下模式:

同时向两个列写入
生成 JSON 类型后,我们开始写入新列:
- err := client.User.Create().
- SetMeta(input.Meta).
- Exec(ctx)
+ var meta types.Meta
+ if err := json.Unmarshal(input.Meta, &meta); err != nil {
+ return nil, err
+ }
+ err := client.User.Create().
+ SetMetaJSON(&meta).
+ Exec(ctx)
为确保写入新列 meta_json 的值被复制到旧列,我们可以利用 Ent 的 Schema Hooks 功能。 在主文件中添加空的 ent/runtime 导入以
注册该 Hook 并避免循环导入:
// 用户的 Hook
func (User) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(
func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *gen.UserMutation) (ent.Value, error) {
meta, ok := m.MetaJSON()
if !ok {
return next.Mutate(ctx, m)
}
if b, err := json.Marshal(meta); err != nil {
return nil, err
}
m.SetMeta(b)
return next.Mutate(ctx, m)
})
},
ent.OpCreate,
),
}
}
确保两字段写入后,我们可以安全地部署到生产环境。
回填旧列的值
现在,在我们的生产数据库中有两列:一列以 blob 存储 meta 对象,另一列以 JSON 存储。 由于 JSON 列最近才添加,第二列可能包含空值,因此我们需要使用旧列的值回填它。
为此,我们手动创建一个 SQL 迁移文件,从旧 blob 列填充新 JSON 列。
你也可以使用 WriteDriver 写 Go 代码来生成此数据迁移文件
创建一个新的空迁移文件:
atlas migrate new --dir file://ent/migrate/migrations
对于 users 表中每个 JSON 值为空的行(即在新列创建前添加的行),我们尝试将 meta 对象解析为有效 JSON。如果成功,我们将用结果值填充 meta_json 列,否则标记为空。
我们接下来编辑迁移文件:
UPDATE users
SET meta_json = CASE
-- 当 meta 是有效 JSON 时,按原样存放
WHEN JSON_VALID(cast(meta as char)) = 1 THEN cast(cast(meta as char) as json)
-- 如果 meta 不是有效 JSON,则存为空对象
ELSE JSON_SET('{}')
END
WHERE meta_json is null;
更改迁移文件后重新生成哈希:
atlas migrate hash --dir "file://ent/mirate/migrations"
我们可以通过在本地数据库上执行所有之前的迁移文件,使用临时数据进行种子,并应用最后一个迁移来测试迁移文件是否按预期工作。
测试完成后,应用迁移文件:
atlas migrate apply \
--dir "file://ent/migrate/migrations"
--url mysql://root:pass@localhost:3306/ent
现在,我们再次部署到生产环境。
重定向读取到新列并删除旧 blob 列
现在我们在 meta_json 列中已有值,可以将读取从旧字段改为新字段。
不再在每次读取时解码 user.meta,而是直接使用 meta_json 字段:
- var meta types.Meta
- if err = json.Unmarshal(user.Meta, &meta); err != nil {
- return nil, err
- }
- if meta.CreateTime.Before(time.Unix(0, 0)) {
- return nil, errors.New("invalid create time")
- }
+ if user.MetaJSON.CreateTime.Before(time.Unix(0, 0)) {
+ return nil, errors.New("invalid create time")
+ }
重定向读取后,我们将更改部署到生产环境。
删除旧列
现在可以从 Ent 模式中移除描述旧列的字段,因为我们不再使用它。
func (User) Fields() []ent.Field {
return []ent.Field{
- field.Bytes("meta"),
field.JSON("meta_json", &types.Meta{}).Optional(),
}
}
使用启用 Drop Column 功能再次生成 Ent 模式:
go run -mod=mod ent/migrate/main.go drop_user_meta_column
现在我们已成功创建新字段、重定向写入、回填数据并删除旧列——我们已准备好进行最终部署。剩下的工作就是将代码合并到版本控制并部署到生产环境!
结束语
在本贴中,我们讨论了如何使用 Atlas 的版本迁移与 Ent 集成,以零停机时间更改生产数据库中的列类型。
有疑问?需要帮助入门?请随时加入 我们的 Ent Discord 服务器。
- 订阅我们的 通讯
- 在 推特 上关注我们
- 在 Gophers Slack 的 #ent 频道加入我们
- 加入我们的 Ent Discord 服务器