前言

Gossip 协议,这个名字听起来像办公室八卦。

但它的内核,远比“八卦”二字深刻得多。

Redis Cluster 选择了 Gossip,而不是 ZooKeeper 那样的集中式协调器。为什么?

三个核心原因:去中心化、弹性扩展、最终一致

集中式方案有一个明显的软肋——元数据存储节点成为单点瓶颈。而 Gossip 把状态更新的压力分散到每一个节点上,集群规模可以水平扩展到数百甚至上千个节点,每个节点的通信负载几乎恒定不变。

本文将从四个维度彻底拆解 Redis 的 Gossip 实现:

  1. 数学根基:Gossip 为什么收敛?收敛速度有多快?

  2. 消息结构clusterMsg 结构体的每个字节在做什么?

  3. 运行时引擎clusterCron 如何驱动整个协议运转?

  4. 故障检测状态机:从 PFAIL 到 FAIL,到底经历了什么?

读完这篇文章,你不仅能看懂 Redis 的集群通信源码,还能理解为什么这种“八卦算法”在分布式系统中无处不在。


第一章:Gossip 的数学根基

1.1 什么是 Gossip 协议?

Gossip 协议,又称 Epidemic Protocol(流行病协议)。这个名字很形象:信息在节点间的传播方式,和病毒在人群中的传播一模一样。

核心机制异常简单:

每个节点周期性地随机选择若干个其他节点,交换彼此已知的状态信息。经过若干轮之后,所有节点的状态收敛到一致。

Gossip 协议的起源可以追溯到 Xerox PARC(施乐帕洛阿尔托研究中心)的一篇论文,最初被称为“八卦算法”或“病毒算法”。

下面这张图展示了三种 Gossip 传播模式的演进:

  • 直接邮寄(Direct Mail):节点状态变化时主动推送给邻居,但消息可能丢失,不保证最终一致。

  • 反熵(Anti-Entropy):节点间定期交换全部数据摘要并修复差异,能保证最终一致,但通信开销大。

  • 谣言传播(Rumor Mongering):有新信息时积极传播,信息传遍全网后转为被动模式,是 Redis 实际采用的策略。

1.2 收敛速度的数学保证

Gossip 协议最迷人的特性是什么?指数级的收敛速度

在均匀随机选点、fanout(每次传播的目标数)为常数的经典模型下,信息传播到全网所需的轮次期望为 O(log N),其中 N 是节点总数。

举个例子:假设集群有 100 个节点,每个节点每次随机选 3 个节点交换信息。那么一条新信息传遍全网的期望轮次大约是 5-7 轮。如果每秒执行一轮,那就是 5-7 秒。

中国科学院的研究者进一步证明:在满足有限时间收敛的条件下,随机 Gossip 算法能保证在有限时间收敛内一定会收敛,而且是理论上可能存在的最快算法。

这个数学保证意味着什么?

Gossip 协议天然适合大规模分布式系统。节点数从 10 增长到 10000,信息传播延迟只从 O(log 10) 增长到 O(log 10000)——也就是从约 3 轮变成约 14 轮,远远慢于线性增长。

1.3 与 SWIM 协议的关系

这里必须提到一个重要的概念:SWIM(Scalable Weakly-consistent Infection-style Process Group Membership Protocol)。

SWIM 是 Gossip 协议在成员管理领域的一个经典实现。它把成员管理问题拆成了两个独立组件:

  1. 故障检测组件(Failure Detector):负责检测节点是否存活。

  2. 传播组件(Dissemination Component):负责将成员变更信息传播到全网。

这种“检测-传播”分离的设计,后来被 Redis Cluster、Consul、Cassandra 等大量分布式系统借鉴。Redis Cluster 的 PFAIL → FAIL 两阶段故障检测,本质上就是 SWIM 思想的工程实践。


第二章:消息结构——clusterMsg 

2.1 总线端口:为什么是 16379?

在深入消息结构之前,先明确一个常被忽略的细节。

Redis Cluster 中每个节点都有两个端口:

  • 数据端口:默认 6379,用于处理客户端请求。

  • 集群总线端口(Cluster Bus Port):数据端口 + 10000,默认 16379,用于节点间 Gossip 通信。

为什么需要独立的总线端口?

因为集群内部通信使用二进制协议,而客户端通信使用 RESP 文本协议。将它们分离到不同端口,可以避免协议混淆,也便于网络层的流量控制和监控。集群总线使用的协议就是 RCmb(Redis Cluster message bus)——我们马上会看到这个魔数。

2.2 clusterMsg 结构体全景

Gossip 协议在 Redis 中的核心数据结构是 clusterMsg,定义在 cluster.h 中。下面给出它的完整结构(基于 Redis 7.0 源码),并附上每个字段的详细注释:

typedef struct {
    // ========== 消息头(Header) ==========
    char sig[4];                // 魔数 "RCmb"(Redis Cluster message bus)
    uint32_t totlen;            // 消息总长度(字节)
    uint16_t ver;               // 协议版本,当前为 1
    uint16_t port;              // 发送者的数据端口(如 6379)
    uint16_t type;              // 消息类型:PING/PONG/MEET/FAIL/UPDATE...
    uint16_t count;             // gossip 部分包含的节点数量
    
    // ========== 发送者基本信息 ==========
    uint64_t currentEpoch;      // 发送者视角下的集群统一纪元(用于选举)
    uint64_t configEpoch;       // 主节点唯一的配置纪元(冲突时自增)
    uint64_t offset;            // 主从复制偏移量
    char sender[CLUSTER_NAMELEN]; // 发送者节点 ID(40 字节)
    unsigned char myslots[CLUSTER_SLOTS/8]; // 位图,标识发送者负责的槽(16384 bits = 2048 bytes)
    char slaveof[CLUSTER_NAMELEN]; // 如果是从节点,记录其主节点 ID
    char myip[NET_IP_STR_LEN];  // 发送者 IP 地址
    char notused1[34];          // 预留字段(向后兼容)
    
    // ========== 更多元信息 ==========
    uint16_t cport;             // 发送者的集群总线端口(port + 10000)
    uint16_t flags;             // 发送者节点标志(MASTER/SLAVE/PFAIL/FAIL...)
    unsigned char state;        // 发送者视角下的集群状态
    unsigned char mflags[3];    // 消息标志(如 CLUSTERMSG_FLAG0_PAUSED)
    
    // ========== 消息体(Union,按类型不同而不同) ==========
    union clusterMsgData data;
} clusterMsg;

这个结构体大约 2.5KB。其中最大的一块是 myslots 字段(2048 字节),它用位图的方式表示 16384 个槽的归属。每一位代表一个槽,1 表示该槽由当前节点负责。

下面这张图直观展示了 clusterMsg 的内存布局:

2.3 字段深潜:epoch、flags 和 myslots

currentEpoch vs configEpoch:这是最容易混淆的两个字段。

  • configEpoch:每个主节点独有的标识,类似于“节点版本号”。当两个主节点声称负责同一个槽时,configEpoch 较大的那个胜出,另一个则被迫自增。

  • currentEpoch:集群级别的统一纪元,用于选举投票。它类似于 Raft 中的 term,确保整个集群在同一个时间线上达成共识。

flags:节点状态标志位。常见的取值包括:

标志 含义
CLUSTER_NODE_MASTER 主节点
CLUSTER_NODE_SLAVE 从节点
CLUSTER_NODE_PFAIL 疑似下线(Possible Failure)
CLUSTER_NODE_FAIL 确认下线
CLUSTER_NODE_HANDSHAKE 握手状态(尚未完成加入)
CLUSTER_NODE_MEET 需要发送 MEET 消息
CLUSTER_NODE_NOADDR 无地址(无法连接)

myslots:2048 字节的位图,每一位对应一个槽。Redis 将全部数据划分为 16384 个槽,为什么是这个数字?

原因有三:

  1. 16384 足够多,可以均匀分配给数千个节点。

  2. 16384 bits = 2048 bytes,这个大小刚好可以让 clusterMsg 保持在 MTU 以内,避免 IP 分片。

  3. CRC16 算法的输出范围是 0-16383,天然匹配。

2.4 消息类型

Redis Cluster 定义了 9 种消息类型:

#define CLUSTERMSG_TYPE_PING     0   // 心跳探测,携带 gossip 信息
#define CLUSTERMSG_TYPE_PONG     1   // PING 的回复,同样携带 gossip 信息
#define CLUSTERMSG_TYPE_MEET     2   // 请求加入集群
#define CLUSTERMSG_TYPE_FAIL     3   // 广播节点下线
#define CLUSTERMSG_TYPE_PUBLISH  4   // Pub/Sub 消息转发
#define CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 5  // 请求故障转移投票
#define CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK     6  // 投票确认
#define CLUSTERMSG_TYPE_UPDATE   7   // 更新槽位配置
#define CLUSTERMSG_TYPE_MFSTART  8   // 手动故障转移开始

PING 和 PONG 是最频繁的消息类型。它们不仅用于心跳检测,更重要的是携带 gossip 信息——即发送者已知的其他节点的状态。

2.5 Gossip Section 的精妙设计

clusterMsg 的消息体中,PING/PONG/MEET 类型携带的是 clusterMsgDataGossip 结构数组,这就是 Gossip Section

typedef struct {
    char nodename[CLUSTER_NAMELEN];  // 被描述的节点 ID
    uint32_t ping_sent;              // 最近一次向该节点发送 PING 的时间戳
    uint32_t pong_received;          // 最近一次收到该节点 PONG 的时间戳
    char ip[NET_IP_STR_LEN];         // 该节点 IP
    uint16_t port;                   // 该节点数据端口
    uint16_t cport;                  // 该节点总线端口
    uint16_t flags;                  // 该节点状态标志
    uint32_t notused1;               // 对齐填充
} clusterMsgDataGossip;

这个数组的长度是可变的。在 clusterMsg 的头部,count 字段记录了 gossip section 中包含多少个这样的条目。每个条目描述一个节点。

关键设计:发送者不会一次性把自己的全部节点列表发出去,而是随机挑选一部分。为什么?

  1. 控制消息大小:如果集群有 1000 个节点,全部发送会超过 MTU,导致 IP 分片,性能急剧下降。

  2. 保证传播效率:数学证明,即使每次只发送部分节点,经过 O(log N) 轮后信息仍然能覆盖全网。

每次 PING 消息携带的 gossip 条目数有一个动态范围:

  • 最少 3 个:确保即使在小集群中也有足够的冗余。

  • 最多 total_nodes - 2 个:也就是约等于全部已知节点。

此外,Redis 还有一个精巧的优化:pong_received 字段的复用。如果接收者发现自己记录的某节点 pong_received 比发送者记录的时间更旧,且满足一定条件,接收者会直接采纳发送者的时间戳。这样就减少了不必要的 PING 发送,节约了带宽。


第三章:运行时引擎——从 clusterCron 到消息收发

3.1 事件循环中的 Gossip 调度

Redis 是一个单线程的事件驱动系统,所有功能都挂在 aeMain 事件循环中。Gossip 协议的调度入口是 clusterCron 函数,它被 serverCron 以每秒 10 次的频率调用。

// 简化的调用链
int main() {
    initServer();
    aeMain(server.el);  // 事件循环
}

int serverCron() {
    run_with_period(100) {  // 每 100 毫秒执行一次 = 每秒 10 次
        if (server.cluster_enabled)
            clusterCron();
    }
}

为什么是每秒 10 次?这是一个精心选择的频率:

  • 太高(比如每秒 100 次):网络和 CPU 开销过大。

  • 太低(比如每秒 1 次):故障检测延迟太长,收敛速度慢。

  • 每秒 10 次意味着每 100 毫秒一次,正好在响应性和资源消耗之间找到平衡点。

clusterCron 函数每次执行时会做以下事情:

3.2 随机选择算法:不只“随机”那么简单

Gossip 协议的核心是“随机选择通信节点”。但 Redis 的实现远不止简单的 rand()

// clusterCron 中的节点选择逻辑(伪代码)
void clusterCron(void) {
    int fresh_ping_sent = 0;
    
    // 遍历所有已知节点
    for (each node in server.cluster->nodes) {
        // 条件1:如果超过一半超时时间没有收到 PONG,立即发送
        if (now - node->pong_received > server.cluster_node_timeout / 2) {
            clusterSendPing(node->link, CLUSTERMSG_TYPE_PING);
            fresh_ping_sent++;
            continue;
        }
        
        // 条件2:否则,收集到候选列表,后续选最久未通信的
        // ...
    }
    
    // 从候选列表中每次选择若干最久未通信的节点(数量动态计算,最多不超过 5 个)
    // ...
    
    // 每 10 次循环,额外随机挑选 1 个节点(防止信息孤岛)
    if (!(iteration % 10)) {
        // 随机选一个节点发送 PING
    }
}

这里有三个精妙的设计:

设计一:优先响应“快超时”的节点。 如果一个节点距离上次收到 PONG 的时间已经超过了 cluster_node_timeout / 2,那么本轮立即给它发 PING,不管它是不是“最久未通信”。这是一种“预防性”机制,避免节点被误判为下线。

设计二:每次选择若干最久未通信的节点(数量动态计算,最多不超过 5 个) 这是经典 gossip 算法中的“反熵”策略——优先与那些信息可能最“陈旧”的节点同步。5 这个数字是经验值:在几百个节点的集群中,5 个节点足以保证 O(log N) 的收敛速度,同时控制消息量。

设计三:每 10 次循环随机选 1 个节点。 这是为了打破“信息孤岛”。如果每次都选“最久未通信”的节点,可能会出现一种情况:某些节点之间永远没有直接通信,只能通过中间节点中转。随机选择可以增加拓扑的连通性,加速信息扩散。

3.3 消息接收:clusterProcessPacket

当节点收到消息时,网络 IO 触发 clusterReadHandler,最终调用 clusterProcessPacket 进行处理:

void clusterReadHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
    // 从 socket 读取数据
    // ...
    clusterProcessPacket(link);  // 核心处理函数
}

int clusterProcessPacket(clusterLink *link) {
    clusterMsg *hdr = (clusterMsg*) link->rcvbuf;
    
    // 1. 检查魔数
    if (memcmp(hdr->sig, "RCmb", 4) != 0) return 1;
    
    // 2. 查找或创建发送者节点对象
    clusterNode *sender = clusterLookupNode(hdr->sender);
    if (!sender && type == CLUSTERMSG_TYPE_MEET) {
        // MEET 消息:新节点加入
        sender = createClusterNode(...);
        clusterAddNode(sender);
    }
    
    // 3. 更新发送者状态(ping_sent / pong_received 等)
    // ...
    
    // 4. 处理 gossip section
    if (sender) {
        clusterProcessGossipSection(hdr, link);
    }
    
    // 5. 根据消息类型执行特定逻辑
    switch (hdr->type) {
        case CLUSTERMSG_TYPE_PING:
            clusterSendPing(link, CLUSTERMSG_TYPE_PONG);  // 回复 PONG
            break;
        case CLUSTERMSG_TYPE_FAIL:
            markNodeAsFailingIfNeeded(...);
            break;
        // ...
    }
}

这里有一个值得注意的安全设计:消息魔数校验。每条消息的开头 4 个字节必须是 RCmb(Redis Cluster message bus)。这可以快速过滤掉非 Gossip 协议的流量,防止协议混淆攻击。

3.4 Gossip Section 处理:clusterProcessGossipSection

这是整个 Gossip 协议最核心的函数——如何根据别人告诉你的信息,更新自己的世界观

void clusterProcessGossipSection(clusterMsg *hdr, clusterLink *link) {
    uint16_t count = ntohs(hdr->count);  // gossip 条目数量
    clusterMsgDataGossip *g = (clusterMsgDataGossip*) hdr->data.ping.gossip;
    
    for (int i = 0; i < count; i++) {
        // 查找本地是否已有该节点
        clusterNode *node = clusterLookupNode(g->nodename);
        
        if (node) {
            // 节点已存在:更新信息
            
            // 关键1:如果收到关于自己的 gossip,忽略(防止信息污染)
            if (node == myself) continue;
            
            // 关键2:采纳更新的 configEpoch
            // 关键3:采纳更新的 pong_received 时间戳(带宽优化)
            // 关键4:如果发送者报告该节点为 FAIL 或 PFAIL,
            //       则调用 clusterNodeAddFailureReport 增加失败报告数
            if (g->flags & (CLUSTER_NODE_FAIL | CLUSTER_NODE_PFAIL)) {
                if (clusterNodeAddFailureReport(node, sender)) {
                    // 如果失败报告数达到多数派,标记为 FAIL
                    markNodeAsFailingIfNeeded(node);
                }
            }
        } else {
            // 节点不存在:创建新节点对象(黑名单机制暂不处理)
            // 只在收到 MEET 或特定条件下才真正加入
        }
        
        g++;  // 下一个 gossip 条目
    }
}

这个函数体现了 Gossip 协议的 “信任但验证” 原则:

  • 信任:接收发送者提供的节点信息,更新本地状态。

  • 验证:不盲目信任。对于 FAIL 状态,需要收集多个独立节点的报告(多数派),才最终确认。

这里还有一个精妙的带宽优化:关于 pong_received 时间戳的采纳逻辑。如果接收者发现发送者的 pong_received 更新(且合理),就直接采纳,避免自己再发一次 PING。这体现了“能省则省”的工程哲学。


第四章:故障检测——从 PFAIL 到 FAIL 的状态流转

4.1 两阶段故障检测:PFAIL 和 FAIL

Redis Cluster 的故障检测分为两个阶段:

  • PFAIL(Possible Failure,疑似下线):节点 A 主观认为节点 B 可能挂了。

  • FAIL(Confirmed Failure,确认下线):集群中的多数主节点都认为节点 B 挂了。

这是一个 主观 → 客观 的确认过程。

为什么需要两阶段?因为网络分区和瞬时的网络抖动很常见。如果一超时就判定为 FAIL,会导致频繁的误判和故障转移,严重影响稳定性。两阶段设计大大降低了误判率。

4.2 PFAIL 的判定条件

在 clusterCron 中,每个节点会检查与其他节点的通信状态:

// 检查是否超时
delay = now - node->ping_sent;
if (delay > server.cluster_node_timeout) {
    // 超时,且不处于 PFAIL 或 FAIL,则标记为 PFAIL
    if (!(node->flags & (CLUSTER_NODE_PFAIL | CLUSTER_NODE_FAIL))) {
        node->flags |= CLUSTER_NODE_PFAIL;
    }
}

这里的 cluster_node_timeout 是一个可配置参数(默认 15000 毫秒)。调大它可以降低误判率,但会增加故障检测延迟;调小则相反。

4.3 从 PFAIL 到 FAIL:多数派确认

当一个节点 A 将节点 B 标记为 PFAIL 后,它会通过 Gossip 协议传播这一信息。其他节点收到后,会记录“A 认为 B 是 PFAIL”。这就是 clusterNodeAddFailureReport 函数做的事情。

当某个节点收集到足够多的 PFAIL 报告后,就会将 B 标记为 FAIL:

void markNodeAsFailingIfNeeded(clusterNode *node) {
    // 1. 节点必须处于 PFAIL 状态
    // 2. 自己必须能够访问该节点(防止网络分区误判)
    // 3. 收集到的失败报告数必须达到多数派
    int needed_quorum = (server.cluster->stats_num_masters / 2) + 1;
    int failures = clusterNodeFailureReportsCount(node);
    
    if (failures < needed_quorum) return;
    
    // 标记为 FAIL
    node->flags |= CLUSTER_NODE_FAIL;
    node->flags &= ~CLUSTER_NODE_PFAIL;
    
    // 广播 FAIL 消息
    clusterSendFail(node->name);
}

这里有三个关键条件:

  1. 节点必须处于 PFAIL 状态:这是本地主观判断的前提。

  2. 自己必须能访问该节点:如果自己都访问不了,说明可能是网络分区,不应该贸然判定别人 FAIL。

  3. 失败报告数 ≥ 多数派:这是最核心的条件,确保判定是“客观”的。

4.4 FAIL 消息的广播与处理

一旦节点被标记为 FAIL,发起节点会立即广播一条 CLUSTERMSG_TYPE_FAIL 消息。收到 FAIL 消息的节点会:

  1. 无条件将目标节点标记为 FAIL(不需要再次收集多数派)。

  2. 如果自己是从节点且主节点是目标节点,则发起故障转移。

这种“一锤定音”的设计,确保了 FAIL 状态能在 O(log N) 时间内传遍全网,触发快速的故障转移。


第五章:串联——一个完整的故事

现在我们把所有碎片拼接起来,讲一个完整的故事:一个新节点如何加入 Redis Cluster,以及一个节点故障如何被检测和转移

5.1 节点加入流程

  1. 管理员执行 CLUSTER MEET <ip> <port>,新节点向种子节点发送 MEET 消息。

  2. 种子节点将新节点加入本地节点列表,并回复 PONG。

  3. 在后续的 PING/PONG 交互中,种子节点通过 gossip section 向其他节点传播新节点的信息。

  4. 其他节点收到 gossip 后,主动向新节点发送 MEET,建立连接。

  5. 新节点通过与各节点的 gossip 交互,逐步学习到整个集群的拓扑和槽位分布。

整个过程完全自动化,不需要任何中心协调器。

5.2 故障检测与转移流程

  1. 从节点 B 定期向主节点 A 发送 PING,检测心跳。

  2. 超时后,B 将 A 标记为 PFAIL,并通过 gossip 传播。

  3. 其他主节点(如 C 和 D)收到 gossip 后,也会检查 A 的状态。

  4. 当 C 收集到足够多的 PFAIL 报告(多数派)后,将 A 标记为 FAIL,并广播 FAIL 消息。

  5. B 收到 FAIL 消息后,立即发起故障转移选举。

  6. B 获得多数主节点的投票后,晋升为新的主节点,接管 A 负责的槽位。

整个故障检测到转移的过程,从第一个节点发现 PFAIL 到完成转移,通常在 cluster_node_timeout 的 1-2 倍时间内完成。

5.3 数据流向全景图

下面这张图展示了整个 Gossip 通信的数据流向:

从这个全景图中可以看出,Gossip 协议在 Redis 中的实现是一个双工、持续、自愈的系统:

  • 双工:PING 和 PONG 互为镜像,双方都在交换信息。

  • 持续:每秒 10 次的频率,保证了信息的及时传播。

  • 自愈:故障检测和转移机制内建于协议中,无需人工干预。


总结:Gossip 的哲学与局限

它为什么这么优秀?

Gossip 协议的成功,源于它深刻理解了分布式系统的核心矛盾:强一致性 vs 可用性 vs 分区容错(CAP 定理)。

它主动放弃了强一致性,换来了:

  • 极致的水平扩展性:节点数增加,每个节点的负载几乎恒定。

  • 天然的容错性:没有单点,任何节点挂掉都不影响协议运转。

  • 实现的简洁性:核心逻辑只有几百行代码,易于理解和维护。

它有什么局限?

Gossip 协议不是银弹。它有几个公认的局限性:

  1. 最终一致性意味着延迟:信息传播需要 O(log N) 轮,集群规模越大,收敛时间越长。

  2. 网络分区时的脑裂风险:如果发生对称分区(主节点数量为偶数时),两边都可能独立选出新主,需要额外的 epoch 机制来仲裁。

  3. 跨地域部署的挑战:广域网的高延迟会放大 Gossip 的收敛延迟,需要引入分层 Gossip(Hierarchical Gossip)等优化手段。

关于网络分区补充

理论上主节点数量设置为奇数,当发生分区,最多只有一个分区能获得多数派选出新主,当分区恢复后,通过configEpoch自动解决新旧主冲突,版本号大的胜出。

但在数据层面,如果客户端连的旧主,且没有要求 WAIT 同步复制,此时写入的数据在分区恢复后可能会丢失。

一图胜千言

最后的思考

理解 Gossip 协议,不仅仅是读懂几百行 C 代码。

它代表了一种设计哲学:在分布式系统中,有时候“足够好”比“完美”更有价值。强一致性很美,但代价是可用性和扩展性的牺牲。Gossip 用“最终一致性”换来了无与伦比的弹性和简洁性。

下次你在代码中调用 CLUSTER MEET,或者看到 Redis 日志中的 PFAIL 和 FAIL 时,希望你脑海中能浮现出这篇文章中的那些数据结构和流程图。因为它们,才是 Redis Cluster 在数千个节点中稳定运行的真正原因。


参考资料

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐