「一套代码支持三种数据库」听起来像架构能力的证明,实际更像一份长期维护合同。这篇以 Limit Blog 为例,说明本地为什么用 SQLite、生产为什么推荐 PostgreSQL、MySQL 兼容到底兼容到什么程度,以及 GORM 的 AutoMigrate、三种全文检索、连接池和迁移回滚各自的天花板在哪里。
1. 三种数据库的诱惑与账单
先看一个常见的失败路径:本地用 SQLite 开发,所有测试通过;上线切 PostgreSQL,第一周遇到三个问题——LIKE 大小写行为变了,唯一键冲突的错误文本对不上,搜索接口直接报「关系不存在」。代码没改,行为却变了。
这不是 GORM 的错。ORM 能抹平「增删改查」的语法,抹不平三件事:
- 方言能力:全文索引、JSON 查询、
ON CONFLICT、窗口函数; - 类型语义:布尔、时间、字符串长度、排序规则;
- 错误形态:唯一约束、死锁、连接中断的报错各不相同。
所以项目定了一条硬约束:核心逻辑不使用方言特有语法,三种数据库跑同一套业务代码;只有全文检索按方言实现,但藏在统一接口后面。
2. 角色分工:SQLite 起步、Postgres 生产、MySQL 兼容
三种库不是三个平等的选项,它们在这个项目里各有明确位置:
| 场景 | 数据库 | 选择理由 | 主要限制 |
|---|---|---|---|
| 本地开发、单机小站、演示 | SQLite | 零运维、单文件、测试启动快 | 单写者,备份靠文件,FTS 是另一套实现 |
| 生产推荐 | PostgreSQL | 并发写、GIN 全文索引、类型与事务行为稳定 | 需要独立服务、备份与升级 |
| 已有环境 / 兼容目标 | MySQL 8 | 部署面广,ngram 支持中文全文 |
排序规则、时区、索引行为差异多 |
本地默认 SQLite,是为了让「克隆仓库、make run」不需要先装数据库。生产示例配置默认 PostgreSQL,是因为写入并发、备份工具和全文索引都更省心。仓库里同时保留了 MySQL 的生产配置,Compose 默认也起 MySQL,目的是给已有 MySQL 的环境一个入口,而不是说两套代码。
但 SQLite 上的测试通过,不等于 PostgreSQL 上通过。三库兼容要靠真实实例上的集成测试,后面单独讲。
3. 切换点只有一处
数据库驱动的选择集中在 pkg/repository.NewDB。业务代码拿到的始终是 *gorm.DB,不感知方言:
switch driver := conf.Data.Db.Type; driver {
case "mysql":
gormConfig.PrepareStmt = true
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
case "postgres":
gormConfig.PrepareStmt = true
db, err = gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}), gormConfig)
case "sqlite":
gormConfig.PrepareStmt = false
db, err = gorm.Open(sqlite.Open(dsn), gormConfig)
default:
panic("unknown db driver")
}
三个驱动的配置差异不是随手写的:
- MySQL / PostgreSQL 开启
PrepareStmt,减少重复 SQL 的解析开销; - SQLite 关闭
PrepareStmt,因为 schema 变化后旧的 prepared statement 可能失效; - PostgreSQL 使用
PreferSimpleProtocol,避免隐式 prepared statement 与某些 DDL 场景冲突。
公共部分放在 DefaultGormConfig:SkipDefaultTransaction: true 让单条写入不再自动包事务,事务边界由 Service 显式声明;NowFunc 统一返回 UTC,规避 SQLite 文本时间、MySQL datetime、PostgreSQL timestamptz 的时区差异;TranslateError: false 则把唯一键识别交给自己的 IsDuplicateKey,保证三库拿到的错误形态一致。
配置只改两个字段:
data:
db:
type: postgres # sqlite | mysql | postgres
dsn: host=postgres user=blog password=<db-password> dbname=blog port=5432 sslmode=disable TimeZone=Asia/Shanghai
pool:
maxOpen: 8
maxIdle: 4
maxLifeTime: 60
SQLite 的 DSN 建议显式打开 WAL 与忙等待,否则并发写入很容易直接报 database is locked:
file:./data/blog.db?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_foreign_keys=on
「只改配置」只对代码成立,不对运维成立。 换库之后,连接池参数、备份方式、全文索引、迁移脚本都要跟着换。下面逐项说。
4. AutoMigrate 的边界
开发环境建表走 POST /api/init/db,内部是 GORM 的 AutoMigrate:
if !initEnabled {
return nil, api.ErrFeatureDisabled
}
if err := db.AutoMigrate(models...); err != nil {
return nil, api.MapError(ctx, err, nil)
}
models 由 system.Models() 与 business.Models() 聚合,顺序是先系统表、后业务表。生产环境必须把 server.init.enable 关掉:这个入口没有鉴权,却能触发全库 DDL。
AutoMigrate 的能力边界必须记清楚:它只做增量。新增列、新增索引可以;删除列、修改列类型、重命名表、重命名索引都不行。它也不会理解数据迁移,比如把 status 从字符串 "0"/"1" 改成 int8,只能另写脚本。
因此项目维护两条路径:
| 路径 | 入口 | 用途 |
|---|---|---|
| 自动建表 | POST /api/init/db → AutoMigrate |
开发、自托管首次启动 |
| 权威 DDL | deploy/sql/schema.sql + seed.sql |
生产、审查、可版本化 |
schema.sql 是目标契约,不是进度快照。测试会把 schema.sql 与 AutoMigrate 分别建库,断言共同覆盖的表上列名与归一化类型一致;某个模型落地后如果还留在「待补白名单」里,测试会失败,强制把白名单收紧。这条测试用 make schema-check 执行,是防止 DDL 悄悄漂移的关键。
真正需要改结构时,走 deploy/sql/migrations。仓库里已经有一个表重命名的例子:m0-01-rename-oss-to-media.sql。它的注释写得很直白——AutoMigrate 不会重命名表,也不会改列类型,有数据时必须单独执行。
5. FTS 三种实现与迁移代价
全文检索是「三库兼容」里唯一不装作统一的部分。接口只有三个方法:
type Searcher interface {
Ensure(ctx context.Context) error
Search(ctx context.Context, keyword string, limit, offset int) ([]Result, int64, error)
Reindex(ctx context.Context) (int64, error)
}
search.New(db) 按 db.Dialector.Name() 选择实现,默认落到 SQLite。三种实现的能力差异如下:
| 数据库 | 索引结构 | 中文处理 | 重建方式 |
|---|---|---|---|
| SQLite | FTS5 虚拟表 + 触发器 | trigram 分词,少于 3 字回退 LIKE |
删除全表后重新插入 |
| MySQL | FULLTEXT ... WITH PARSER ngram |
ngram 分词 | OPTIMIZE TABLE |
| PostgreSQL | GIN + to_tsvector('simple', ...) |
simple 不切中文,叠加 ILIKE 兜底 |
REINDEX INDEX |
SQLite 的实现依赖三个触发器,让 biz_article 的增删改自动同步到 FTS 表:
CREATE VIRTUAL TABLE IF NOT EXISTS article_search USING fts5(
article_id UNINDEXED, title, summary, content, tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS article_search_ai AFTER INSERT ON biz_article BEGIN
INSERT INTO article_search(article_id,title,summary,content)
VALUES (new.article_id,new.title,new.summary,new.content);
END;
tokenize='trigram' 让中文至少三个字时能命中;少于三个字(比如「Go」)走 LIKE 回退。查询用 bm25(article_search) 排序,比 LIKE 的「包含即命中」更接近相关性排序。
PostgreSQL 这边,simple 分词器不会切中文,所以实现里同时保留 tsvector 与 ILIKE:
CREATE INDEX IF NOT EXISTS idx_article_search
ON biz_article USING GIN (to_tsvector('simple',
coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(content,'')));
这条 SQL 有两个代价。第一,CREATE INDEX 不是 CONCURRENTLY,大表上会阻塞写入;数据量大时应该在维护窗口手动建并发索引。第二,表达式索引要求查询侧的表达式完全一致,改一个 coalesce 就会失效。
迁移数据库时,检索是最容易被忘记的一步:
Ensure只创建索引表、索引和触发器,不会回填已有文章;- 切库后必须执行一次
POST /api/admin/search/reindex; - MySQL 的
Reindex实际是OPTIMIZE TABLE,返回的计数是 0,不要拿它当「重建了多少篇」; - SQLite 重建会先
DELETE再INSERT,期间检索结果可能短暂为空。
还有一个跨库差异:SQLite 的 FTS5 表不在 GORM 模型里,AutoMigrate 不会创建它,只能靠启动时 Ensure;漏掉就会报「no such table: article_search」。
6. 连接池参数与容量估算
连接池当前支持三个参数:
| 参数 | SQLite | PostgreSQL / MySQL(建议) | 说明 |
|---|---|---|---|
maxOpen |
1 | 20 | SQLite 是库级写锁,多连接会频繁 SQLITE_BUSY |
maxIdle |
1 | maxOpen / 2 |
空闲连接不必太多 |
maxLifeTime |
不敏感 | 3600 秒 | 避开数据库侧的空闲回收 |
这里有一个必须指出的现状:NewDB 直接调用 SetMaxOpenConns(conf.Data.Pool.MaxOpen),没有按驱动覆写。也就是说,如果配置里写 maxOpen: 8,SQLite 也会真的开 8 个连接。切到 SQLite 部署时,要手动把 maxOpen 改成 1,否则并发写入会开始报锁等待。
容量怎么估?公开 API 的目标是 P95 小于 100ms,缓存命中时小于 20ms。按 200 QPS 估算,Little's Law 下平均并发大约是 200 × 0.02 = 4 个请求;即使全部落到数据库、每个查询 10ms,也只有 2 个并发。所以 PostgreSQL / MySQL 的 20 个连接对单实例足够,真正的瓶颈通常不是连接数,而是慢查询和缺失索引。
SQLite 的账不一样:maxOpen = 1 把所有读写串行化。个人博客的写入是低频的(发布、评论、计数落库),读又大部分被缓存挡住,所以 1 个连接够用;如果哪天的读 QPS 真的上来,正确做法是加缓存或换 PostgreSQL,而不是把 SQLite 的连接数调大。
配置里的 maxLifeTime: 60 是示例值,生产建议提到 3600 秒,避免连接频繁重建;项目没有单独的 maxIdleTime 配置。
7. 备份、恢复与迁移步骤
三种库的备份方式不同,但原则相同:先备份,再验证能恢复,最后才切库。
# SQLite:停写后复制,或用在线备份
sqlite3 ./data/blog.db ".backup './backup/blog-$(date +%F).db'"
# PostgreSQL
pg_dump -Fc blog > backup/blog-$(date +%F).dump
# MySQL
mysqldump --single-transaction --routines blog > backup/blog-$(date +%F).sql
项目还提供内容级备份接口:GET /api/admin/backup/export 导出 11 张内容与配置表,POST /api/admin/backup/import 只允许空库导入。这个接口的边界要看清:它不包含用户、令牌、媒体二进制。所以它是「内容迁移」工具,不是「整站灾备」工具;整站恢复仍然要靠数据库原生备份加上 data/uploads。
从 SQLite 迁到 PostgreSQL,推荐按下面的顺序做:
- 冻结写入:进入维护窗口,停止管理端写入,记录当前版本号与各表行数。
- 备份并验证:备份旧库与
data/uploads,在临时目录恢复一次,确认能查询。 - 新库建表:开发/自托管可用
POST /api/init/db;生产执行schema.sql,再执行seed.sql。 - 迁移数据:空库可以用备份导入接口恢复内容;用户、令牌、媒体记录需要原生导出或单独的 ETL。项目没有内置跨库 ETL,这一步不要假设「点一下就能迁」。
- 同步权限:执行
POST /api/admin/permissions/sync,把内置权限点补齐;否则角色授权可能缺项。 - 切换配置:修改
data.db.type与dsn,重启服务,跑一遍登录、发文、评论、上传的冒烟。 - 重建检索:执行
POST /api/admin/search/reindex,分别用英文与中文关键词验证。 - 观察并保留旧库:旧库保持只读至少一个备份周期,确认无回滚需求后再下线。
回滚策略要提前写清楚,而不是出事时再想:
- 保留旧配置与旧库,回滚时把
type/dsn切回去并重启; - 回滚会丢切换后的新写入,所以切库必须配维护窗口,不能边写边切;
- 不要指望 AutoMigrate 降级:它不会删列、不会改回类型,回滚靠「切回旧库」,不是「把新库改旧」;
- 二进制版本要匹配:新版本可能写入了旧代码不认识的列,回滚时通常要一起回滚应用版本。
8. 一套代码三种库的测试矩阵
三库兼容不能靠 review,要靠可重复执行的测试。当前仓库的测试分四层:
| 测试 | 覆盖什么 | 怎么跑 |
|---|---|---|
| 单元 / Service | 业务逻辑,默认 SQLite 内存库 | make test |
| 集成 | 三库 AutoMigrate、建表、SELECT 1、唯一键归一 |
DB=postgres make test-integration |
| FTS | 真实 MySQL / PostgreSQL 的中英文检索 | DB=mysql make test-fts |
| Schema 契约 | schema.sql 与 AutoMigrate 的列名、类型一致 |
make schema-check |
集成测试的关键断言不是「能连上」,而是三件事:每个模型都建出了表、SELECT 1 能执行、repository.IsDuplicateKey 能识别当前数据库的唯一键错误。最后一条最容易被忽略:MySQL 返回 errno 1062,PostgreSQL 返回 SQLSTATE 23505,SQLite 返回 UNIQUE constraint failed 文本,三者必须统一,否则 slug 冲突处理会在某个库上失效。
FTS 测试则要求真实实例,因为三种实现完全不同。仓库里的 test-fts 会用英文和中文关键词各查一次,验证的不是「有没有结果」,而是「分词与回退逻辑是否按预期工作」。
需要如实说明:CI 里的 MySQL / PostgreSQL 矩阵还没有完全跑全。目前本地用 Docker 起实例验证,make test-fts 需要手动注入 DSN;make ci-local 默认只跑 SQLite。这是一个已知缺口,不是「已经三库全绿」。
最后列一下写代码时最容易踩的方言坑:
- SQLite 忽略
size,不要指望在 SQLite 上断言varchar(255); - SQLite 与 PostgreSQL 的索引名共享 schema 命名空间,索引名必须带表名前缀;
- MySQL 的
LIKE大小写取决于 collation,PostgreSQL 的LIKE区分大小写,统一用LOWER(col) LIKE LOWER(?); - 不使用
ON DUPLICATE KEY/ON CONFLICT,改成「先查后写 + 唯一索引兜底」; - 不使用数据库端 JSON 查询,
meta/config列只做整存整取。
9. 小结
「一套代码三种库」的真正成本不在驱动切换,而在四件事:schema 的演进方式、全文检索的方言实现、连接池与备份的运维差异、以及测试矩阵是否真的覆盖了三种库。
这个项目的取舍是:SQLite 负责「零依赖启动」,PostgreSQL 负责「生产推荐」,MySQL 负责「已有环境兼容」;核心逻辑不碰方言,检索按方言实现但收敛到 Searcher 接口;AutoMigrate 只用于开发建表,生产以 schema.sql 为契约,结构变更走迁移脚本。
如果只能记住一句话:数据库可以换,迁移路径必须提前设计;AutoMigrate 不是迁移工具,它只是建表工具。




评论
加载中…