备忘用笔记,围绕本项目 server 的 MySQL 配置展开。忘了直接翻这一篇。

数据库相关的三个层面,逐层递进:

  1. 配置:连哪个库、用什么参数(本项目 internal/config)。
  2. 迁移:表结构怎么随代码演进(goose + SQL 文件)。
  3. 部署:迁移在什么时候、由谁执行(Docker / K8s)。

一、数据库配置字段速查(本项目 config.go)

Database 结构长这样:

go
 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含义
Hosthost数据库地址(容器里是服务名,不是 127.0.0.1)
Portport端口,默认 3306
Useruser账号
Passwordpassword密码
DBNamedbname库名

1.2 驱动行为参数

字段yaml含义
ParseTimeparse_time让驱动把 MySQL 的 DATE/DATETIME/TIMESTAMP 解析成 Go 的 time.Time。用字符串 "true"/"false" 存,避免 bool 零值无法区分「未配置」和「显式 false」
Charsetcharset客户端连接字符集,默认 utf8mb4
Locloc解析 time.Time 时用哪个时区,默认 Asia/Shanghai

1.3 连接池参数

字段yaml含义默认
MaxOpenConnsmax_open_conns池里最多同时打开的连接数(在用 + 空闲)100
MaxIdleConnsmax_idle_conns池里最多保留多少空闲连接20
ConnMaxLifetimeMinconn_max_lifetime_min一个连接从创建起最多存活多少分钟,到点关闭重建60

注意:MaxIdleConns 必须 ≤ MaxOpenConns,否则无效回退默认值。

1.4 默认值与校验

  • 端口缺省/越界 → 3306;池参数 < 1 → 默认值;idle > open → 回退。
  • parse_time 只允许 "true"/"false",否则回退 "true"。
  • validate() 必填项只查 host + user(这两个有值基本说明整块配了)。
  • 配置示例(server/config.yaml):
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: 60

1.5 拼 DSN(go-sql-driver/mysql)

text
1user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
  • loc 里的 / 要 URL 转义成 %2F。
  • parseTime=true 对应配置里的 parse_time。

二、什么是数据库迁移

用代码/版本化的方式,管理数据库表结构的每一次变更,而不是手动去库里敲 CREATE TABLE / ALTER TABLE。

2.1 为什么需要

表结构一定会变,人工改动的问题:

  • 本地改了,测试/生产忘了改 → 环境不一致。
  • 同事拉代码,不知道要执行哪些 SQL。
  • 出问题无法回滚,也不知道当前是哪个版本。

2.2 长什么样

text
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

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

sql
1DROP TABLE users;

第二次加 email:

000002_add_email_to_users.up.sql

sql
1ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL AFTER name;

000002_add_email_to_users.down.sql

sql
1ALTER TABLE users DROP COLUMN email;

3.2 down 是 up 的反向操作

up 操作down 操作
ADD COLUMN emailDROP COLUMN email
DROP COLUMN ageADD COLUMN age INT
MODIFY COLUMN age BIGINTMODIFY 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

表里已有数据,加新列要注意老数据:

sql
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,通过版本化的、只增不改的迁移文件,在部署流程里自动、有序地执行。

核心是这三点(形式不重要):

  1. 版本化:每次变更都有唯一编号和记录。
  2. 只追加、不可变:已执行过的迁移文件永不修改。
  3. 自动化执行:在部署流水线里,靠系统不靠人。

4.1 共识细节

  1. 迁移文件进版本控制,和代码一起 review / 部署。
  2. 已执行过的迁移永不修改,要改就写新迁移。
  3. 生产 forward-only,不回滚 schema,写错了补新迁移。
  4. 一个迁移只做一件事,粒度要小(锁表时间短、易 review)。
  5. 版本号有序唯一:递增序号 000001_ 或时间戳 20240115103000_。
  6. 迁移由部署流程自动执行,并加锁防并发。
  7. 破坏性变更用「扩展-收缩」模式:加新列 → 双写 → 回填 → 读新列 → 删旧列。
  8. 大表操作注意锁和耗时,MySQL 8 可用 ALGORITHM=INSTANT。

4.2 标准 vs 反面教材

标准做法反面教材
迁移文件 + 版本表手动在 Navicat 里改表
只追加、不回改改已上线的迁移文件
部署时自动跑、带锁谁想起来谁手动执行
forward-only,错就补新迁移依赖 down 回滚生产
小步、单一职责一个文件塞几十条 DDL
数据迁移和结构迁移分开建表顺便 UPDATE 几十万行
本地/测试都跑一遍只在生产手测

4.3 「两个文件」不是唯一形式

风格代表工具形式
up/down 两个文件golang-migratexxx.up.sql + xxx.down.sql
单文件,注释分段goose一个 .sql 里 -- +goose Up / -- +goose Down
只写 upFlyway(社区版)、Atlas只有正向脚本
代码成对方法Rails、Djangoup()/down()

五、goose 实战

5.1 目录结构

text
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.sql

goose 单文件风格:

sql
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 编进二进制,保证「这个版本的代码」和「这个版本的迁移」一起发布:

go
 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. 独立 CLIgoose -dir ./migrations mysql "$DSN" up简单场景、运维操作

A 的写法:

go
 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 推荐落地:同一镜像,两个入口

text
1server/cmd/tabapp/main.go     # 应用
2server/cmd/migrate/main.go    # 迁移:go run ./cmd/migrate up
  • 二进制里 embed 迁移文件,保证版本一致。
  • 生产部署先跑 ./tabapp-migrate up,成功后再起 ./tabapp。
  • 本地开发可在 main 里判断,mode == "debug" 时启动即迁移:
go
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 服务(推荐)

yaml
 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 干净。
yaml
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 脚本(简单但有坑)

sh
1#!/bin/sh
2set -e
3./tabapp-migrate up      # 先迁移
4exec ./tabapp            # 再启动

只适合单副本。scale app=3 或多副本滚动时,多个容器会同时迁移(虽然 goose 有版本表 + 锁,但别赌)。

6.4 Docker 特有注意点

  1. DSN 里 host 变了:容器里 127.0.0.1 是容器自己,要用 Compose 服务名 / K8s Service 名。配置建议支持环境变量覆盖。
  2. 镜像自包含:迁移 SQL 要 //go:embed 进二进制,或 COPY migrations/ 进镜像。
  3. 一个镜像两个入口:tabapp + tabapp-migrate,或 tabapp migrate 子命令。
  4. 权限分离:迁移账号给 DDL,应用账号给 DML。
  5. 失败中断发布:migrate 退出码非 0 → 编排层停止后续 app。
  6. 回滚不会自动 down:容器回滚只是退回旧镜像;生产 forward-only,写错补新迁移。

七、常见坑

  1. 手动改表:不走迁移,环境立刻不一致。
  2. 改已执行的迁移文件:别人库里已经跑过,改了不生效还制造差异。
  3. 生产依赖 down 回滚:schema 回滚可能丢数据、和已写入数据不兼容。
  4. 启动即迁移 + 多副本:并发 ALTER,卡锁、超时、半迁移。
  5. 加 NOT NULL 列不给默认值:表非空时直接失败。
  6. 不加 healthcheck 就跑迁移:MySQL 没 ready,连接被拒。
  7. 迁移失败却继续启动应用:应用连到半成品 schema,错误更难查。
  8. 用 GORM AutoMigrate 上生产:不看迁移历史,容易产生不可控差异。
  9. DSN 用 127.0.0.1:容器内指自己,连不上 MySQL。
  10. loc 不转义:Asia/Shanghai 里的 / 在 DSN 中要写 Asia%2FShanghai。

八、速查表

迁移工具选型

工具风格特点
golang-migrateup/down 两文件纯 SQL,通用,自带锁
goose单文件注释分段轻量,支持 SQL 或 Go 函数,支持 embed
Flyway只 up企业常用,社区版无 down
Atlas声明式可从代码/ORM 生成迁移
GORM AutoMigrate自动开发方便,生产不推荐

goose 常用命令 / API

bash
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 sql
go
1goose.SetBaseFS(fs)          // 用 embed
2goose.SetDialect("mysql")
3goose.Up(db, "migrations")
4goose.Down(db, "migrations")
5goose.Status(db, "migrations")

迁移时机对照

text
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,成功后再起应用。