Skip to content
Go back

Redis Cluster Gossip协议——去中心化的元数据传播

Redis Cluster Gossip:去中心化的节点发现与故障检测

一句话结论(30s)

Gossip 的本质是去中心化的元数据传播:因为中心化协调者(NameServer/ZooKeeper)有单点风险,所以每个节点每 100ms 随机 PING/PONG 交换节点视图,像流行病传播一样在 O(log N) 轮内让全集群知晓状态变更;代价是最终一致(秒级延迟),这是 CAP 中典型的 AP 取舍。

核心原理(2min)

每个节点维护已知节点列表,每 100ms 随机选 N 个节点发 PING(携带自身 nodeId/IP/flags/epoch、随机几个其他节点信息、以及自己的 slot 位图),收到 PONG 后合并视图,实现节点自动发现与故障标记。故障检测分两级:PFAIL 是单节点主观判定,PFAIL 经 Gossip 传播、过半主节点确认后升级为 FAIL(客观)并触发故障转移。16384 槽位 = 2^14,槽位位图正好 16384 bit = 2KB 塞进心跳包;若用 65536 槽位心跳包膨胀到 8KB、Gossip 带宽开销翻 4 倍,所以 16384 是心跳包大小与槽位粒度之间的精确平衡。

底层深入(5-10min)

为什么不用中心化方案?

中心化的 NameServer(如 RocketMQ)或 ZooKeeper 有单点风险——协调者宕机则集群元数据不可更新。Redis Cluster 选择了完全去中心化的 Gossip 协议。

思考:为什么去中心化要选「随机 PING/PONG」而不是「每个节点向所有节点广播」?顺着推一步——广播虽然 O(1) 轮就能让全集群知道,但每节点每 100ms 发 O(N) 条消息,总流量 O(N²),节点一多带宽直接爆炸;随机挑固定数量节点,总流量降成 O(N),代价是传播从 O(1) 轮变成 O(log N) 轮。用「收敛慢一点点」换「带宽可扩展、无单点」,这就是 Gossip 的核心权衡。

Gossip 的工作原理

每个节点维护一个已知节点列表。
每 100ms:
  1. 随机选 N 个已知节点
  2. 发送 PING(含自己的节点视图)
  3. 收到 PONG(含对方的节点视图)
  4. 合并视图 → 发现新节点/标记故障节点

PING 消息携带的信息

PING/PONG 消息体(~2KB):
  - 发送者自身信息(nodeId, IP, port, flags, epoch)
  - 发送者已知的随机几个其他节点的信息(gossip 传播)
  - 发送者的 slot 分配表

每个节点通过随机的 PING/PONG 交换,逐步扩散集群全局状态。 类似于流行病传播模型——O(log N) 轮内所有节点知晓状态变更。

源码:clusterSendPing 随机挑选节点塞进 gossip section

/* cluster_legacy.c — clusterSendPing() 计算 gossip 条数并随机采样节点 */
/* How many gossip sections we want to add? 1/10 of the number of nodes
 * and anyway at least 3. Why 1/10? */
wanted = floor(dictSize(server.cluster->nodes)/10);
if (wanted < 3) wanted = 3;
if (wanted > freshnodes) wanted = freshnodes;

/* Include all the nodes in PFAIL state, so that failure reports are
 * faster to propagate to go from PFAIL to FAIL state. */
int pfail_wanted = server.cluster->stats_pfail_nodes;

/* Populate the gossip fields */
int maxiterations = wanted*3;
while(freshnodes > 0 && gossipcount < wanted && maxiterations--) {
    dictEntry *de = dictGetRandomKey(server.cluster->nodes);
    clusterNode *this = dictGetVal(de);

    /* Don't include this node: the whole packet header is about us
     * already, so we just gossip about other nodes.
     * Also, don't include the receiver. Receiver will not update its state
     * based on gossips about itself. */
    if (this == myself || this == link->node) continue;

    /* PFAIL nodes will be added later. */
    if (this->flags & CLUSTER_NODE_PFAIL) continue;

    /* In the gossip section don't include:
     * 1) Nodes in HANDSHAKE state.
     * 3) Nodes with the NOADDR flag set.
     * 4) Disconnected nodes if they don't have configured slots.
     */
    if (this->flags & (CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_NOADDR) ||
        (this->link == NULL && this->numslots == 0))
    {
        freshnodes--; /* Technically not correct, but saves CPU. */
        continue;
    }

    /* Do not add a node we already have. */
    if (this->last_in_ping_gossip == cluster_pings_sent) continue;

    /* Add it */
    clusterSetGossipEntry(hdr,gossipcount,this);
    this->last_in_ping_gossip = cluster_pings_sent;
    freshnodes--;
    gossipcount++;
}

这段就是「PING 携带随机节点信息」的核心:每次心跳携带 wanted = max(节点数/10, 3) 条 gossip 记录,通过 dictGetRandomKey 从节点字典里随机采样,跳过自己、接收方、握手中的节点。注释里专门解释了为何是 1/10——这样 PFAIL 节点的故障报告在 node_timeout*2 窗口内能被过半数主节点收到,既省带宽又保证 FAIL 升级能成立。

思考:为什么 gossip 条数取「节点数的 1/10」而不是全部?反着推——如果每个心跳塞全部节点信息,心跳包会随集群规模线性膨胀、带宽不可控;如果塞太少,PFAIL 报告扩散太慢、FAIL 升级迟迟无法过半。1/10 是「带宽」与「故障传播速度」之间的经验平衡点,注释里已点明它的目标:保证 PFAIL 在 node_timeout*2 窗口内传达到过半数主节点。

故障检测

两个阶段:

  1. PFAIL(Possible Failure):单个节点 PING 超时 → 标记目标为 PFAIL(主观)
  2. FAIL(Confirmed Failure):PFAIL 信息通过 Gossip 传播 → 半数以上主节点确认 → 目标标记为 FAIL(客观)→ 触发故障转移

源码:clusterCron 定时传播 + PFAIL 判定

/* cluster_legacy.c — clusterCron() 周期性心跳与主观故障标记 */
/* Ping some random node 1 time every 10 iterations, so that we usually ping
 * one random node every second. */
if (!(iteration % 10)) {
    int j;

    /* Check a few random nodes and ping the one with the oldest
     * pong_received time. */
    for (j = 0; j < 5; j++) {
        de = dictGetRandomKey(server.cluster->nodes);
        clusterNode *this = dictGetVal(de);

        /* Don't ping nodes disconnected or with a ping currently active. */
        if (this->link == NULL || this->ping_sent != 0) continue;
        if (this->flags & (CLUSTER_NODE_MYSELF|CLUSTER_NODE_HANDSHAKE))
            continue;
        if (min_pong_node == NULL || min_pong > this->pong_received) {
            min_pong_node = this;
            min_pong = this->pong_received;
        }
    }
    if (min_pong_node) {
        serverLog(LL_DEBUG,"Pinging node %.40s", min_pong_node->name);
        clusterSendPing(min_pong_node->link, CLUSTERMSG_TYPE_PING);
    }
}

/* ... 遍历节点,超时未收到任何数据则标记 PFAIL ... */
if (node_delay > server.cluster_node_timeout) {
    /* Timeout reached. Set the node as possibly failing if it is
     * not already in this state. */
    if (!(node->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL))) {
        node->flags |= CLUSTER_NODE_PFAIL;
        update_state = 1;
        if (clusterNodeIsMaster(myself) && server.cluster->size == 1) {
            markNodeAsFailingIfNeeded(node);
        } else {
            serverLog(LL_DEBUG,"*** NODE %.40s possibly failing", node->name);
        }
    }
}

clusterCron 每 100ms 执行一次(由 serverCron 驱动),上面是它定时传播的核心:每 10 次迭代随机挑 5 个节点、PING 其中 pong_received 最旧的那个,保证全集群心跳不会出现长时间静默。第二段则判断 node_delay(距上次 ping 或收到数据的时间)超过 cluster_node_timeout 就置 CLUSTER_NODE_PFAIL——这是「主观下线」的源码实现。

源码:clusterProcessGossipSection 汇总故障报告升级 FAIL

/* cluster_legacy.c — clusterProcessGossipSection() 合并他人视角的故障标记 */
/* Update our state accordingly to the gossip sections */
node = clusterLookupNode(g->nodename, CLUSTER_NAMELEN);
/* Ignore gossips about self. */
if (node && node != myself) {
    /* We already know this node.
       Handle failure reports, only when the sender is a master. */
    if (sender && clusterNodeIsMaster(sender)) {
        if (flags & (CLUSTER_NODE_FAIL|CLUSTER_NODE_PFAIL)) {
            if (clusterNodeAddFailureReport(node,sender)) {
                serverLog(LL_VERBOSE,
                    "Node %.40s (%s) reported node %.40s (%s) as not reachable.",
                    sender->name, sender->human_nodename, node->name, node->human_nodename);
            }
            markNodeAsFailingIfNeeded(node);
        } else {
            if (clusterNodeDelFailureReport(node,sender)) {
                serverLog(LL_VERBOSE,
                    "Node %.40s (%s) reported node %.40s (%s) is back online.",
                    sender->name, sender->human_nodename, node->name, node->human_nodename);
            }
        }
    }
}

收到 PING/PONG 的 gossip 段后,节点把「别人也认为某节点故障」的信息累加到该节点的故障报告列表里,然后 markNodeAsFailingIfNeeded 判断是否已过半数主节点确认、进而把 PFAIL 升级为 FAIL。这是 PFAIL 从主观变成客观的关键一步,说明 FAIL 不是某个节点拍板,而是 Gossip 传播后的多数派共识。

思考:为什么要 PFAIL(主观)→ FAIL(客观)两级判定,而不是某个节点 PING 超时就拍板 FAIL?关键在「误判」——单节点的网络抖动/分区会让它误以为别人挂了,若立刻触发故障转移,会造成脑裂或多余的主从切换。让多个节点各自主观判断、再通过 Gossip 汇聚成「过半数主节点确认」的客观结论,本质就是把「单点误判」升级成「多数派共识」,牺牲一点恢复速度换正确性。

去中心化的代价:最终一致性

Gossip 是最终一致的——新加入的节点需要几秒到几十秒才能被全集群知晓。与强一致性方案(Raft/ZooKeeper)不同,Gossip 允许短暂的状态不一致(某节点认为 A 在线,另一节点认为 A 已故障),但最终会收敛到一致。

16384 槽位的设计考量

CRC16(key) % 16384 → 16384 = 2^14,槽位信息压缩进心跳包。每个节点的心跳包包含自己的槽位位图(16384 bit = 2KB)。如果槽位是 65536,心跳包将膨胀到 8KB——Gossip 的带宽开销翻 4 倍。16384 是心跳包大小(保持在 ~2KB)和槽位粒度之间的精确平衡。

思考:16384 这个数是怎么来的?先列约束——每个节点的心跳包要携带自己的 slot 位图,16384 bit = 2KB 正好能塞进心跳;若换成 65536,位图膨胀到 8KB、Gossip 带宽翻 4 倍。那为什么不更小?因为 slot 是迁移与负载均衡的最小粒度,太少会让单槽数据过多、迁移粒度太粗。16384 = 2^14,是「心跳包体积」和「槽位粒度」的精确平衡点。

总结

Gossip 让 Redis Cluster 不需要 NameServer/ZooKeeper——去中心化、无单点、自动发现。代价是状态传播有延迟(秒级),符合 CAP 的 AP 选择。

章末提问

Q1:Gossip 协议为什么能在 O(log N) 轮内让全集群知晓状态变更?跟广播、中心化协调者比各有什么取舍?

回答思路:结论——因为每个节点每轮随机向固定数量节点扩散、且「已感染」的节点数按比例指数增长,所以 O(log N) 轮收敛。因为:Gossip 类比流行病传播,知晓状态的节点数每轮翻倍;对比广播是 O(1) 轮但流量 O(N²)、中心化协调者存在单点故障,Gossip 用「收敛慢一点」换「无单点 + 带宽可扩展」,是 CAP 中 AP 的典型选择。

Q2:Redis Cluster 的故障检测为什么分 PFAIL 和 FAIL 两级?直接 PING 超时就标记 FAIL 有什么问题?

回答思路:结论——两级是为了防误判,把「单节点主观判断」升级为「过半数主节点的客观共识」。因为:单个节点 PING 超时可能只是它自己的网络分区,若立刻 FAIL 并触发故障转移会脑裂;只有 PFAIL 经 Gossip 传播、过半主节点都确认后才升级 FAIL 并转移,牺牲一点恢复速度换来避免错误的故障转移。

Q3:为什么 Redis Cluster 用 16384 个 slot,而不是 65536 或更少?

回答思路:结论——16384 = 2^14 是心跳包大小与槽位粒度的平衡。因为:每个节点心跳要携带自己的 slot 位图,16384 bit = 2KB,若 65536 则膨胀到 8KB、Gossip 带宽翻 4 倍;若槽位太少,单槽数据量过大、迁移粒度太粗。所以 16384 让心跳包保持在约 2KB 的同时又保证足够的迁移粒度。


Share this post on:

Previous Post
ZGC——着色指针与读屏障实现亚毫秒级停顿
Next Post
RocketMQ DLedger——基于Raft的CommitLog多数派提交