从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 | if startConfigPath != "etc/ppanel.yaml" && c.MySQL.Addr == "" { |
如果要支持第二种数据库,”加一个 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_INCREMENT、TINYINT(1)、ENGINE=InnoDB。PostgreSQL 拿到这些文件一条都跑不通。
model 层
这是最复杂的部分。MySQL 专属写法散落在十几个文件里,有的是显式的——FIND_IN_SET、DATE_FORMAT——有的是隐式的:保留字裸写、数值类型的文本匹配、隐式类型转换。静态搜索只能抓到前一类,后一类只有等跑 PostgreSQL 时才会炸出来。
三、设计驱动抽象层
啃下这块骨头之前,先定目标:调用方代码不应该知道底层是哪个数据库,所有驱动差异收敛到 orm 包内部。
3.1 驱动标识与规范化
定义常量,和 NormalizeDriver 函数处理用户输入的各种别名:
1 | const ( |
默认值是 MySQL,所有现有配置文件零改动。
3.2 统一配置结构体
orm.Config 增加 Driver 字段,其他字段在两个驱动间复用:
1 | type Config struct { |
然后在 orm.Mysql 上挂两个方法,把 DSN 格式差异藏起来:
1 | func (m Mysql) Driver() string { |
3.3 ConnectDatabase:统一连接入口
1 | func ConnectDatabase(m Mysql) (*gorm.DB, error) { |
新增的依赖:
1 | gorm.io/driver/postgres v1.6.0 |
注意 gorm.io/driver/postgres 底层用的是 pgx/v5,而不是老的 lib/pq。两者对 GORM 的上层接口无差异,但 pgx 性能更好、对连接池的控制更细。
四、改造配置层和初始化流程
4.1 config.Config 的接口化
MySQL 字段不能直接改名——现有 YAML 配置文件反序列化靠字段名匹配,改名就是破坏性变更。所以保留原字段,新增方法做兼容层:
1 | func (c *Config) DatabaseConfig() orm.Config { |
配置写回 YAML 时也统一走 Database 字段:
1 | newConfig := config.File{ |
这样三件事同时成立:
- 新部署:配置文件里出现
Database.Driver: postgres - 老用户升级:
MySQL字段自动回退,行为不变 - 老用户升级后重写配置:自动迁移到新格式
4.2 初始化向导的改造
handleInitConfig 的请求体里新增 databaseDriver 字段,对接 buildDatabaseConfig:
1 | dbConfig, err := buildDatabaseConfig( |
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 | func HandleMySQLTest(c *gin.Context) { |
五、迁移文件:从一套 DDL 到两套
这是最枯燥但不能省的工作。所有迁移文件平铺在 database/ 下的局面不能再继续了——两种数据库的 DDL 语法差异,混在一个目录里迟早要出事。
新的目录结构:
1 | initialize/migrate/database/ |
以第一版建 users 表为例,差异其实比想象中多:
MySQL 版本:
1 | CREATE TABLE `users` ( |
PostgreSQL 版本:
1 | CREATE TABLE users ( |
几个核心差异:
| 场景 | 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 | // mysql |
框架自动定位到对应目录的 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 | func CommaSeparatedContains(column string, value interface{}) clause.Expr { |
调用方只需写:
1 | db.Where(orm.CommaSeparatedContains("group_ids", groupID)) |
这种模式后来被反复使用——不是所有问题都能靠一个 helper 解决,但这种”生成驱动相关的 SQL 片段,暴露统一接口”的模式,贯穿了整个改造。
6.2 数值字段的文本 LIKE
ticket.port 是 INT 类型,但后台搜索框中用户可能输入”端口号片段”来筛选。原来的代码做字符串 LIKE 匹配。MySQL 会隐式做类型转换,所有行都能正常返回。PostgreSQL 直接报类型错误。
这种场景没有特别优雅的解法。我加了一层驱动判断:
1 | if orm.CurrentDriver() == orm.DriverPostgres { |
不优雅,但稳妥。理想的方案是把这类场景收敛到 helper 里,但 port 的模糊搜索只出现在后台管理页面的全能搜索框里,调用点很少,就先以最小改动保证正确性。
6.3 Node BeforeUpdate 中的错误 Model 引用
这是整轮改造中价值最大的发现。
Node 的 BeforeUpdate hook 里有一段排序校验逻辑:
1 | func (n *Node) BeforeUpdate(tx *gorm.DB) error { |
Server 和 Node 恰好在结构上有点像(都有 ID、Sort 字段),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 字段同理,分别出现在 subscribe、announcement、document 三个表里。
修复:不用手写字符串,改用 GORM 的 clause.Eq,它会自动对列名做安全引号处理:
1 | // internal/model/system/scope.go |
调用方从:
1 | db.Where("key = ?", k).Where("category = ?", cat) |
变成:
1 | db.Where(system.WhereCategoryKey(cat, k)) |
安全、驱动无关。
改动波及了大约 10 个文件——凡是涉及 system 表 key 的 updateXxxConfigLogic.go 都要改。每个文件一两行,但必须全部过一遍,不能遗漏。
6.5 查询函数的隐式副作用
这是一个设计问题,不是 SQL 兼容性问题。但在 PostgreSQL 的严格事务隔离下,它从”不优雅”变成了一个真实的 bug。
FindUsersSubscribeBySubscribeId —— 函数名语义是”查找”——但函数体里实际还有写操作:
1 | func FindUsersSubscribeBySubscribeId(id uint) ([]*UserSubscribe, error) { |
调用方按函数名做信任判断,以为它只读不做写入。在 PostgreSQL 的事务隔离下,这种模式更容易造成并发写冲突。
拆分成两个职责清晰的函数:
1 | func FindUsersSubscribeBySubscribeId(id uint) ([]*UserSubscribe, error) { |
函数名就是语义协议,调用方按名称做信任判断。这段经历让我在 review 中养成了一个习惯:看到一个叫 Find*、Get*、Query* 的函数,多看一眼它的函数体里有没有 Update、Insert、Delete。
七、搜索性能:顺带修了一轮索引
在改兼容性的过程中,我注意到搜索查询里大量使用 %keyword% 写法。这种写法两端都加通配符,B-Tree 索引完全用不上,一定是全表扫描。既然已经在改数据层了,顺手优化这块。
7.1 搜索 Helper
1 | func PrefixLike(keyword string) string { |
EscapeLike 之前的搜索代码里完全没有,用户输入包含 % 或 _ 时搜索语义会被静默篡改。
搜索策略调整:email、用户名等”用户通常从头输入”的字段改用 PrefixLike,可以命中 B-Tree 索引;节点备注、套餐名称等必须支持任意位置匹配的保留 ContainsLike。
7.2 搜索索引迁移
MySQL 版本加普通 B-Tree 索引,不做特殊处理。
PostgreSQL 版本引入 pg_trgm extension 和 GIN 索引来加速包含匹配:
1 | CREATE EXTENSION IF NOT EXISTS pg_trgm; |
pg_trgm 把字符串按三个字符一组切分,用 GIN 索引存储。几十万行以上的表,这个索引能把 LIKE '%keyword%' 从秒级降到毫秒级。
部署注意事项:CREATE EXTENSION 需要超级用户权限,托管 PostgreSQL 需要在控制台手动启用或请 DBA 协助。我在 PR 描述里特意标注了这一点。
八、向后兼容:让已有 MySQL 用户无感升级
整个改造过程中,我心里一直挂着一条红线:已有 MySQL 用户什么都不用改,二进制替换重启即可。
保障机制:
config.Config保留MySQL字段。Database.Driver为空时自动回退读MySQL。NormalizeDriver("")默认返回"mysql"。- 路由
/init/mysql/test保留,内部转发给新函数。 - 迁移文件只迁移目录,内容不改。
新配置格式示例:
1 | Database: |
也可以只写:
1 | Database: |
Driver 留空则默认为 MySQL。
九、技术取舍:一个没有重命名的命名
细心的读者可能注意到:在整篇文章里,承载 PostgreSQL 逻辑的 Go 类型仍然叫 orm.Mysql。
1 | type Mysql struct { Config } // 同时也处理 Postgres 连接 |
为什么不重命名为 orm.DBConfig 或 orm.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 留给你的一切便利假设。