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 Cluster Redis Sentinel Twemproxy Codis
架构类型 去中心化 中心化代理 中心化代理 中心化代理
数据分片 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)

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

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

# 这些键会被分配到同一个槽位
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-timeout 15000ms 超过此时间未收到心跳,标记为 PFAIL
cluster-slave-validity-factor 10 从节点断连时间超过 timeout * factor,不参与故障转移
cluster-migration-barrier 1 主节点最少拥有的从节点数

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-timeout 5000-15000ms 故障检测超时时间
cluster-replica-validity-factor 0 生产环境设为 0,避免从节点因短暂断连失去选举资格
cluster-migration-barrier 1 允许自动迁移的阈值
cluster-require-full-coverage no 节点故障时是否继续提供部分服务

典型场景配置:

# 生产环境配置
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-cluster Python Pipeline, Pub/Sub
Jedis Java Pipeline, Transaction, Lua
go-redis Go Pipeline, Pub/Sub, Sentinel
node-redis-cluster Node.js Pipeline, Promise API
phpredis PHP 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 消息类型

消息类型 用途
MEET 1 新节点加入集群
PING 2 节点心跳检测
PONG 3 PING 响应
FAIL 4 宣布节点故障
PUBLISH 5 跨节点 Pub/Sub
FAILOVER_AUTH_REQUEST 10 请求投票
FAILOVER_AUTH_ACK 11 投票响应

6.3 性能优化建议

优化项 配置/方法 效果
禁用 THP echo never > /sys/kernel/mm/transparent_hugepage/enabled 避免内存延迟分配
调整哈希表大小 hash-max-ziplist-entries 512 减少内存碎片
使用 Pipeline 客户端批量发送命令 减少 RTT
连接池 复用连接 减少 TCP 握手开销
避免热点键 使用 Hash Tags 均衡负载 避免单点压力
禁用 swap swapoff -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编程工具,助力开发者即刻编程。

更多推荐