前言
上一篇文章我们在境内服务器上部署了一台 PostgreSQL 主库,并接入了 Prometheus + Grafana 监控。
现在数据量越来越大,我希望能再起一台从库,一方面做异地灾备,另一方面也可以分担一些只读查询的压力。
由于主库虽然有公网 IP 但已经通过防火墙关闭了所有对外端口,正好可以借助 Tailscale 内网把两台机器打通,再通过 PostgreSQL 的流复制 (Streaming Replication) + 物理复制槽 (Physical Replication Slot) 实现异步同步。
方案概述
前置条件:主库已经按照 部署 PostgreSQL 并结合 Prometheus 和 Grafana 进行监控 一文部署完成,且两台机器已经加入同一个 Tailscale 网络。
| 角色 | 配置 | 说明 |
|---|---|---|
| 主库 | 4H12G + 1TB NVMe | 有公网 IP 但通过防火墙关闭了所有端口,仅 Tailscale 内网可达 |
| 从库 | 4H8G + 100GB SSD | 内存约为主库的一半,硬盘性能相近 |
步骤概览:
- 修改主库的复制相关配置(
wal_level、max_wal_senders、max_slot_wal_keep_size、wal_compression等) - 主库创建专用复制用户和物理复制槽,并放行 Tailscale 网段的
replication连接 - 在从库服务器上以相同方式安装
PostgreSQL 18 - 通过
pg_basebackup经Tailscale内网拉取主库基础数据 - 启动从库并验证主从同步
- 根据从库的资源情况调优配置
选用 物理复制槽 而不是
wal_keep_size是因为:复制槽能保证 WAL 不被过早回收,断线后可以续传;同时配合max_slot_wal_keep_size又能限制主库为复制保留的 WAL 上限,避免从库长期掉线把主库磁盘塞满。
操作步骤
一、修改主库配置
上一篇文章里我们为了纯单机性能把 wal_level = minimal、max_wal_senders = 0,现在要做流复制就必须改回去,并且额外开启复制槽相关的参数。
1、修改 wal_level、max_wal_senders、max_replication_slots
nano /etc/postgresql/18/main/postgresql.conf
# 物理复制只需要 replica 级别
wal_level = replica
# 允许从库通过 walsender 连过来,预留几个
max_wal_senders = 10
# 允许创建的复制槽数量上限
max_replication_slots = 10
2、设置 max_slot_wal_keep_size 限制 WAL 保留上限
这一项是为我们需求量身定制的。主库每 60 秒大约会产生 60MB 的 WAL(约 1MB/s),如果从库断线后主库还无限制地保留 WAL,磁盘很快就会被吃掉。
通过 max_slot_wal_keep_size 设置上限后,当某个复制槽要求保留的 WAL 超过这个值时,该槽会被自动标记为 lost(无效),主库就会正常回收 WAL:
max_slot_wal_keep_size = 10GB
按 1MB/s 的产生速度,10GB 大约能容忍约 10240 / 60 ≈ 170 分钟(约 2.8 小时)的断线。超过这个时间从库就需要重新 pg_basebackup 一遍,我个人觉得这个取舍是合适的。
3、开启 wal_compression 压缩 WAL 减少复制流量
PostgreSQL 18 支持用 lz4 压缩 WAL 中的整页镜像 (full page writes, FPW),能在几乎不增加 CPU 负担的前提下减少一部分 WAL 体积,从而降低复制流量:
# 用 lz4 压缩 WAL 中的整页镜像,几乎不占 CPU
wal_compression = lz4
需要注意
wal_compression只压缩整页镜像 (FPW) 这一部分,不会压缩全部 WAL。具体能省多少取决于负载里 FPW 的占比——checkpoint后第一次改动某个页面时才会写 FPW,所以写入越集中、checkpoint间隔越长,FPW 占比越高,压缩收益越大;反之如果是大量追加型 INSERT、FPW 占比本就不高,收益会比较有限。实际效果建议改完后实测对比一下。另外
lz4需要编译时启用支持(官方源的包默认带),也可以用压缩率更高的zstd,代价是多花一点 CPU。这个参数reload即可生效,不必重启。
如果想验证它实际省了多少,可以在改之前和改之后各测一次每分钟的 WAL 产出,用同一段方法对比:
SELECT pg_current_wal_lsn(); -- 记下返回值,等 60 秒
SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '上一步的LSN'));
测的时候要让窗口跨过至少一次
checkpoint,否则窗口内没产生 FPW,压不压都看不出差别。可以先手动CHECKPOINT;再开始计时。如果光靠压缩还压不下来,进一步降流量的方向通常是:拉长
checkpoint_timeout(减少 FPW 总量)、清理高写入表上几乎不用的冗余索引(每个索引更新都要写 WAL),或者评估改用逻辑复制(只传行级变更,不传 FPW / 索引内部结构 / vacuum 噪声)。这些往往比单纯压缩更能压低总流量,但限制和取舍也更多,按需取舍。
4、拉长 checkpoint 间隔进一步减少 FPW
接着上一节的思路。wal_compression 是”WAL 产出后再压”,而拉长 checkpoint 间隔则是”从源头少产 WAL”——对物理复制的流量往往更管用。
原理是:FPW(整页镜像)只在每次 checkpoint 后第一次改动某个页面时才会写一份。checkpoint 越频繁,同一个热点页就被反复写整页镜像越多次;把间隔拉长,等于让更多改动摊薄到一次 FPW 上,FPW 总量随之下降。
checkpoint_timeout = 30min # 默认 5min,拉长到 15~30min
checkpoint_completion_target = 0.9 # 让刷盘在间隔内平缓摊开,避免 IO 尖峰
max_wal_size = 8GB # 必须配套调大,详见下
min_wal_size = 2GB # 顺手调大,减少 WAL 文件频繁回收/新建
这里的关键是 max_wal_size 一定要配套调大。checkpoint 有两个触发条件,谁先到触发谁:一是时间到了 checkpoint_timeout,二是 WAL 累积量逼近 max_wal_size。如果 max_wal_size 太小,遇到写入峰值时 WAL 会先撑满、提前触发 checkpoint,那你拉长的 checkpoint_timeout 根本到不了,减 FPW 的效果就白费了。所以要把 max_wal_size 留足,确保 checkpoint 是由时间触发而不是被 WAL 量逼着提前做。
一个常见的疑问:拉长
checkpoint间隔会不会让从库的复制延迟变大?不会。 复制延迟取决于 WAL 多快被传输和回放,而 WAL 是在事务提交时实时写出、立即推给从库的,跟checkpoint无关。checkpoint管的是主库把脏页刷到自己的数据文件,是本地持久化动作,和”数据多久同步到从库”是两条线。拉长
checkpoint唯一的代价是主库崩溃后的恢复时间会变长(要从上一个checkpoint开始重放更多 WAL),日常运行的复制实时性、数据安全性都不受影响。对”想省上传流量”这个目标来说,这笔取舍通常很划算。另外注意
max_wal_size是软上限,实际占用峰值可能短时超过它,调大前要确认pg_wal所在分区放得下,留足余量。
这几个参数都是 reload 级,不用重启:
sudo -u postgres psql -c "SELECT pg_reload_conf();"
sudo -u postgres psql -c "SHOW checkpoint_timeout; SHOW max_wal_size; SHOW checkpoint_completion_target;"
改完后建议确认 checkpoint 确实是按时间触发的(而不是被 WAL 量提前逼出来)。PostgreSQL 18 默认 log_checkpoints = on,直接看日志即可:
sudo tail -f /var/log/postgresql/postgresql-18-main.log | grep -i checkpoint
看到 checkpoint starting: time 就对了,说明是间隔到点触发的;如果看到 checkpoint starting: wal,说明 WAL 量先撑到了上限、提前触发了,意味着 max_wal_size 在你的峰值下还不够,需要再往上提。
验证流量降幅时,要让新间隔实际跑过至少两个
checkpoint周期(约 1 小时)再用上一节那段测 WAL 的方法量,否则旧节奏还没切换过来,测不准。测量窗口也尽量落在两次checkpoint中间、别紧贴checkpoint(刚做完那段 FPW 最密集,会偏高)。
5、放宽 wal_sender_timeout 适配 Tailscale 网络抖动
wal_sender_timeout 是主库 walsender 进程的心跳超时,默认 60s。在 Tailscale 网络下,DERP 中转、节点休眠唤醒、移动网络切换等情况都可能造成几十秒级别的延迟,按默认值容易触发不必要的重连。这里放宽到 180s:
wal_sender_timeout = 180s
从库侧对应的
wal_receiver_timeout也要一起调大保持一致,那部分放在 “四、从库的资源适配优化” 里再处理。
6、配置 pg_hba.conf 放行 replication 连接
我现在的 pg_hba.conf 末尾已经有:
host all all 0.0.0.0/0 scram-sha-256
host all all ::/0 scram-sha-256
但需要注意:pg_hba.conf 里 replication 是一个特殊的伪库名,all 并不会匹配 replication 连接,必须显式新增一条规则。
Tailscale 默认的 IPv4 网段是 100.64.0.0/10,我们只放行这个网段的复制连接:
nano /etc/postgresql/18/main/pg_hba.conf
# 在尾部追加,只允许 Tailscale 内网的 replicator 用户做复制
host replication replicator 100.64.0.0/10 scram-sha-256
7、重启主库使配置生效
wal_level 和 max_wal_senders 的修改必须重启才能生效,下一步的 “创建物理复制槽” 又依赖这些参数已经生效,所以在创建用户和复制槽之前先重启一次:
sudo systemctl restart postgresql@18-main.service
sudo systemctl status postgresql@18-main.service
重启完成后顺手确认一下新的 wal_level 已经生效:
sudo -u postgres psql -c "SHOW wal_level;"
# 应该输出 replica
8、创建专用复制用户
登录主库:
sudo -u postgres psql
-- REPLICATION 角色属性是关键
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replicator_password';
9、创建物理复制槽
仍然在 psql 中:
SELECT pg_create_physical_replication_slot('replica_slot_1');
查看一下:
SELECT slot_name, slot_type, active FROM pg_replication_slots;
此时 active 应为 f(因为还没有从库连上来),slot_type 为 physical。
二、在从库上安装 PostgreSQL 18
完全沿用上一篇文章 部署 PostgreSQL 并结合 Prometheus 和 Grafana 进行监控 中 “安装 PostgreSQL 数据库” 一节的步骤,从官方源装一个 18 版本即可:
sudo apt install postgresql-18
安装完成后不需要做任何 postgresql.conf / pg_hba.conf 的初始化(包括 listen_addresses),因为这些配置很快就会被 pg_basebackup 从主库拷贝过来的版本整体覆盖。
三、使用 pg_basebackup 初始化从库
1、停止从库的 PostgreSQL 服务
sudo systemctl stop postgresql@18-main.service
2、清空数据目录
pg_basebackup 要求目标目录为空:
sudo -u postgres rm -rf /var/lib/postgresql/18/main/*
3、通过 Tailscale 内网执行 pg_basebackup
sudo -u postgres pg_basebackup \
-h <your-master-tailscale-hostname> \
-U replicator \
-D /var/lib/postgresql/18/main \
-P -v -R \
-X stream \
-S replica_slot_1 \
-c fast
执行时会提示输入 replicator 用户的密码。
几个参数说明:
| 参数 | 含义 |
|---|---|
-h | 主库地址。可以是 Tailscale IP(如 100.x.x.x),也可以直接用 Tailscale MagicDNS 的主机名 |
-R | 写入 standby.signal 文件,并把 primary_conninfo、primary_slot_name 追加到 postgresql.auto.conf,省去手动配置 |
-X stream | 在拉取基础备份的同时开一个连接持续接收 WAL,避免大数据库备份过程中 WAL 被回收 |
-S replica_slot_1 | 复用我们在主库上创建好的物理复制槽 |
-P -v | 显示进度和详细日志 |
-c fast | 让主库立即做一次 fast checkpoint,避免按 checkpoint_completion_target 慢慢摊开(默认 spread checkpoint 在 checkpoint_timeout = 15min 时最坏要等十几分钟才开始真正传数据) |
4、确认从库的复制配置
看一下 pg_basebackup -R 帮我们写好的连接信息:
sudo cat /var/lib/postgresql/18/main/postgresql.auto.conf
应该能看到类似这样的两行:
primary_conninfo = 'user=replicator password=replicator_password host=<your-master-tailscale-hostname> ...'
primary_slot_name = 'replica_slot_1'
同时 standby.signal 应该存在:
ls /var/lib/postgresql/18/main/standby.signal
5、启动从库
sudo systemctl start postgresql@18-main.service
sudo systemctl status postgresql@18-main.service
观察日志确认 streaming 已经建立:
sudo tail -f /var/log/postgresql/postgresql-18-main.log
看到 started streaming WAL from primary 字样即代表成功。
四、从库的资源适配优化
从库只有 4H8G(约为主库的一半),存储依旧是 SSD,所以内存类参数按主库的一半设置,存储类参数和主库保持一致。
1、调小 shared_buffers
按主库 2048MB 的一半设置:
nano /etc/postgresql/18/main/postgresql.conf
shared_buffers = 1024MB
2、调小 work_mem
按主库 16MB 的一半:
work_mem = 8MB
3、调小 maintenance_work_mem
按主库 512MB 的一半:
maintenance_work_mem = 256MB
4、effective_io_concurrency 和 random_page_cost 保持一致
两台机器存储都是 SSD(一台 NVMe、一台普通 SSD,性能差异并不影响这两个参数的取值),保持和主库一致即可:
effective_io_concurrency = 200
random_page_cost = 1.1
5、hot_standby 保持默认开启
PostgreSQL 18 默认 hot_standby = on,从库会自动接受只读查询,无需额外配置。
如果将来要在从库上跑长查询(比如分析类报表),可能会和 WAL 回放冲突而被 cancel,那时再考虑调高
max_standby_streaming_delay,目前不动。
6、放宽 wal_receiver_timeout 与主库的 wal_sender_timeout 对齐
和主库的 wal_sender_timeout 保持一致,避免 Tailscale 网络抖动时双方对超时的判断不同步:
wal_receiver_timeout = 180s
7、重启从库
sudo systemctl restart postgresql@18-main.service
五、验证主从复制
1、主库视角
在主库上执行:
SELECT client_addr, state, sync_state, write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
应该能看到一条记录,client_addr 是从库的 Tailscale IP,state = streaming,sync_state = async。
再看复制槽状态:
SELECT slot_name, active, wal_status, safe_wal_size
FROM pg_replication_slots;
active 应该变成 t,wal_status 为 reserved。
2、从库视角
SELECT pg_is_in_recovery();
-- 应该返回 t
SELECT status, sender_host, written_lsn, flushed_lsn, latest_end_lsn
FROM pg_stat_wal_receiver;
status = streaming 即正常。
3、写入测试
主库上:
CREATE TABLE replica_test (id serial PRIMARY KEY, msg text, created_at timestamptz DEFAULT now());
INSERT INTO replica_test (msg) VALUES ('hello from primary');
几秒后从库上查询,应该能立刻看到数据:
SELECT * FROM replica_test;
顺便确认从库确实是只读的:
INSERT INTO replica_test (msg) VALUES ('try write on replica');
-- ERROR: cannot execute INSERT in a read-only transaction
六、复制槽失效后的恢复
如果从库长时间断线导致复制槽要保留的 WAL 超过 max_slot_wal_keep_size,主库上 pg_replication_slots.wal_status 会变成 lost,从库再连回来时会报:
ERROR: requested WAL segment ... has already been removed
此时的恢复方式就是按 “三、使用 pg_basebackup 初始化从库” 重新拉取一次基础备份,复制槽继续沿用即可。如果你希望复制槽也重建,先在主库上:
SELECT pg_drop_replication_slot('replica_slot_1');
SELECT pg_create_physical_replication_slot('replica_slot_1');
再执行 pg_basebackup。
结束。