---
title: "从MySQL Only到PostgreSQL+MySQL：一次从驱动抽象到 SQL 兼容的完整改造"
source: https://www.embermoth.blog/2026/05/19/ppanel-postgresql-support/
date: 2026-05-19
updated: 2026-05-19
tags: [ppanel, golang, postgresql, gorm, database]
categories: [开发笔记]
---

# 从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` 字段，整个程序读配置、存配置、校验配置都直接操作它：

```go
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_INCREMENT`、`TINYINT(1)`、`ENGINE=InnoDB`。PostgreSQL 拿到这些文件一条都跑不通。

### model 层

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

* * *

## 三、设计驱动抽象层

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

### 3.1 驱动标识与规范化

定义常量，和 `NormalizeDriver` 函数处理用户输入的各种别名：

```go
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` 字段，其他字段在两个驱动间复用：

```go
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 格式差异藏起来：

```go
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：统一连接入口

```go
func ConnectDatabase(m Mysql) (*gorm.DB, error) {
    switch m.Driver() {
    case DriverPostgres:
        return connectPostgres(m)
    default:
        return connectMySQL(m)
    }
}
```

新增的依赖：

```plaintext
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 配置文件反序列化靠字段名匹配，改名就是破坏性变更。所以保留原字段，新增方法做兼容层：

```go
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` 字段：

```go
newConfig := config.File{
    // ...
    Database: c.DatabaseConfig(), // 原来是 MySQL: c.MySQL
}
```

这样三件事同时成立：

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

### 4.2 初始化向导的改造

`handleInitConfig` 的请求体里新增 `databaseDriver` 字段，对接 `buildDatabaseConfig`：

```go
dbConfig, err := buildDatabaseConfig(
    request.DatabaseDriver,
    request.MysqlHost, request.MysqlPort,
    request.MysqlDatabase, request.MysqlUser, request.MysqlPassword,
)
```

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

迁移的调用从：

```go
migrate.Migrate(dsn).Up()
```

改成：

```go
migrate.Migrate(dbClient.Driver(), dbClient.MigrationDsn()).Up()
```

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

```go
func HandleMySQLTest(c *gin.Context) {
    HandleDatabaseTest(c) // 向前兼容
}
```

* * *

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

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

新的目录结构：

```plaintext
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 版本：**

```sql
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 版本：**

```sql
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` 参数：

```go
// 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：

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

原来的查询：

```go
db.Where("FIND_IN_SET(?, group_ids)", groupID)
```

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

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

```go
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},
    }
}
```

调用方只需写：

```go
db.Where(orm.CommaSeparatedContains("group_ids", groupID))
```

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

### 6.2 数值字段的文本 LIKE

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

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

```go
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 引用

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

`Node` 的 `BeforeUpdate` hook 里有一段排序校验逻辑：

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

`Server` 和 `Node` 恰好在结构上有点像（都有 `ID`、`Sort` 字段），MySQL 下 GORM 虽然表名走了 `servers` 表，但查询结果恰好包含这些字段，校验逻辑阴差阳错地通过了。这个 bug 一直静默存在了半年之久。

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

修复只是一行文本变化：

```go
var existing Node // 修正
```

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

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

### 6.4 保留字裸写

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

```go
db.Where("key = ?", k)
```

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

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

```go
// 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},
    }
}
```

调用方从：

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

变成：

```go
db.Where(system.WhereCategoryKey(cat, k))
```

安全、驱动无关。

改动波及了大约 10 个文件——凡是涉及 `system` 表 `key` 的 `updateXxxConfigLogic.go` 都要改。每个文件一两行，但必须全部过一遍，不能遗漏。

### 6.5 查询函数的隐式副作用

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

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

```go
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 的事务隔离下，这种模式更容易造成并发写冲突。

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

```go
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*` 的函数，多看一眼它的函数体里有没有 `Update`、`Insert`、`Delete`。

* * *

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

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

### 7.1 搜索 Helper

```go
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 索引来加速包含匹配：

```sql
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.  迁移文件只迁移目录，内容不改。

新配置格式示例：

```yaml
Database:
  Driver: postgres
  Addr: localhost:5432
  Username: postgres
  Password: password
  Dbname: ppanel
  Config: sslmode=disable&TimeZone=Asia/Shanghai
```

也可以只写：

```yaml
Database:
  Addr: localhost:3306
  Username: root
  Password: password
  Dbname: ppanel
```

`Driver` 留空则默认为 MySQL。

* * *

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

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

```plaintext
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 留给你的一切便利假设。
