Docker PostgreSQL 15 升级 18:一次耗时三小时的全实例迁移记录
我原本只是想做一件很普通的事:在 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_dump、pg_dumpall、pg_restore 和 psql 的路径放在:
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_dump 或 pg_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 带来的工具熟悉度和维护成本。
数据库选型不是一场功能列表比赛。对小团队和个人项目来说,能不能稳定备份、升级和排障,同样属于数据库能力的一部分。