--- 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-迁移工具]]