用INFO replication观察过一次主从同步的人,应该都有过类似的经历:从库刚配好replicaof,日志里刷出一堆“MASTER <-> REPLICA sync started”,主库那边同步进度也跳了好几个状态,最后两边都指向online,数据也追平了。过程看起来很简单,但真去翻Redis源码,你会发现主从之间从建立连接到数据完全同步,中间夹杂着身份确认、能力协商、同步请求、全量搬数据、进度上报等好几个环节,远比想象中复杂。
很多人一听到“三次握手”,第一反应是TCP三次握手,其实Redis主从复制里的“三次握手”完全是另一码事。它指的是从库与主库在应用层的三轮交互:先确认链路和质量,再做同步协商、决定全量还是增量,最后通过ACK确认把从库正式转正。把这三个阶段搞清楚,主从复制的源码就入门了大半。这篇文章是Redis主从复制源码阅读系列的第2篇,目标很明确:把主从之间这三次握手,从replication.c和server状态机出发,一层层拆开给你看。
1. 先理解主从复制的整体图景
1.1 主从复制不是“复制”那么简单
很多人对主从复制的理解停留在“从库把主库的数据拿过来一份”。这个说法方向没错,但它远远低估了这套机制的严谨程度。Redis的主从复制在实现上是一套完整的状态机,从执行REPLICAOF命令开始,到从库进入online状态,中间要经历连接、握手、协商、传输、加载、确认等阶段,任何一个环节出问题,都会影响最终结果。
我在阅读源码前,习惯先用状态机的视角把流程过一遍。Redis把整个复制过程拆成了多个状态,核心状态定义在replication.c里,常见的有:REPL_STATE_NONE(初始无复制)、REPL_STATE_CONNECT(等待连接主库)、REPL_STATE_CONNECTING(连接已建立)、REPL_STATE_RECEIVE_PING_REPLY(等待PONG)、REPL_STATE_SEND_PSYNC(发送同步请求)、REPL_STATE_RECEIVE_PSYNC_REPLY(等待同步回复)、REPL_STATE_TRANSFER(接收RDB)、REPL_STATE_CONNECTED(已在线)。你看到这一长串状态,就知道主从复制绝不是一个简单的“拷贝”动作,而是一套严谨的、可追踪的通信协议。
从源码阅读的角度,这套状态机是最好的“地图”。你不需要一次性看懂所有函数,只需要跟着状态跳转走:从库每次状态变化,背后一定有一个对应的函数在驱动;主库每收到一条指令,也一定有一个对应的命令处理器在响应。把这套地图刻在脑子里,看代码时就不会迷路。
1.2 “三次握手”这个提法是怎么来的
严格来说,Redis官方文档里并没有“主从三次握手”这个术语。这个说法更多是源码阅读者在分析过程中的一种归纳,目的是把这套冗长的交互流程,简化成三个便于理解和记忆的阶段。正因为这是“约定俗称”的分析模型,不同的人可能划分方式不同,你需要先接受我这里的划分。
我习惯把三次握手定义为三个阶段。第一次握手:从库与主库建立TCP连接后,先发送PING探活,随后通过REPLCONF上报自己的监听端口和能力(capa),主库完成对该客户端的初步标记;第二次握手:从库发送PSYNC命令,携带runid和偏移量,主库根据自身复制积压缓冲区情况,决定回复+FULLRESYNC还是+CONTINUE,双方确认同步方式;第三次握手:全量数据RDB传输完成后,从库加载完毕并向主库发送REPLCONF ACK,主库确认从库的复制进度并标记为online,随后进入持续的增量传播阶段。
这样划分的好处是:第一次解决“连接是否可用、你是谁”的问题,第二次解决“怎么同步、从哪开始”的问题,第三次解决“数据交接是否完成、后续怎么持续”的问题。和TCP三次握手一样,每一轮都有明确目的,每一轮失败都能在状态里找到对应的位置。
1.3 源码地图:把要反复打开的文件提前摊开
主从复制的核心代码几乎都集中在src/replication.c,少量状态定义散落在server.h和server.c里。我的建议是,读源码前先把下面这张“函数地图”过一遍,理解每个函数的角色,再跟着流程走会轻松很多。
| 函数名 | 所属节点 | 职责说明 |
|---|---|---|
| replicaofCommand | 从库/主库 | REPLICAOF命令入口,触发复制关系建立 |
| replicationSetMaster | 从库 | 记录主库地址,设置连接状态 |
| replicationCron | 从库 | 周期性检查复制状态,负责发起连接和重连 |
| syncWithMaster | 从库 | 主从连接建立后的核心回调,驱动三次握手 |
| readSyncBulkPayload | 从库 | 读取并加载主库发来的RDB数据 |
| syncCommand | 主库 | 处理从库发来的PSYNC命令,决定全量/增量 |
| replconfCommand | 主库 | 处理REPLCONF命令,识别从库端口、能力与ACK |
| replicationFeedSlaves | 主库 | 把写命令传播给所有在线从库 |
你需要记住一个总原则:从库侧的动作集中在“如何发起连接、如何发起请求、如何处理回复”,主库侧的动作集中在“如何接受请求、如何判断同步方式、如何发送数据”。接下来每一节,我都会一边贴代码逻辑,一边解释它在这张地图上的位置。
2. 第一次握手:从库连上来,先把“我是谁”说清楚
2.1 连接从何开始:REPLICAOF之后的内部状态变化
先看从库侧。当你在从库执行REPLICAOF masterip masterport时,入口函数是replicaofCommand。这个命令的工作分两种情况:如果参数是“NO ONE”,就清空复制设置;如果带了主库地址,就调用replicationSetMaster。
replicationSetMaster的核心逻辑很简短,但信息量很大:它会记录主库的host和port,把server.repl_state置为REPL_STATE_CONNECT,同时把master_runid清空、master_initial_offset设为-1。这几个动作的含义是:从库还没连上主库,也不知道主库的身份标识和同步起点,一切要从零开始。
之后进入复制的主循环。从库的replicationCron定时任务(由server.hz驱动,默认每秒10次)会检查server.repl_state,如果发现处于REPL_STATE_CONNECT,就会调用connectWithMaster尝试建立TCP连接。这里的连接是非阻塞的,connectWithMaster注册一个可写事件,事件回调就是syncWithMaster。换句话说,当TCP连接真正建立的那一刻,Redis的io线程会触发syncWithMaster,第一次握手正式开场。
这里有个值得新手注意的细节:从库并不是自己写了个死循环去connect,而是把连接过程交给事件循环,连接成功后再由回调函数接管后续动作。理解这一点对读源码非常重要,Redis很多网络交互都是这种事件驱动模型,函数之间不是“顺序调用”,而是“通过事件串联”。
2.2 先PING后报身份:SYNC握手的第一步干了三件事
syncWithMaster是第一次握手的关键函数。站在源码角度,它内部会根据server.repl_state的不同取值,完成阶段性的任务。连接刚建立时状态是REPL_STATE_CONNECTING,此时从库会向主库发送一条PING命令,并等待PONG回复。这一步和TCP握手里的SYN很相似,本质是验证链路是通的、主库的协议解析是正常的、版本兼容性没问题。
收到PONG后,状态进入下一步,从库紧接着发送两条REPLCONF命令。第一条是“REPLCONF listening-port <从库端口>”,作用是把从库的监听端口告知主库。主库收到后,会把端口记录到对应client结构体的repl_listening_port字段,之后你在主库执行INFO replication时,看到的“slave0:ip=xxx,port=xxx”就是靠这个字段展示的。第二条是“REPLCONF capa eof capa psync2”,这是能力协商,告诉主库“我支持RDB的EOF格式,我支持psync2协议”。能力协商是一个很容易被忽略但对兼容性至关重要的机制,不同版本的Redis对同步协议的支持程度不同,主库需要根据从库上报的能力选择合适的数据传输方式。
我对第一次握手有一个形象类比:PING是敲门,听没听见;REPLCONF listening-port是递名片,让主库知道来访者是谁;REPLCONF capa是亮出自己带了哪些证件,方便后面办业务。这三个动作全部完成,从库的状态才推进到REPL_STATE_SEND_PSYNC,准备进入第二次握手。
2.3 主库侧的反应:第一次握手时主库做了什么
从主库的视角看,第一次握手接收到的PING、REPLCONF都只是普通命令,但主库对待它们的方式早有设计。当从库建立TCP连接后,主库在accept时会给这个client打上CLIENT_SLAVE以外的普通标记,直到从库发送REPLCONF后,主库才在replconfCommand中提取出端口、能力等元数据。
这里需要特别提示一点:第一次握手的过程中,主库并不会立刻把这个连接当作正式的复制客户端。真正让主库“认定”这是一个从库的,是第二次握手时收到的PSYNC命令。所以在源码里,你会在syncCommand函数中找到明确的处理逻辑:一旦收到PSYNC,主库才会启动复制相关的数据结构,比如创建复制积压缓冲区(repl_backlog)、把客户端标记为从库、加入server.slaves链表。
我最初读这块源码时,一直以为“从库连接成功 = 已经是复制客户端了”,后来发现不是。这个认知纠正很重要:主库对从库的“身份认定”是分步完成的,第一步只登记信息,第二步才生效复制关系。这也解释了为什么主库日志里出现“Replica asks for synchronization”的时机,总是比连接建立的时机晚——因为那是PSYNC到达的时刻。
3. 第二次握手:PSYNC协商,真正决定的是全量还是增量
3.1 别把PSYNC理解成“同步请求”:它是断点续传协商
很多资料把PSYNC解释成“同步命令”,这没错,但不够准确。从源码角度来看,PSYNC更接近于一种“断点续传协商”:从库请求的不是“给我一份全量数据”,而是“我已知某个数据流位置,请你告诉我能否从这个位置继续”。Redis把从库已知的复制进度抽象成两个核心参数:主库的runid和复制偏移量offset。
runid是一个40位十六进制随机字符串,在Redis每次启动时生成,用于标识主库的“身份代际”。如果主库重启,runid就变了。offset则是复制数据流的字节偏移量,从库消费到哪就到哪。从库发起PSYNC时,会带上自己记录的master_runid和master_repl_offset。如果这是从库第一次同步,它对主库的runid和offset一无所知,就会带上特殊的“? -1”。
源码里PSYNC命令的经典形态是“PSYNC ”。从库在syncWithMaster中构建这条命令时,会检查server.master_runid是否为空,为空就发送“PSYNC ? -1”,否则发送“PSYNC ”。为什么首次要用“?-1”?因为-1表示“我不知道同步位置”,?表示“我不知道主库身份”。主库收到这种请求,只能认为从库没有任何基础,必须做全量同步。
3.2 主库的判决:+FULLRESYNC还是+CONTINUE
主库收到PSYNC后,进入syncCommand函数。这个函数的职责是“判决同步方式”。判决的逻辑可以拆成三步。第一步,主库检查从库发送的runid是否等于当前主库的server.replid(即当前身份);第二步,主库检查从库发送的offset是否落在自己的复制积压缓冲区repl_backlog范围内;第三步,主库检查复制积压缓冲区是否存在。只有三个条件同时满足,主库才回复“+CONTINUE ”,表示可以继续增量复制。
任何一个条件不满足,主库都会选择全量重同步,回复“+FULLRESYNC ”。这条回复信息量很大:从库收到后,会把主库的replid写进server.master_runid,把回复的offset记成server.master_initial_offset。此后从库的复制位置就从master_initial_offset开始追。你看到这里会发现,第二次握手的本质是:主库做一个数学判断——你能不能从我记录的进度继续;能,就增量;不能,就全量。
我有一段时间对“+CONTINUE”有一个误解:以为支持增量同步的前提是从库之前连过、且数据没断过。其实核心是runid + backlog。就算网络断过,只要主库没重启(runid没变),且从库的offset还在backlog里,就能部分重同步。反过来,如果主库重启导致runid变了,即使网络一直连着,也必须全量。Redis重启丢runid这件事,是生产环境里“周期性全量同步”最常见的元凶之一。
3.3 全量重同步背后的准备:BGSAVE与传输方式选择
当主库决定全量同步后,还有一堆细节要处理。syncCommand会先把从库加入server.slaves链表,设置它的复制状态为SLAVE_STATE_WAIT_BGSAVE_START(等待开启后台保存),然后尝试触发BGSAVE。这里有一个优化点:如果主库本身已经有一份正在生成的RDB,且当前没有其他从库在等这份RDB,主库可以让该从库复用同一份RDB,而不是再fork一次。
BGSAVE的落盘方式也分两种:RDB目标为磁盘(disk)和RDB目标为从库socket(socket)。disk方式先把RDB写到本地磁盘,再读出来发给从库;socket方式则直接把RDB流式地写给从库。源码中这个选择由repl-diskless-sync配置控制,二者有不同适用场景:disk方式实现简单,但对磁盘IO有压力;socket方式能减少一次磁盘读写,但对网络稳定性要求高,且从库必须实时接收。
从库侧此时进入REPL_STATE_RECEIVE_PSYNC_REPLY阶段,等待主库的+FULLRESYNC或+CONTINUE。一旦收到+FULLRESYNC,从库解析出主库的replid和初始offset,状态推进到REPL_STATE_TRANSFER,开始进入真正的全量数据传输阶段。到这里,第二次握手结束,但距离从库“转正”还差最后一步。
4. 第三次握手:RDB搬完后的身份转正与增量接力
4.1 RDB传输期间从库在干嘛
第二次握手确定了全量同步后,主库后台生成RDB,生成完毕就把RDB数据推送给从库。从库在REPL_STATE_TRANSFER阶段,readSyncBulkPayload函数会负责接收数据。这个函数的名字很形象:读取“同步的批量数据”,也就是RDB文件。
接收过程中有几个关键细节值得注意。首先,从库并不会一边收一边写磁盘,而是先把数据写入临时文件(通常是temp- .rdb),等整个RDB接收完整后,再一次性加载到内存。其次,RDB数据流会有一个EOF标记,老版本用“EOF作为文件结尾”的方式,新版本通过capa能力协商后,会用文件长度前缀的方式判断数据是否完整。这就是第一次握手时从库上报“capa eof”的用武之地。
等RDB文件完整接收后,从库会做两件事:先清空自己现有的数据,再把RDB加载进内存。源码中你会看到类似“FLUSHALL”逻辑的执行,紧接着调用rdbLoad加载新数据。这一过程对业务是有阻塞风险的,尤其是从库上数据量大的场景,加载期间从库无法对外服务。生产环境里安排全量同步,一定要评估好从库的容量和业务压力。
4.2 REPLCONF ACK:第三次握手的核心动作
RDB加载完成后,从库状态进入REPL_STATE_CONNECTED。这个状态代表着复制关系正式建立,但主库侧还没100%确认从库已经“接住”了数据。真正让主库确认的,是从库随后发起的REPLCONF ACK请求。
REPLCONF ACK的发送频率在Redis中是每秒一次或根据配置的repl-ping-replica-period调整。从库会周期性发送“REPLCONF ACK <已复制的偏移量>”,这个偏移量就是从库当前追到的主库复制流位置。主库在replconfCommand中收到ACK后,会更新该从库的repl_ack_off字段,同时记录最近一次收到ACK的时间。这个字段的作用很多:主库INFO replication里展示的lag就是据此计算,主从故障检测也依赖它,甚至在主库提升从库时也要参考ACK进度来决定可以放行多少数据。
我把这理解成第三次握手的核心:从库用ACK数据包告诉主库“我的数据已经加载完了,我从这个偏移量开始等你喂命令”。主库只有在收到ACK后,才会真正把从库标记为完全online状态,开始正常推送增量命令。对增量复制而言,ACK不只是一次性动作,它是一种持续的心跳和进度上报,类似TCP连接建立后的保活机制。
4.3 online之后,增量数据是怎么喂给从库的
第三次握手完成,主从之间的数据流转才进入常态:增量传播。增量传播的底层机制是命令传播管道。主库每处理一条写命令,在真正执行的同时,会调用propagate把命令写入AOF缓冲和复制缓冲,最终由replicationFeedSlaves函数把命令追加到每个在线从库的输出缓冲区。
源码里replicationFeedSlaves做的事情有两件:一是把命令写入server.repl_backlog(复制积压缓冲区),保留最近一段时间的命令供断线重连时部分重同步;二是遍历server.slaves链表,把命令写入每个非WAIT状态的从库client发送缓冲区。主库靠事件循环把缓冲区数据刷到网络,从库再接收到、追加到自己的复制流里。
从库同时也在继续向上汇报自己的offset。主库每次收到ACK时更新进度,如果发现从库的offset已经追平,lag就变小;如果长时间收不到ACK,lag会增长,主库会认为从库可能断连。整个循环就是:主库写命令并分发,从库消费并回报进度,二者通过offset对齐。第三握手里那个“REPLCONF ACK”机制,在这一阶段持续发挥着作用。
4.4 断线重连、重启场景下,三次握手有什么不同
如果说前三次握手描述的是“从零开始建立复制”的理想路径,那么生产环境里更常见、更需要理解的是“复制中断后重连”的路径。重连时有个重要差异:第一次握手仍然是PING + REPLCONF全套,但从库发送PSYNC时不再是“? -1”,而是会带上自己断线前记录的runid和offset。
这条信息的价值在于:如果主库身份没变(runid匹配)、且从库的断线位置还在主库backlog里,主库就回复+CONTINUE,把断线期间的命令补发给从库。这时第三次握手会省去RDB传输的漫长过程,从库只需要补齐一小段gap就能online,效率非常高。这也是Redis主从复制最优雅的地方:设计初期就把“断点续传”作为一等公民。
但要注意,部分重同步是有条件的。如果主库在从库断开期间也重启过,runid变化,部分重同步必然失败,回退为全量重同步;如果断线时间太长,backlog被覆盖也会全量。你在源码里找createReplicationBacklog和修改backlog大小的配置(repl-backlog-size),就能理解为什么建议生产环境把backlog调大——它直接决定了断线窗口可以多长。
5. 源码阅读实录:如何自己追一遍三次握手
5.1 跟着状态机走一遍:源码追踪路径
读主从复制源码最怕“一头扎进函数细节出不来”。我自己摸索下来,比较高效的方式是“状态驱动追踪法”:先明确当前状态,再找驱动状态变化的函数,然后顺藤摸瓜。第一次握手可以从replicaofCommand开始,顺着replicationSetMaster、replicationCron、connectWithMaster一路走到syncWithMaster;第二次握手在syncWithMaster内部看它如何发送PSYNC,然后跳到主库的syncCommand;第三次握手跟着readSyncBulkPayload和replconfCommand走。
如果你用gdb调试,建议在这几个地方打断点:syncWithMaster(看第一次握手时的状态流转)、syncCommand(看主库的判断逻辑,在返回+FULLRESYNC还是+CONTINUE处最直观)、replconfCommand(看ACK处理)。配合Redis的日志级别,你几乎能看到从库每一行日志背后对应的源码位置。比如从库日志“Master replied to PING, replication can continue...”对应的是第一次握手PONG到达;日志“Full resync requested by master”对应第二次握手的+FULLRESYNC回复;日志“MASTER <-> REPLICA sync: Finished with success”对应第三次握手完成。
5.2 用日志和INFO命令复现三次握手
光看代码还不够,实际操作一次能帮你把代码和现象对应起来。建议准备一个最小复现环境:本机起两个Redis实例,一个主一个从,从库执行REPLICAOF后,立刻观察两边日志。
主库侧出现的关键日志顺序是:“Replica : asks for synchronization”后紧跟“Full resync requested by replica : ”,代表第二次握手全量判决完成;随后“Starting BGSAVE for SYNC”代表RDB开始生成。从库侧则是:“MASTER <-> REPLICA sync started” → “Master replied to PING” → “Full resync requested” → “MASTER <-> REPLICA sync: receiving N bytes from master” → “MASTER <-> REPLICA sync: Flushing old data” → “Loading DB in memory” → “MASTER <-> REPLICA sync: Finished with success”。把日志按时间排一排,三次握手的轮廓就非常清晰了。
INFO replication也值得同时观察。从库侧,master_link_status从down变为up、master_sync_in_progress从1变0,是第三次握手完成的重要信号;主库侧,connected_slaves从0变1,且slave列表里的state字段从wait_bgsave变为online,说明主库已经认了这个从库。这套现象和源码对照着看,你会对状态机有非常具体的体感。
5.3 我在阅读和实验中踩过的三个坑
最后分享几个我实际踩过的坑。第一个坑是把REPLCONF ACK当成只有同步完成后才发送的东西,其实从库online后是周期性持续发送的,甚至在加载RDB期间也会尝试发送旧位置的ACK。理解了这一点,你就知道查看lag时,如果lag一直很大,可能不是网络问题,而是主库事务繁忙导致处理ACK延迟。
第二个坑是忽略了runid变化对部分重同步的致命影响。我给一个测试主库配置了maxmemory策略,在订阅压力下频繁崩溃重启,从库每次都会触发全量同步,日志里全是full resync。当时排查很久才意识到:重启后runid变了,backlog也是空的,部分重同步在理论上就不成立。这个案例也提醒我,生产环境里想让从库稳定走增量复制,主库的稳定性比网络带宽更重要。
第三个坑是RDB传输期间调整了repl-timeout,导致全量同步反复失败。repl-timeout控制的是主从之间各类交互的超时时间,如果设置得太小,比如1秒,遇到RDB传输耗时较长的情况,从库长时间没收到数据就会判断超时,主动断开,整个握手前功尽弃。源码里replicationCron的检活逻辑依赖这个超时值,调参之前一定要先评估全量数据量和网络环境。
回过头看,Redis主从复制的三次握手,并不存在一个名叫“handshake”的函数,而是由状态机、命令处理和事件回调共同搭起来的一套协作流程。这也是阅读网络协议类源码最容易踩坑的地方:功能不是某一个函数完成的,而是分散在一组函数里完成。当你习惯了按照“状态驱动函数、函数推进状态”的方式去读代码,Redis主从复制的全貌就会变得非常清晰。如果你也想自己动手追一遍,建议就从这篇文章里的函数地图开始,把日志和源码对照着啃一遍,比我在这里写一万字都管用。