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 内部实现依赖 dump 和 restore。
容错
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>
故障转移:从节点如何晋升为主?
当主节点宕机,从节点通过以下流程晋升为主:
- 故障检测:所有节点定期 PING 主节点,若
cluster-node-timeout内无响应,则标记为主观下线PFAIL。待多数主节点同意后标记为客观下线FAIL。 - 从节点发起选举:符合条件的从节点(数据较新、优先级高)发起
FAILOVER AUTHORIZATION请求,获得多数主节点投票的从节点成为新的主节点。 - 获胜的从节点执行
CLUSTER FAILOVER TAKEOVER升级为主节点。接管原主的所有哈希槽,广播通知集群拓扑变更。 - 客户端重定向,客户端收到
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 配置指定。
我们以搭建三节点集群(没有副本节点)为例:
- 重要配置参数:
port 7000 # 节点服务端口
cluster-enabled yes # 必须设置为yes来启用集群模式
cluster-config-file nodes-7000.conf # 集群状态配置文件,由redis自动维护
cluster-node-timeout 15000 # 节点超时时间ms,判定节点是否失联
- 启动三个 redis 节点实例
- 任选一台节点,使用
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.
- 创建成功后,执行
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
- 添加
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.
- 迁移槽,可以通过 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
- 集群均衡,自动计算,尽量自动计算,手动的 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(不再是集群的一部分) |
| HARD | CLUSTER 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 集群的备份很难满足数据一致性。
恢复流程
步骤:
- 准备新集群(空实例),配置
cluster-enabled yes,不执行--cluster create - 恢复数据文件:将备份的 dump.rdb 和 nodes.conf 文件放入对应节点目录
- 启动 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
参考文档: