Redis Cluster

Redis Cluster 是官方提供的 Redis 集群化方案。Redis Cluster 是去中心化的。

设计目标

redis cluster 是 redis 的分布式实现,其设计目标:高性能和线性扩展能力,最高可达 1000 个节点。实现非常高的性能和可扩展性,同时保留较弱但合理的写入安全性和可用性,是 Redis Cluster 的主要目标。

性能近乎是线性扩展的,在正常情况下,客户端会缓存集群节点槽位信息,客户端会直接访问正确的节点,只有少数情况下才会发生跳转的情况。

基本实现原理

在 redis cluster 中,节点负责保存数据,并维护集群状态,包括将键映射到正确的节点。集群节点能够自动发现其他节点,检测不可用的节点,并在发生故障时提升副本节点为主节点,以便继续运行。

Redis Cluster 将所有数据划分为 16384 个槽位,每个节点负责一部分槽位。槽位的信息存储于每个节点中,不像 codis 需要另外的分布式存储空间来存储节点槽位信息。当 redis cluster 的客户端来连接集群时也会得到一份集群的槽位配置信息。这样当客户端要查找某个 key 时,可以直接定位到目标节点。客户端为了可以直接定位某个具体的 key 所在的节点,需要缓存槽位相关信息,这样才可以准确快速地定位到相应的节点。同时因为可能会存在客户端与服务器存储槽位的信息不一致的情况,还需要纠正机制来实现槽位信息的校验调整。

#define CLUSTER_SLOTS 16384    

另外,Redis Cluster 的每个节点会将集群的配置信息持久化到配置文件中,所以必须确保配置文件是可写的,并且尽量不要人工修改配置文件。

当集群扩缩容时,槽位数量不变,槽位会在节点间迁移,槽的计算逻辑不变。其设计思想其实就是一致性哈希

节点使用 Gossip 协议来同步集群的信息,发现新节点,发送 PING 包以确保所有其他节点正常工作,并发送所需的集群消息以指示特定情况。

槽位定位算法

redis cluster 默认会对 key 值使用 crc16 算法进行 hash,得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。

哈希标签:redis cluster 也允许用户强制把某个 key 挂在特定槽位上,通过在 key 字符串中嵌入 tag 标记,这样就可以强制 key 所挂的槽位等于 tag 所在的槽位。通过 {} 标记键的一部分,强制键映射到同一槽(例如 user:{123}:name 和 user:{123}:email 会在同一槽)。对于有标记键的 key,在进行 crc16 计算时,仅会计算标记 {} 内的部分,故会映射在同一槽中。

/* 我们共有16384个哈希槽(hash slots)。给定一个键(key),其哈希槽是通过对该键计 算CRC16校验值,并取其最低有效14位得到的。然而,如果键中包含 {...} 模式,则仅对 { 和 } 之间的部分进行哈希。这一机制在未来可能用于强制将某些键分配到同一个节点上(前提是当前没有正在进行重新分片操作)。
*/
unsigned int keyHashSlot(char *key, int keylen) {
    int s, e; /* start-end indexes of { and } */

    for (s = 0; s < keylen; s++)
        if (key[s] == '{') break;

    /* No '{' ? Hash the whole key. This is the base case. */
    if (s == keylen) return crc16(key,keylen) & 0x3FFF;

    /* '{' found? Check if we have the corresponding '}'. */
    for (e = s+1; e < keylen; e++)
        if (key[e] == '}') break;

    /* No '}' or nothing between {} ? Hash the whole key. */
    if (e == keylen || e == s+1) return crc16(key,keylen) & 0x3FFF;

    /* If we are here there is both a { and a } on its right. Hash
     * what is in the middle between { and }. */
    return crc16(key+s+1,e-s-1) & 0x3FFF;
}

为啥采用 crc16?因为速度快,且哈希值满足与 16384 进行取模要求,对哈希冲突的要求不高,有冲突也没有关系,只要能大致均匀分布即可,速度快是最重要的。

跳转

前面提到 redis 客户端会缓存集群的槽位配置信息,但也可能会存在客户端与服务器存储槽位的信息不一致的情况,这时就需要跳转机制来解决这个问题。当客户端向一个错误的节点发出了指令后,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令 MOVED 携带目标操作的节点地址,告诉客户端去连接这个节点以获取数据。客户端收到 MOVED 指令后,要立即纠正本地的槽位映射表,后续所有 key 将使用新的槽位映射表。

当客户端向 redis 服务端发送命令时,都会经过 processCommand 函数处理该命令的请求。

int processCommand(client *c) {
    // 集群模式下,在此处进行集群重定向
    // 两种情况下不会进行跳转:
    //   1. 发送方是本节点的主节点
    //   2. 命令中不含有key
    if (server.cluster_enabled &&
        !mustObeyClient(c) &&
        !(!(c->cmd->flags&CMD_MOVABLE_KEYS) && c->cmd->key_specs_num == 0 &&
          c->cmd->proc != execCommand))
    {
        int error_code;
        // 尝试解析该命令应路由到哪个节点
        clusterNode *n = getNodeByQuery(c,c->cmd,c->argv,c->argc,
                                        &c->slot,&error_code);
        // 目标节点不是当前节点,需要进行重定向
        if (n == NULL || n != server.cluster->myself) {
            if (c->cmd->proc == execCommand) {
                discardTransaction(c);
            } else {
                flagTransaction(c);
            }
            // 向客户端返回MOVED/ASK指令
            clusterRedirectClient(c,n,c->slot,error_code);
            c->cmd->rejected_calls++;
            return C_OK;
        }
    }

}

集群变更感知

当服务器节点变更时,客户端应该立即得到通知以实时刷新自己的节点关系表。那么客户端是如何得到通知的呢?分两种情况:

  • 目标节点挂掉了,客户端会抛出一个 ConnectionError,紧接着会随机挑一个节点来重试,这时被重试的节点会通过 MOVED 指令告知目标槽位被分配到的新的节点地址。
  • 运维手动修改了集群信息,将主节点切换到其他节点,并将旧的主节点移除出集群。这时打在旧的主节点上的指令会收到一个 ClusterDown 的错误,告知当前节点所在集群不可用(当前节点不再属于之前的集群)。这时客户端就会关闭所有的连接,清空槽位映射关系表,然后向上层抛错。待下一条指令过来时,就会重新尝试初始化节点信息。

迁移

redis cluster 提供了迁移工具 redis-trib,可以让运维人员手动调整槽位的分配情况。使用 ruby 语言开发,通过组合各种原生的 redis cluster 指令来实现。redis 迁移的单位是槽,redis 一个槽一个槽地进行迁移,当一个槽正在迁移时,这个槽就处于中间过渡状态。

迁移过程:源节点对当前的 key 执行 dump 指令得到序列化内容,然后通过”客户端”向目标节点发送 restore 指令携带序列化的内容作为参数,目前节点再进行反序列化就可以将内容恢复到目标节点的内存中。每个槽位的所有 key 列表可通过 keysinslot 指令获取。实际上,就是 migrate 指令进行迁移,migrate 内部实现依赖 dumprestore

容错

redis cluster 可以为每个主节点设置若干个从节点,当主节点发生故障时,集群将会自动将其中某个从节点提升为主节点。如果某个主节点没有从节点,那么当它发生故障时,集群将完全处于不可用状态。不过,redis 也提供了一个参数 cluster-require-full-coverage 可以允许部分节点发生故障,其他节点还可以继续提供对外访问。

对于网络抖动问题,redis cluster 提供了 cluster-node-timeout 参数,当某个节点持续 timeout 的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有该选项,网络抖动会导致主从频繁切换。

可能下线(PFail,Possibly Fail)与确定下线(Fail):Redis cluster 是去中心化的,一个节点认为某个节点失联了并不代表所有的节点都认为它失联了,所以集群还得经过一次协商的过程,只有大多数节点都认定某个节点失联了,集群才认为该节点需要进行主从切换来容错。

redis cluster 节点采用 gossip 协议来广播自己的状态以及改变对整个集群的认知。当一个节点发现某个节点失联了(PFail),它会将这条消息向整个集群广播,其他节点就可以收到这个失联信息,如果收到了某个节点失联的节点数量已经到达了集群的大多数,就可以标记该失联节点为确定下线状态(Fail),然后向整个集群广播,强迫其他节点也接受该节点已经下线的事实,并立即对该失联节点进行主从切换。

注意事项

  • 哈希冲突:不同键可能映射到同一槽位,但是只要数据分布均匀即可。
  • cluster 不支持事务:redis cluster 是分布式的,当前 redis 仅支持单机事务,不支持分布式事务。
  • cluster 的 mget 方法比 redis 要慢很多,因为执行时被拆分成了多个 get 命令。
  • cluster 的 rename 命令不再是原子的,它需要将数据从源节点转移到目标节点。修改 name 后 key 的 hash 发生了改变,对应映射的槽位也发生了改变。
  • redis cluster 不支持多数据库,仅支持数据库 0,SELECT 命令是不允许的。
  • 写入安全性:redis cluster 不保证不丢数据,已确认写入的情况下也可能会丢失。以下是导致在故障期间丢失多数分区中已确认写入的场景示例:写入可能到达主节点,但主节点在回复客户端的同时,写入可能未通过主节点和副本节点之间使用的异步复制传播到副本。如果主节点在写入未到达副本的情况下崩溃,且主节点在足够长的时间内不可达以致其副本被提升,那么写入将永远丢失。在主节点完全突然故障的情况下,这通常很难观察到,因为主节点会尝试几乎同时回复客户端(确认写入)和副本(传播写入)。然而,这是一种真实世界的故障模式。

丢失数据的根源还是在于主从异步复制,没有同步备节点,从节点总是落后于主节点。

源码分析

节点启动

节点启动后,verifyClusterConfigWithData 函数用于验证从磁盘加载的数据是否与集群配置一致。

  • 如果发现某些键属于当前节点不应该负责的哈希槽,则按以下方式处理:
    • 如果根据当前集群配置,没有其他节点负责这些槽,则将这些槽添加到本节点的负责范围内。
    • 如果根据当前配置,已有其他节点负责这些槽,则从本节点的视角将这些槽标记为 IMPORTING 状态。这样做一方面可以解释为何本节点持有这些槽的数据。另一方面也能让 redis-cli 意识到该问题,从而尝试修复。
  • 如果我们在 db0 以外的数据库中发现了数据,则返回 C_ERR,通知调用者应退出服务器并报错,或采取其他措施。
int main(int argc, char **argv) {
    initServer() {
        if (server.cluster_enabled) clusterInit(); // 集群本节点初始化
    }

    if (!server.sentinel_mode) {
        loadDataFromDisk();
        if (server.cluster_enabled) {
            if (verifyClusterConfigWithData() == C_ERR) {
                serverLog(LL_WARNING,
                    "You can't have keys in a DB different than DB 0 when in "
                    "Cluster mode. Exiting.");
                exit(1);
            }
        }
    }

    aeMain(server.el);
}

void clusterInit(void) {
    server.cluster->nodes = dictCreate(&clusterNodesDictType);

    // 加载本节点配置
    if (clusterLoadConfig(server.cluster_configfile) == C_ERR) {
        /* No configuration found. We will just use the random name provided
         * by the createClusterNode() function. */
        myself = server.cluster->myself =
            createClusterNode(NULL,CLUSTER_NODE_MYSELF|CLUSTER_NODE_MASTER);
        serverLog(LL_NOTICE,"No cluster configuration found, I'm %.40s",
            myself->name);
        clusterAddNode(myself);
        saveconf = 1;
    }
    if (saveconf) clusterSaveConfigOrDie(1);    
}

启动后,服务端会周期性执行 serverCron,调用 clusterCron

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    /* Run the Redis Cluster cron. */
    run_with_period(100) {
        if (server.cluster_enabled) clusterCron();
    }
   // ...
}

clusterCron 是 redis cluster 的核心函数,每 100ms 执行一次,负责维护集群健康状态、节点通信和故障转移。核心功能包括:

  • 节点连接维护:管理集群总线连接
  • 故障检测,识别可能失效的节点
  • 拓扑优化,处理主从关系与节点迁移
  • 状态同步,更新集群全局视图
  • 故障转移协调,处理自动/手动故障转移
void clusterCron(void) {
    di = dictGetSafeIterator(server.cluster->nodes);
    while((de = dictNext(di)) != NULL) {
        clusterNode *node = dictGetVal(de);
        /* The sequence goes:
         * 1. We try to shrink link buffers if possible.
         * 2. We free the links whose buffers are still oversized after possible shrinking.
         * 3. We update the latest memory usage of cluster links.
         * 4. We immediately attempt reconnecting after freeing links.
         */
        clusterNodeCronResizeBuffers(node);
        clusterNodeCronFreeLinkOnBufferLimitReached(node);
        clusterNodeCronUpdateClusterLinksMemUsage(node);
        // 处理连接,连接恢复
        if(clusterNodeCronHandleReconnect(node, handshake_timeout, now)) continue;
    }

    // 检测失效节点,每100ms随机选择一个节点发送PING消息
    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);
        }
    }

    // 检查孤儿主节点,如果存在孤儿主节点,则可以从拥有最多从节点的主节点迁移从节点到孤儿主节点,要求源主节点至少有2个健康从节点。
    orphaned_masters = 0;
    max_slaves = 0;
    this_slaves = 0;
    di = dictGetSafeIterator(server.cluster->nodes);
    while((de = dictNext(di)) != NULL) {
        clusterNode *node = dictGetVal(de);
        now = mstime(); /* Use an updated time at every iteration. */

        if (node->flags &
            (CLUSTER_NODE_MYSELF|CLUSTER_NODE_NOADDR|CLUSTER_NODE_HANDSHAKE))
                continue;
    
        /* Orphaned master check, useful only if the current instance
         * is a slave that may migrate to another master. */
        if (nodeIsSlave(myself) && nodeIsMaster(node) && !nodeFailed(node)) {
            int okslaves = clusterCountNonFailingSlaves(node);

            /* A master is orphaned if it is serving a non-zero number of
             * slots, have no working slaves, but used to have at least one
             * slave, or failed over a master that used to have slaves. */
            if (okslaves == 0 && node->numslots > 0 &&
                node->flags & CLUSTER_NODE_MIGRATE_TO)
            {
                orphaned_masters++;
            }
            if (okslaves > max_slaves) max_slaves = okslaves;
            if (myself->slaveof == node)
                this_slaves = okslaves;
        }    
    }

}

关键定义:

typedef struct clusterNode {
    mstime_t ctime; /* 节点对象创建时间,用于调试和状态跟踪 */
    char name[CLUSTER_NAMELEN]; /* 节点唯一标识, hex string, sha1-size */
    int flags;      /* 节点状态标识位 CLUSTER_NODE_MASTER 主节点, CLUSTER_NODE_SLAVE 从节点,CLUSTER_NODE_FAIL 标记为故障状态, CLUSTER_NODE_MYSELF 本节点 */
    uint64_t configEpoch; /* 配置逻辑时钟 */
    unsigned char slots[CLUSTER_SLOTS/8]; /* 位图结构(16384 个槽位),每个 bit 表示对应槽是否由该节点负责 */
    uint16_t *slot_info_pairs; /* 槽位的区间对表示法(如 [0, 5000], [5001, 10000]),用于优化内存和遍历效率 */
    int slot_info_pairs_count; /* 有效区间对的数量 */
    int numslots;   /* 实际负责的槽位总数 */
    int numslaves;  /* 从节点数量 */
    struct clusterNode **slaves; /* 指向从节点的指针数组 */
    struct clusterNode *slaveof; /* 指向主节点的指针,从节点特有,可能为NULL,如果主节点未被发现 */
    unsigned long long last_in_ping_gossip; /* The number of the last carried in the ping gossip section */
    mstime_t ping_sent;      /* 最新ping的发送时间 */
    mstime_t pong_received;  /* 最新pong的接收时间 */
    mstime_t data_received;  /* 最新接收数据的时间 */
    mstime_t fail_time;      /* 标记未FAIL状态的时间 */
    mstime_t voted_time;     /* Last time we voted for a slave of this master */
    mstime_t repl_offset_time;  /* 偏移量更新时间戳 */
    mstime_t orphaned_time;     /* Starting time of orphaned master condition */
    long long repl_offset;      /* 最新已知的复制偏移量 */
    char ip[NET_IP_STR_LEN];    /* Latest known IP address of this node */
    sds hostname;               /* The known hostname for this node */
    int port;                   /* Latest known clients port (TLS or plain). */
    int pport;                  /* 明文客户端端口,当port为TLS端口时使用 */
    int cport;                  /* 集群内部通信端口 */
    clusterLink *link;          /* TCP/IP link established toward this node */
    clusterLink *inbound_link;  /* TCP/IP link accepted from this node */
    list *fail_reports;         /* List of nodes signaling this as failing */
} clusterNode;

集群初始化

redis-cli --cluster create 命令创建初始化集群。主要完成以下任务:

  • 节点验证与连接
  • 哈希槽分配
  • 主从节点拓扑构建
  • 集群配置同步

整体流程图下:

graph LR
A[redis-cli --cluster create node1 node2 ...] --> B[客户端解析节点列表]
B --> C[连接所有节点,验证状态]
C --> D[分配哈希槽(16384个)]
D --> E[向每个主节点发送 CLUSTER ADDSLOTS]
E --> F[向从节点发送 CLUSTER REPLICATE]
F --> G[各节点间互相握手 MEET]
G --> H[集群配置生效]

槽位分配是在 redis-cli 执行 --cluster create 时由客户端进行分配的,分配好后客户端向服务端发送 CLUSTER ADDSLOTS 命令,服务端接收到后检查这些操作是否被占用,将命令中的槽位分配给本节点,更新内部 clusterNode.slots 槽位映射表。

配置主从关系也是客户端 redis-cli 对每个从节点执行 CLUSTER REPLICATE <主节点NodeID> 命令进行配置,从节点服务端收到后,切换为从节点并开始复制指定主节点。

redis 服务端只负责执行指令和维持状态,不参与槽位分配等逻辑。

核心源码如下:

static int clusterManagerCommandCreate(int argc, char **argv) {
    cluster_manager.nodes = listCreate();
    for (i = 0; i < argc; i++) {
        char *addr = argv[i];
        char *ip = NULL;
        int port = 0;

        parseClusterNodeAddress(addr, &ip, &port, NULL)
        clusterManagerNode *node = clusterManagerNewNode(ip, port, 0);
        clusterManagerNodeConnect(node) // 创建连接
        clusterManagerNodeIsCluster(node, &err) // 验证节点是否为集群模式
        clusterManagerNodeLoadInfo(node, 0, &err)
        if (!clusterManagerNodeIsEmpty(node, &err)) { // 非空节点检测
            clusterManagerPrintNotEmptyNodeError(node, err);
            if (err) zfree(err);
            freeClusterManagerNode(node);
            return 0;
        }
        listAddNodeTail(cluster_manager.nodes, node);
    }

    // 检查集群节点数量,最小规模为3
    int node_len = cluster_manager.nodes->len;
    int replicas = config.cluster_manager_command.replicas;
    int masters_count = CLUSTER_MANAGER_MASTERS_COUNT(node_len, replicas);
    if (masters_count < 3) {
        clusterManagerLogErr(
            "*** ERROR: Invalid configuration for cluster creation.\n"
            "*** Redis Cluster requires at least 3 master nodes.\n"
            "*** This is not possible with %d nodes and %d replicas per node.",
            node_len, replicas);
        clusterManagerLogErr("\n*** At least %d nodes are required.\n",
                             3 * (replicas + 1));
        return 0;
    }
    clusterManagerLogInfo(">>> Performing hash slots allocation "
                          "on %d nodes...\n", node_len);

    // 哈希槽槽位分配,分配是按顺序,16384/主节点数,平均分配
    for (i = 0; i < masters_count; i++) {
        clusterManagerNode *master = masters[i];
        long last = lround(cursor + slots_per_node - 1);
        if (last > CLUSTER_MANAGER_SLOTS || i == (masters_count - 1))
            last = CLUSTER_MANAGER_SLOTS - 1;
        if (last < first) last = first;
        printf("Master[%d] -> Slots %ld - %ld\n", i, first, last);
        master->slots_count = 0;
        for (j = first; j <= last; j++) {
            master->slots[j] = 1;
            master->slots_count++;
        }
        master->dirty = 1;
        first = last + 1;
        cursor += slots_per_node;
    }

    // 反亲和性节点分配,优先将主节点分散到不同的物理机(通过IP分组)
    // 轮询策略:interleaved数组确保相同IP的节点不会连续分配
    
   clusterManagerNode *first_node = interleaved[0];
    for (i = 0; i < (interleaved_len - 1); i++)
        interleaved[i] = interleaved[i + 1];
    interleaved[interleaved_len - 1] = first_node;
    int assign_unused = 0, available_count = interleaved_len;
assign_replicas:
    for (i = 0; i < masters_count; i++) {
        clusterManagerNode *master = masters[i];
        int assigned_replicas = 0;
        // 主从关系构建,优先为从节点选择不同IP的机器
        while (assigned_replicas < replicas) {
            if (available_count == 0) break;
            clusterManagerNode *found = NULL, *slave = NULL;
            int firstNodeIdx = -1;
            for (j = 0; j < interleaved_len; j++) {
                clusterManagerNode *n = interleaved[j];
                if (n == NULL) continue;
                if (strcmp(n->ip, master->ip)) {
                    found = n;
                    interleaved[j] = NULL;
                    break;
                }
                if (firstNodeIdx < 0) firstNodeIdx = j;
            }
            if (found) slave = found;
            else if (firstNodeIdx >= 0) {
                slave = interleaved[firstNodeIdx];
                interleaved_len -= (firstNodeIdx + 1);
                interleaved += (firstNodeIdx + 1);
            }
            if (slave != NULL) {
                assigned_replicas++;
                available_count--;
                if (slave->replicate) sdsfree(slave->replicate);
                slave->replicate = sdsnew(master->name);
                slave->dirty = 1;
            } else break;
            printf("Adding replica %s:%d to %s:%d\n", slave->ip, slave->port,
                   master->ip, master->port);
            if (assign_unused) break;
        }
    }
    if (!assign_unused && available_count > 0) {
        assign_unused = 1;
        printf("Adding extra replicas...\n");
        goto assign_replicas;
    }
    for (i = 0; i < ip_count; i++) {
        clusterManagerNodeArray *node_array = ip_nodes + i;
        clusterManagerNodeArrayReset(node_array);
    }
    clusterManagerOptimizeAntiAffinity(ip_nodes, ip_count);
    clusterManagerShowNodes();
    int ignore_force = 0;
    if (confirmWithYes("Can I set the above configuration?", ignore_force)) {
        listRewind(cluster_manager.nodes, &li);
        while ((ln = listNext(&li)) != NULL) {
            clusterManagerNode *node = ln->value;
            char *err = NULL;
            int flushed = clusterManagerFlushNodeConfig(node, &err);   // 在该函数内部调用cluster addslots
            if (!flushed && node->dirty && !node->replicate) {
                if (err != NULL) {
                    CLUSTER_MANAGER_PRINT_REPLY_ERROR(node, err);
                    zfree(err);
                }
                success = 0;
                goto cleanup;
            } else if (err != NULL) zfree(err);
        }
        clusterManagerLogInfo(">>> Nodes configuration updated\n");
        clusterManagerLogInfo(">>> Assign a different config epoch to "
                              "each node\n");
        int config_epoch = 1;
        listRewind(cluster_manager.nodes, &li);
        while ((ln = listNext(&li)) != NULL) {
            clusterManagerNode *node = ln->value;
            redisReply *reply = NULL;
            reply = CLUSTER_MANAGER_COMMAND(node,
                                            "cluster set-config-epoch %d",
                                            config_epoch++);
            if (reply != NULL) freeReplyObject(reply);
        }
        clusterManagerLogInfo(">>> Sending CLUSTER MEET messages to join "
                              "the cluster\n");
        clusterManagerNode *first = NULL;
        char first_ip[NET_IP_STR_LEN]; /* first->ip may be a hostname */
        listRewind(cluster_manager.nodes, &li);
        while ((ln = listNext(&li)) != NULL) {
            clusterManagerNode *node = ln->value;
            if (first == NULL) {
                first = node;
                /* Although hiredis supports connecting to a hostname, CLUSTER
                 * MEET requires an IP address, so we do a DNS lookup here. */
                if (anetResolve(NULL, first->ip, first_ip, sizeof(first_ip), ANET_NONE)
                    == ANET_ERR)
                {
                    fprintf(stderr, "Invalid IP address or hostname specified: %s\n", first->ip);
                    success = 0;
                    goto cleanup;
                }
                continue;
            }
            redisReply *reply = NULL;
            if (first->bus_port == 0 || (first->bus_port == first->port + CLUSTER_MANAGER_PORT_INCR)) {
                /* CLUSTER MEET bus-port parameter was added in 4.0.
                 * So if (bus_port == 0) or (bus_port == port + CLUSTER_MANAGER_PORT_INCR),
                 * we just call CLUSTER MEET with 2 arguments, using the old form. */
                reply = CLUSTER_MANAGER_COMMAND(node, "cluster meet %s %d",
                                                first_ip, first->port);
            } else {
                reply = CLUSTER_MANAGER_COMMAND(node, "cluster meet %s %d %d",
                                                first_ip, first->port, first->bus_port);
            }
            int is_err = 0;
            if (reply != NULL) {
                if ((is_err = reply->type == REDIS_REPLY_ERROR))
                    CLUSTER_MANAGER_PRINT_REPLY_ERROR(node, reply->str);
                freeReplyObject(reply);
            } else {
                is_err = 1;
                fprintf(stderr, "Failed to send CLUSTER MEET command.\n");
            }
            if (is_err) {
                success = 0;
                goto cleanup;
            }
        }
        /* Give one second for the join to start, in order to avoid that
         * waiting for cluster join will find all the nodes agree about
         * the config as they are still empty with unassigned slots. */
        sleep(1);
        // 阻塞直到集群形成
        clusterManagerWaitForClusterJoin();
        /* Useful for the replicas */
        listRewind(cluster_manager.nodes, &li);
        while ((ln = listNext(&li)) != NULL) {
            clusterManagerNode *node = ln->value;
            if (!node->dirty) continue;
            char *err = NULL;
            int flushed = clusterManagerFlushNodeConfig(node, &err);
            if (!flushed && !node->replicate) {
                if (err != NULL) {
                    CLUSTER_MANAGER_PRINT_REPLY_ERROR(node, err);
                    zfree(err);
                }
                success = 0;
                goto cleanup;
            } else if (err != NULL) {
                zfree(err);
            }
        }
        // Reset Nodes
        listRewind(cluster_manager.nodes, &li);
        clusterManagerNode *first_node = NULL;
        while ((ln = listNext(&li)) != NULL) {
            clusterManagerNode *node = ln->value;
            if (!first_node) first_node = node;
            else freeClusterManagerNode(node);
        }
        listEmpty(cluster_manager.nodes);
        if (!clusterManagerLoadInfoFromNode(first_node)) {
            success = 0;
            goto cleanup;
        }
        clusterManagerCheckCluster(0);
    }

}

集群高可用

槽位分配策略:

  • 均匀分配:理想情况下,N 个主节点,每个节点负责 16384/N 个槽位
  • 允许不均:可通过 redis-cli --cluster reshard
  • 分配时机:
    • 集群创建时(--cluster create
    • 扩缩容时(动态迁移)

槽位只分配给主节点,从节点仅复制主节点的数据,故障时可被提升为新主,接管原主节点的槽位。

从节点分配方式

创建集群时指定:

redis-cli --cluster create \
  192.168.1.10:7000 192.168.1.10:7001 192.168.1.10:7002 \  # 主
  192.168.1.10:7003 192.168.1.10:7004 192.168.1.10:7005 \  # 从
  --cluster-replicas 1
  • --cluster-replicas 1 表示每个主节点分配一个从节点
  • redis-cli 会自动配对,尽量避免主从在同一物理机

手动添加从节点

# 1. 启动新节点(空实例)
# 2. 将其设为某主节点的从
redis-cli -p 7003 CLUSTER REPLICATE <主节点Node ID>

故障转移:从节点如何晋升为主?

当主节点宕机,从节点通过以下流程晋升为主:

  1. 故障检测:所有节点定期 PING 主节点,若 cluster-node-timeout 内无响应,则标记为主观下线 PFAIL。待多数主节点同意后标记为客观下线 FAIL
  2. 从节点发起选举:符合条件的从节点(数据较新、优先级高)发起 FAILOVER AUTHORIZATION 请求,获得多数主节点投票的从节点成为新的主节点。
  3. 获胜的从节点执行 CLUSTER FAILOVER TAKEOVER 升级为主节点。接管原主的所有哈希槽,广播通知集群拓扑变更。
  4. 客户端重定向,客户端收到 MOVED <slot>
sequenceDiagram
    participant Master
    participant Replica1
    participant Replica2
    participant OtherMasters

    Note over Master: 宕机(如 kill -9)
    OtherMasters->>OtherMasters: Gossip 传播 PFAIL
    OtherMasters->>OtherMasters: 多数派投票 → 标记为 FAIL
    Replica1->>Replica1: 检测到 master=FAIL
    Replica1->>OtherMasters: 发起 failover 请求
    OtherMasters-->>Replica1: 投票授权
    Replica1->>Replica1: 提升为新 master
    Replica1->>Replica2: 通知拓扑变更
    Replica2->>Replica2: 切换复制源(指向新 master)

redis cluster 集群搭建

集群中的每个节点都有一个唯一的名称。节点名称是 160 位随机数的十六进制表示,在节点首次启动时获取(通常使用 /dev/urandom)。节点会将其 ID 保存在节点配置文件中,并永久使用相同的 ID,除非系统管理员删除节点配置文件,或通过 CLUSTER RESET 命令请求进行硬重置。

每个 Redis Cluster 节点都有一个额外的 TCP 端口用于接收来自其他 Redis Cluster 节点的传入连接。该端口将通过数据端口加上 10000 得出,或者可以通过 cluster-port 配置指定。

我们以搭建三节点集群(没有副本节点)为例:

  1. 重要配置参数:
port 7000     # 节点服务端口
cluster-enabled yes    # 必须设置为yes来启用集群模式
cluster-config-file nodes-7000.conf  # 集群状态配置文件,由redis自动维护
cluster-node-timeout 15000  # 节点超时时间ms,判定节点是否失联
  1. 启动三个 redis 节点实例
  2. 任选一台节点,使用 redis-cli --cluster create 命令创建初始化集群。命令执行后会提示你确认节点分配方案,输入 yes 即可完成集群创建。
postgres@slpc:~/redis/cluster$ redis-cli -p 7000 --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002
>>> Performing hash slots allocation on 3 nodes...  
Master[0] -> Slots 0 - 5460           # 槽位分配
Master[1] -> Slots 5461 - 10922         
Master[2] -> Slots 10923 - 16383
M: 799ae73e4300b495fc475c3d908c32e4d1018a44 127.0.0.1:7000
   slots:[0-5460] (5461 slots) master
M: 06b3b0b4a19e9f1efb94055614d782dea6eee4b0 127.0.0.1:7001
   slots:[5461-10922] (5462 slots) master
M: a2fcec028ddf56c3ada5850dcc73f2cf1bb56e03 127.0.0.1:7002
   slots:[10923-16383] (5461 slots) master
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
..
>>> Performing Cluster Check (using node 127.0.0.1:7000)
M: 799ae73e4300b495fc475c3d908c32e4d1018a44 127.0.0.1:7000
   slots:[0-5460] (5461 slots) master
M: a2fcec028ddf56c3ada5850dcc73f2cf1bb56e03 127.0.0.1:7002
   slots:[10923-16383] (5461 slots) master
M: 06b3b0b4a19e9f1efb94055614d782dea6eee4b0 127.0.0.1:7001
   slots:[5461-10922] (5462 slots) master
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
  1. 创建成功后,执行 cluster info 查看集群状态:
postgres@slpc:~/redis/cluster$ redis-cli -p 7000 
127.0.0.1:7000> cluster info
cluster_state:ok        # 集群状态ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:3
cluster_size:3
cluster_current_epoch:3
cluster_my_epoch:1
cluster_stats_messages_ping_sent:213
cluster_stats_messages_pong_sent:219
cluster_stats_messages_sent:432
cluster_stats_messages_ping_received:217
cluster_stats_messages_pong_received:213
cluster_stats_messages_meet_received:2
cluster_stats_messages_received:432

查看节点信息:

127.0.0.1:7000> cluster nodes
799ae73e4300b495fc475c3d908c32e4d1018a44 127.0.0.1:7000@17000 myself,master - 0 1761898525000 1 connected 0-5460
a2fcec028ddf56c3ada5850dcc73f2cf1bb56e03 127.0.0.1:7002@17002 master - 0 1761898526417 3 connected 10923-16383
06b3b0b4a19e9f1efb94055614d782dea6eee4b0 127.0.0.1:7001@17001 master - 0 1761898527427 2 connected 5461-10922

集群模式下需要 redis-cli 使用 -c 参数,另外如果配置了 ACL,则需要使用 --user--pass 参数,重定向时需要用户以及密码进行认证。AUTH 命令只对当前连接生效,redis cluster 要求客户端自己管理多节点连接,redis-cli 是一个简单客户端,不会跨连接记录认证状态,除非启动时提供 --user--pass 参数。高级客户端库如 redis-py 会在内部为每个节点连接自动认证,只需要初始化一次。

postgres@slpc:~/works$ redis-cli -c --user admin --pass admin_password
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
127.0.0.1:6379> get k1
-> Redirected to slot [12706] located at 192.168.232.138:6379
(nil)
192.168.232.138:6379> get k2
-> Redirected to slot [449] located at 192.168.232.128:6379
"v2"
192.168.232.128:6379> set k1
(error) ERR wrong number of arguments for 'set' command
192.168.232.128:6379> set k1 v1
-> Redirected to slot [12706] located at 192.168.232.138:6379
OK

redis cluster 内置高可用机制,自身已集成自动故障转移和主从切换功能,无需依赖 Sentinel。

redis cluster 搭建高可用集群

集群创建主从高可用需要添加从节点,指定 --cluster-replicas N,每个主节点分配 N 个从节点。

redis-cli --cluster create host1:port1 host2:port2 ... --cluster-replicas N

必须所有节点都是空实例(未加入集群、无数据)。

CLUSTER REPLICATE 是 Redis Cluster 手动运维的核心命令之一,用于灵活调整主从拓扑。生产环境中,建议通过 redis-cli --cluster add-node --slave 自动完成(它内部调用此命令)。

redis cluster 扩缩容

扩容(增加主节点):

添加主节点 add-node -> 迁移槽 reshard -> 集群均衡 rebalance

  1. 添加
root@slpc:/var/log/redis# /usr/local/redis/bin/redis-cli --cluster add-node 192.168.232.128:6381 192.168.232.128:6379 --cluster-master-id 831e4c891d2025233320461e4b6aa3ea3e9c3c97 --user admin --pass admin_password
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
>>> Adding node 192.168.232.128:6381 to cluster 192.168.232.128:6379
>>> Performing Cluster Check (using node 192.168.232.128:6379)
M: 831e4c891d2025233320461e4b6aa3ea3e9c3c97 192.168.232.128:6379
   slots:[0-5460] (5461 slots) master
M: ca62daac7e5028e18f65def8a3ffdfb519c7be63 192.168.232.138:6379
   slots:[10923-16383] (5461 slots) master
M: e0856d63d6a1170c0755f74782ba828be23e8fc2 192.168.232.137:6379
   slots:[5461-10922] (5462 slots) master
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
>>> Getting functions from cluster
>>> Send FUNCTION LIST to 192.168.232.128:6381 to verify there is no functions in it
>>> Send FUNCTION RESTORE to 192.168.232.128:6381
>>> Send CLUSTER MEET to node 192.168.232.128:6381 to make it join the cluster.
[OK] New node added correctly.
  1. 迁移槽,可以通过 reshard 手动指定迁移某个槽,或者使用 rebalance 自动迁移,迁移前可模拟 --cluster-simulate 执行一遍
root@slpc:/var/log/redis# /usr/local/redis/bin/redis-cli --cluster rebalance 192.168.232.128:6379 --cluster-use-empty-masters --cluster-simulate --user admin --pass admin_password
  1. 集群均衡,自动计算,尽量自动计算,手动的 reshard 仅在自动计算无法满足要求或特殊运维情况使用。
root@slpc:/var/log/redis# /usr/local/redis/bin/redis-cli --cluster rebalance 192.168.232.128:6379 --cluster-use-empty-masters --user admin --pass admin_password
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
>>> Performing Cluster Check (using node 192.168.232.128:6379)
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
>>> Rebalancing across 4 nodes. Total weight = 4.00
Moving 1366 slots from 192.168.232.137:6379 to 192.168.232.128:6381
######################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################
Moving 1365 slots from 192.168.232.138:6379 to 192.168.232.128:6381
#####################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################
Moving 1365 slots from 192.168.232.128:6379 to 192.168.232.128:6381
#####################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################################

缩容(缩减主节点):

迁出所有槽 reshard -> 删除主节点 del-node

添加从节点:

添加从节点,无需分配槽位 add-node --cluster-slave

重置集群:重置节点的集群状态,使其不再属于任何集群、清空槽分配,移除节点 ID,cluster reset hard

模式命令行为
SOFT(默认)CLUSTER RESET SOFT- 清空集群配置(节点列表、槽分配)
- 保留当前数据库数据
- 生成新的节点 ID
- 节点变为独立 master(不再是集群的一部分)
HARDCLUSTER RESET HARD- 执行 SOFT 的所有操作
- 额外执行 FLUSHALL(清空所有数据)
- 完全干净的重置

redis cluster 备份恢复

备份流程

redis cluster 备份与恢复比单机版 redis 复杂,因数据分布在多个节点(分片)且包含集群拓扑元数据。

备份后,集群拓扑以及槽位信息可能会发生变化,例如分片数量可能会增加,槽位可能会被重分配。为了解决这个问题。

需要备份的内容:

  • RDB/AOF 数据文件:每个节点的实际数据
  • nodes.conf 文件:集群拓扑元数据

如果仅备份 rdb 文件则会导致集群状态丢失,节点不知道自己负责哪些槽。

备份方案 1:节点级 RDB + nodes.conf 元数据

步骤:

a. 对每个节点执行 BGSAVE b. 复制数据 rdb 和 nodes.conf 元数据文件

备份方案 2:通过 redis-cli --cluster backup 备份数据

步骤:

a. 运行 redis-cli --cluster backup

这两种备份,都不适应集群拓扑变化以及槽位重分配的情况。为了解决这个问题,可采取类似逻辑备份的思想。

备份方式:

a. 对每个分片执行 bgsave,生成 rdb 快照文件,上传到对象存储中。 b. 恢复时,通过 redisshake 工具,读取从对象存储下载的 rdb 文件,重放 rdb,加载到最新集群中。 c. 另一种办法是,每个分片都启动一个 redis 实例,通过 redis-cli --cluster import 的方式从各个分片的 redis 实例中导入数据。

无论哪种方式,redis cluster 集群的备份很难满足数据一致性。

恢复流程

步骤:

  1. 准备新集群(空实例),配置 cluster-enabled yes,不执行 --cluster create
  2. 恢复数据文件:将备份的 dump.rdb 和 nodes.conf 文件放入对应节点目录
  3. 启动 redis

redis 启动后,会加载 rdb 数据,读取 nodes.conf 恢复集群拓扑,自动与其他节点握手 gossip

redis cluster 相关命令

postgres@slpc:~$ redis-cli --cluster help
Cluster Manager Commands:
  create         host1:port1 ... hostN:portN     # 创建新集群
                 --cluster-replicas <arg>        # 指定从节点数
  check          host:port                       # 检查集群健康状态
                 --cluster-search-multiple-owners  #强制检测槽冲突
  info           host:port                        # 查看集群信息
  fix            host:port                          # 修复集群,自动修复未分配的槽,从节点未正确复制等委托
                 --cluster-search-multiple-owners
                 --cluster-fix-with-unreachable-masters   # 即使主节点宕机也尝试修复(谨慎使用)
  reshard        host:port                      # 手动迁移槽(扩缩容核心)
                 --cluster-from <arg>
                 --cluster-to <arg>         # 目标主节点ID
                 --cluster-slots <arg>      # 迁移多少个槽
                 --cluster-yes              # 跳过确认(自动化脚本用)
                 --cluster-timeout <arg>    
                 --cluster-pipeline <arg>
                 --cluster-replace          # 迁移后删除源节点
  rebalance      host:port                       # 集群均衡,自动均衡槽位分布,自动计算并迁移槽,使各主节点负载均衡。
                 --cluster-weight <node1=w1...nodeN=wN>  # 指定节点权重,按权重分配槽位
                 --cluster-use-empty-masters    # 允许空主节点参与均衡
                 --cluster-timeout <arg>
                 --cluster-simulate    # 仅模拟,不执行
                 --cluster-pipeline <arg>
                 --cluster-threshold <arg>
                 --cluster-replace
  add-node       new_host:new_port existing_host:existing_port  # 添加新节点,添加为主节点,必须先启动新redis实例(空),添加后需手动reshard分配槽(主节点)或自动复制(从节点)
                 --cluster-slave   # 添加为从节点
                 --cluster-master-id <arg>  # 指定主节点ID
  del-node       host:port node_id     # 删除节点,如果是主节点,则必须先迁移走所有槽(reshard),如果是从节点,可直接删除。删除后该节点可以重新加入其他集群
  call           host:port command arg arg .. arg  # 批量执行命令
                 --cluster-only-masters    # 对所有主节点执行某个命令
                 --cluster-only-replicas   # 对所有从节点执行某个命令
  set-timeout    host:port milliseconds    
  import         host:port                 # 从外部redis导入数据
                 --cluster-from <arg>
                 --cluster-from-user <arg>
                 --cluster-from-pass <arg>
                 --cluster-from-askpass
                 --cluster-copy           # 保留源数据
                 --cluster-replace        # 覆盖集群中已存在的key
  backup         host:port backup_directory   # 备份集群
  help           

For check, fix, reshard, del-node, set-timeout you can specify the host and port of any working node in the cluster.

Cluster Manager Options:
  --cluster-yes  Automatic yes to cluster commands prompts

参考文档:

Redis 集群规范