Docker PostgreSQL 15 升级 18:一次耗时三小时的全实例迁移记录

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
PostgreSQLDockerpg_upgrade数据库选型

我原本只是想做一件很普通的事:在 pgAdmin 4 中备份一个数据库,在同一个 PostgreSQL 15 实例中新建数据库,再把备份还原进去。

结果还原日志先报错,随后问题一路延伸到客户端工具版本、PostgreSQL 主版本升级、Docker 数据目录和第三方升级镜像。前后折腾了两三个小时。期间有一次 PostgreSQL 18 看起来已经启动,实际却是一个只有默认 postgres 数据库的空实例。

这次经历也动摇了我之前对数据库选型的判断。我知道 PostgreSQL 功能很强,但当一次常规升级需要在图形工具、命令行程序、容器目录和外部镜像之间来回确认时,“功能更强”并不能抵消全部成本。

起点只是一次 pgAdmin 4 还原

最早看到的两条错误是:

ERROR: unrecognized configuration parameter "transaction_timeout"
Command was: SET transaction_timeout = 0;

ERROR: schema "public" already exists
Command was: CREATE SCHEMA public;

后面的表、数据、序列、索引和外键仍然继续恢复,最后是:

pg_restore: warning: errors ignored on restore: 2
pg_restore: error: utility failed with exit code: 1

第二条比较直观。新数据库默认已经有 public schema,备份又尝试创建一次,所以报已存在。

第一条更绕。虽然备份和还原都在同一个数据库实例中操作,pgAdmin 4 实际调用的是安装在本机的 pg_restore,它不一定和服务端 PostgreSQL 版本相同。较新的客户端工具生成了 PostgreSQL 15 不认识的会话参数,错误于是出现在还原日志里。

更让我不适应的是,工具版本不在还原窗口里选择。pgAdmin 4 把 pg_dumppg_dumpallpg_restorepsql 的路径放在:

File → Preferences → Paths → Binary paths

官方文档也说明了 pgAdmin 会在这里按 PostgreSQL 版本寻找本地工具。这个设计不能说错,但它很难让人从“同一实例内备份和还原”联想到“本地二进制版本不一致”。错误发生在还原窗口,真正要检查的设置却藏在全局首选项里。

我决定直接从 PostgreSQL 15 升到 18

当时的 Compose 配置很普通:

postgres:
  image: postgres:15-alpine
  container_name: server_postgres
  restart: always
  ports:
    - "5432:5432"
  volumes:
    - ./data/postgres/data:/var/lib/postgresql/data

数据库没有自定义扩展,也没有复杂的复制拓扑。我想要的是实例升级,不是导出一个数据库再手动拼回角色、权限和其他数据库。因此我没有把 pg_dumppg_dumpall 当成主方案,而是选择 pg_upgrade

PostgreSQL 官方支持直接跨主版本升级,不要求依次经过 16 和 17。pg_upgrade 的作用就是在不做完整逻辑导入导出的情况下升级整个 cluster。这里的 cluster 指一个 PostgreSQL 实例,包括其中的多个数据库和系统目录,不是某一个单独数据库。

理论上这正好符合我的需求。真正麻烦的是 Docker 官方镜像不会看到旧数据目录后自动替你运行 pg_upgrade。工具需要同时访问旧版本和新版本的可执行文件、旧数据目录和新数据目录,官方镜像的目录约定在 PostgreSQL 18 又发生了变化。

PostgreSQL 18 改了 Docker 数据目录

PostgreSQL 17 及以前,官方 Docker 镜像通常把持久化目录挂载到:

/var/lib/postgresql/data

从 PostgreSQL 18 开始,默认 PGDATA 变成带主版本号的路径:

/var/lib/postgresql/18/docker

镜像声明的 volume 也上移到:

/var/lib/postgresql

这个变化是为了让不同主版本的数据目录可以共存,也方便 pg_upgrade --link 使用同一个挂载根目录。从长期设计看有道理。对正在把旧 Compose 从 15 改到 18 的人来说,它会立刻改变宿主机目录结构。

我第一次处理时就生成了这样的路径:

/root/device/server/data/postgres/18/18/docker

我以为外层 18 是最终版本目录,镜像又按照自己的 PGDATA 在里面创建了一层 18/docker。路径没有先算清楚,结果就多套了一层。

最危险的一次失败:空库看起来已经启动成功

后来使用 pgautoupgrade 封装 pg_upgrade,第一次执行时把宿主机的这个目录:

/root/device/server/data/postgres

挂载到了容器:

/var/lib/postgresql

但真实的 PostgreSQL 15 版本文件位于:

/root/device/server/data/postgres/data/PG_VERSION

也就是说,升级容器看到的是:

/var/lib/postgresql/data/PG_VERSION

而升级镜像检测旧实例时需要挂载根目录直接包含 PG_VERSION=15。我多挂了一层。它没有识别到旧实例,也就没有执行我以为正在执行的升级。

日志却很容易让人放松警惕:

PostgreSQL Database directory appears to contain a database; Skipping initialization
database system is ready to accept connections

容器正常运行,PostgreSQL 18 也能接受连接。但这只能证明某个 PostgreSQL 18 实例启动了,不能证明 PostgreSQL 15 已经升级。

接下来出现了两个更直接的信号:

  • 原来的数据库全部不见了,只剩默认的 postgres
  • 命令中临时填写的 POSTGRES_PASSWORD=unused 真的成了新实例密码。

unused 原本只是为了满足升级镜像的环境变量要求。如果旧实例被正确识别,pg_upgrade 会迁移已有角色和密码,这个值不会接管最终实例。它实际生效,说明镜像初始化了空数据库。

到这里可以确认:这不是“升级后数据丢了”,而是根本没有发生升级。

原 PostgreSQL 15 数据还在

当时最重要的检查不是继续猜密码,而是找出所有 PG_VERSION

find /root/device/server/data/postgres \
  -maxdepth 5 \
  -type f \
  -name PG_VERSION \
  -print \
  -exec cat {} \;

原实例仍然位于:

/root/device/server/data/postgres/data/PG_VERSION
15

旧数据没有被 PostgreSQL 18 转换,也没有被覆盖。这个结果至少保住了回退条件,但我需要的从来不是“恢复 PostgreSQL 15”。我知道怎样把旧容器重新启动。问题是跑了一遍所谓升级流程,却只得到一个空 PostgreSQL 18,然后流程就断了。

最后收敛为升级副本

我不愿意直接在唯一一份 PostgreSQL 15 数据上继续试。最后保留的目录结构是:

/root/device/server/data/postgres/data       # 原 PostgreSQL 15
/root/device/server/data/postgres/upgrade    # 临时升级副本
/root/device/server/data/postgres/18/docker  # PostgreSQL 18 最终数据

整个过程只操作 upgrade。失败时删除临时副本即可,原来的 data 不动。

停止服务并确认版本

cd /root/device/server
docker compose stop postgres

cat /root/device/server/data/postgres/data/PG_VERSION

预期输出:

15

所有会写数据库的业务服务也要先停止,否则复制出来的数据目录可能不是一致状态。

复制原数据目录

先确认临时目录和最终目录不存在:

test ! -e /root/device/server/data/postgres/upgrade
test ! -e /root/device/server/data/postgres/18

再复制 PostgreSQL 15 数据:

cp -a \
  /root/device/server/data/postgres/data \
  /root/device/server/data/postgres/upgrade

这里必须再次检查:

cat /root/device/server/data/postgres/upgrade/PG_VERSION

预期仍然是 15。这一步看似重复,却能直接阻止之前那种“挂载根目录不含 PG_VERSION”的问题。

对副本执行升级

docker run --rm \
  --name postgres_upgrade_15_to_18 \
  --mount type=bind,source=/root/device/server/data/postgres/upgrade,target=/var/lib/postgresql \
  -e POSTGRES_PASSWORD=unused \
  -e PGAUTO_ONESHOT=yes \
  pgautoupgrade/pgautoupgrade:18-alpine

这一次,容器内的路径应当直接成立:

/var/lib/postgresql/PG_VERSION = 15

不能只看容器退出码或“数据库可连接”。日志必须明确出现类似下面的升级阶段:

Performing PG upgrade on version 15 database files. Upgrading to version 18
Running pg_upgrade command is complete
Upgrade to PostgreSQL 18 complete

如果没有这些内容,就不能继续切换正式数据库。

检查 PostgreSQL 18 结果

cat /root/device/server/data/postgres/upgrade/18/docker/PG_VERSION

预期输出:

18

成功后再把完整版本目录移动到最终位置:

mv \
  /root/device/server/data/postgres/upgrade/18 \
  /root/device/server/data/postgres/18

最终应当存在:

/root/device/server/data/postgres/18/docker/PG_VERSION

原 PostgreSQL 15 数据仍保留在 /root/device/server/data/postgres/data,没有必要急着删除。

切换 Compose

postgres:
  image: postgres:18-alpine
  container_name: server_postgres
  restart: always
  ports:
    - "5432:5432"
  volumes:
    - ./data/postgres:/var/lib/postgresql

然后重新创建数据库容器:

docker compose up -d --force-recreate postgres

全实例升级不能只检查版本号

SELECT version() 返回 PostgreSQL 18,只能证明服务端版本。之前那个空实例同样可以通过这项检查。

我现在会把验收拆开:

docker exec server_postgres \
  psql -U postgres -d postgres \
  -c 'SELECT version();'

docker exec server_postgres \
  psql -U postgres -d postgres \
  -c '\l'

docker exec server_postgres \
  psql -U postgres -d postgres \
  -c '\du'

至少要确认这些内容:

  • 服务端确实是 PostgreSQL 18;
  • 原实例中的所有数据库都存在,不是只剩 postgres
  • 角色和登录密码没有被临时环境变量替换;
  • 关键 schema、表和记录数符合预期;
  • 应用使用原连接配置可以正常读写。

如果使用了扩展,还要检查 pg_extension,并确认 PostgreSQL 18 环境中安装了对应版本。pg_upgrade 会检查二进制兼容性,但它不能替你准备所有外部模块。

为什么一个普通升级花了两三个小时

单看最后的命令,升级似乎不长。时间主要耗在确认每一层到底做了什么。

pgAdmin 4 的还原窗口没有直接告诉我当前调用的是哪个版本的 pg_restore。Docker 官方镜像只负责初始化和启动 PostgreSQL,不负责跨主版本升级。pg_upgrade 是官方工具,却要求同时准备两套二进制和两个数据目录。为了把它放进容器,又要理解第三方升级镜像的目录检测逻辑。最后,PostgreSQL 18 还修改了官方镜像的 PGDATA 约定。

这些东西分别都有文档。问题在于它们没有形成一个顺手的升级体验。某一步目录挂错,工具不一定直接告诉你“没有找到 PostgreSQL 15”;它可能继续初始化一个新实例,并给出“ready to accept connections”这种完全正确、但对升级目标没有意义的日志。

这也是我对 PostgreSQL 工具生态最不满意的地方。可用工具并不是没有,但它们更像一组需要操作者自己拼起来的零件。

pgAdmin 4 和 MySQL Workbench 的差距

我以前使用 MySQL Workbench 时,并不觉得它完美。这个博客里也记录过一次 Docker 更新 MySQL 后无法启动。数据库升级本来就有风险,MySQL 当然也会出问题。

但从日常开发和管理体验看,我仍然认为 MySQL Workbench 更完整。连接管理、SQL 开发、Server Administration、数据建模和 Migration Wizard 都在同一个产品里。它至少让我知道自己正在使用哪套能力,以及下一步应该去哪里。

pgAdmin 4 给我的感觉完全不同。界面能完成查询、备份和还原,但真正执行工作的经常是外部 PostgreSQL 二进制。版本路径放在另一个设置页面,主版本升级又回到命令行和容器。遇到问题后,我很难只沿着一个工具把原因查到底。

所以我不太认同“PostgreSQL 明显优于 MySQL”这种笼统结论。至少在我实际用到的功能范围里,MySQL 没有表现出明显不足;相反,它的官方图形工具更符合我对快速开发的预期。

我开始怀疑当初的数据库选型

现在很多新项目默认推荐 PostgreSQL。理由不难理解:它有丰富的数据类型、扩展能力、较完整的 SQL 功能,也被大量云数据库和开发平台作为默认选项。

这些优点都是真的,但它们不一定是每个项目最重要的指标。

如果项目主要是常规业务表、索引、事务和分页查询,没有依赖 PostgreSQL 扩展,也没有大量使用它独有的数据能力,那么真正影响开发速度的可能是另一组问题:团队是否熟悉运维工具,备份还原是否直观,主版本升级要花多少时间,出现问题时能否快速定位。

我的项目由自己维护,数据库跑在 Docker 中,也没有托管平台替我处理升级。放在这个条件下回头看,当初选择 PostgreSQL 未必是最合适的决定。它在功能上没有错,但我没有充分使用那些优势,却承担了工具和运维上的额外成本。

这不等于现在应该立刻迁回 MySQL。已经运行的系统还有 schema、SQL、驱动、测试和数据迁移成本。我的结论更简单:如果重新做一个强调快速交付、业务模型常规的小项目,我不会再因为“现在大家都用 PostgreSQL”就直接选它。我会先确认项目是否真的需要 PostgreSQL,再比较 MySQL 带来的工具熟悉度和维护成本。

数据库选型不是一场功能列表比赛。对小团队和个人项目来说,能不能稳定备份、升级和排障,同样属于数据库能力的一部分。

参考资料