396 lines
16 KiB
Markdown
396 lines
16 KiB
Markdown
---
|
||
tags: [GORM, Go, ORM, MySQL, PostgreSQL, SQLite, SQL Server]
|
||
create time: 2026-04-28 00:00
|
||
---
|
||
|
||
# 多数据库支持
|
||
|
||
## 概述
|
||
|
||
GORM 采用「驱动适配器(Driver Adapter)」架构——你写一份 GORM 代码,通过切换 `gorm.Open()` 的 dialector 就能连接不同的数据库后端。**理想情况下**所有操作都应该无缝兼容,但现实是:**不同数据库的方言差异**会让某些功能表现出微妙甚至明显的行为不同。
|
||
|
||
> [!question] 核心问题
|
||
> 如果代码要在三种数据库上都能跑,那是不是意味着每个查询都要写三遍?答案当然是否定的——GORM 的优势恰恰在于"一次编写,多处运行"。那么问题来了:**哪些操作能保证一致?哪些地方需要特别注意?** 本章就是为了解答这些问题。
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Code["你的 GORM 代码"] --> Adapter{"Driver Adapter"}
|
||
|
||
Adapter --> |mysql| MySQL["MySQL / MariaDB"]
|
||
Adapter --> |postgres| PG["PostgreSQL"]
|
||
Adapter --> |sqlite| SQLite["SQLite"]
|
||
Adapter --> |mssql| MSSQL["SQL Server"]
|
||
|
||
style Code fill:#4FC08D,color:#fff
|
||
style Adapter fill:#EAB308,color:#fff
|
||
style MySQL fill:#3B82F6,color:#fff
|
||
style PG fill:#8B5CF6,color:#fff
|
||
style SQLite fill:#A0AEC0,color:#fff
|
||
style MSSQL fill:#F59E0B,color:#000
|
||
```
|
||
|
||
> [!tip] 驱动适配器的本质
|
||
> GORM 内部会为每种数据库维护一个 **dialector(方言鉴别器)**。调用 `Create`、`Find` 等操作时,dialector 会负责将 GORM 的中间表达式翻译成对应数据库的 SQL 方言。理解这一点就能明白:为什么同样的 `db.Limit(10)` 在不同数据库中生成的 SQL 完全不同。
|
||
|
||
## 驱动安装与初始化
|
||
|
||
### MySQL / MariaDB
|
||
|
||
```go
|
||
import "gorm.io/driver/mysql"
|
||
|
||
dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
|
||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||
// 注意:MySQL 是最常见的选择,DSN 参数也是最多的
|
||
```
|
||
|
||
**DSN 必配参数详解**:
|
||
|
||
| 参数 | 优先级 | 推荐值 | 说明 |
|
||
|------|--------|--------|------|
|
||
| `charset` | ⭐⭐⭐ | `utf8mb4` | 必须用 `utf8mb4`,传统的 `utf8` 在 MySQL 中只支持 3 字节,无法存储 emoji |
|
||
| `parseTime` | ⭐⭐⭐ | `True` | 自动把数据库的 datetime 转成 Go `time.Time`,否则你会拿到 `driver.Value` |
|
||
| `loc` | ⭐⭐⭐ | `Local` 或 `Asia/Shanghai` | 解决北京时间偏移问题;不设置的话可能拿到 UTC 时间 |
|
||
| `timeout` | ⭐⭐ | `30s` | TCP 握手阶段超时 |
|
||
| `readTimeout` | ⭐⭐ | `30s` | 从服务器读取响应超时 |
|
||
| `writeTimeout` | ⭐⭐ | `30s` | 向服务器发送请求超时 |
|
||
|
||
> [!important] utf8 vs utf8mb4 常见陷阱
|
||
> 很多开发者不知道 MySQL 的 `utf8` 实际上是 "utf8mb3"——它最多支持 3 字节的字符。遇到 emoji、生僻汉字时会直接报错。这就是为什么 DSN 里必须明确指定 `utf8mb4`(真正的全量 UTF-8)。
|
||
>
|
||
> ```go
|
||
> // 如果你在代码中也设置了 CharacterSet,别忘了:
|
||
> db.Set("gorm:table_options", "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||
> ```
|
||
|
||
### PostgreSQL
|
||
|
||
```go
|
||
import "gorm.io/driver/postgres"
|
||
|
||
dsn := "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=require TimeZone=Asia/Shanghai"
|
||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||
// PostgreSQL 使用标准的 Connection String 格式
|
||
```
|
||
|
||
**PostgreSQL 特有注意**:
|
||
|
||
- `sslmode` 在开发环境可设为 `disable`,生产环境保持 `require`(最低要求)或 `verify-full`(最强校验)
|
||
- `TimeZone` 要与服务端一致,否则 `time.Time` 字段会出现几小时的偏移
|
||
- PG 对大小写敏感:未加引号的标识符会自动转为小写,这意味着 struct 字段名映射时要格外小心
|
||
|
||
> [!warning] PostgreSQL 的大写陷阱
|
||
> 如果你在建表时用的是双引号 `"UserName"`,那后续所有查询都必须也带上双引号,否则 PG 会去找 `username`(小写)。**建议:建表一律用小写,避免此坑。**
|
||
|
||
### SQLite
|
||
|
||
```go
|
||
import "gorm.io/driver/sqlite"
|
||
|
||
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
|
||
// 最简单的一种——只需要一个文件路径
|
||
```
|
||
|
||
**SQLite 注意事项**:
|
||
|
||
- 默认是文件级排他锁,并发写入会有阻塞
|
||
- 高并发场景使用 WAL + 共享缓存模式:`file:test.db?cache=shared&_journal=WAL`
|
||
- **不支持外键约束**——`AutoMigrate` 不会创建外键,需要在原始 SQL 中手动添加
|
||
- 所有列本质上都是「无类型」的(type affinity),GORM 的类型映射在某些边界情况下可能有意外表现
|
||
|
||
> [!tip] SQLite 开启 WAL 模式
|
||
> WAL(Write-Ahead Logging)允许读写并发,性能提升显著:
|
||
> ```go
|
||
> db, _ := gorm.Open(sqlite.Open("file:test.db?cache=shared&_journal=WAL&_timeout=5000"), &gorm.Config{})
|
||
> // cache=shared:允许多个连接共享缓存
|
||
> // _journal=WAL:启用预写日志
|
||
> // _timeout:锁定等待超时(毫秒)
|
||
> ```
|
||
|
||
### SQL Server
|
||
|
||
```go
|
||
import "gorm.io/driver/mssql"
|
||
|
||
dsn := "sqlserver://user:password@host:1433?database=dbname&encrypt=disable"
|
||
db, err := gorm.Open(mssql.Open(dsn), &gorm.Config{})
|
||
```
|
||
|
||
**SQL Server 特有注意**:
|
||
|
||
- 连接字符串使用 URI 格式,与其他驱动风格不同
|
||
- SQL Server 默认强制 TLS 加密,开发环境下需加 `encrypt=disable`
|
||
- 数据类型和自增逻辑与 MySQL/PG 都不同(详见下方方言差异表)
|
||
|
||
## 方言差异速查表
|
||
|
||
这是跨数据库迁移时最容易踩坑的部分。下面这张表汇总了最常见的差异场景:
|
||
|
||
| 特性 | MySQL | PostgreSQL | SQLite | SQL Server |
|
||
|------|-------|-----------|--------|------------|
|
||
| `time.Time` | DATETIME / TIMESTAMP | TIMESTAMPTZ | TEXT(序列化后) | DATETIME2 |
|
||
| `string` | VARCHAR(n) | VARCHAR / TEXT | TEXT | NVARCHAR(n) |
|
||
| `uint` | INT UNSIGNED | BIGINT | INTEGER | INT |
|
||
| `float64` | DOUBLE | DOUBLE PRECISION | REAL | FLOAT |
|
||
| 自增主键 | AUTO_INCREMENT | SERIAL / BIGSERIAL | AUTOINCREMENT | IDENTITY(1,1) |
|
||
| NOW() 函数 | NOW() | NOW() | DATETIME('now') | GETUTCDATE() |
|
||
| LIMIT 语法 | LIMIT N OFFSET M | LIMIT N OFFSET M | LIMIT N OFFSET M | TOP N / OFFSET FETCH |
|
||
| 软删除索引 | ⚠️ InnoDB 唯一约束 Bug | ✅ 正常 | ✅ 正常 | ✅ 正常 |
|
||
| ON DELETE | CASCADE / SET NULL 等 | CASCADE / SET NULL 等 | ❌ 不支持 | CASCADE / SET NULL 等 |
|
||
|
||
> [!question] 为什么 uint 在 PG 中变成 BIGINT?
|
||
> Go 的 `uint` 在 64 位系统上是 8 字节(与 `int64` 同宽),而 MySQL 有独立的 `INT UNSIGNED` 正好对齐。但 PostgreSQL 没有无符号整数类型,所以 GORM 将其映射为最大的匹配类型 `BIGINT`。这就引出了一个问题:**如果你的代码中写了 `uint`,迁移到 PG 后会不会出现溢出?** 答案是:只要数据范围在 BIGINT 内就没问题,但如果业务依赖的是 32 位上限,就需要改用 `uint32`。
|
||
|
||
### CREATE TABLE 实际输出对比
|
||
|
||
同一个 struct 在不同数据库中生成的建表语句差异很大:
|
||
|
||
```go
|
||
type User struct {
|
||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||
Name string `gorm:"size:64;not null"`
|
||
Age int `gorm:"not null;default:0"`
|
||
CreatedAt time.Time `gorm:"autoTime"`
|
||
}
|
||
```
|
||
|
||
| 数据库 | 实际建表语句摘要 |
|
||
|--------|----------------|
|
||
| MySQL | `id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, age INT NOT NULL DEFAULT 0, created_at DATETIME` |
|
||
| PostgreSQL | `id BIGSERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL, age INT NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE` |
|
||
| SQLite | `id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, created_at TEXT` |
|
||
|
||
> [!warning] autoTime 的隐藏机制
|
||
> `autoTime` tag 在 SQLite 中的表现尤其值得注意:因为 SQLite 没有原生的 DATETIME 类型,GORM 会把 `time.Time` 序列化为 RFC 3339 格式的字符串存入 TEXT 列。读出来的时候再反序列化回来。这意味着你在 SQLite 中无法使用数据库层面的时间函数(如 `WHERE created_at > '2024-01-01'` 来利用索引)。
|
||
|
||
## AutoMigrate 跨数据库的注意事项
|
||
|
||
```go
|
||
// MySQL 下执行迁移
|
||
err := db.AutoMigrate(&User{}, &Order{})
|
||
|
||
// PostgreSQL 下执行同样的迁移
|
||
pgDB, _ := gorm.Open(postgres.Open(pgDSN), &gorm.Config{})
|
||
err = pgDB.AutoMigrate(&User{}, &Order{})
|
||
// 注意:同样一个 User struct,在 PG 中会自动用 BIGSERIAL 而非 AUTO_INCREMENT
|
||
```
|
||
|
||
> [!warning] AutoMigrate 的本质局限
|
||
> `AutoMigrate` 的设计原则是 **"只加不改"**——它能新增表和新增列,但**无法修改已有列的结构**(比如改列名、改类型、改约束)。这在任何数据库中都一样,但在 SQLite 下表现得最极端:SQLite 根本不支持 ALTER TABLE 的大部分操作,所以 AutoMigrate 实际上只能新建一张表然后把旧数据搬过来。
|
||
>
|
||
> **最佳实践**:使用专门的迁移工具(Goose、golang-migrate、Migration),手写版本化的 SQL 脚本。这样你可以精确控制每个版本的迁移和回滚,而不是依赖 AutoMigrate 的黑盒行为。
|
||
|
||
## 查询语法差异
|
||
|
||
### LIMIT / OFFSET 分页
|
||
|
||
```go
|
||
// 标准分页写法——GORM 会自动处理底层方言差异
|
||
db.Limit(10).Offset(20).Find(&users)
|
||
|
||
// 底层翻译后的 SQL:
|
||
// MySQL: SELECT * FROM users LIMIT 10 OFFSET 20
|
||
// PostgreSQL: SELECT * FROM users LIMIT 10 OFFSET 20
|
||
// SQLite: SELECT * FROM users LIMIT 10 OFFSET 20
|
||
// SQL Server: SELECT * FROM users ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
|
||
```
|
||
|
||
> [!important] SQL Server 分页的性能陷阱
|
||
> 老版本的 GORM 驱动会把 SQL Server 的分页转换为子查询方案(`TOP N WHERE id NOT IN (SELECT TOP M ...)`),在大数据量下性能较差。升级到最新版驱动后已改用 `OFFSET FETCH` 标准语法。升级前务必确认你用的驱动版本 ≥ v1.5.0。
|
||
|
||
### UUID 与原生函数
|
||
|
||
不同数据库生成 UUID 的方式完全不同,GORM 只能做基本映射,具体的默认值必须由你指定:
|
||
|
||
```go
|
||
type Order struct {
|
||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid()"` // PostgreSQL:内置函数 gen_random_uuid()
|
||
// MySQL 要用 type:char(36);default:(UUID())
|
||
// SQL Server 要用 type:uniqueidentifier;default:newid()
|
||
// SQLite 需要用 Hook 或在 Go 层生成
|
||
}
|
||
```
|
||
|
||
> [!tip] SQLite 下的 UUID 生成方案
|
||
> 由于 SQLite 没有内置 UUID 函数,推荐的两种方式:
|
||
> 1. **Go 层生成**:在 `BeforeCreate` Hook 中用 `github.com/google/uuid` 生成
|
||
> 2. **触发器**:用 SQLite 的 BEFORE INSERT TRIGGER 自动生成
|
||
>
|
||
> ```go
|
||
> func (o *Order) BeforeCreate(tx *gorm.DB) error {
|
||
> if o.ID == uuid.Nil {
|
||
> o.ID = uuid.New()
|
||
> }
|
||
> return nil
|
||
> }
|
||
> ```
|
||
|
||
### 软删除与唯一索引
|
||
|
||
这是最隐蔽的一个坑——MySQL InnoDB 引擎在处理软删除(`DeletedAt` 字段)时,**唯一约束不会自动排除已软删除的行**。换句话说:
|
||
|
||
```go
|
||
type Product struct {
|
||
gorm.Model
|
||
SKU string `gorm:"uniqueIndex"`
|
||
}
|
||
|
||
// 假设 SKU="ABC" 的记录被软删除了
|
||
// 此时你还能再次插入 SKU="ABC" 的新记录吗?
|
||
// MySQL: ✅ 可以(唯一的 bug 行为——软删除行不参与唯一约束检查)
|
||
// PG: ❌ 不可以(正确行为——唯一约束包含软删除行)
|
||
```
|
||
|
||
> [!danger] MySQL 软删除唯一约束 Bug
|
||
> 如果你用了软删除 + 唯一索引,在 MySQL 下可能出现"同一 SKU 多条有效记录"的数据不一致问题。
|
||
>
|
||
> **解决方案**:
|
||
> 1. 用复合唯一索引:`uniqueIndex:idx_sku_active`,并在查询时总是带上 `DeletedAt` 条件
|
||
> 2. 或者换用 PG/SQL Server——它们的行为是正确的
|
||
|
||
## 高级用法
|
||
|
||
### 多数据库连接管理
|
||
|
||
生产环境中通常需要同时连接多个数据库(比如 MySQL 存业务数据、Redis 做缓存、ES 做搜索),以下是常见模式:
|
||
|
||
```go
|
||
var (
|
||
masterDB *gorm.DB // 主库
|
||
slaveDB *gorm.DB // 从库 / 其他数据库
|
||
)
|
||
|
||
func initMultiDB() error {
|
||
var err error
|
||
|
||
masterDB, err = gorm.Open(mysql.Open(os.Getenv("MASTER_DSN")), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Info),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("master db: %w", err)
|
||
}
|
||
|
||
slaveDB, err = gorm.Open(postgres.Open(os.Getenv("SLAVE_DSN")), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("slave db: %w", err)
|
||
}
|
||
|
||
// 配置连接池(两种数据库都可以用 DB.SqlDB() 访问底层 *sql.DB)
|
||
sqlDB, _ := masterDB.DB()
|
||
sqlDB.SetMaxOpenConns(50)
|
||
sqlDB.SetMaxIdleConns(10)
|
||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||
|
||
return nil
|
||
}
|
||
```
|
||
|
||
> [!tip] 连接池调优经验值
|
||
> - `MaxOpenConns`:根据 QPS × 平均查询耗时估算。一般 Web 应用 20~100 足够
|
||
> - `MaxIdleConns`:设为 `MaxOpenConns` 的 10%~25%,过多空闲连接会浪费资源
|
||
> - `ConnMaxLifetime`:建议设 1 小时,避免与数据库侧的连接超时策略冲突
|
||
|
||
### 环境自适应连接
|
||
|
||
```go
|
||
func newDB(env string) (*gorm.DB, error) {
|
||
var driverFunc func(string) gorm.Dialector
|
||
|
||
switch env {
|
||
case "dev":
|
||
driverFunc = func(dsn string) gorm.Dialector {
|
||
return sqlite.Open(dsn)
|
||
}
|
||
default:
|
||
driverFunc = func(dsn string) gorm.Dialector {
|
||
return postgres.Open(dsn)
|
||
}
|
||
}
|
||
|
||
dsn := map[string]string{
|
||
"dev": "file:test_dev.db?cache=shared",
|
||
"staging": os.Getenv("DATABASE_URL"),
|
||
"prod": os.Getenv("PROD_DATABASE_URL"),
|
||
}[env]
|
||
|
||
return gorm.Open(driverFunc(dsn), &gorm.Config{
|
||
Logger: ternary(env == "prod", logger.Default.LogMode(logger.Silent), logger.Default),
|
||
})
|
||
}
|
||
|
||
func ternary[T any](cond bool, a, b T) T {
|
||
if cond { return a }; return b
|
||
}
|
||
```
|
||
|
||
### 多数据库事务
|
||
|
||
当需要在一个事务中操作多个数据库时,GORM 本身不提供分布式事务支持,但可以分别管理各自的事务:
|
||
|
||
```go
|
||
// 分别在不同的 db 实例上开启事务
|
||
tx1 := masterDB.Begin()
|
||
tx2 := slaveDB.Begin()
|
||
|
||
// 独立提交
|
||
if err := tx1.Create(&product).Error; err != nil {
|
||
tx1.Rollback()
|
||
tx2.Rollback() // 两个都需要回滚
|
||
return err
|
||
}
|
||
if err := tx2.Create(&auditLog).Error; err != nil {
|
||
tx1.Rollback()
|
||
tx2.Rollback()
|
||
return err
|
||
}
|
||
|
||
tx1.Commit()
|
||
tx2.Commit()
|
||
```
|
||
|
||
> [!important] 跨数据库事务不是 ACID 的
|
||
> 上面的模式叫做 **"两阶段提交"的非正式实现**——本质上两个事务是独立的。如果 tx1 成功但 tx2 失败,你就有了数据不一致状态。真正的分布式事务需要使用 XA 协议或 Saga 模式,但这超出了 GORM 的能力范围。
|
||
>
|
||
> **建议**:能在一个数据库内完成的操作就不要跨库,减少一致性复杂度。
|
||
|
||
## 最佳实践总结
|
||
|
||
### 数据库选型决策矩阵
|
||
|
||
| 场景 | 推荐数据库 | 理由 |
|
||
|------|-----------|------|
|
||
| 快速原型 / CLI 工具 | SQLite | 零配置,单文件 |
|
||
| 个人项目 / 博客 | PostgreSQL | 免费、功能全、JSONB 强大 |
|
||
| 企业级业务系统 | MySQL / PostgreSQL | 社区成熟,生态丰富 |
|
||
| Windows 技术栈企业 | SQL Server | Active Directory 集成好 |
|
||
|
||
### 开发环境 vs 生产环境
|
||
|
||
> [!tip] 为什么推荐开发用 SQLite?
|
||
>
|
||
> - **零配置**:不需要启动任何数据库服务,`go run` 即可
|
||
> - **单文件**:方便分享测试数据集,CI/CD 中直接用内存数据库
|
||
> - **语法兼容性**:大部分标准 SQL 都能工作
|
||
>
|
||
> **但请记住**:SQLite **不能**替代生产数据库做压力测试——它不支持行级锁、事务隔离级别可调、复杂聚合优化器等关键特性。**开发用 SQLite 验证逻辑,上线前必须在真实数据库上做一轮完整回归测试。**
|
||
|
||
### 防坑 Checklist
|
||
|
||
在准备多数据库部署时,逐项核对:
|
||
|
||
- [ ] 所有 `time.Time` 字段的时区设置是否已明确
|
||
- [ ] 是否避免了 `uint` 类型(改用 `int` / `int64` 降低迁移风险)
|
||
- [ ] 软删除 + 唯一索引的场景是否在目标数据库上过测
|
||
- [ ] 自定义 SQL(`db.Raw()`)是否做了方言检查
|
||
- [ ] `AutoMigrate` 之外是否有版本化的迁移脚本
|
||
- [ ] 连接池参数是否针对生产环境调优
|
||
- [ ] 是否在所有目标数据库上都跑过完整的 CI 测试
|
||
|
||
## 关联笔记
|
||
|
||
- [[01-安装与初始化]]
|
||
- [[02-模型定义]]
|
||
- [[17-迁移工具]]
|