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_levelreplicaPG10之前叫hot_standby、max_wal_senders这些参数就清楚了它们决定的是这个通道能否开启、能开几条不是传输什么。3. 一次复制会话的完整生命周期从握手到持续数据流3.1 复制模式的入口replication链路与普通SQL连接是两回事流复制协议走的是libpq协议的复制模式入口很特殊。登入时客户端必须在连接参数里声明replicationdatabase老版本用dbnamereplication的方式或者直接把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 host10.0.0.1 port5432 dbnamereplication userrepslot replicationdatabase -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来走一遍完整旅程希望能帮你把协议跑通事务里执行UPDATEPostgreSQL后端进程修改共享缓冲区中的数据页同时生成一条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_lsnwalsender已经发出去的WAL位置网卡层面已交给TCPwrite_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_commitremote_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_typeReplication、wait_eventsync_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切到N1。新主库生成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_sizePG13以前是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_commitremote_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的基线截图存下来。这样将来无论哪个环节出了问题我至少有一份它曾经正常时是什么样子的对照样本。排查复制问题最怕的不是问题复杂而是没有基线数据可以比对。