This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/13-多数据库支持.md
T
2026-04-28 20:56:51 +08:00

396 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-迁移工具]]