PostgreSQL 的大版本升级一直是个很大的槽点。旧版本的 PostgreSQL 数据文件不能直接使用新的大版本 PostgreSQL 打开,必须手动升级。常用的升级方式有:

  1. 使用 pg_upgrade 工具升级:很多情况下数据文件的改动并没有那么大,大部分实际的文件可以沿用,只需要升级一些结构,因此 pg_upgrade 可以比较快速地完成升级,但会造成 downtime,同时需要同时有新旧版本的 postgres 主程序,容器部署的会出问题。似乎在最新的 PostgreSQL 18;
  2. 使用 pg_dumpallpsql:先用前者把旧版本里整个数据库 dump 出来,再用后者执行到新版本里,但也有 downtime,并且比较慢;
  3. 使用逻辑复制:PostgreSQL 允许跨版本逻辑复制,因此建新库并配置为旧库的逻辑复制,等新库跟上后切换过去即可;

这次想升级自己的 Misskey 与 Matrix 的 PostgreSQL 数据库,因此分别使用了 3、2 两种方法。1、2 方法相对简单,并且前人之述备矣,因此就不写了,这里单独写写使用逻辑复制的经验。同时此例子不仅适用于大版本升级,亦适用于 PostgreSQL 的迁移,不过后者需要保证网络安全,例如套层隧道。

环境

笔者的 Misskey 安装直接继承了 Misskey 官方的配置,因此 PostgreSQL 是部署在 Docker Compose 项目中一个容器里的。旧的 PostgreSQL 版本为 16,升级目标版本为 18。

升级的数据量大概有不到 10 GiB,最大的几张表为 note 与 drive_file,均在百万行、GB 级别。

概要

  1. 启用 Logical WAL Level
  2. 启动临时的新版数据库容器
  3. 从旧数据库导出 schema 等 DDL 到新数据库
  4. 配置 Publication/Subscription
  5. 等待初期表格同步完毕
  6. 切换新旧数据库
    • 将旧数据库只读或退出应用服务器
    • 等待新数据库完成追上旧数据库
    • 将旧数据库的 sequence 信息导入新数据库
    • 停止新旧服务器,交换两者数据文件夹
    • 修改 compose 文件,升级其中的数据库版本
    • 从 compose 启动数据库检查是否能成功打开数据库
    • 重启应用服务器
  7. 检查应用是否工作正常
  8. 删除 Subscription、删除或归档旧数据库文件

过程

首先编辑 postgres.conf 文件,将 WAL 等级设置为逻辑,这样后面才能启用逻辑复制。如果本来没有用任何配置文件的话,需要自行从容器里面把这个文件捞出来,再通过挂载加 postgres -c config_file=挂进去的路径 的方法指定。

wal_level = logical

(重启后在 psql 中)
misskey=# show wal_level;
 wal_level 
-----------
 logical
(1 row)

启动一个临时的 PostgreSQL 18 实例,挂载一个新的数据目录,并使用相同的 postgres.conf

docker container run \
--volume ./db2:/var/lib/postgresql/18/docker \
--volume ./postgres.conf:/etc/postgres.conf \
--rm \
-e POSTGRES_PASSWORD=Misskey设置里面的数据库密码 \
-e POSTGRES_USER=misskey \
-e POSTGRES_DB=misskey \
--network=misskey_internal_network \
postgres:18 postgres -c config_file=/etc/postgres.conf

这里注意几点:

  1. 挂的新的数据目录,不能与先前版本为同一目录;
  2. 通过挂载+-c 指定了配置文件;
  3. 网络指定了 misskey_internal_network,在这个例子中网络名是通过 docker network ls 得到的,应该与 Misskey 的 compose.yml 中指定的内部网络(即旧数据库所在网络)一致。不过读者应当按自己的情况调整网络配置来保证新容器中的 PostgreSQL 能访问到旧数据库。

打开另一个终端窗口,先从旧数据库中拷出来数据库结构 DDL 导入到新数据库:

# 需要修改默认用户名与容器名
docker container exec misskey-db-1 pg_dumpall --globals-only -U misskey > roles.sql  # 对整个实例执行一次
docker container exec misskey-db-1 pg_dumpall --schema-only -U misskey > schema.sql  # 对每个 schema 执行
docker container exec -i 新数据库容器psql -U misskey < roles.sql
docker container exec -i 新数据库容器 psql -U misskey < schema.sql

其次使用 docker exec -it misskey-db-1 psql -U misskey 进入旧的容器的 psql,同样请根据自己的情况调整容器名与数据库名。在 psql 中,创建一个专门用于迁移的用户 migrator 与一个数据 PUBLICATION:

CREATE USER migrator WITH PASSWORD 'misskey' REPLICATION;
GRANT pg_read_all_data TO migrator;
CREATE PUBLICATION upgrade_pub FOR ALL TABLES;
\q

再使用 docker exec -it 刚刚新建的新容器名 psql -U misskey 进入新容器,同样根据自己情况调整命令。在 psql 中,创建指向旧数据库的 SUBSCRIPTION:

CREATE SUBSCRIPTION upgrade_sub_postgres
CONNECTION 'host=db user=migrator dbname=misskey password=misskey'
PUBLICATION upgrade_pub WITH (disable_on_error = true);

当然,需要根据需求修改 hostdbnamepassword 等的值。此时需要观察先前 docker run 中的日志,如果没有报错则新数据库应该已经开始向旧数据库拉取,从日志应该能看到一个个表被同步过来。

有几种方法可以检查同步有没有正常进行、有没有完成,首先可以如此查看当前正在进行的同步:

misskey=# SELECT datname, relid::REGCLASS AS table_name, bytes_processed, bytes_total FROM pg_stat_progress_copy;
 datname | table_name | bytes_processed | bytes_total 
---------+------------+-----------------+-------------
 misskey | note       |       472128681 |           0
 misskey | drive_file |       582139504 |           0                         
(2 rows)  

同步分为两个阶段,第一个阶段会将 SUBSCRIPTION 创建之前的数据通过类似 COPY 语句的机制拷贝到新数据库,因此上述命令会返回值,表示初始传送还没有完成。第二个阶段是后续不断地将 publisher 写进去的新数据同步到 subscriber。第一阶段完成后上述操作应该显示零行输出。另一种方法是读取 pg_stat_subscription

misskey=# \x on    -- 修改结果展示方式
misskey=# select * from pg_stat_subscription;        
-[ RECORD 1 ]---------+------------------------------
subid                 | 19741
subname               | upgrade_sub_postgres
worker_type           | table synchronization
pid                   | 212
leader_pid            | 
relid                 | 17926
received_lsn          | 
last_msg_send_time    | 2025-10-22 09:03:55.573832+00
last_msg_receipt_time | 2025-10-22 09:03:55.573832+00
latest_end_lsn        | 
latest_end_time       | 2025-10-22 09:03:55.573832+00
-[ RECORD 2 ]---------+------------------------------
subid                 | 19741
subname               | upgrade_sub_postgres
worker_type           | apply
pid                   | 139
leader_pid            | 
relid                 | 
received_lsn          | F4/80E05ED0
last_msg_send_time    | 2025-10-22 09:27:31.514561+00
last_msg_receipt_time | 2025-10-22 09:27:31.515095+00
latest_end_lsn        | F4/80E05ED0
latest_end_time       | 2025-10-22 09:27:31.514561+00

注意到还存在一个 table synchronization 状态的 worker,说明表格的第一阶段同步还没有完成。

当表格第一阶段同步全部完成,同时第二阶段同步也基本追上来后,就到了进行 switchover 的时候了,也是此系统极短的 downtime 时刻。事实上可以通过自动化 + pgBouncer 将这个过程的 downtime 降到最低,不过笔者手动做了。这一步要做的事的概要已经在前文写过了,而使用 pgBouncer 的做法在参考文献里面有写。

首先将旧数据库只读,退出应用数据库或设置 pgBouncer 停止写入,此时进入 downtime。其次等待新数据库追上来,此处笔者忘了保存输出。

-- 首先在旧数据库(publish 端)查看最后一个 WAL LSN
misskey=# select pg_current_wal_lsn();

-- 再在新数据库(subscribe 端)查看当前的 WAL LSN 是否一致
misskey=# select * from pg_stat_subscription;

-[ RECORD 1 ]---------+------------------------------
subid                 | 19741
subname               | upgrade_sub_postgres
worker_type           | apply
pid                   | 139
leader_pid            | 
relid                 | 
received_lsn          | F4/80EB8758
last_msg_send_time    | 2025-10-22 09:27:56.641478+00
last_msg_receipt_time | 2025-10-22 09:27:56.648834+00
latest_end_lsn        | F4/80EB8758
latest_end_time       | 2025-10-22 09:27:56.641478+00

读写量不大的数据库应当很快就完成同步。接下来导入旧数据库中的序列数据(用于自增字段等),这一部分无法被 replicate,同时最新 sequence 也不在最初的 DDL 中:

-- 在旧数据库的 psql 上
misskey=# COPY (
  SELECT format('SELECT setval(''%I.%I'', %s);',
           schemaname, sequencename, last_value + 1000
         )
    FROM pg_sequences where last_value is not null
) TO '/tmp/sequences-postgres.sql';

再退到主机上将其拷出来并应用在新数据中(注意容器名)
docker container cp misskey-db-1:/tmp/sequences-postgres.sql .
docker container exec -i 新数据库容器psql -U misskey < sequences-postgres.sql # 当然这步也能直接 copy paste 到新数据库的 psql 窗口

停止新旧两个数据库容器(使用 docker container stop 等,不要强停),并交换新旧数据文件名(例如 mv db db_old; mv db2 db),再修改 docker compose 等“正式配置”中的服务器版本到新版,最后使用 docker compose up db 单独创建并启动新版数据库,检查其是否能正常启动。这步如果用 pgBouncer 做可能没法那么“优雅”地和 compose 融为一体,但对于传统的直接在系统上装数据库的情况,pgBouncer 一下就能完成切换,只需要更新数据库地址端口并重新启用写入即可。

最后,重启应用服务器,并检查是否正常读取写入数据库。成功后,进入正式数据库的容器,删除 subscription。

ALTER SUBSCRIPTION upgrade_sub_postgres DISABLE;
ALTER SUBSCRIPTION upgrade_sub_postgres SET (slot_name = NONE);
DROP SUBSCRIPTION upgrade_sub_postgres;

数据库就升级好了。

参考文献