从MySQL Only到PostgreSQL+MySQL:一次从驱动抽象到 SQL 兼容的完整改造

TL;DR — 为已有项目新增第二种数据库支持,工作量远大于”加个驱动依赖”。
本文记录 ppanel-server 从 MySQL-only 到同时支持 MySQL + PostgreSQL 的完整改造:重构配置抽象层、拆分两套迁移 DDL、修复 model 层 20+ 个 MySQL 专属写法、顺带优化搜索索引。
涉及 2 个 PR、6 次提交、约 40 个文件改动。最有价值的发现是:引入异构数据库测试,意外暴露了一个 MySQL 下潜伏了半年的隐性 bug。


一、故事的起点

事情始于社群里一条不算特别紧迫的请求——“能支持 PostgreSQL 吗?”

这不是第一次有人提了。ppanel-server 的社群中,PostgreSQL 支持的呼声断断续续出现了好几次。有人在用托管 PostgreSQL(Supabase、Neon、RDS for PostgreSQL),有人的服务器上已经跑着 PostgreSQL 实例,不想为 ppanel 再单独维护一套 MySQL。这些声音一直挂着,没人真的动手做。

我其实一直知道这件事早晚要面对。所以某天我下定决心后,先做了一件事——走进代码,看看如果要加 PostgreSQL,到底要动多少东西。

结果比我预想的要深得多。

配置结构体直接叫 MySQL,初始化向导的路由叫 /init/mysql/test,迁移 SQL 全是 MySQL 方言,model 层散落着 FIND_IN_SET 和各种 MySQL 专属写法。不是一两个地方的问题——是整个数据层被 MySQL 渗透透了。

最麻烦的地方在于:很多 MySQL 专属写法是隐形的。FIND_IN_SET 可以用 grep 搜出来,但隐式类型转换、保留字裸写、MySQL 独有的 SQL 方言散落在 GORM 的查询字符串里,静态分析根本抓不全。这意味着即使我把抽象层和迁移层改好了,PostgreSQL 能不能真正跑起来,只有跑起来才知道。

我决定认真做一次,不打补丁。不是”加个 driver import 再修几个报错”,而是从架构层面让 ppanel-server 真正支持两种数据库。

后来的故事证明,这个判断是对的——这项工作最终拆成了两个 PR:#130 负责基础设施(抽象层、配置层、迁移层),#133 专门修 model 层在 PostgreSQL 下的兼容性问题。


二、摸清现状:MySQL 假设渗透到了哪里

动手之前,我先做了一次全面的代码审计。MySQL 的假设一共渗透了四个层面。

配置层

config.Config 里一个 MySQL orm.Config 字段,整个程序读配置、存配置、校验配置都直接操作它:

1
2
3
4
5
if startConfigPath != "etc/ppanel.yaml" && c.MySQL.Addr == "" {
return true
}
// ...
c.MySQL = *cfg

如果要支持第二种数据库,”加一个 PostgreSQL 字段”是最直接的做法,但也是最难维护的——调用方代码里会到处出现 if db == MySQL { ... } else { ... }。必须抽象出一个驱动无关的接口。

初始化流程

首次部署的 Web 向导里,handleInitConfig 直接拼 MySQL 格式的 DSN(user:pass@tcp(host:port)/db),硬编码调用 gorm.Open(mysql.Open(dsn)) 来测试连通性。路由 /init/mysql/test 则直接用了 gorm.Open 的 MySQL dialector。整个流程没有任何切换数据库的入口。

ORM 连接层

orm 包里只有一个 ConnectMysql 函数,import 的是 gorm.io/driver/mysql

迁移文件

所有 SQL 文件平铺在 initialize/migrate/database/ 下。CREATE TABLE 用的是 AUTO_INCREMENTTINYINT(1)ENGINE=InnoDB。PostgreSQL 拿到这些文件一条都跑不通。

model 层

这是最复杂的部分。MySQL 专属写法散落在十几个文件里,有的是显式的——FIND_IN_SETDATE_FORMAT——有的是隐式的:保留字裸写、数值类型的文本匹配、隐式类型转换。静态搜索只能抓到前一类,后一类只有等跑 PostgreSQL 时才会炸出来。


三、设计驱动抽象层

啃下这块骨头之前,先定目标:调用方代码不应该知道底层是哪个数据库,所有驱动差异收敛到 orm 包内部。

3.1 驱动标识与规范化

定义常量,和 NormalizeDriver 函数处理用户输入的各种别名:

1
2
3
4
5
6
7
8
9
10
11
12
13
const (
DriverMySQL = "mysql"
DriverPostgres = "postgres"
)

func NormalizeDriver(driver string) string {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "postgres", "postgresql", "pg":
return DriverPostgres
default:
return DriverMySQL
}
}

默认值是 MySQL,所有现有配置文件零改动。

3.2 统一配置结构体

orm.Config 增加 Driver 字段,其他字段在两个驱动间复用:

1
2
3
4
5
6
7
8
9
10
11
type Config struct {
Driver string
Addr string
Username string
Password string
Dbname string
Config string // DSN 附加参数
MaxIdleConns int
MaxOpenConns int
SlowThreshold int
}

然后在 orm.Mysql 上挂两个方法,把 DSN 格式差异藏起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (m Mysql) Driver() string {
return NormalizeDriver(m.Config.Driver)
}

func (m Mysql) MigrationDsn() string {
if m.Driver() == DriverPostgres {
return fmt.Sprintf("postgres://%s:%s@%s/%s?%s",
m.Config.Username, m.Config.Password,
m.Config.Addr, m.Config.Dbname, m.Config.Config)
}
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s",
m.Config.Username, m.Config.Password,
m.Config.Addr, m.Config.Dbname, m.Config.Config)
}

3.3 ConnectDatabase:统一连接入口

1
2
3
4
5
6
7
8
func ConnectDatabase(m Mysql) (*gorm.DB, error) {
switch m.Driver() {
case DriverPostgres:
return connectPostgres(m)
default:
return connectMySQL(m)
}
}

新增的依赖:

1
2
gorm.io/driver/postgres v1.6.0
github.com/jackc/pgx/v5 v5.6.0

注意 gorm.io/driver/postgres 底层用的是 pgx/v5,而不是老的 lib/pq。两者对 GORM 的上层接口无差异,但 pgx 性能更好、对连接池的控制更细。


四、改造配置层和初始化流程

4.1 config.Config 的接口化

MySQL 字段不能直接改名——现有 YAML 配置文件反序列化靠字段名匹配,改名就是破坏性变更。所以保留原字段,新增方法做兼容层:

1
2
3
4
5
6
7
8
9
10
func (c *Config) DatabaseConfig() orm.Config {
if c.Database.Driver != "" {
return c.Database
}
return c.MySQL // 回退到老字段
}

func (c *Config) SetDatabaseConfig(cfg orm.Config) {
c.Database = cfg
}

配置写回 YAML 时也统一走 Database 字段:

1
2
3
4
newConfig := config.File{
// ...
Database: c.DatabaseConfig(), // 原来是 MySQL: c.MySQL
}

这样三件事同时成立:

  • 新部署:配置文件里出现 Database.Driver: postgres
  • 老用户升级MySQL 字段自动回退,行为不变
  • 老用户升级后重写配置:自动迁移到新格式

4.2 初始化向导的改造

handleInitConfig 的请求体里新增 databaseDriver 字段,对接 buildDatabaseConfig

1
2
3
4
5
dbConfig, err := buildDatabaseConfig(
request.DatabaseDriver,
request.MysqlHost, request.MysqlPort,
request.MysqlDatabase, request.MysqlUser, request.MysqlPassword,
)

buildDatabaseConfig 集中做三件事:规范化驱动名、验证合法性、注入各驱动的默认 DSN 参数(PostgreSQL 默认 sslmode=disable&TimeZone=Asia/Shanghai)。

迁移的调用从:

1
migrate.Migrate(dsn).Up()

改成:

1
migrate.Migrate(dbClient.Driver(), dbClient.MigrationDsn()).Up()

同时新增 /init/database/test 路由,老的 /init/mysql/test 内部转发到新函数:

1
2
3
func HandleMySQLTest(c *gin.Context) {
HandleDatabaseTest(c) // 向前兼容
}

五、迁移文件:从一套 DDL 到两套

这是最枯燥但不能省的工作。所有迁移文件平铺在 database/ 下的局面不能再继续了——两种数据库的 DDL 语法差异,混在一个目录里迟早要出事。

新的目录结构:

1
2
3
4
5
6
7
8
9
initialize/migrate/database/
├── mysql/
│ ├── 00001_init_schema.up.sql
│ ├── 00002_init_basic_data.up.sql
│ └── ...
└── postgres/
├── 00001_init_schema.up.sql
├── 00002_init_basic_data.up.sql
└── ...

以第一版建 users 表为例,差异其实比想象中多:

MySQL 版本:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`password` VARCHAR(255) NOT NULL,
`balance` DECIMAL(10,2) DEFAULT 0.00,
`status` TINYINT(1) DEFAULT 1,
`is_admin` TINYINT(1) DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

PostgreSQL 版本:

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE users (
id BIGSERIAL NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
balance DECIMAL(10,2) DEFAULT 0.00,
status SMALLINT DEFAULT 1,
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (id)
);
CREATE INDEX idx_users_email ON users(email);

几个核心差异:

场景 MySQL PostgreSQL
自增主键 BIGINT AUTO_INCREMENT BIGSERIAL
布尔值 TINYINT(1) + 0/1 BOOLEAN + TRUE/FALSE
时间戳默认值 CURRENT_TIMESTAMP NOW()
更新时戳 ON UPDATE CURRENT_TIMESTAMP 需 trigger
引擎指定 ENGINE=InnoDB 无需
字符集 CHARSET utf8mb4 库级别

我用 golang-migrate 这个框架,它支持按子目录区分数据库驱动,只需要在调用时传入正确的 database 参数:

1
2
3
4
// mysql
migrate.Migrate("mysql", "user:pass@tcp(localhost:3306)/ppanel?...").Up()
// postgres
migrate.Migrate("postgres", "postgres://user:pass@localhost:5432/ppanel?...").Up()

框架自动定位到对应目录的 migration 文件。工作量集中在翻译 DDL 上——全量迁移文件大约 20 组,逐条翻译、逐条确认功能等价。


六、跑起来之后:model 层挖坑记

PR #130 合并之后,我长舒了一口气。抽象层、配置层、迁移层都过了,理论上 PostgreSQL 应该能跑起来了。

然后搭了一个 PostgreSQL 实例,启动服务,开始逐个调用接口。

日志里刷出来十几行错误。

有些是预料之中的——FIND_IN_SET、保留字——改了就行。有些则完全出乎意料,比如一个 MySQL 下潜伏了半年的 bug 因为 PostgreSQL 的严格类型检查而炸了出来。

以下按修复顺序,摘几个代表性的问题讲讲。

6.1 FIND_IN_SET

项目里多处用逗号分隔的字段存关联 ID:

1
node.group_ids = "1,3,7"

原来的查询:

1
db.Where("FIND_IN_SET(?, group_ids)", groupID)

FIND_IN_SET 是 MySQL 专有函数,在 PostgreSQL 下报错 function does not exist

修复:在 orm 包里增加 CommaSeparatedContains,对两种数据库生成不同 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
func CommaSeparatedContains(column string, value interface{}) clause.Expr {
if currentDriver == DriverPostgres {
return clause.Expr{
SQL: fmt.Sprintf("? = ANY(string_to_array(%s, ','))", column),
Vars: []interface{}{fmt.Sprintf("%v", value)},
}
}
return clause.Expr{
SQL: fmt.Sprintf("FIND_IN_SET(?, %s)", column),
Vars: []interface{}{value},
}
}

调用方只需写:

1
db.Where(orm.CommaSeparatedContains("group_ids", groupID))

这种模式后来被反复使用——不是所有问题都能靠一个 helper 解决,但这种”生成驱动相关的 SQL 片段,暴露统一接口”的模式,贯穿了整个改造。

6.2 数值字段的文本 LIKE

ticket.portINT 类型,但后台搜索框中用户可能输入”端口号片段”来筛选。原来的代码做字符串 LIKE 匹配。MySQL 会隐式做类型转换,所有行都能正常返回。PostgreSQL 直接报类型错误。

这种场景没有特别优雅的解法。我加了一层驱动判断:

1
2
3
4
5
if orm.CurrentDriver() == orm.DriverPostgres {
db = db.Where("port::TEXT LIKE ?", "%"+keyword+"%")
} else {
db = db.Where("CAST(port AS CHAR) LIKE ?", "%"+keyword+"%")
}

不优雅,但稳妥。理想的方案是把这类场景收敛到 helper 里,但 port 的模糊搜索只出现在后台管理页面的全能搜索框里,调用点很少,就先以最小改动保证正确性。

6.3 Node BeforeUpdate 中的错误 Model 引用

这是整轮改造中价值最大的发现。

NodeBeforeUpdate hook 里有一段排序校验逻辑:

1
2
3
4
5
func (n *Node) BeforeUpdate(tx *gorm.DB) error {
var existing Server // 错误!应该是 Node
tx.Where("id = ?", n.ID).First(&existing)
// ... 用 existing.Sort 做校验
}

ServerNode 恰好在结构上有点像(都有 IDSort 字段),MySQL 下 GORM 虽然表名走了 servers 表,但查询结果恰好包含这些字段,校验逻辑阴差阳错地通过了。这个 bug 一直静默存在了半年之久。

但在 PostgreSQL 下,这条 SQL 走了 servers 表并返回了错误的数据,排序校验逻辑在不正确的数据上运行,导致后续的状态更新调用产生 500。沿着错误栈一层层追下去,最终定位到 BeforeUpdate 的这个位置时,我盯着屏幕愣了几秒——“这也行?”

修复只是一行文本变化:

1
var existing Node // 修正

但这件事让我意识到:异构数据库的价值不仅是适配更多用户,它还扮演了”额外的编译器”的角色——在更好的类型系统、更严格的语法约束下,它发现了常规测试从未触达的 bug。

一个项目中同时跑两种数据库,它们的差异点本身就是一套隐性测试。

6.4 保留字裸写

PostgreSQL 对 SQL 保留字比 MySQL 严格得多。最典型的是 system 表的 key 列:

1
db.Where("key = ?", k)

MySQL 下虽然 key 是保留字,但 DML 中 MySQL 会隐式加反引号。PostgreSQL 直接报语法错误。show 字段同理,分别出现在 subscribeannouncementdocument 三个表里。

修复:不用手写字符串,改用 GORM 的 clause.Eq,它会自动对列名做安全引号处理:

1
2
3
4
5
6
7
// internal/model/system/scope.go
func WhereCategoryKey(category, key string) []clause.Interface {
return []clause.Interface{
clause.Eq{Column: "category", Value: category},
clause.Eq{Column: "key", Value: key},
}
}

调用方从:

1
db.Where("key = ?", k).Where("category = ?", cat)

变成:

1
db.Where(system.WhereCategoryKey(cat, k))

安全、驱动无关。

改动波及了大约 10 个文件——凡是涉及 systemkeyupdateXxxConfigLogic.go 都要改。每个文件一两行,但必须全部过一遍,不能遗漏。

6.5 查询函数的隐式副作用

这是一个设计问题,不是 SQL 兼容性问题。但在 PostgreSQL 的严格事务隔离下,它从”不优雅”变成了一个真实的 bug。

FindUsersSubscribeBySubscribeId —— 函数名语义是”查找”——但函数体里实际还有写操作:

1
2
3
4
5
6
7
8
9
10
11
12
func FindUsersSubscribeBySubscribeId(id uint) ([]*UserSubscribe, error) {
var list []*UserSubscribe
db.Where("subscribe_id = ?", id).Find(&list)

// ⚠️ 顺手激活待激活的订阅
for _, item := range list {
if item.Status == StatusPending {
db.Model(item).Update("status", StatusActive)
}
}
return list, nil
}

调用方按函数名做信任判断,以为它只读不做写入。在 PostgreSQL 的事务隔离下,这种模式更容易造成并发写冲突。

拆分成两个职责清晰的函数:

1
2
3
4
5
6
7
8
9
10
11
func FindUsersSubscribeBySubscribeId(id uint) ([]*UserSubscribe, error) {
var list []*UserSubscribe
db.Where("subscribe_id = ?", id).Find(&list)
return list, nil
}

func ActivatePendingSubscribesBySubscribeId(id uint) error {
return db.Model(&UserSubscribe{}).
Where("subscribe_id = ? AND status = ?", id, StatusPending).
Update("status", StatusActive).Error
}

函数名就是语义协议,调用方按名称做信任判断。这段经历让我在 review 中养成了一个习惯:看到一个叫 Find*Get*Query* 的函数,多看一眼它的函数体里有没有 UpdateInsertDelete


七、搜索性能:顺带修了一轮索引

在改兼容性的过程中,我注意到搜索查询里大量使用 %keyword% 写法。这种写法两端都加通配符,B-Tree 索引完全用不上,一定是全表扫描。既然已经在改数据层了,顺手优化这块。

7.1 搜索 Helper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func PrefixLike(keyword string) string {
return EscapeLike(keyword) + "%"
}

func ContainsLike(keyword string) string {
return "%" + EscapeLike(keyword) + "%"
}

func EscapeLike(keyword string) string {
keyword = strings.ReplaceAll(keyword, "\\", "\\\\")
keyword = strings.ReplaceAll(keyword, "%", "\\%")
keyword = strings.ReplaceAll(keyword, "_", "\\_")
return keyword
}

EscapeLike 之前的搜索代码里完全没有,用户输入包含 %_ 时搜索语义会被静默篡改。

搜索策略调整:email、用户名等”用户通常从头输入”的字段改用 PrefixLike,可以命中 B-Tree 索引;节点备注、套餐名称等必须支持任意位置匹配的保留 ContainsLike

7.2 搜索索引迁移

MySQL 版本加普通 B-Tree 索引,不做特殊处理。

PostgreSQL 版本引入 pg_trgm extension 和 GIN 索引来加速包含匹配:

1
2
3
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_nodes_remark_trgm
ON nodes USING gin(remark gin_trgm_ops);

pg_trgm 把字符串按三个字符一组切分,用 GIN 索引存储。几十万行以上的表,这个索引能把 LIKE '%keyword%' 从秒级降到毫秒级。

部署注意事项:CREATE EXTENSION 需要超级用户权限,托管 PostgreSQL 需要在控制台手动启用或请 DBA 协助。我在 PR 描述里特意标注了这一点。


八、向后兼容:让已有 MySQL 用户无感升级

整个改造过程中,我心里一直挂着一条红线:已有 MySQL 用户什么都不用改,二进制替换重启即可。

保障机制:

  1. config.Config 保留 MySQL 字段。Database.Driver 为空时自动回退读 MySQL
  2. NormalizeDriver("") 默认返回 "mysql"
  3. 路由 /init/mysql/test 保留,内部转发给新函数。
  4. 迁移文件只迁移目录,内容不改。

新配置格式示例:

1
2
3
4
5
6
7
Database:
Driver: postgres
Addr: localhost:5432
Username: postgres
Password: password
Dbname: ppanel
Config: sslmode=disable&TimeZone=Asia/Shanghai

也可以只写:

1
2
3
4
5
Database:
Addr: localhost:3306
Username: root
Password: password
Dbname: ppanel

Driver 留空则默认为 MySQL。


九、技术取舍:一个没有重命名的命名

细心的读者可能注意到:在整篇文章里,承载 PostgreSQL 逻辑的 Go 类型仍然叫 orm.Mysql

1
type Mysql struct { Config }  // 同时也处理 Postgres 连接

为什么不重命名为 orm.DBConfigorm.Driver?这是一个真实的遗留设计债务。

做这个改造时,orm.Mysql 被引用在几十个文件里——作为函数参数类型、结构体字面量初始化、方法接收者。改名意味着全量搜索替换,增加 reviewer 的心智负担,而且没有任何行为收益。在当时”最小改动原则”的约束下,我选择保留命名。

这是一个 tradeoff:新增 PostgreSQL 支持已经改变了 40 个文件,再叠一个全量重命名,PR diff 会膨胀到根本没法 review。改命名应该是一个独立的、纯重构的 PR——从效果上看,早晚会做。但适配本身不必须等它。

这和学习接口的一个道理:先让它 work,再让它 clean。


十、回顾

两个 PR,六次提交,约四十个文件改动:

PR 提交 内容
#130 ee53e6e PostgreSQL 驱动支持,配置抽象,迁移拆分
#130 dad79dd CI 发布流程调整
#133 064cc96 model 层 PostgreSQL 兼容性修复
#133 1ec853e 拆分订阅查询和激活逻辑
#133 b4629b8 搜索查询优化和索引迁移
#133 7b074e2 MySQL 保留字修复

几条值得记录的教训:

1. 驱动抽象要尽早做。 如果一开始 orm 层就是 ConnectDatabase(driver, config) 的形态,后来加驱动只是加一个 case 的工作量。等到代码里散落了上百处 mysql.Open 的调用,就很难优雅了。

2. GORM 不能屏蔽所有 SQL 差异。 FIND_IN_SET、保留字、数值类型转换这些问题,GORM 的 standardized API 覆盖不到。写 model 层时要有跨数据库意识,预期之外的 SQL 字符不该出现在 db.Where() 的字符串里。

3. 函数名就是语义协议。 Find* 里写 Update 是程序员的信任违约。被 PostgreSQL 逼着修了这个问题后,我现在 code review 里多了一条默认检查。

4. 异构数据库测试的价值超过适配本身。 这是整件事里最意外的收获。引入 PostgreSQL 不仅让项目支持了更多用户,它还像一个”额外的编译器”一样,用更严格的类型检查暴露了一个 MySQL 下潜伏半年的 bug。选择支持多种数据库,不仅仅是扩大用户面——它在替你运行一条额外的、价值极高的测试套件。

5. 迁移 SQL 早分早省事。 两套 DDL 语法差异放到一个目录里,不管理论上多小心,迟早混淆。目录结构是最简单的强制约束。

6. 向后兼容不是可选项,是硬条件。 已经有用户在生产环境跑着你的软件,你的改进不能让他们升级后亮红灯。保留路由、保留字段、设定合理的默认值——这些不会出现在 feature 列表里,但比你写的新功能更重要。

最后:如果你在考虑为一个 MySQL-only 的项目加 PostgreSQL 支持,我的建议就是 做,并且做彻底。因为你会发现,真正难的不是加 PostgreSQL,而是揭开 MySQL 留给你的一切便利假设。