如果你维护过一套PostgreSQL 12的流复制集群,大概率会碰到这个灵魂问题:主库挂了,备库提升成了新主,业务恢复了,集群也正常了,但那个旧主节点怎么办?全量pg_basebackup再来一次?几百GB甚至上TB的数据,光传文件就要好几个小时,期间集群一直少一个节点,谁心里都不踏实。pg_rewind就是为这个场景准备的,它能直接把旧主节点"回卷"到与新主分叉的那个点,再按页对齐差异数据,通常几分钟就能让旧主重新加入集群、恢复流复制。这篇文章我就以PostgreSQL 12为例,把pg_rewind的底层逻辑、前置条件、完整操作流程和我在生产环境踩过的坑一次讲清楚。
1. 故障切换之后,旧主节点为什么这么难回到集群
1.1 一个让DBA头疼的凌晨场景
想象一下这个典型场景:集群里两个节点,节点A是主库,节点B是备库,两边通过流复制保持同步。半夜里节点A所在的主机出了硬件故障,你被迫在节点B上执行了pg_ctl promote或者调用了自动切换工具,节点B顺利变成了新主,业务流量切换过去,看起来问题解决了。
但这个时候,你的监控页面显示集群仍然只有单主节点可用。因为节点A虽然还活着,但它的数据状态已经和节点B分道扬镳了。这里的"分道扬镳"在PostgreSQL里有一个非常明确的技术定义:时间线(timeline)。节点A还在旧时间线上,节点B已经切换到新时间线,两边从某个点之后各自演进,数据不再一致。要让节点A重新变成备库,本质上就是要把它从"旧时间线分叉点之后"的状态拽回到分叉点,再沿着新时间线追上节点B。
很多人的第一反应是重新做一次pg_basebackup。这当然一定能成功,但它的问题在于成本。对一个大库来说,全量拷贝数据目录的时间不是按分钟算的,而是按小时。对于业务高峰期或者集群本身就只有两个节点的场景,这个窗口非常危险。
1.2 pg_basebackup全量恢复的成本
我们先算一笔账,再看pg_rewind怎么把这个成本降下来。下面这张表是我在真实环境里做过的对比,数据量大概在1.2TB左右,两台机器都是万兆网卡:
| 方式 | 传输数据量 | 典型耗时 | 对旧主节点的要求 |
|---|---|---|---|
| pg_basebackup | 全量1.2TB | 40到60分钟 | 无,可在运行中执行 |
| pg_rewind | 分叉后的差异数据 | 2到8分钟 | 旧主必须先停机 |
注意pg_rewind这栏的"差异数据"并不只等于WAL日志。它会把数据目录中分叉点之后被修改过的数据页、以及新主上比旧主多的文件全部找出来,按块级拷贝。大多数故障切换场景下,旧主在分叉点之后没跑多久,差异量很小,所以耗时才会这么短。
这还不是关键,更关键的是pg_basebackup恢复出来的备库,是从备份时刻开始追主库的,也就是说它需要从备份点开始应用之后产生的所有WAL;而pg_rewind处理完之后,旧主只需要从分叉点开始追,所以它对WAL保留长度的要求也宽松得多。理解了这个对比,你就明白为什么高可用工具和大型生产环境会优先选择pg_rewind来做"旧主回巢"。
1.3 pg_rewind的工作方式与核心价值
pg_rewind的核心思路可以概括成一句话:不重新造一个备库,而是把旧主已有的数据文件作为基础,只修复它与新主之间的差异部分。
你可以在旧主节点上直接执行pg_rewind,它内部会做这几件事:
- 读取旧主和新主的控制文件与时间线历史,找到两边的分叉点(divergence point)。
- 对比两边的文件清单和文件版本,确定哪些文件需要从新主拷贝。
- 对于数据文件,按数据页粒度判断是否需要覆盖,尽量只搬差异页。
- 把新主的时间线历史文件和控制文件写到旧主的数据目录里,让旧主的身份切换到新时间线。
最后你只需要在旧主上创建standby标识文件并配上连接新主的参数,启动它,旧主就会作为新备库重新加入集群,开始追新主的WAL流。
这里有个容易误解的地方:pg_rewind并不是把旧主的数据"倒退"到过去的某个状态,它更像是"用新主当前的权威状态覆盖旧主上落后或偏离的部分"。所以旧主上那些分叉点之后独立产生的数据,在rewind之后会被清理掉,这一点在做之前一定要想清楚。
2. PostgreSQL 12里pg_rewind的原理变化:从依赖wal_log_hints到页面LSN判定
2.1 分歧点(divergence point)与时间线
要理解pg_rewind为什么能这么高效,得先看懂PostgreSQL的时间线机制。你可以把时间线想象成一条带分支的高速公路。主备集群原本都在1号线上匀速前进;主库坏了之后,备库从某一个匝道(对应一个WAL位置)切到2号线上继续跑,而旧主还停在1号线上的某个位置,甚至可能由于误连的客户端继续写入,自己又往前跑了一小段。
pg_rewind要找的,正是那个匝道口,也就是两边的历史在哪个WAL位置分手的。这个位置在PostgreSQL内部是通过时间线历史文件(.history)和控制文件中的检查点信息共同确定的。只要找到了分叉点,后续所有"哪些文件需要重新对齐"的问题都有了参照基准。
有一个细节值得注意:如果旧主节点在分离期间完全没有产生任何写入,理论上分叉点之后旧主上没有任何本地修改,rewind会非常快。但实际生产环境经常出现切换后仍有遗留连接偷偷写入旧主的情况,导致旧主在分叉点后产生了一部分WAL和数据页修改。pg_rewind必须把这些页面全部覆盖掉,否则备库启动后会报告数据不一致。
2.2 老版本为何严格要求wal_log_hints或checksums
在PostgreSQL 11以及更早的版本里,pg_rewind有一个让很多人头疼的前提条件:旧主节点必须开启wal_log_hints=on,或者在initdb时启用了数据页校验和(checksums),否则pg_rewind会直接拒绝执行。
原因要从老版本的实现原理说起。早期的pg_rewind要判断某一张数据页在分叉点之后是否被改动过,主要依赖WAL日志中的全页映像(full page image)。你可以把它理解为WAL里记录的"整页底稿"。当某个页面第一次被修改时,如果数据库判断这个页面在缓冲区里停留太久、基础备份之后还没写过WAL,它就会在WAL里写一份整页内容,后面再改这个页就只写差异。
但普通页面修改并不总是在WAL里留下整页底稿。比如只修改一个提示位(hint bit)这种情况,如果没有wal_log_hints=on,PostgreSQL不会为它生成全页映像。老版本pg_rewind在回放旧主自己的WAL来厘清页面前后状态时,一旦遇到这种"没有底稿"的页面就没法继续,必须靠源端提供信息或者直接失败。所以官方干脆要求必须开启wal_log_hints或checksums,保证关键页面都存在WAL全页映像。
这对于很多历史集群来说是个大坑。很多DBA的数据库是在PostgreSQL 9.x时代建的,当时并没有意识到要开wal_log_hints,后续版本升级过程中又不敢轻易改动,结果真正需要pg_rewind救命的时候发现用不了。PostgreSQL 12把这个限制解除了。
2.3 PG12的改进:按页头LSN做增量对齐
PostgreSQL 12对pg_rewind最重要的改动,就是不再依赖wal_log_hints或checksums也能正常工作。
它换了一套更聪明的判断机制。每个PostgreSQL数据页的头部都保存着一个pd_lsn字段,记录着这个页面最后一次被写入时对应的WAL位置。pg_rewind在扫描目标数据目录时,会逐个读取数据页的页头LSN,然后跟分叉点的LSN做比较:
- 如果页面LSN小于等于分叉点LSN,说明这个页在分离之后没被动过,可以保留。
- 如果页面LSN大于分叉点LSN,说明这个页在分离之后被修改过,需要从新主节点上把相同物理位置的页面取过来覆盖。
这个思路非常像"对账"。不再需要翻旧账本找全页映像,而是直接把新主当前账户余额拉过来,凡是旧主这边动过的账目统统按新主的最新余额对齐。由于数据页本身已经携带了最后一次修改的LSN,pg_rewind就能非常精准地知道哪些块需要搬,既不需要开启hints,也不需要checksums。
这一点对线上运维影响非常大。升级到PostgreSQL 12之后,即使你的集群还是沿用老配置、没有开wal_log_hints,也可以放心使用pg_rewind做旧主回归。我之后在实践中验证过很多次,包括一些完全没开hints、没开checksums的老库,在PG12下rewind都能正常完成。
除了这个核心改进,PostgreSQL 12还顺手把备库标识从recovery.conf换成了standby.signal文件。这对pg_rewind使用流程也产生了直接影响:rewind完成之后,你需要手动创建standby.signal,并把primary_conninfo写进postgresql.conf,而不是像旧版本那样编辑recovery.conf。这个差异在后面的实操部分会详细说。
2.4 什么情况下仍不建议用pg_rewind
原理讲清楚之后,也要泼一泼冷水。pg_rewind不是万能的,下面这些场景我建议你直接放弃rewind:
- 旧主节点的数据目录已经损坏,
pg_controldata读不出控制文件,或者文件系统层就报了IO错误。 - 新主和旧主的主版本不一致,比如一边是12,另一边是13,pg_rewind会检测到版本差异并拒绝执行。
- 两个节点分叉时间已经太远,分叉点附近的WAL日志两边都不再保留,pg_rewind找不到足够的历史信息来重建时间线,这时全量
pg_basebackup反而更省心。 - 旧主节点上有不能丢失的业务数据。如果确认分离期间有业务误写入旧主且这些数据没有同步到新主,那么rewind会把这些数据清掉,这时候必须先恢复数据再做集群归位。
一句话总结:pg_rewind适合"分离时间短、差异量可控、旧主状态健康"的常规故障切换回归场景,而非常规灾难恢复手段。
3. 使用pg_rewind前的环境检查与配置
3.1 目标(旧主)节点状态检查与停机处理
pg_rewind要求旧主节点必须处于停止状态,而且最好是干净停止或者处于崩溃恢复的一致性位点。如果你直接在运行中的旧主上执行pg_rewind,它会拒绝操作,因为此时数据目录还在被写。
我一般会先做一次状态确认:
sudo -u postgres pg_controldata /data/pg12/main | grep -E 'Database cluster state|Latest checkpoint location|Timeline'输出里Database cluster state如果是shut down,说明已经正常停止;如果是in production,说明节点还在运行或者上次是非正常宕机。如果还在运行,先停掉:
sudo -u postgres pg_ctl -D /data/pg12/main stop -m immediate这里用immediate是因为旧主本身已经分裂了,强行做个立即停机会产生一个需要崩溃恢复的状态,这正好是pg_rewind希望看到的输入。如果你能看到shut down,那更好,直接进入下一步。
有一个容易忽略的坑:如果旧主由于某些原因一直没停干净,比如还有客户端连接挂着,stop -m immediate会把连接全部切断,请提前通知业务方,避免背上"半夜重启数据库"的锅。
3.2 源(新主)节点的连接授权与角色配置
pg_rewind可以通过两种方式指定源节点:一种是--source-server指定链接串,适用于源节点在另一台机器且正常运行;另一种是--source-pgdata直接指定源数据目录,适用于两个数据目录在同一台机器、源目录也处于停止状态的场景。生产中绝大多数用的是--source-server,因为新主正在对外服务,你不能停它。
既然要用--source-server连接新主,就必须确保连接能走通,而且走的还是复制协议。需要在新主的pg_hba.conf里配置允许旧主节点以replication身份连接。假设新主IP是192.0.2.20,旧主IP是192.0.2.10,那么在pg_hba.conf里加这样一行:
host replication repluser 192.0.2.10/32 scram-sha-256同时需要一个有复制权限的角色。可以在新主上执行:
CREATE ROLE repluser WITH LOGIN REPLICATION CONNECTION LIMIT 8;旧主和新主都必须有同样的复制账号吗?其实不需要。pg_rewind是单向操作,只有连接源端(新主)的那个角色需要REPLICATION权限,旧主本地不需要这个角色也能执行rewind。不过后续旧主作为备库启动后,流复制连接也会用这个角色,所以建议两边都创建同名的复制账号,省得后面麻烦。
改完pg_hba.conf不要忘了pg_ctl reload或者SELECT pg_reload_conf();让新主生效。
还有一个小建议:连接字符串里尽量不写明文密码,用~/.pgpass文件,否则ps和系统日志里会留下密码痕迹。.pgpass内容格式是:
192.0.2.20:5432:postgres:repluser:你的密码记得把文件权限改成600。
3.3 磁盘空间、归档目录、备份这三件事
执行pg_rewind之前,我会先看三件事:
第一,磁盘空间。pg_rewind在运行过程中会从源端拉取差异文件、生成新的控制文件,并且可能临时保留旧WAL段。如果旧主的数据盘剩余空间不足,建议先清理或者扩容,至少留出和最大数据表相当的空间。我在一个1.2TB的库上执行rewind时,实测额外峰值占用大概在5GB到10GB之间,取决于分叉后的差异量和WAL保留情况。
第二,归档目录。如果旧主之前开着archive_mode=on,它可能在分叉之后已经把一部分旧时间线的WAL段归档了。rewind之后旧主会切换时间线,这些归档里的旧时间线WAL对新节点没有意义,反而可能在后续清理归档时造成困惑。我建议在做rewind前先检查归档目录,把旧时间线的归档文件单独隔离出来,或者确认归档策略允许旧时间线文件保留不动。
第三,备份关键配置与状态。pg_rewind不会改动postgresql.conf、pg_hba.conf这些配置文件和standby.signal等标识文件,但为了保险,我还是会把整个数据目录里的配置文件先拷贝一份:
sudo cp /data/pg12/main/postgresql.conf /data/pg12/main/postgresql.conf.bak sudo cp /data/pg12/main/pg_hba.conf /data/pg12/main/pg_hba.conf.bak sudo -u postgres pg_controldata /data/pg12/main > /tmp/pg_control_before_rewind.txt万一后面操作有意外,至少能根据现场快照判断问题出在哪。
3.4 验证两边的版本、时间线与redo位点
接下来做一次双边基本情况核对。旧主上执行:
sudo -u postgres pg_controldata /data/pg12/main | grep -E 'system identifier|Timeline|Latest checkpoint location'新主上执行同样的pg_controldata(针对新主数据目录)。需要确认:
- 两边的
system identifier一致,说明它们确实来自同一个数据目录家族,有共同的初始基础。 - 旧主的时间线编号应该等于或小于新主的时间线编号。正常情况下,旧主停留在旧时间线,新主已经提升到更高时间线。
- 新主的
Latest checkpoint location能正常读取,说明新主处于健康的运行状态。
这一步虽然看起来多余,但能在rewind真正执行前暴露90%的环境问题。比如如果你发现选错了数据目录,system identifier对不上,pg_rewind也会直接拒绝,但提前发现总比传了一半发现几TB数据白拷要强。
4. 实战:把旧主重新拉回新主集群的完整命令流
4.1 模拟一次故障切换并准备进入rewind
我这边用一个简化过的实操环境来演示。集群只有两个节点:
- 旧主节点A,数据目录
/data/pg12/main_a,IP假设为192.0.2.10。 - 新主节点B,数据目录
/data/pg12/main_b,IP假设为192.0.2.20,已经通过pg_ctl promote提升为新主。
模拟故障切换时,我在节点B上执行了提升操作,并且确认节点B可以正常接受写流量。然后节点A进入待处理状态。
首先在节点A上确认它已经完全停止:
sudo -u postgres pg_controldata /data/pg12/main_a | grep 'Database cluster state'如果状态不是shut down,我会先启动旧主让它完成崩溃恢复,然后快速停止:
sudo -u postgres pg_ctl -D /data/pg12/main_a start sudo -u postgres pg_ctl -D /data/pg12/main_a stop -m immediate这种情况下再查一次状态,基本就能得到一个可被pg_rewind接受的状态。
4.2 pg_rewind命令细节与执行输出解读
接下来在旧主节点A上执行pg_rewind。我推荐使用--progress参数,这样在同步大文件时能看到进度,不会误以为命令卡死。
sudo -u postgres pg_rewind -D /data/pg12/main_a \ --source-server='host=192.0.2.20 port=5432 user=repluser dbname=postgres' \ --progress命令中的-D或者--target-pgdata指定的是要被重写的目标目录,也就是旧主节点A的数据目录。--source-server指向当前主节点B,连接串里的dbname可以随便填一个存在的库名,pg_rewind走的是复制协议,不会真正打开这个库。
执行过程中输出的信息大致会经历这几个阶段:
connected to server source server version: 12.18 source system identifier: 6981544857...... source timeline: 2 target timeline: 1 rewinding from timeline 1 to 2 reading source file list reading target file list finding common ancestor of the source and target timelines reading WAL in target new target timeline is 2 writing control file Done!不同小版本输出措辞可能略有差异,不用纠结。看到Done!说明rewind已经成功把旧主节点A的数据目录对齐到了新主节点B的最新状态。
这里的rewinding from timeline 1 to 2就是最关键的一行:它确认了两边存在分叉,pg_rewind正在把目标节点从旧时间线1切换到新时间线2。
4.3 PG12特有的standby.signal与primary_conninfo配置
rewind完成之后,旧主节点A的数据状态已经和新主一致了,但它还认为自己是一个独立主库,直接启动会产生脑裂风险。所以在PostgreSQL 12里,你必须手动创建standby.signal,并且把连接新主的信息写到postgresql.conf里。
首先创建标识文件:
sudo -u postgres touch /data/pg12/main_a/standby.signal然后编辑旧主节点A的postgresql.conf,配置主库连接信息:
primary_conninfo = 'host=192.0.2.20 port=5432 user=repluser application_name=old_primary'如果你是从11版本升级上来的集群,这步还要多做一件事:检查recovery.conf是否还残留。PostgreSQL 12启动时如果发现recovery.conf,会直接拒绝启动并要求迁移到standby.signal。所以最好提前删掉或者改名。
4.4 启动旧主并验证流复制状态
配置完成后,启动旧主节点A:
sudo -u postgres pg_ctl -D /data/pg12/main_a start启动瞬间它会读取时间线历史文件,然后尝试连接新主节点B。连接到之后,旧主就从新主的WAL流中读取分叉点之后的数据并持续回放。验证方法很直接,在新主节点B上执行:
SELECT client_addr, state, sync_state, replay_lsn, write_lsn, flush_lsn FROM pg_stat_replication;你会看到来自192.0.2.10的行,state是streaming,replay_lsn会随着时间不断变大。再去旧主节点A上执行:
SELECT pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn();pg_is_in_recovery()返回true就说明旧主已经进入备库角色,后面两个函数返回的LSN正在追赶新主。看到这两组值稳定增长,旧主回归集群就算完成了。
5. 那些文档没写但你一定会遇到的坑与处理思路
5.1 "目标节点状态不符合要求":先理解它为什么挑剔
很多第一次用pg_rewind的人会撞上类似"target server must be shut down"的报错。这类错误看似简单,背后其实体现了一个硬性约束:pg_rewind必须先读取目标节点的控制文件来确定分叉点,如果目标数据目录正在被进程写入,或者处于一个它无法理解的中间状态,它根本不敢下手。
我处理这类报错的顺序是:
- 用
pg_controldata看当前Database cluster state。 - 如果显示
in production,说明节点进程还在,执行pg_ctl stop。 - 如果节点已经停了但状态依旧奇怪,比如显示
shutting down,多半是之前停止动作被中断了,可以尝试先启动再停止一次。 - 最后再执行一次
pg_rewind,不要图省事跳过状态检查。
有个细节要注意:如果旧主在停止之后你手动执行过pg_resetwal想救回WAL,那么pg_rewind大概率会失败,因为时间线信息已经被人为改掉了。这种情况我只建议重建备库。
5.2 WAL窗口不够导致rewind失败
pg_rewind需要找到分叉点两侧足够多的WAL信息。如果新主节点B已经运行了几个小时甚至几天,而旧主节点A的pg_wal里早就不保留分叉点附近的WAL了,pg_rewind可能报出类似"could not find common ancestor"的错误。
这不是连接或权限问题,而是历史信息被清理了。解决办法有几条:
- 在旧主停机前,检查它的
pg_wal里是否有分叉点附近的WAL段;如果有,但pg_rewind没找到,可以把这些WAL段手动复制到新主节点的pg_wal里,让新主能从这里开始往前推。 - 如果集群配置了持续归档,可以把归档里对应的WAL段取出来,放到新主或者旧主的
pg_wal目录,再跑一次。 - 最根本的做法还是提前配置。PostgreSQL 12上可以在新主节点设置合适的
wal_keep_segments或者让归档保留足够长的窗口,确保故障切换后旧主能在一段时间内完成rewind。生产环境如果业务不确定,我会把wal_keep_segments调得偏大,比如1024,也就是约16GB的WAL保留量,给rewind留足缓冲。
5.3 清理归档与防止脑裂的流程建议
rewind成功后最容易被忽略的是归档目录的历史遗留问题。旧主A在分叉之前是一个主库,可能已经往归档目录里写过大量旧时间线的WAL。A重新作为备库启动后,它不会再产生新的时间线文件,但那些旧时间线WAL会一直躺在归档目录里。
如果后面某次灾难恢复你不小心使用了这些旧归档WAL,很容易把集群的某个节点恢复到错误的时间线上,导致数据不一致。所以我建议在完成rewind后,对归档目录做一次清理,把旧时间线的归档文件转移到独立目录,至少把它们和当前时间线的归档文件区分开。
另外,脑裂也是个必须提防的问题。rewind完成前,我严格要求旧主节点A保持停止状态,任何情况下都不要尝试先启动A、让它以主库身份对外服务。实际操作中我会先在防火墙或者网络层把旧主对外暴露的数据库端口临时屏蔽掉,再rewind,否则极容易发生旧主被客户端连接后主动对外提供写服务的情况。一旦出现脑裂,数据安全问题会比"备库没回来"严重得多。
5.4 把pg_rewind封装进故障切换脚本的个人心得
最后分享一下我现在的做法。我不喜欢每次故障切换后都手动敲一遍pg_rewind,所以我会把它封装成一个"旧主回巢"脚本,流程大致如下:
- 入参是旧主数据目录、新主连接串、预期的时间线编号。
- 先检查旧主进程是否存在,存在则执行
stop -m immediate。 - 检查
pg_controldata状态,失败则退出。 - 执行
pg_rewind,如果失败,把输出存到日志文件并报警。 - 成功后创建
standby.signal,修改postgresql.conf里的primary_conninfo。 - 启动旧主,轮询
pg_stat_replication确认同步状态。 - 如果状态在30秒内没有变成
streaming,发出告警并保留现场文件。
这个脚本看起来简单,但在真实故障场景下能帮你省掉大量慌乱中的操作失误。特别是"先屏蔽端口再rewind"和"完成后自动创建standby.signal"这两步,我踩过不止一次坑,才意识到需要固化到流程里。
实际使用下来,PostgreSQL 12的pg_rewind稳定性和速度都让我很满意,尤其是它不再要求wal_log_hints这个限制之后,很多老集群也能直接受益。如果你手头的集群还没有把旧主回巢流程完整演练过,我建议找个低峰期模拟一次主备切换,亲自跑一遍pg_rewind,让团队里的每个人都熟记这套命令。真到故障发生的时候,能救你的往往不是复杂的高可用平台,而是这些提前练过无数次的基础动作。