PostgreSQL 备份与恢复详细教程:用 Docker 实际还原一次数据库
先把三个检查结果放在前面:源库有 3 篇文章;备份恢复到另一只 PostgreSQL 容器后,3 篇文章和两条站点设置都一致;在恢复库新增文章,拿到的编号是 4,源库仍然只有 3 条。
这篇把整个过程写出来。除了生成备份文件,还会检查文件是否完整复制、目标库是否为空、表结构和内容是否回来,以及重复恢复时会发生什么。
这里使用演示数据,不连接博客正在使用的数据库。实际验证环境是 Docker Desktop 的 Linux 容器,PostgreSQL 17.11、Adminer 5.5.1;两只数据库容器使用独立数据卷,位于同一台 Docker 主机。这证明了导出和恢复流程,换云服务器时还要另外处理文件传输、数据库扩展和应用切换。
下面创建文件的命令按 Linux Shell 编写,适合在 Ubuntu、Debian 服务器的 SSH 终端执行。Windows 使用 WSL 终端时,也可以按相同方式操作。先确认 docker version、docker compose version 能正常返回,本文不重复安装 Docker。
配套文件:下载 Compose 配置与初始化 SQL。解压后把 .env.example 复制为 .env,再按下文步骤操作;归档里不包含任何线上数据库或真实口令。
1:准备一套独立的演示环境
这里会启动一个源数据库 db,一个查看数据的网页工具 adminer。恢复时再启动 restored_db,它有自己的数据卷,不会读写源库的卷。
先创建目录:
mkdir -p ~/pg-backup-demo/backups
cd ~/pg-backup-demo
umask 077后面的 Compose 命令都在这个目录执行。umask 077 用于限制新建文件的默认权限,备份里通常会有完整业务数据,不适合放进网站公开目录。
1.1:演示密码
创建 .env:
cat > .env <<'EOF'
BLOG_DB_PASSWORD=LabPg_20261016_x7
EOF
chmod 600 .env这是本文公开的演示口令,方便对照操作。真实环境请换成自己的随机密码,不要沿用它,也不要把真实 .env 提交到公开仓库。
1.2:Compose 配置
创建 compose.yaml:
cat > compose.yaml <<'EOF'
name: pg-backup-demo
services:
db:
image: postgres:17
environment:
POSTGRES_USER: blogdemo
POSTGRES_DB: blogdemo
POSTGRES_PASSWORD: ${BLOG_DB_PASSWORD:?请先创建 .env 并设置演示密码}
volumes:
- source_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U blogdemo -d blogdemo"]
interval: 3s
timeout: 3s
retries: 20
restored_db:
image: postgres:17
profiles: ["restore"]
environment:
POSTGRES_USER: blogdemo
POSTGRES_DB: blogdemo
POSTGRES_PASSWORD: ${BLOG_DB_PASSWORD:?请先创建 .env 并设置演示密码}
volumes:
- restored_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U blogdemo -d blogdemo"]
interval: 3s
timeout: 3s
retries: 20
adminer:
image: adminer:5
environment:
ADMINER_DEFAULT_SERVER: db
ports:
- "127.0.0.1:18982:8080"
depends_on:
db:
condition: service_healthy
volumes:
source_data:
restored_data:
EOF这里有几处需要先说明:
db和restored_db都没有ports,5432 不会被发布到宿主机;Adminer 通过 Compose 网络里的服务名连接它们。source_data与restored_data是两份不同的数据卷。恢复服务如果误用source_data,就失去了独立验证的意义。restored_db放在restoreprofile 中,普通启动不会把它一起拉起来。- 管理界面只绑定
127.0.0.1:18982。在云服务器部署时,需要通过 SSH 转发访问,后面会给命令。
健康检查特意带了 -h 127.0.0.1。官方镜像初始化期间会短暂启动只接受本地 Unix socket 的服务,检查 TCP 连接可以避开把这个临时启动阶段当作最终服务就绪的情况。
本例使用 PostgreSQL 17,对应的数据目录是 /var/lib/postgresql/data。不要把标签直接改成另一个大版本后继续套用旧数据卷;尤其 PostgreSQL 18 及以后的官方镜像有数据目录和挂载点调整,升级要按对应镜像文档处理。
镜像标签后续可能更新,本文记录的是本次实际使用的版本。需要重复相同环境时,可以通过 docker image inspect postgres:17 --format '{{json .RepoDigests}}' 记录摘要;Adminer 镜像也同样处理。
1.3:准备两张表
创建 seed.sql:
cat > seed.sql <<'EOF'
CREATE TABLE posts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
title text NOT NULL,
body text NOT NULL
);
INSERT INTO posts (slug, title, body) VALUES
('docker-nginx', 'Docker 反向代理配置', '入口 80,后端 8080。'),
('ssh-tunnel', 'SSH 本地端口转发', '只转发需要访问的端口。'),
('postgres-backup', 'PostgreSQL 备份演练', '备份后在另一只容器中恢复。');
CREATE TABLE site_settings (
key text PRIMARY KEY,
value jsonb NOT NULL
);
INSERT INTO site_settings VALUES
('site', '{"name":"演示博客","language":"zh-CN"}'),
('features', '{"comments":true,"page_size":10}');
EOFposts 有自增编号、主键和唯一的 slug;site_settings 放了两条 JSON 设置。这样恢复时除了数行数,还能检查中文、JSON、约束和序列。
这份 SQL 是初始化演示数据用的,只执行一次。再次执行会提示表已存在,不需要为此去删正式数据库。
2:启动源库,确认数据确实写进去了
docker compose config -q
docker compose up -d --wait db adminer
docker compose ps -aconfig -q 正常时没有输出。第二条会等待数据库健康检查通过;如果你的 Compose 不支持 --wait,先更新 Compose 插件,或者用 up -d 启动后检查 ps -a,等 db 显示 healthy 再继续。
首次使用需要拉取镜像。国内服务器如果停在镜像下载阶段,先处理 Docker Hub 的连接问题,或使用云厂商提供且自己信任的镜像服务。镜像还没下载完成时,改数据库密码、反复重启容器都不能解决下载超时。
把 SQL 文件复制进源库容器并执行:
docker compose cp seed.sql db:/tmp/seed.sql
docker compose exec -T db psql -U blogdemo -d blogdemo -v ON_ERROR_STOP=1 -f /tmp/seed.sqlON_ERROR_STOP=1 让脚本遇到 SQL 错误时停下,不要把一串错误夹在输出里,最后只看命令执行过了就继续备份。正常输出包含两次 CREATE TABLE,以及 INSERT 0 3、INSERT 0 2。
查询源库:
docker compose exec -T db psql -U blogdemo -d blogdemo -c "SELECT id, slug, title FROM posts ORDER BY id;"
docker compose exec -T db psql -U blogdemo -d blogdemo -c "SELECT key, value FROM site_settings ORDER BY key;"应当看到编号 1、2、3 的三篇文章,以及 features、site 两条设置。如果中文乱码,先核对终端和 SQL 文件是不是 UTF-8,别在还没确认原数据正确时就开始迁移。
在浏览器里看同一份数据
在运行 Docker 的电脑上打开 http://127.0.0.1:18982/,登录时填写:
.env 中的演示密码数据库blogdemo注意系统默认可能是 MySQL / MariaDB,要切到 PostgreSQL。服务器写 db,不要写 127.0.0.1,因为 Adminer 和数据库也在不同容器里。
如果 Docker 在云服务器上,在自己的电脑另开终端,建立转发:
ssh -N -o ExitOnForwardFailure=yes -L 127.0.0.1:18983:127.0.0.1:18982 your_user@203.0.113.10把用户名、示例 IP 换成自己的 SSH 信息。保持终端连接,电脑浏览器访问 http://127.0.0.1:18983/。不需要因此把数据库端口或管理页面开放到公网。
登录后,点击 posts 对应的“选择”,查看三条记录。
图 1:左上连接名为 db,文章表是初始化时写入的 3 条记录。
这个网页工具只用于核对结果,后面的备份与恢复由 PostgreSQL 自带工具完成,不依赖网页导出功能。
3:导出备份,并把文件拿到宿主机
3.1:先在容器里生成归档
docker compose exec -T db pg_dump -U blogdemo -d blogdemo -Fc -f /tmp/blogdemo.dump成功时通常没有输出。-Fc 表示自定义归档格式,要用 pg_restore 恢复;它不是可以直接交给 psql 执行的普通 SQL 文本。文件名写成 .sql 也不会改变实际格式。
这里把文件先写到容器里的 /tmp,避免把二进制归档通过宿主机 Shell 的文本重定向传出来。-T 关闭伪终端分配,方便脚本运行;真正的备份内容由 -f 直接写入文件。
使用源库容器内自带的 pg_dump,可以避免本机旧版客户端连接新版服务器时提示版本不匹配。它只导出指定的 blogdemo 数据库,角色、口令、整个集群的其他数据库,不会因此全部备份。
3.2:复制出来,检查大小和摘要
docker compose cp db:/tmp/blogdemo.dump backups/blogdemo.dump
chmod 600 backups/blogdemo.dump
ls -lh backups/blogdemo.dump
sha256sum backups/blogdemo.dump
docker compose exec -T db sha256sum /tmp/blogdemo.dump最后两条的 SHA-256 应当相同,说明容器里的归档与宿主机上的副本逐字节一致。本次这个小样本生成了 3675 字节的归档;版本、对象定义等变化会影响大小,不要求你的文件也是这个数字,更不能只看文件大小判断备份是否可用。
摘要比对只能检查复制前后是否一致,不能证明业务数据完整,也不能代替实际恢复。做完下一节才能知道目标环境是否接受这份归档。
/tmp/blogdemo.dump 不是数据卷里的文件。删除、重建源容器后,它可能消失,所以一定要确认宿主机的 backups/blogdemo.dump 已经拿到。备份如果只留在同一台服务器上,服务器磁盘损坏时也可能一起丢,长期保留的副本还应另存到独立备份位置。本文使用固定文件名便于对照;日常备份应按时间生成不同文件,并设置保留策略,避免每次覆盖唯一副本。
3.3:看一下归档目录
docker compose exec -T db pg_restore --list /tmp/blogdemo.dump本例能看到 posts、site_settings 的表定义与表数据,以及 posts 序列的 SEQUENCE SET 条目。看到文件能被识别、目录里有相应对象,再继续恢复。
这里仍不是一次完整的损坏检查:能够读出归档目录,不等于后面的每个数据块都一定能读取。恢复后的业务查询才是下一步验证。
4:把归档恢复到另一只空数据库容器
4.1:启动恢复实例
docker compose --profile restore up -d --wait restored_db
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -Atc "SELECT count(*) FROM pg_tables WHERE schemaname='public';"第一次启动时,第二条应该输出 0,表示 public schema 里还没有用户表。源库和恢复库的数据库名都叫 blogdemo,但所在服务不同:db 是源库,restored_db 是目标库。后面每条命令都要看清服务名。
如果输出不是 0,先停下来确认是不是之前已经恢复过。docker compose down 默认会保留数据卷,再次启动不等于拿到一个空数据库。不要为了让教程继续跑下去,直接去清空不确定用途的数据卷。
4.2:复制归档,再执行恢复
docker compose cp backups/blogdemo.dump restored_db:/tmp/blogdemo.dump
docker compose exec -T restored_db pg_restore -U blogdemo -d blogdemo --exit-on-error --single-transaction --no-owner --no-privileges /tmp/blogdemo.dump成功时同样可能没有输出。这几个参数分别负责:
--exit-on-error:遇到 SQL 错误就退出,不在报错后继续往下执行。--single-transaction:把这次恢复放进同一个事务,遇到错误时回滚这次事务中的改动。它不会帮你回滚数据库之外的文件,也不是整台服务器的快照。--no-owner:不要求重建原库的对象所有者,恢复对象由当前执行账户创建。--no-privileges:不照搬原库的 GRANT / REVOKE。迁移正式业务时,要单独按目标环境重建需要的账户权限。
本例的 blogdemo 账户由镜像初始化,有较高权限,方便演示。不要据此给正式应用账户也授予同样权限。换成受限备份账户时,需要确认它能读取所有要导出的对象;目标端也要有创建相应对象的权限。
5:核对数据,再验证一次新增操作
5.1:先看行数和中文
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -c "SELECT id, slug, title FROM posts ORDER BY id;"
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -c "SELECT key, value FROM site_settings ORDER BY key;"现在恢复库应当也是 3 篇文章、2 条设置。中文标题、JSON 里的站点名称和配置值都应该对得上。
只数行数还不够。两张表都各有三行,并不意味着每一列内容都相同。这个演示再把文章表的四列按 id 排序,导出成 CSV 后比较:
docker compose exec -T db psql -U blogdemo -d blogdemo -c "COPY (SELECT id,slug,title,body FROM posts ORDER BY id) TO STDOUT WITH CSV HEADER" > backups/source-posts.csv
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -c "COPY (SELECT id,slug,title,body FROM posts ORDER BY id) TO STDOUT WITH CSV HEADER" > backups/restored-posts.csv
diff -u backups/source-posts.csv backups/restored-posts.csv这里重定向的是文本 CSV,不是前面的二进制归档。diff 没有输出且退出码为 0,表示这两份查询结果相同。本次验证中,文章表四列与两条 JSON 设置分别比对通过。
换成自己的数据库,要选稳定排序的业务键、需要核对的字段,并覆盖相关表。这个小样本的比较结果不能代替真实系统里所有表、文件和业务流程的验收。
5.2:检查约束和自增编号
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -c "SELECT conname, contype FROM pg_constraint WHERE conrelid='posts'::regclass ORDER BY conname;"可以看到 posts_pkey 和 posts_slug_key,类型分别为 p(主键)和 u(唯一约束)。再向恢复库插入一篇新文章:
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -c "INSERT INTO posts (slug,title,body) VALUES ('restore-check','恢复后新增的文章','这条只写入恢复库。') RETURNING id;"本次返回编号 4。这比只看旧记录多检查了一步:恢复后的表不仅能读,接下来的自增编号也接得上。
注意上面的新增语句只执行一次。重复执行会触发 slug 唯一约束,而且失败的插入也可能消耗序列值;之后编号出现间隔不代表恢复损坏。
在 Adminer 里登出,再将服务器改成 restored_db,其他登录项保持相同。查看 posts,现在应有 4 条,最后一条正是刚新增的记录。
图 2:连接名已变成 restored_db。前三条来自备份,第 4 条是在恢复后新写入的,源库不会因此增加记录。
最后对两个实例分别数一遍:
docker compose exec -T db psql -U blogdemo -d blogdemo -Atc "SELECT count(*) FROM posts;"
docker compose exec -T restored_db psql -U blogdemo -d blogdemo -Atc "SELECT count(*) FROM posts;"结果应是 3 和 4。这能排除“两个连接实际上都在读同一个库”的一类误操作。真正迁移应用时,还要把应用连到目标实例,检查登录、读写等实际功能;本文没有修改任何线上应用连接。
6:恢复过程里几个容易误判的问题
6.1:重复恢复提示表已存在
如果在已经恢复好的目标库上,再执行一次第 4.2 节的 pg_restore,本例会报类似错误:
pg_restore: error: could not execute query: ERROR: relation "posts" already exists这时应该先核对目标库。前面使用 --single-transaction,本次重复恢复的事务会回滚,已有的 4 条文章仍在;源库仍是 3 条。这是本次实际检查过的结果。
不要看见“已存在”就直接补 --clean。该选项会尝试删除归档对应的现有对象,目标上如果已经接入业务、产生了新的数据,就可能被你覆盖。演练时应另选空目标;正式覆盖恢复则需要先明确停写、备份目标当前状态和恢复范围。
在网页里把第 5.2 节的新增语句重复执行,也会看到唯一约束报错。它说明这次尝试使用了已经存在的 slug,不需要为了消除错误去删约束。
图 3:重复插入 restore-check 时触发唯一约束,报错中能看到具体约束名和冲突值。
6.2:归档目录能看,恢复却报 end of file
为了确认前面的文件检查到底能查出什么,验证时另复制了一份归档,只截掉副本末尾的 100 字节。结果是 pg_restore --list 仍然能列出目录,真正恢复时却报:
pg_restore: error: could not read from input file: end of file这次故障使用的是另外新建的空数据库,恢复带了 --single-transaction。失败后查询 public schema,表数量仍是 0,没有留下恢复到一半的表。原始归档没有被修改,源库和已经验证好的恢复库也不受影响。
碰到这类错误,先比较传输两端的摘要,检查是不是拿错了文件、复制被中断或存储空间不足。容器里的归档仍在时,可以重新复制后比对;原始归档本身已经损坏时,应重新备份或改用其他完整副本。重命名扩展名、反复导入都不会把缺失的数据补回来。
不用在自己的唯一备份上重做这个截断实验。这里保留结果,是为了说明“能列目录”和“能完整恢复”是两件需要分别验证的事。
6.3:改了 .env,网页登录仍提示密码错误
官方 PostgreSQL 镜像的 POSTGRES_PASSWORD 在初始化空数据目录时设置口令。已经有数据卷后,修改 .env 再重启,不会自动改掉库中已有账户的口令。
先确认 Adminer 的系统选的是 PostgreSQL,服务器是 db 或 restored_db,数据库名和用户名也没有写反。如果用过旧数据卷,再核对初始化时设置的口令。不要因为登录失败就删卷,正式改口令应通过数据库的账户管理操作完成。
容器内的 psql 能连接,也不能单独证明网页里输入的密码正确。本例容器内部本地连接与 Adminer 跨容器 TCP 连接使用的认证路径不同,排查时要看失败的是哪一段。
7:接到自己的业务库之前,补查这几件事
数据库之外的文件。 pg_dump 不会顺带备份上传图片、配置文件、证书和对象存储内容。即使文章数据恢复了,附件目录没带过去,网站仍会出现缺图。
版本与扩展。 本例两端都是 PostgreSQL 17.11,源端工具也来自同一镜像。换版本迁移时先查工具兼容范围;目标需要的扩展和支持文件,也要预先准备。出现 extension ... is not available 时,先解决目标环境的扩展,不要把后续错误忽略掉。
恢复点之后的写入。 pg_dump 可以在源库继续服务时取得一致的数据库快照,但备份开始后的新修改不会神奇地同步到这份文件。正式切换前需要安排最终备份、停写窗口或合适的增量同步方案,避免新文章还写进旧库。
备份是否可取回。 至少保留日期、来源数据库、服务器与工具版本、文件大小和校验值。复制到备份位置后再核对摘要,定期拿副本做恢复演练。不要等原服务器已经坏了,才发现唯一的文件还留在那台服务器里。
8:结束演示,保留哪些东西
回到本文目录,停止演示服务:
cd ~/pg-backup-demo
docker compose --profile restore down它会停止并移除本文项目的容器和网络,默认保留两个命名数据卷,也保留宿主机目录中的归档和 CSV。后续重新启动时会沿用旧数据,不能再把它当作第一次初始化来重复执行建表脚本。
本文清理步骤不加 -v。只有确认这两个卷确实属于实验、里面的演示数据也不再需要时,再单独删除它们。不要为了收尾顺手运行清理整台 Docker 主机的命令。
如果用了 SSH 转发,最后关闭对应终端。自己的业务库还要继续迁移时,先保留源实例、归档和验证记录,再做应用切换。
参考文档:PostgreSQL 17 的 pg_dump、pg_restore、PostgreSQL 官方镜像、Adminer 官方镜像。