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/16B2E3E0SLOT:指定要使用的复制槽,主库会据此保留WAL。TIMELINE:从哪条时间线开始读取,不指定默认用当前时间线。- 最后的LSN:起始位置。
收到这条命令后,walsender就进入CopyBoth模式,开始源源不断地往这个连接上推WAL数据。所谓CopyBoth,意思是这个连接的主库和备库都可以主动向对方发送数据——主库推WAL,备库推反馈。这是流复制协议和普通SQL协议最大的结构差异。
3.4 一条WAL record从主库到备库的旅程
我用一条普通UPDATE产生的WAL record来走一遍完整旅程,希望能帮你把协议跑通:
- 事务里执行UPDATE,PostgreSQL后端进程修改共享缓冲区中的数据页,同时生成一条WAL record写入WAL缓冲区。
- 事务提交或达到wal_writer_flush_after阈值时,WAL record被刷到主库的WAL segment文件。
- 主库的walsender进程醒过来,发现WAL字节流有新增内容,从自己的读取位置开始,把这部分字节读出来。
- walsender将这段字节打包成
XLogData消息,在CopyBoth数据流里发给备库的walreceiver。消息里除了WAL字节本身,还携带了三个关键值:本条WAL数据的起始LSN、当前WAL末端位置(wal_end)、发送时间。 - walreceiver收到消息后,把字节追加到备库自己的WAL文件中,然后触发备库的startup进程从该位置恢复重放。
- 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_lsn | walsender已经发出去的WAL位置 | 网卡层面已交给TCP |
| write_lsn | walreceiver已写入备库WAL文件(未刷盘) | 备库内存/页缓存层面 |
| flush_lsn | walreceiver已把WAL刷到磁盘 | 备库断电不丢 |
| apply_lsn | startup进程已重放完成 | 备库查询可见的最远位置 |
你排查延迟时,必须先搞清楚到底是哪一层卡住了。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_write | write_lsn到达 | 备库进程收到且写入文件,但未刷盘 |
| on(默认) | flush_lsn到达 | 备库WAL已刷到磁盘 |
| remote_apply | apply_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的基线截图存下来。这样将来无论哪个环节出了问题,我至少有一份"它曾经正常时是什么样子"的对照样本。排查复制问题,最怕的不是问题复杂,而是没有基线数据可以比对。