数据库配置、迁移与容器化部署:从 config 到 goose
目录
备忘用笔记,围绕本项目
server的 MySQL 配置展开。忘了直接翻这一篇。
数据库相关的三个层面,逐层递进:
- 配置:连哪个库、用什么参数(本项目
internal/config)。 - 迁移:表结构怎么随代码演进(goose + SQL 文件)。
- 部署:迁移在什么时候、由谁执行(Docker / K8s)。
一、数据库配置字段速查(本项目 config.go)
Database 结构长这样:
1type Database struct {
2 Host string `yaml:"host"`
3 Port int `yaml:"port"`
4 User string `yaml:"user"`
5 Password string `yaml:"password"`
6 DBName string `yaml:"dbname"`
7 ParseTime string `yaml:"parse_time"`
8 Charset string `yaml:"charset"`
9 Loc string `yaml:"loc"`
10
11 MaxOpenConns int `yaml:"max_open_conns"`
12 MaxIdleConns int `yaml:"max_idle_conns"`
13 ConnMaxLifetimeMin int `yaml:"conn_max_lifetime_min"`
14}1.1 连接五件套
| 字段 | yaml | 含义 |
|---|---|---|
Host | host | 数据库地址(容器里是服务名,不是 127.0.0.1) |
Port | port | 端口,默认 3306 |
User | user | 账号 |
Password | password | 密码 |
DBName | dbname | 库名 |
1.2 驱动行为参数
| 字段 | yaml | 含义 |
|---|---|---|
ParseTime | parse_time | 让驱动把 MySQL 的 DATE/DATETIME/TIMESTAMP 解析成 Go 的 time.Time。用字符串 "true"/"false" 存,避免 bool 零值无法区分「未配置」和「显式 false」 |
Charset | charset | 客户端连接字符集,默认 utf8mb4 |
Loc | loc | 解析 time.Time 时用哪个时区,默认 Asia/Shanghai |
1.3 连接池参数
| 字段 | yaml | 含义 | 默认 |
|---|---|---|---|
MaxOpenConns | max_open_conns | 池里最多同时打开的连接数(在用 + 空闲) | 100 |
MaxIdleConns | max_idle_conns | 池里最多保留多少空闲连接 | 20 |
ConnMaxLifetimeMin | conn_max_lifetime_min | 一个连接从创建起最多存活多少分钟,到点关闭重建 | 60 |
注意:
MaxIdleConns必须 ≤MaxOpenConns,否则无效回退默认值。
1.4 默认值与校验
- 端口缺省/越界 → 3306;池参数
< 1→ 默认值;idle > open→ 回退。 parse_time只允许"true"/"false",否则回退"true"。validate()必填项只查host+user(这两个有值基本说明整块配了)。- 配置示例(
server/config.yaml):
1database:
2 host: 127.0.0.1
3 port: 3306
4 user: hello88
5 password: hello88
6 dbname: hello88
7 parse_time: true
8 charset: utf8mb4
9 loc: Asia/Shanghai
10 max_open_conns: 100
11 max_idle_conns: 20
12 conn_max_lifetime_min: 601.5 拼 DSN(go-sql-driver/mysql)
1user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghailoc里的/要 URL 转义成%2F。parseTime=true对应配置里的parse_time。
二、什么是数据库迁移
用代码/版本化的方式,管理数据库表结构的每一次变更,而不是手动去库里敲 CREATE TABLE / ALTER TABLE。
2.1 为什么需要
表结构一定会变,人工改动的问题:
- 本地改了,测试/生产忘了改 → 环境不一致。
- 同事拉代码,不知道要执行哪些 SQL。
- 出问题无法回滚,也不知道当前是哪个版本。
2.2 长什么样
1migrations/
2 000001_create_users.up.sql # 正向:建 users 表
3 000001_create_users.down.sql # 反向:删 users 表
4 000002_add_email_to_users.up.sql
5 000002_add_email_to_users.down.sql每个文件只做一件事,执行记录存在数据库的元数据表里(如 goose_db_version、schema_migrations),这样:
- 新环境:从头按序执行 → 得到最新结构。
- 已有环境:只执行没跑过的。
- 回滚:跑对应的 down。
类比:给数据库表结构做 Git,每次改动是一个 commit,数据库里存着当前在哪个 commit。
三、迁移文件:up / down
3.1 一个完整对照
第一次建表(已有):
000001_create_users.up.sql
1CREATE TABLE users (
2 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
3 name VARCHAR(64) NOT NULL,
4 age INT NOT NULL,
5 PRIMARY KEY (id)
6) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;000001_create_users.down.sql
1DROP TABLE users;第二次加 email:
000002_add_email_to_users.up.sql
1ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL AFTER name;000002_add_email_to_users.down.sql
1ALTER TABLE users DROP COLUMN email;3.2 down 是 up 的反向操作
| up 操作 | down 操作 |
|---|---|
ADD COLUMN email | DROP COLUMN email |
DROP COLUMN age | ADD COLUMN age INT |
MODIFY COLUMN age BIGINT | MODIFY COLUMN age INT |
CREATE TABLE ... | DROP TABLE ... |
DROP TABLE ... | CREATE TABLE ... |
ADD INDEX idx (col) | DROP INDEX idx |
ADD UNIQUE KEY uk (col) | DROP INDEX uk |
3.3 加列时 NULL 还是 NOT NULL
表里已有数据,加新列要注意老数据:
1-- 允许为空:最简单,老数据 email 为 NULL
2ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL AFTER name;
3
4-- 不允许为空:必须给默认值,否则已有行无法填值会失败
5ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '' AFTER name;若业务要求必填但老数据需先留空:先加 NULL 列 → 回填数据 → 下一次迁移再改 NOT NULL。
3.4 down 的致命局限
down 只能还原结构,还原不了数据。 例如删除列后 down 把列加回来,但列里的数据已经丢了。所以 down 是「best effort」,常用于本地/测试环境重置。
四、行业标准做法
没有官方标准,但业界有很强的共识,一句话:
把数据库结构变更当作代码,和业务代码一起进 Git,通过版本化的、只增不改的迁移文件,在部署流程里自动、有序地执行。
核心是这三点(形式不重要):
- 版本化:每次变更都有唯一编号和记录。
- 只追加、不可变:已执行过的迁移文件永不修改。
- 自动化执行:在部署流水线里,靠系统不靠人。
4.1 共识细节
- 迁移文件进版本控制,和代码一起 review / 部署。
- 已执行过的迁移永不修改,要改就写新迁移。
- 生产 forward-only,不回滚 schema,写错了补新迁移。
- 一个迁移只做一件事,粒度要小(锁表时间短、易 review)。
- 版本号有序唯一:递增序号
000001_或时间戳20240115103000_。 - 迁移由部署流程自动执行,并加锁防并发。
- 破坏性变更用「扩展-收缩」模式:加新列 → 双写 → 回填 → 读新列 → 删旧列。
- 大表操作注意锁和耗时,MySQL 8 可用
ALGORITHM=INSTANT。
4.2 标准 vs 反面教材
| 标准做法 | 反面教材 |
|---|---|
| 迁移文件 + 版本表 | 手动在 Navicat 里改表 |
| 只追加、不回改 | 改已上线的迁移文件 |
| 部署时自动跑、带锁 | 谁想起来谁手动执行 |
| forward-only,错就补新迁移 | 依赖 down 回滚生产 |
| 小步、单一职责 | 一个文件塞几十条 DDL |
| 数据迁移和结构迁移分开 | 建表顺便 UPDATE 几十万行 |
| 本地/测试都跑一遍 | 只在生产手测 |
4.3 「两个文件」不是唯一形式
| 风格 | 代表工具 | 形式 |
|---|---|---|
| up/down 两个文件 | golang-migrate | xxx.up.sql + xxx.down.sql |
| 单文件,注释分段 | goose | 一个 .sql 里 -- +goose Up / -- +goose Down |
| 只写 up | Flyway(社区版)、Atlas | 只有正向脚本 |
| 代码成对方法 | Rails、Django | up()/down() |
五、goose 实战
5.1 目录结构
1server/internal/database/
2 database.go # 建 *sql.DB 连接池
3 migrate.go # embed + goose 入口
4 migrations/
5 000001_create_users.sql
6 000002_add_email_to_users.sqlgoose 单文件风格:
1-- +goose Up
2ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL AFTER name;
3
4-- +goose Down
5ALTER TABLE users DROP COLUMN email;5.2 embed + Migrate
用 Go 的 embed 把 SQL 编进二进制,保证「这个版本的代码」和「这个版本的迁移」一起发布:
1// internal/database/migrate.go
2package database
3
4import (
5 "database/sql"
6 "embed"
7
8 "github.com/pressly/goose/v3"
9)
10
11//go:embed migrations/*.sql
12var migrationsFS embed.FS
13
14func Migrate(db *sql.DB) error {
15 goose.SetBaseFS(migrationsFS)
16 if err := goose.SetDialect("mysql"); err != nil {
17 return err
18 }
19 return goose.Up(db, "migrations")
20}5.3 什么时候执行:3 种时机
| 时机 | 做法 | 适合 |
|---|---|---|
| A. 应用启动时 | Load配置 → 连库 → Migrate() → 启动服务 | 本地开发、小项目、单实例 |
| B. 部署流程独立一步 | 部署时先跑 migrate(initContainer / Job / CI step),成功后再起应用 | 生产推荐 |
| C. 独立 CLI | goose -dir ./migrations mysql "$DSN" up | 简单场景、运维操作 |
A 的写法:
1func run(level *slog.LevelVar) error {
2 cfg, err := config.Load(path)
3 if err != nil { return err }
4
5 db, err := database.Open(cfg.Database) // 建连接池
6 if err != nil { return err }
7
8 if err := database.Migrate(db); err != nil {
9 return fmt.Errorf("数据库迁移失败: %w", err) // 失败就别启动
10 }
11
12 // ...启动 HTTP 服务...
13}5.4 为什么生产推荐 B
- 多实例并发:服务 3 个副本会同时跑迁移,容易互相踩。
- 权限分离:迁移需要 DDL 权限,应用账号通常只给 DML。
- 失败隔离:迁移失败时应用根本不该起,Job 失败 → 部署中止。
- 启动更快/更可控。
5.5 推荐落地:同一镜像,两个入口
1server/cmd/tabapp/main.go # 应用
2server/cmd/migrate/main.go # 迁移:go run ./cmd/migrate up- 二进制里 embed 迁移文件,保证版本一致。
- 生产部署先跑
./tabapp-migrate up,成功后再起./tabapp。 - 本地开发可在
main里判断,mode == "debug"时启动即迁移:
1if cfg.Server.Mode == "debug" {
2 if err := database.Migrate(db); err != nil {
3 return fmt.Errorf("数据库迁移失败: %w", err)
4 }
5}六、Docker 部署
原理和单机一样:迁移是应用启动前的独立步骤,只是落到容器编排上。容器天生会水平扩缩容 / 滚动重启,所以比单机更需要拆开。
6.1 Compose:独立的一次性 migrate 服务(推荐)
1services:
2 db:
3 image: mysql:8
4 environment:
5 MYSQL_ROOT_PASSWORD: root
6 MYSQL_DATABASE: tabapp
7 healthcheck:
8 test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
9 interval: 5s
10 timeout: 3s
11 retries: 10
12
13 migrate:
14 image: tabapp:1.2.3
15 command: ["./tabapp-migrate", "up"] # 同一镜像,跑迁移入口
16 environment:
17 DB_HOST: db # 容器内是服务名,不是 127.0.0.1
18 DB_USER: tabapp
19 DB_PASSWORD: secret
20 DB_NAME: tabapp
21 depends_on:
22 db:
23 condition: service_healthy # 等 MySQL 真正可连
24
25 app:
26 image: tabapp:1.2.3
27 depends_on:
28 migrate:
29 condition: service_completed_successfully # 迁移成功才启动
30 ports:
31 - "8080:8080"要点:
migrate是一次性容器,跑完退出。app依赖migrate完成且成功,不是 started。db要有healthcheck,否则 MySQL 没 ready 迁移就跑了。
6.2 Kubernetes:Job / initContainer
- Job:部署流程先跑 Job 迁移,成功后再滚动更新 Deployment。
- initContainer:塞进每个 Pod,多副本时每个副本都会跑,不如 Job 干净。
1initContainers:
2 - name: migrate
3 image: tabapp:1.2.3
4 command: ["./tabapp-migrate", "up"]
5 envFrom:
6 - secretRef: { name: db-secret }6.3 entrypoint 脚本(简单但有坑)
1#!/bin/sh
2set -e
3./tabapp-migrate up # 先迁移
4exec ./tabapp # 再启动只适合单副本。scale app=3 或多副本滚动时,多个容器会同时迁移(虽然 goose 有版本表 + 锁,但别赌)。
6.4 Docker 特有注意点
- DSN 里 host 变了:容器里
127.0.0.1是容器自己,要用 Compose 服务名 / K8s Service 名。配置建议支持环境变量覆盖。 - 镜像自包含:迁移 SQL 要
//go:embed进二进制,或COPY migrations/进镜像。 - 一个镜像两个入口:
tabapp+tabapp-migrate,或tabapp migrate子命令。 - 权限分离:迁移账号给 DDL,应用账号给 DML。
- 失败中断发布:
migrate退出码非 0 → 编排层停止后续app。 - 回滚不会自动 down:容器回滚只是退回旧镜像;生产 forward-only,写错补新迁移。
七、常见坑
- 手动改表:不走迁移,环境立刻不一致。
- 改已执行的迁移文件:别人库里已经跑过,改了不生效还制造差异。
- 生产依赖 down 回滚:schema 回滚可能丢数据、和已写入数据不兼容。
- 启动即迁移 + 多副本:并发 ALTER,卡锁、超时、半迁移。
- 加 NOT NULL 列不给默认值:表非空时直接失败。
- 不加 healthcheck 就跑迁移:MySQL 没 ready,连接被拒。
- 迁移失败却继续启动应用:应用连到半成品 schema,错误更难查。
- 用 GORM
AutoMigrate上生产:不看迁移历史,容易产生不可控差异。 - DSN 用 127.0.0.1:容器内指自己,连不上 MySQL。
loc不转义:Asia/Shanghai里的/在 DSN 中要写Asia%2FShanghai。
八、速查表
迁移工具选型
| 工具 | 风格 | 特点 |
|---|---|---|
| golang-migrate | up/down 两文件 | 纯 SQL,通用,自带锁 |
| goose | 单文件注释分段 | 轻量,支持 SQL 或 Go 函数,支持 embed |
| Flyway | 只 up | 企业常用,社区版无 down |
| Atlas | 声明式 | 可从代码/ORM 生成迁移 |
| GORM AutoMigrate | 自动 | 开发方便,生产不推荐 |
goose 常用命令 / API
1goose -dir ./migrations mysql "$DSN" up # 跑全部未执行
2goose -dir ./migrations mysql "$DSN" down # 回滚一个
3goose -dir ./migrations mysql "$DSN" status # 查看状态
4goose -dir ./migrations mysql "$DSN" create add_email sql1goose.SetBaseFS(fs) // 用 embed
2goose.SetDialect("mysql")
3goose.Up(db, "migrations")
4goose.Down(db, "migrations")
5goose.Status(db, "migrations")迁移时机对照
1本地开发 → 启动即迁移(mode=debug)
2CI → 起临时 MySQL 跑 up/down 验证
3测试环境 → 部署时独立一步
4生产 → 独立 Job / initContainer,先迁移成功再滚动更新九、一句话总结
- 配置:五件套 +
parse_time/charset/loc+ 连接池三参数;parse_time用字符串避开 bool 三态。 - 迁移:给数据库表结构做 Git,up 改结构、down 反向撤销(但恢复不了数据)。
- 标准:版本化 + 只追加不可变 + 部署自动化;生产 forward-only,写错补新迁移。
- goose:
//go:embed把 SQL 编进二进制,goose.Up执行。 - 部署:迁移永远和应用启动分开,Docker/K8s 用独立 migrate 容器 / Job,成功后再起应用。