Redis Cluster 集群原理:数据分片与故障转移

前言

Redis Cluster 是 Redis 官方提供的分布式解决方案,在 Redis 3.0 版本中正式引入。它通过数据分片(Sharding)实现了水平扩展,通过自动故障转移(Failover)保证了高可用性。本文将深入剖析 Redis Cluster 的核心原理,包括数据分片机制、故障转移流程、以及源码实现细节。

标签: Redis, Cluster, 集群, 分片, 故障转移


一、Redis Cluster 架构概览

1.1 集群拓扑结构

Redis Cluster 采用去中心化的架构,没有专门的配置服务器或元数据服务器。所有节点通过 Gossip 协议进行通信,每个节点都保存了整个集群的状态信息。

Redis Cluster 架构

重定向

重定向

重定向

复制

复制

复制

心跳

心跳

心跳

客户端

节点 A
Master
Slots: 0-5460

节点 B
Master
Slots: 5461-10922

节点 C
Master
Slots: 10923-16383

从节点 A1
Replica of A

从节点 B1
Replica of B

从节点 C1
Replica of C

核心特点:

  • 16384 个哈希槽:整个集群的数据库被分为 16384 个槽(slot)
  • 节点分配槽位:每个 Master 节点负责处理一定范围的槽
  • 自动故障转移:当 Master 节点故障时,Replica 会自动晋升为 Master
  • 去中心化:所有节点地位平等,通过 Gossip 协议交换状态

1.2 与其他集群方案对比

特性Redis ClusterRedis SentinelTwemproxyCodis
架构类型去中心化中心化代理中心化代理中心化代理
数据分片Hash Slot无一致性哈希Hash Slot
高可用自动故障转移自动故障转移需配合 Sentinel支持
水平扩展动态扩缩容不支持静态配置动态扩缩容
客户端Smart Client普通客户端普通客户端普通客户端
性能损耗几乎无单节点代理转发代理转发
运维复杂度中等较低较高较高

二、数据分片机制:Hash Slot

2.1 哈希槽原理

Redis Cluster 将所有数据映射到 16384 个哈希槽(slot)中:

// cluster.c (Redis 7.2.0)
#define CLUSTER_SLOTS 16384

// 计算键所在的槽位
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;
    
    /* 如果有哈希标签,只计算标签内的哈希值 */
    if (s != keylen && keylen > s+1) {
        for (e = s+1; e < keylen; e++)
            if (key[e] == '}') break;
        
        /* 只有当 } 存在时才使用标签 */
        if (e != keylen && e > s+1) {
            keylen = e - s - 1;
            key = key + s + 1;
        }
    }
    
    /* CRC16 算法计算槽位 */
    return crc16(key, keylen) & (CLUSTER_SLOTS - 1);
}

为什么是 16384?

  1. 心跳包大小:每个节点需要通过 Gossip 协议交换槽位分配信息,16384 个槽位占用 2KB(16384/8 = 2048 字节),心跳包大小适中
  2. 位图效率:2048 字节可以方便地用位图(bitmap)表示,便于快速判断槽位分配
  3. 内存开销:如果槽数太多(如 65536),位图将占用 8KB,增加网络传输开销

2.2 数据分布流程

节点 B (数据所在节点) 节点 A 客户端 节点 B (数据所在节点) 节点 A 客户端 计算槽位: CRC16("user:1001") % 16384 = 12345 槽 12345 不在本节点 (负责 0-5460) 更新本地槽位映射缓存 SET user:1001 "value" MOVED 12345 127.0.0.1:6380 SET user:1001 "value" OK

2.3 哈希标签(Hash Tags)

功能: 确保相关联的键分配到同一个槽位,支持 MGET、MSET、事务等操作。

规则: 只计算 {} 内的部分

# 这些键会被分配到同一个槽位
SET {user:1001}:name "Alice"
SET {user:1001}:age 25
SET {user:1001}:city "Beijing"

# 事务操作可以正常执行
MULTI
GET {user:1001}:name
GET {user:1001}:age
EXEC

源码分析:

// cluster.c (Redis 7.2.0) - 判断是否支持多键操作
int clusterNodeSupportsMultiKey(int node_id, robj **keys, int count) {
    int slot = -1;
    
    for (int i = 0; i < count; i++) {
        int keyslot = keyHashSlot(
            keys[i]->ptr, 
            sdslen(keys[i]->ptr)
        );
        
        if (slot == -1) {
            slot = keyslot;
        } else if (slot != keyslot) {
            /* 键不在同一个槽位 */
            return 0;
        }
    }
    
    return 1;
}

三、故障转移机制

3.1 节点故障检测

Redis Cluster 通过 Gossip 协议和 PFAIL/FAIL 状态机实现故障检测。

超时未收到心跳

重新收到心跳

超过半数Master确认

故障节点恢复

在线

可能故障(PFAIL)

已故障(FAIL)

单个节点标记
cluster_node->flags |=
CLUSTER_NODE_PFAIL

集群标记
cluster_node->flags |=
CLUSTER_NODE_FAIL
触发故障转移

关键时间参数:

参数默认值说明
cluster-node-timeout15000ms超过此时间未收到心跳,标记为 PFAIL
cluster-slave-validity-factor10从节点断连时间超过 timeout * factor,不参与故障转移
cluster-migration-barrier1主节点最少拥有的从节点数

3.2 故障转移流程

当 Master 被标记为 FAIL 后,其从节点会进行选举:

// cluster.c (Redis 7.2.0) - 从节点发起选举
void clusterStartManualFailoverIfNeeded(clusterNode *target) {
    /* 1. 检查是否具备选举资格 */
    if (nodeIsSlave(myself) && 
        myself->slaveof == target &&
        server.cluster_allow_replica_migration) {
        
        /* 2. 计算选举延迟 */
        mstime_t delay = mstime() + 
                         500 + /* 固定延迟 */
                         random() % 500; /* 随机延迟 */
        
        /* 3. 发起选举 */
        clusterSendFailoverAuthIfNeeded();
    }
}

// 请求其他 Master 节点投票
void clusterSendFailoverAuthIfNeeded(clusterNode *node) {
    clusterMsg *msg;
    clusterMsgDataFailoverAuth *auth;
    
    /* 构造 FAILOVER_AUTH_REQUEST 请求 */
    msg = createClusterMessage(CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST);
    
    auth = &msg->data.failover_auth;
    auth->configEpoch = node->configEpoch;
    auth->offset = node->replication_offset;
    
    /* 广播到所有节点 */
    clusterBroadcastMessage(msg);
}

完整故障转移流程:

FAIL 消息 其他节点 Master C Master B Slave A1 Master A (故障) FAIL 消息 其他节点 Master C Master B Slave A1 Master A (故障) 超过 timeout 未收到心跳 标记 M1 为 PFAIL 标记 M1 为 PFAIL 检测到超过半数Master认为 M1 故障 延迟 500-1000ms 避免脑裂 获得超过半数Master投票 成为新 Master 更新路由表 PING 超时无响应 PING 超时无响应 PING (携带 PFAIL 信息) 标记 M1 为 FAIL 广播 FAIL 消息 触发选举 请求投票 (epoch++, offset) 请求投票 授权投票 (auth) 授权投票 (auth) 更新 configEpoch 接管槽位 广播新配置

3.3 选举算法详解

Redis Cluster 使用 Raft-like 选举算法:

投票规则(从节点视角):

  1. 只能投票一次:每个 configEpoch 只能给一个从节点投票
  2. 优先级规则:
    • 复制偏移量更大的优先
    • replica-priority 配置更小的优先(默认 100)
    • 节点 ID 更小的优先
// cluster.c (Redis 7.2.0) - Master 处理投票请求
int clusterProcessFailoverAuthRequest(clusterNode *sender, 
                                       clusterMsg *req) {
    clusterNode *master = sender->slaveof;
    
    /* 1. 检查是否已经投过票 */
    if (myself->failover_auth_sent > 0 &&
        myself->failover_auth_epoch == req->currentEpoch) {
        return 0; /* 本轮已投票 */
    }
    
    /* 2. 检查是否可以投票 */
    if (master->flags & CLUSTER_NODE_FAIL) {
        /* 3. 比较优先级 */
        if (req->offset > master->replication_offset) {
            /* 授权投票 */
            myself->failover_auth_sent = 1;
            myself->failover_auth_epoch = req->currentEpoch;
            
            /* 发送 FAILOVER_AUTH_ACK */
            clusterSendFailoverAuthAck(sender);
            return 1;
        }
    }
    
    return 0;
}

3.4 故障转移配置对比

配置项推荐值作用
cluster-node-timeout5000-15000ms故障检测超时时间
cluster-replica-validity-factor0生产环境设为 0,避免从节点因短暂断连失去选举资格
cluster-migration-barrier1允许自动迁移的阈值
cluster-require-full-coverageno节点故障时是否继续提供部分服务

典型场景配置:

# 生产环境配置
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-replica-validity-factor 0
cluster-migration-barrier 1
cluster-require-full-coverage no

四、集群操作实战

4.1 槽位分配算法

手动分配槽位:

# 将槽位 0-5460 分配给节点 127.0.0.1:7000
redis-cli -c -p 7000 cluster addslots {0..5460}

# 将槽位 5461-10922 分配给节点 127.0.0.1:7001
redis-cli -c -p 7001 cluster addslots {5461..10922}

# 将槽位 10923-16383 分配给节点 127.0.0.1:7002
redis-cli -c -p 7002 cluster addslots {10923..16383}

自动平衡槽位:

# 使用 redis-trib 工具(Redis 5.0+)
redis-cli --cluster rebalance 127.0.0.1:7000 \
    --cluster-threshold 1 \  # 槽位差异阈值
    --cluster-use-empty-masters

4.2 槽位迁移流程

目标节点 源节点 管理员 目标节点 源节点 管理员 loop [迁移槽位中的键] 广播新槽位分配 CLUSTER SETSLOT <slot> IMPORTING <dst_id> CLUSTER SETSLOT <slot> MIGRATING <src_id> MIGRATE <dst_host> <dst_port> <key> 0 <timeout> 键值对数据 迁移完成 CLUSTER SETSLOT <slot> NODE <dst_id> CLUSTER SETSLOT <slot> NODE <dst_id>

源码实现:

// cluster.c (Redis 7.2.0) - 迁移单个键
void migrateCommand(client *c) {
    char *keyword;
    robj *key;
    
    /* 解析参数: MIGRATE host port key dbid timeout */
    keyword = c->argv[3]->ptr;
    key = c->argv[4];
    
    /* 1. 检查键是否在当前槽位 */
    int slot = keyHashSlot(key->ptr, sdslen(key->ptr));
    if (getShardNodeBySlot(slot) != myself) {
        addReplySds(c, sdsnew("-NOAUTH Keys must belong to this node\r\n"));
        return;
    }
    
    /* 2. 序列化键值对 */
    rio payload;
    createObjectPayload(&payload, key);
    
    /* 3. 连接目标节点 */
    int fd = connectToTarget(c->argv[1]->ptr, atoi(c->argv[2]->ptr));
    
    /* 4. 发送 RESTORE 命令 */
    char *cmd = sdscatprintf(sdsempty(), "*2\r\n$6\r\nMIGRATE\r\n$%zu\r\n%s\r\n",
                             sdslen(payload.io.buffer.ptr), 
                             payload.io.buffer.ptr);
    write(fd, cmd, sdslen(cmd));
    
    /* 5. 等待确认 */
    char reply[1024];
    read(fd, reply, sizeof(reply));
    
    /* 6. 删除本地键 */
    dbDelete(&server.db[0], key);
    
    addReply(c, shared.ok);
}

4.3 扩容缩容实战

新增节点:

# 1. 启动新节点
redis-server --port 7006 --cluster-enabled yes

# 2. 加入集群
redis-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000

# 3. 分配槽位
redis-cli --cluster reshard 127.0.0.1:7000 \
    --cluster-from <node_id> \
    --cluster-to <new_node_id> \
    --cluster-slots 4096 \
    --cluster-yes

# 4. 配置复制关系
redis-cli -p 7006 cluster replicate <master_id>

缩容节点:

# 1. 迁移槽位
redis-cli --cluster reshard 127.0.0.1:7000 \
    --cluster-from <node_id> \
    --cluster-to <target_node_id> \
    --cluster-slots 16384 \
    --cluster-yes

# 2. 忘记节点
redis-cli --cluster del-node 127.0.0.1:7000 <node_id>

五、客户端实现原理

5.1 Smart Client 原理

Redis Cluster 客户端需要实现 槽位路由表:

# Python redis-py-cluster 实现
class ClusterNode:
    def __init__(self, host, port, node_id):
        self.host = host
        self.port = port
        self.node_id = node_id
        self.slots = set()  # 节点负责的槽位

class RedisCluster:
    def __init__(self, startup_nodes):
        self.nodes = []
        self.slot_table = [None] * 16384  # 槽位到节点的映射
        self.initialize(startup_nodes)
    
    def initialize(self, startup_nodes):
        """初始化集群连接和槽位表"""
        # 1. 连接到任意节点
        node = startup_nodes[0]
        client = redis.StrictRedis(host=node['host'], port=node['port'])
        
        # 2. 获取集群节点信息
        cluster_nodes = client.execute_command('CLUSTER', 'NODES')
        
        # 3. 解析节点信息并建立连接
        for line in cluster_nodes.split('\n'):
            parts = line.split()
            node_id, addr, flags, master_id, ping_sent, \
            pong_recv, config_epoch, link_state, slots = parts[:9]
            
            host, port = addr.split('@')[0].split(':')
            cluster_node = ClusterNode(host, port, node_id)
            self.nodes.append(cluster_node)
            
            # 4. 解析槽位范围
            for slot_range in slots:
                if '-' in slot_range:
                    start, end = map(int, slot_range.split('-'))
                    for slot in range(start, end + 1):
                        self.slot_table[slot] = cluster_node
                else:
                    slot = int(slot_range)
                    self.slot_table[slot] = cluster_node
    
    def get_node_by_slot(self, slot):
        """根据槽位获取目标节点"""
        return self.slot_table[slot]
    
    def execute_command(self, *args):
        """执行命令并处理重定向"""
        key = self.extract_key(args)
        slot = self.calculate_slot(key)
        node = self.get_node_by_slot(slot)
        
        client = redis.StrictRedis(host=node.host, port=node.port)
        
        try:
            return client.execute_command(*args)
        except redis.ResponseError as e:
            # 处理 MOVED 重定向
            if str(e).startswith('MOVED'):
                _, slot, target_addr = str(e).split()
                host, port = target_addr.split(':')
                self.update_slot_cache(int(slot), host, int(port))
                return self.execute_command(*args)  # 重试
            raise
    
    def calculate_slot(self, key):
        """CRC16 计算槽位"""
        import crc16
        return crc16.crc16(key.encode()) % 16384

5.2 重定向处理

MOVED vs ASK:

类型触发条件行为
MOVED槽位已永久迁移到其他节点更新本地缓存,后续请求直接发送到新节点
ASK槽位正在迁移中,键可能在新旧节点仅本次请求重定向,不更新缓存
def handle_redirection(self, error):
    """处理重定向错误"""
    error_str = str(error)
    
    if error_str.startswith('MOVED'):
        # 槽位永久迁移
        _, slot, target = error_str.split()
        slot = int(slot)
        host, port = target.split(':')
        
        # 更新缓存
        self.slot_table[slot] = self.find_or_create_node(host, int(port))
        
        # 重试
        return self.retry_with_new_node(slot)
    
    elif error_str.startswith('ASK'):
        # 槽位迁移中
        _, slot, target = error_str.split()
        host, port = target.split(':')
        
        # 发送 ASKING 命令
        target_node = self.get_node(host, int(port))
        target_node.execute_command('ASKING')
        
        # 重试(不更新缓存)
        return target_node.execute_command(*self.last_command)

5.3 客户端实现对比

客户端语言Smart Client支持特性生产就绪
redis-py-clusterPython✅Pipeline, Pub/Sub✅
JedisJava✅Pipeline, Transaction, Lua✅
go-redisGo✅Pipeline, Pub/Sub, Sentinel✅
node-redis-clusterNode.js✅Pipeline, Promise API✅
phpredisPHP✅Pipeline, Multi/Exec✅

六、高级特性与优化

6.1 集群总线(Cluster Bus)

Redis Cluster 使用两个 TCP 连接:

  1. 客户端端口:如 6379,用于接收客户端命令
  2. 集群总线端口:客户端端口 + 10000(如 16379),用于节点间通信
// cluster.c (Redis 7.2.0) - 创建集群总线
int clusterBusCreateSocket(void) {
    int bus_fd = anetTcpServer(
        server.neterr, 
        server.port + CLUSTER_PORT_INCR,  /* 10000 */
        server.bindaddr, 
        AF_UNSPEC
    );
    
    anetNonBlock(NULL, bus_fd);
    anetEnableTcpNoDelay(NULL, bus_fd);
    
    return bus_fd;
}

void clusterSendPing(clusterNode *node) {
    /* 通过集群总线发送 PING */
    clusterMsg *msg = createClusterMessage(CLUSTERMSG_TYPE_PING);
    
    /* 包含节点自己的槽位分配信息 */
    for (int i = 0; i < CLUSTER_SLOTS; i++) {
        if (server.cluster->myslots[i] != NULL) {
            msg->data.ping.slots[i] = myself->name;
        }
    }
    
    clusterSendMessage(node, msg, CLUSTER_MSG_TYPE_PING);
}

6.2 Gossip 消息类型

消息类型值用途
MEET1新节点加入集群
PING2节点心跳检测
PONG3PING 响应
FAIL4宣布节点故障
PUBLISH5跨节点 Pub/Sub
FAILOVER_AUTH_REQUEST10请求投票
FAILOVER_AUTH_ACK11投票响应

6.3 性能优化建议

优化项配置/方法效果
禁用 THPecho never > /sys/kernel/mm/transparent_hugepage/enabled避免内存延迟分配
调整哈希表大小hash-max-ziplist-entries 512减少内存碎片
使用 Pipeline客户端批量发送命令减少 RTT
连接池复用连接减少 TCP 握手开销
避免热点键使用 Hash Tags 均衡负载避免单点压力
禁用 swapswapoff -a避免性能抖动

监控指标:

# 槽位分配情况
redis-cli -p 7000 cluster slots | jq '.'

# 节点信息
redis-cli -p 7000 cluster nodes | awk '{print $2, $3, $8, $9}'

# 槽位分布
redis-cli -p 7000 --cluster check 127.0.0.1:7000

# 关键指标
redis-cli -p 7000 info stats | grep cluster
# cluster_connections: 12
# cluster_stats_messages_sent: 15234
# cluster_stats_messages_received: 14856

七、故障排查指南

7.1 常见问题诊断

问题 1:槽位未完全分配

# 症状
redis-cli -c -p 7000 SET key1 value1
(error) CLUSTERDOWN Hash slot not served

# 诊断
redis-cli -p 7000 cluster info
cluster_state:fail  # 集群状态异常
cluster_slots_assigned: 8192  # 只分配了一半槽位

# 解决:分配剩余槽位
redis-cli --cluster fix 127.0.0.1:7000 --cluster-yes

问题 2:节点脑裂

# 症状:多个节点认为自己是 Master
redis-cli -p 7000 cluster nodes | grep master
53e0... myself,master - 0 0 connected 0-5460
92f1... master - 0 0 connected 0-5460  # 重复!

# 原因:网络分区
# 解决:手动合并或重置集群
redis-cli -p 7000 cluster reset  # 在从节点执行

问题 3:从节点无法晋升

# 症状:Master 故障后从节点未接管
redis-cli -p 7000 cluster info
cluster_state:ok
cluster_slots_ok: 10922  # 槽位减少

# 诊断
redis-cli -p 7001 cluster info
cluster_repl_validity_factor: 10  # 从节点断连时间过长

# 解决:调小 validity-factor
redis-cli -p 7001 CONFIG SET cluster-replica-validity-factor 0

7.2 集群状态检查清单

#!/bin/bash
# redis-cluster-check.sh

echo "=== Redis Cluster 健康检查 ==="

# 1. 检查集群状态
echo -e "\n[1] 集群状态"
redis-cli -p 7000 cluster info | grep -E "cluster_state|cluster_slots_ok"

# 2. 检查槽位覆盖
echo -e "\n[2] 槽位分配"
redis-cli -p 7000 cluster slots | jq '[.[] | {range: .[0:2], node: .[2][0]}]'

# 3. 检查复制关系
echo -e "\n[3] 主从关系"
redis-cli -p 7000 cluster replicas $(redis-cli -p 7000 cluster myid)

# 4. 检查节点健康
echo -e "\n[4] 节点状态"
for port in 7000 7001 7002 7003 7004 7005; do
    echo "Node $port:"
    redis-cli -p $port ping
done

# 5. 检查槽位迁移
echo -e "\n[5] 槽位迁移状态"
redis-cli -p 7000 cluster info | grep cluster_slots_assigned

echo -e "\n=== 检查完成 ==="

八、总结

Redis Cluster 通过 16384 个哈希槽实现数据分片,通过 Gossip 协议和 Raft-like 选举实现故障转移,构建了一个去中心化、高可用、可水平扩展的分布式缓存系统。

核心要点:

  1. 数据分片:基于 CRC16 算法将键映射到 16384 个槽位
  2. 故障检测:通过 PFAIL → FAIL 状态机,超过半数 Master 确认
  3. 自动故障转移:从节点通过选举晋升为 Master
  4. Smart Client:客户端维护槽位缓存,处理 MOVED/ASK 重定向
  5. 去中心化:所有节点通过 Gossip 协议交换状态

适用场景:

  • ✅ 数据量超过单机内存(> 50GB)
  • ✅ 需要高可用和自动故障转移
  • ✅ 对写入性能有较高要求
  • ❌ 需要跨slot事务或多键操作(需使用 Hash Tags)
  • ❌ 对延迟极其敏感(集群会增加跳数)

参考资料


版权声明: 本文为原创技术文章,转载请注明出处。

作者: [你的昵称]
发布时间: 2026-04-01
Redis 版本: 7.2.0

Logo

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

更多推荐