news 2026/10/3 3:34:38

PostgreSQL流复制协议:从WAL到排障,彻底搞懂主从同步机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL流复制协议:从WAL到排障,彻底搞懂主从同步机制

1. 流复制协议不是"配置项",是你排障的最后一层眼睛

如果你只把PostgreSQL流复制当成primary_conninfo加max_wal_senders这样的配置项,那你会错过一整个层次的排障能力。我见过太多DBA,主从能跑起来就觉得万事大吉,一遇到复制延迟飙升、备库追不上主库、或者整个集群hang住,就只能对着pg_stat_replication里那几个LSN数值干瞪眼。

直到有一次我因为一个特别诡异的复制问题,被迫去抓walsender和walreceiver之间的网络报文,手动拆解协议帧,才真正理解这套机制是怎么转起来的。那次之后,我再也不把"流复制协议"当摆设了。

这篇文章我打算把流复制协议从WAL的本质、连接生命周期的每一步握手,到备库反馈、同步复制等待、复制槽、级联复制、时间线切换这些底层行为,完整过一遍。内容会涉及一些只有翻源码才能看到的报文格式和工作细节,但我会用实际场景把它们串起来。适合两类人:一类是正在被复制故障折磨的DBA,另一类是想彻底搞明白PostgreSQL复制机制的后端开发。

2. 协议搬运的到底是什么:WAL的物理本质与LSN语义

2.1 WAL就是一串append-only的字节流

流复制协议传输的底层对象不是"事务"、不是"数据变更",而是WAL日志本身——一长串只追加、不修改、不删除的字节流。PostgreSQL每一次数据页的修改,都会先产生一条WAL record,内容包含修改了哪个页、怎么修改、以及事务相关的元信息。这个record被追加到WAL缓冲区,最终落盘到pg_wal目录下的segment文件中。

每个segment固定16MB,从000000010000000000000001这样的十六进制文件名就能看出它的时间线和段编号。协议本身不关心segment文件名的含义,它只关心字节流的位置标记,也就是LSN。

2.2 LSN到底是"日志序号"还是"文件偏移"

LSN全称Log Sequence Number,在PostgreSQL里用0/16B2E3E0这样的十六进制形式展示。很多人以为它是个单调递增的抽象序号,其实它直接对应WAL字节流中的物理偏移:

  • 高32位是segment内的逻辑段偏移(实际上计算时要乘以16MB),
  • 低32位是段内字节偏移。

所以LSN天生是全局有序的,比较两个LSN就可以确定谁先谁后。更重要的是,备库要求主库"从某个LSN之后的所有WAL发给我",其实就是"从整个WAL字节流的某个偏移量继续发",协议的设计因此变得非常干净。

2.3 协议只搬运字节,不解析事务语义

理解这一点是吃透整个流复制的关键:walsender并不理解事务。它接到START_REPLICATION请求后,做的事情只是在WAL字节流上定位到起始LSN,然后不断把新增的字节块打包发送。备库的walreceiver收到后,将字节块写入自己的WAL文件,再由startup进程重放。

物理复制下,主备库跑的是同一串字节,所以备库不需要解析WAL里的逻辑含义——它只需要按顺序重放。逻辑复制之所以能存在,本质上是walsender的输出端加了解析器(pgoutput插件),把WAL里的业务操作翻译成行级修改。但传输层协议是同一套。记住这个结论:逻辑复制和物理复制共享同一个流复制协议,区别只在上游的walsender怎么产生数据。

明白这一点后,你再去看wal_level=replica(PG10之前叫hot_standby)、max_wal_senders这些参数就清楚了:它们决定的是"这个通道能否开启、能开几条",不是"传输什么"。

3. 一次复制会话的完整生命周期:从握手到持续数据流

3.1 复制模式的入口:replication链路与普通SQL连接是两回事

流复制协议走的是libpq协议的复制模式,入口很特殊。登入时客户端必须在连接参数里声明replication=database(老版本用dbname=replication的方式),或者直接把dbname设为replication。一旦进入复制模式,这个连接就不再接受普通SQL了,只能执行复制协议命令,比如IDENTIFY_SYSTEM、START_REPLICATION、TIMELINE_HISTORY、CREATE_REPLICATION_SLOT。

这个模式对应pg_hba.conf里的特殊条目:

# 允许用户 repslot 从任意主机发起物理复制连接 host replication repslot 0.0.0.0/0 scram-sha-256

注意,这里的条目类型是replication,和普通数据库条的host all all井水不犯河水。PG14之前,只有超级用户能发起复制连接;PG14开始,REPLICATION权限被拆成了独立角色属性,可以只给一个普通用户REPLICATION权限而不给它超级用户权限。这是很多做权限收敛的团队容易踩的坑:你在PG13里习惯性把复制账号建成超级用户,升级到PG14之后,DBA来问你为什么一个复制账号有superuser权限。

如果你想手动验证协议握手,可以用psql直接敲:

psql "host=10.0.0.1 port=5432 dbname=replication user=repslot replication=database" -c "IDENTIFY_SYSTEM;"

返回的四列信息依次是systemid、timeline、xlogpos、dbname。systemid是数据目录初始化时生成的全局唯一ID,所有通过备份和复制继承下来的节点共享同一个systemid。这在你排查"这台备库到底是不是从我这台主库克隆来的"时候特别有用。

3.2 IDENTIFY_SYSTEM与时间线协商

握手的第一步永远是IDENTIFY_SYSTEM。备库拿到主库当前的时间线和最新LSN后,结合自己本地WAL的最后一个有效重放点,决定下一步请求从哪里开始。

这一步有个值得一提的细节:备库本地如果已经有WAL(比如它之前是主库,或做过级联复制),它会比较自己的时间线和主库的时间线。如果主库时间线更高,说明主库曾经经历过failover或者pg_rewind,备库可能需要通过TIMELINE_HISTORY命令拉取时间线历史文件,理解在两个时间线之间的分叉点在哪里,避免在错误的时间线上重放。

3.3 START_REPLICATION:明确的起始点

一旦确定起点,备库发送START_REPLICATION命令。对于物理复制,常见形式是:

START_REPLICATION [SLOT slotname] [TIMELINE tli] 0/16B2E3E0
  • SLOT:指定要使用的复制槽,主库会据此保留WAL。
  • TIMELINE:从哪条时间线开始读取,不指定默认用当前时间线。
  • 最后的LSN:起始位置。

收到这条命令后,walsender就进入CopyBoth模式,开始源源不断地往这个连接上推WAL数据。所谓CopyBoth,意思是这个连接的主库和备库都可以主动向对方发送数据——主库推WAL,备库推反馈。这是流复制协议和普通SQL协议最大的结构差异。

3.4 一条WAL record从主库到备库的旅程

我用一条普通UPDATE产生的WAL record来走一遍完整旅程,希望能帮你把协议跑通:

  1. 事务里执行UPDATE,PostgreSQL后端进程修改共享缓冲区中的数据页,同时生成一条WAL record写入WAL缓冲区。
  2. 事务提交或达到wal_writer_flush_after阈值时,WAL record被刷到主库的WAL segment文件。
  3. 主库的walsender进程醒过来,发现WAL字节流有新增内容,从自己的读取位置开始,把这部分字节读出来。
  4. walsender将这段字节打包成XLogData消息,在CopyBoth数据流里发给备库的walreceiver。消息里除了WAL字节本身,还携带了三个关键值:本条WAL数据的起始LSN、当前WAL末端位置(wal_end)、发送时间。
  5. walreceiver收到消息后,把字节追加到备库自己的WAL文件中,然后触发备库的startup进程从该位置恢复重放。
  6. walreceiver周期性向主库回送一条Standby status update反馈,里面带着三个位置:已经写入本地WAL文件的write位置、已经刷到磁盘的flush位置、已经重放完成的apply位置。

到这一步,一次数据的完整生命周期才算闭环。你去看pg_stat_replication视图里的write_lsn、flush_lsn、apply_lsn,它们不是walsender自己猜的,就是从第6步的反馈消息里解析出来的。这解释了为什么这个视图里的延迟值会有"sent很高、apply很低"的情况——因为sent只是"发出去了",apply才是"真正生效了"。

4. 备库反馈与同步复制的协议真相

4.1 write/flush/apply三层的语义差别

很多初学者把备库延迟简单理解为"主备LSN差值",其实LSN本身就是有语义分层的:

位置含义主库端能看到什么
sent_lsnwalsender已经发出去的WAL位置网卡层面已交给TCP
write_lsnwalreceiver已写入备库WAL文件(未刷盘)备库内存/页缓存层面
flush_lsnwalreceiver已把WAL刷到磁盘备库断电不丢
apply_lsnstartup进程已重放完成备库查询可见的最远位置

你排查延迟时,必须先搞清楚到底是哪一层卡住了。sent追不上主库,大概率是网络带宽或walsender读取慢;write追不上sent,可能TCP接收窗口或备库磁盘写慢;flush追不上write,备库磁盘fsync太慢;apply追不上flush,备库CPU/IO在重放时吃紧,或者有长查询阻塞。

4.2 Standby status update的协议帧与频率

备库反馈消息装在CopyData数据帧里,消息类型标识是d,完整结构大致是:

Byte1('d') - 反馈消息类型 Int64 write_lsn - 已写入WAL文件的位置 Int64 flush_lsn - 已刷盘的位置 Int64 apply_lsn - 已重放的位置 Int64 send_time - 反馈发送时间(微秒) Byte1 request_reply - 是否请求主库立即回复

字段顺序和源码里StandbyStatusUpdate结构体完全对应。反馈频率由wal_receiver_status_interval控制,默认10秒一次。这个10秒意味着:如果你的replication_commit配置了synchronous_commit=remote_apply,即使一切正常,一次事务最多也可能多等10秒才感知到备库进度变化。想降低等待延迟,就可以把这个参数调小到1秒甚至500ms,代价是备库每秒钟都要多一条反馈消息。

这个细节在线上低延迟场景里价值很大。我有一次把wal_receiver_status_interval从10秒改成2秒后,同步备库的提交延迟肉眼可见地降了一个档次。

4.3 同步复制:主库事务是如何被"卡住"的

同步复制的本质就是:事务提交时,主库后端进程在等待备库的一个反馈位置。

配置项synchronous_commit有几个取值,对照协议反馈语义看就非常清晰:

synchronous_commit主库等待备库反馈到达的位置含义
off不等待异步模式,主库崩溃可能丢事务
local只刷本地WAL不等待任何备库
remote_writewrite_lsn到达备库进程收到且写入文件,但未刷盘
on(默认)flush_lsn到达备库WAL已刷到磁盘
remote_applyapply_lsn到达备库已完成重放,可读

on是默认值,所以PostgreSQL的同步复制默认保证"备库刷盘"。备库反馈到达后,主库的后端进程才从同步等待中醒来,向客户端返回提交成功。

需要特别注意一点:同步等待发生时,主库上那个等待事务的后端进程状态在pg_stat_activity里会显示为wait_event_type=Replication、wait_event=sync_rep_wait或sync_rep_wait_flush。如果你看到这个状态,说明你的同步备库反馈超时了。这里有个非常常见的坑:很多人把primary_conninfo里的application_name漏配或改错,导致synchronous_standby_names里匹配不到任何备库。这时候PostgreSQL不是报错,而是主库直接进入等死状态——事务挂住,连接越积越多,直到把连接池打满。这个行为的机制就在协议层:主库在等一个永远不会来的flush反馈。

检查思路是:pg_stat_replication里application_name列和synchronous_standby_names里的名字是否完全一致。此外,PostgreSQL 15开始支持QUORUM和FIRST两种同步优先级语法,但无论如何,备库没有在协议层面完成连接,同步就是会卡主库。

5. 复制槽、级联复制和时间线切换里的协议暗礁

5.1 复制槽:一条协议层"请保留这些WAL"的契约

流复制协议本身不管WAL保留。若主库的WAL被循环回收,备库又追得太慢,备库下一次请求起始LSN时会发现那个位置的文件已经没了,直接报错:

FATAL: requested WAL segment 00000001000000000000003E has already been removed

这种情况下备库基本只能重新pg_basebackup。复制槽就是为解决这个问题设计的:备库在START_REPLICATION命令里带上SLOT slotname,主库的walsender就会登记这个槽,并记录备库当前消费到的位置(restart_lsn)。主库做WAL清理时,会检查所有复制槽的restart_lsn,绝不删除任何仍然需要的WAL文件。

在pg_replication_slots视图里,你可以看到restart_lsn这个字段。它是由备库的反馈推进的,本质上是协议反馈位中write_lsn或flush_lsn的一个投影(具体取哪个取决于不同实现和版本,但语义上就是"备库已经不需要的WAL之前可以清理")。

这个机制的反面风险非常大:如果备库长期宕机,没有反馈,restart_lsn就会卡住不动,主库的pg_wal目录会一直涨,直到磁盘被撑爆。我处理过一起事故,一台同步备库被误关机两周,主库磁盘从40%直接涨到100%,原因就是复制槽把WAL全部钉在了那里。所以生产环境一定要对pg_wal目录大小做监控,同时配置max_slot_wal_keep_size限制每个槽最多保留多少WAL,或者在备用库恢复无望时手动DROP_REPLICATION_SLOT。

逻辑复制槽还有个更隐蔽的坑:逻辑槽除了restart_lsn之外,还记录catalog_xmin,用来保护逻辑解码所需的系统目录版本。如果下游消费端断连超过一定时间,catalog_xmin不推进,主库的VACUUM就无法清理死元组,表膨胀会非常恐怖。排查线索同样在协议层:下游消费进程是不是还保持着连接、有没有一直在回confirmed_flush_lsn。

5.2 级联复制:备库同时是"消费者"和"生产者"

级联复制出现后,PostgreSQL的一个备库会同时运行两个角色:

  • 作为walreceiver,它从上游主库拉WAL;
  • 作为walsender,它向下游的其它备库推WAL。

这里协议上的难点在于:下游备库反馈的Standby status update,级联备库要决定是否透传给上游。PostgreSQL 9.6之前,级联备库不会转发下游的反馈,导致主库对同步状态判断失真,常常出现"下游同步了、主库却以为没有同步"的情况。9.6之后,级联链路中下游反馈会一路向上穿透,主库因此能拿到真正的末端备库位置。

实际维护中,级联链路的排查比单级复杂得多。你可能会看到主库的pg_stat_replication里只有一个备库位置,而这个位置其实是中间层的,真正的副本在中间层下面。此时必须在每一层都查一下pg_stat_replication和pg_stat_wal_receiver,才能定位延迟到底发生在哪一段链路上。我之前排过一个问题,主库到中间层一切正常,延迟为0,末端备库却落后了200GB。追查发现是中间层和末端备库之间的网络带宽被某个备份任务挤占,而主库完全感知不到,因为它看到的反馈位置只是中间层的位置。

5.3 时间线切换:failover之后旧主库为什么连不回来

时间线(timeline)是PostgreSQL区分WAL历史上不同分支的机制。一次failover,原备库被提升为新主库,它的时间线从1切到2(或从N切到N+1)。新主库生成00000002.history文件,记录分叉点位置。

协议层面的行为是:walsender读取WAL时永远沿着当前时间线读,如果备库请求的起始LSN落在分叉点之前的旧时间线上,并且指定了旧时间线,walsender就会跨越分叉点继续读取新时间线的WAL,相当于"自动续接"。

这就是为什么旧主库恢复后不能简单连回新主库:它自己最后一小段WAL是在旧时间线1上产生的,和新的时间线2存在分叉。备库如果发现本地WAL的最后一个位置和新主库的时间线起始位置不匹配,会尝试通过TIMELINE_HISTORY理解新时间线,但如果分叉点之前的WAL内容和新主库已经不一致,重放就会卡住或出错。标准解决办法就是对旧主库执行pg_rewind,让它以新主库当前状态为基准,把自身回退到分叉点之前再重新建立复制。pg_rewind能工作的前提是旧主库开启了wal_log_hints或wal_keep_size保留了足够的分叉点附近WAL——这又是协议层WAL保留逻辑的一个实际应用。

6. 排障实战:哪些问题必须从协议层面找答案

6.1 pg_stat_replication与pg_stat_wal_receiver的对照阅读

排障的第一步永远是先看视图。但这里我必须强调:pg_stat_replication是主库视角,pg_stat_wal_receiver是备库视角。两边对照才能拼出完整画面。

主库上:

SELECT application_name, state, sync_state, sent_lsn, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag FROM pg_stat_replication;

备库上:

SELECT status, receive_start_lsn, received_lsn, last_msg_send_time, last_msg_receipt_time FROM pg_stat_wal_receiver;

state列显示streaming只代表TCP连接活着、协议在推流,不代表备库没有落后。延迟的真相在write_lag、flush_lag、replay_lag这几列——它们正是主库根据收到的反馈消息时间戳和发送时间戳算出来的。

6.2 三个高频故障的协议排查链路

故障一:备库FATAL说WAL segment已被移除

排查链路:先看pg_replication_slots里对应槽的restart_lsn,再看pg_wal目录里最老的segment文件名,对比restart_lsn和当前LSN之间的WAL量是否超出max_slot_wal_keep_size或磁盘上实际保留量。如果没有配置复制槽,那问题大概率出在wal_keep_size(PG13以前是wal_keep_segments)太小,或备库断线时间太长,已超出主库保留窗口。解决措施:给备库配槽、调大wal_keep_size、缩短wal_receiver_status_interval让状态反馈更频繁。根治之后建议把复制槽和WAL保留监控一起做,防止再次发生。

故障二:新备库连不上主库,日志里没有明确的连接拒绝

基本是max_wal_senders达到上限。注意,pg_stat_replication里可能看不到满额的状态,因为空闲的walsender连接也在计数。要查:

SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'walsender';

以及pg_stat_activity里所有backend_type为walsender的行,看看是否有僵死的复制连接长期占用。这类连接占用槽位的判定逻辑在协议状态机里:walsender认为自己还在正常流复制,但备库端可能已经重启,TCP半开连接还没被系统检测到。调低主库的wal_sender_timeout(默认60秒)有助于更快回收这些僵死连接。

故障三:主库没有明显负载,但同步备库提交延迟很高

我遇到过一个案例,应用侧搭建了对延迟极其敏感的同步复制环境,synchronous_commit=remote_apply,但实测提交耗时经常跳到10秒以上。第一反应查备库磁盘、CPU、网络,全部正常。最后发现原因是wal_receiver_status_interval默认10秒,备库的feedback太稀疏,主库的提交经常要等下一次反馈到达才能确认。把间隔调到1秒后,提交耗时直降。这就是协议反馈频率直接影响业务延迟的典型例子。

6.3 手拆协议报文:tcpdump下的walsender数据流

有些问题看视图和日志依然无解,尤其当你怀疑协议层异常时,就需要直接抓包看原始报文。

用tcpdump抓5432端口的复制流量:

tcpdump -i eth0 -s 0 -w pg_repl.cap host 10.0.0.1 and port 5432

然后用tcpdump -A -r pg_repl.cap或者Wireshark打开。Wireshark对PostgreSQL协议有基础解析,但对流复制消息的支持有限。你真正想看的时候,可以直接看TCP payload的字节流。

一个典型的XLogData消息报文,跳过TCP/IP头之后,从PostgreSQL消息头开始解析:

协议消息长度(4字节) -> 表示后面数据的字节数 CopyData标识(1字节,0x64) -> 'd',表示这是CopyData消息 消息类型标识(1字节,0x77) -> 'w',表示这是XLogData 8字节的起始LSN 8字节的WAL末端LSN 8字节的发送时间(微秒) ... WAL数据内容 ...

如果看到0x6b(字符k),那就是Keepalive消息,后面跟着WAL末端位置和发送时间,最后一个字节是reply_requested标记,表示主库要求备库立即回一条feedback。

如果你能亲手拆出这样一个报文,恭喜你,你已经完全进入协议层了。以后遇到任何质疑"主库到底有没有把数据发出去"的争论,你不用再去跟人打嘴仗,直接抓包给他看字节。

我个人的建议是:不要把抓包当常规手段,但一定要在脑子里建立"协议层是最终裁决者"的意识。视图是协议的投影,日志是协议的外围,报文才是协议本身。当投影和外围都解释不了问题时,去报文中找答案,这是最稳妥的兜底策略。

最后分享一个小习惯:我在每套主从环境初始化完之后,都会用IDENTIFY_SYSTEM手动验证一次复制链路,记录下systemid和初始timeline,同时把pg_stat_replication和pg_stat_wal_receiver的基线截图存下来。这样将来无论哪个环节出了问题,我至少有一份"它曾经正常时是什么样子"的对照样本。排查复制问题,最怕的不是问题复杂,而是没有基线数据可以比对。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:34:36

基于DAG区块链的联邦学习框架:去中心化聚合与个性化模型实战

简介:这份资源是一套基于DAG区块链的联邦学习框架Python实现,面向计算机、数学、电子信息等专业的学生与研究人员,适合用作课程设计、期末大作业或毕业设计参考,也适合想深入理解去中心化联邦学习与个性化建模的开发者。项目将DAG…

作者头像 李华
网站建设 2026/10/3 3:34:17

AVO正演从理论到实践:Zoeppritz方程、Aki-Richards近似与Python实现

简介:这份资源面向石油物探方向的研究生及地震数据处理初学者,聚焦AVO正演模型实验与地震数据正演这一核心课题。包内共4个cpp源码文件,压缩包约12KB,均为C实现的正演程序,涵盖加噪音条件下的AVO正演模型实验、角度区域…

作者头像 李华
网站建设 2026/10/3 3:34:15

Lumerical farfieldpolar3d复电场远场分析全解析

1. 这不是个“命令”,而是一把打开远场光学世界的三维标尺如果你刚在Lumerical FDTD Script里敲下farfieldpolar3d,却只看到一串报错或空数组,别急着翻文档——这根本不是个孤立的函数调用,而是整套远场建模逻辑的终点站。我第一次…

作者头像 李华
网站建设 2026/10/3 3:33:46

PostgreSQL v19 新特性解读:INSERT ON CONFLICT DO SELECT 与 UPSERT 语义补全

最近在跟进 PostgreSQL 新版本动态时,我用 DeepSeek 把社区里零零散散的讨论梳理了一遍,最值得展开聊的一条是 v19 的 INSERT ... ON CONFLICT ... DO SELECT。刚开始我也以为这只是 UPSERT 语法多了一个分支,后来把邮件列表、commitfest 议题…

作者头像 李华
网站建设 2026/10/3 3:33:17

宽带GSC波束形成实战:麦克风阵列语音增强Python实现

1. 为什么宽带GSC波束形成是智能音箱落地的“咽喉要道”你拆开市面上任何一款中高端智能音箱,比如某米、某度、某为的主力型号,十有八九会看到一块印着4~8个麦克风的小PCB板。它不发声,却决定着整台设备的“听觉智商”。很多人以为…

作者头像 李华
网站建设 2026/10/3 3:33:16

ARIMA-CNN-LSTM混合模型实战:Python实现与参数调优全攻略

做时间序列预测这些年,ARIMA、CNN、LSTM这三个词经常被单独拎出来讲,但真正把它们拧成一个模型去干活的项目其实不多。我最近刚好完成了一个基于ARIMA-CNN-LSTM混合模型的预测研究,用Python整套实现下来,踩了不少坑,也…

作者头像 李华