🎰 Redis集群的Slot分片:16384个槽位的魔法!

102 阅读9分钟

副标题:深入理解Redis Cluster的数据分布和迁移机制 🔀


🎬 开场:为什么Redis需要集群?

单机Redis的瓶颈

场景:某社交平台的用户数据

用户量:1亿
每个用户数据:1KB
总数据量:100GB

单机Redis:
├── 内存:100GB  💰 太贵!
├── QPS:10万    ⚡ 性能瓶颈!
└── 可用性:单点故障  ❌ 风险高!

解决方案:Redis集群
├── 分片:数据分散到多个节点
├── 高可用:主从复制
└── 扩展性:动态增减节点

📚 核心概念

什么是Slot(槽)?

Redis Cluster把所有数据分成16384个槽位

16384个槽位:
├── 槽0    ─┐
├── 槽1    ─┤
├── 槽2    ─┤  分配给节点1
├── ...    ─┤
├── 槽5460 ─┘
├── 槽5461 ─┐
├── 槽5462 ─┤  分配给节点2
├── ...    ─┤
├── 槽10922─┘
├── 槽10923─┐
├── ...    ─┤  分配给节点3
└── 槽16383─┘

为什么是16384个槽?

为什么不是其他数字?

1️⃣ 足够大:
   16384个槽可以支持足够多的节点

2️⃣ 不能太大:
   心跳包大小:
   - 16384个槽 = 2KB
   - 65536个槽 = 8KB (太大了!)

3️⃣ 2的整数次幂:
   16384 = 2^14
   方便位运算

4️⃣ 实际推荐节点数:
   3-6个主节点,最多不超过1000个
   16384个槽完全够用

🏗️ Slot分片机制

1️⃣ 数据如何分配到Slot?

CRC16算法:

key → CRC16(key) → % 16384 → slot编号

例子:
key = "user:1000"

Step 1: 计算CRC16
CRC16("user:1000") = 52374

Step 2: 取模
52374 % 16384 = 3222

Step 3: 得到slot
key "user:1000" 属于 slot 3222

代码实现

/**
 * 计算key所属的slot
 */
public class SlotCalculator {
    
    private static final int SLOT_COUNT = 16384;
    
    /**
     * 计算slot编号
     */
    public static int calculateSlot(String key) {
        // 1. 提取hash tag(如果有)
        String hashKey = getHashKey(key);
        
        // 2. 计算CRC16
        int crc = CRC16.crc16(hashKey.getBytes());
        
        // 3. 取模
        return crc % SLOT_COUNT;
    }
    
    /**
     * 提取hash tag
     * 
     * 例如:
     * "user:{100}:name"  → hash tag = "100"
     * "user:100:name"    → hash tag = "user:100:name"
     */
    private static String getHashKey(String key) {
        int start = key.indexOf('{');
        if (start != -1) {
            int end = key.indexOf('}', start + 1);
            if (end != -1 && end != start + 1) {
                // 提取{}中的内容
                return key.substring(start + 1, end);
            }
        }
        return key;
    }
}

2️⃣ Hash Tag(哈希标签)

问题:多个相关的key如何放在同一个slot?

场景:用户的多个数据
user:1000:profile  → slot 3222
user:1000:orders   → slot 8901  ❌ 不在同一个slot
user:1000:cart     → slot 12345

解决方案:Hash Tag

user:{1000}:profile  → 只计算{1000}的hash
user:{1000}:orders   → 只计算{1000}的hash
user:{1000}:cart     → 只计算{1000}的hash
                     → 都在同一个slot ✅

使用示例

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 使用Hash Tag保证相关数据在同一slot
     */
    public void saveUserData(Long userId, UserData data) {
        // 使用hash tag: {userId}
        String profileKey = "user:{" + userId + "}:profile";
        String ordersKey = "user:{" + userId + "}:orders";
        String cartKey = "user:{" + userId + "}:cart";
        
        // 这3个key会分配到同一个slot
        // 可以使用mget、pipeline等批量操作
        redisTemplate.opsForValue().set(profileKey, data.getProfile());
        redisTemplate.opsForValue().set(ordersKey, data.getOrders());
        redisTemplate.opsForValue().set(cartKey, data.getCart());
    }
    
    /**
     * 批量获取用户数据(在同一个slot,效率高)
     */
    public UserData getUserData(Long userId) {
        String profileKey = "user:{" + userId + "}:profile";
        String ordersKey = "user:{" + userId + "}:orders";
        String cartKey = "user:{" + userId + "}:cart";
        
        // 一次性获取(同一个节点)
        List<Object> values = redisTemplate.opsForValue().multiGet(
            Arrays.asList(profileKey, ordersKey, cartKey)
        );
        
        return buildUserData(values);
    }
}

3️⃣ Slot分配规则

3个主节点的集群:

节点1 (127.0.0.1:7000):
└─ 负责 slot 0-5460     (5461个槽)

节点2 (127.0.0.1:7001):
└─ 负责 slot 5461-10922  (5462个槽)

节点3 (127.0.0.1:7002):
└─ 负责 slot 10923-16383 (5461个槽)

总计:16384个槽

🔀 客户端路由机制

1️⃣ MOVED重定向

场景:key不在当前节点

客户端 → 节点1: GET user:1000
                  ↓
               计算slot 3222
                  ↓
               slot 3222 不在节点1
                  ↓
节点1 → 客户端: MOVED 3222 127.0.0.1:7001
                  ↓
客户端 → 节点2: GET user:1000
                  ↓
节点2 → 客户端: "张三"  ✅

示例

# 连接节点1
$ redis-cli -c -p 7000

127.0.0.1:7000> GET user:1000
-> Redirected to slot [3222] located at 127.0.0.1:7001
"张三"

# -c 参数表示cluster模式,自动跟随重定向

2️⃣ ASK重定向

场景:slot正在迁移中

节点1 → 节点2: 正在迁移 slot 3222
               部分key已迁移,部分还在节点1

客户端 → 节点1: GET user:1000
                  ↓
节点1检查:user:1000 已迁移到节点2
                  ↓
节点1 → 客户端: ASK 3222 127.0.0.1:7001
                  ↓
客户端 → 节点2: ASKING
                GET user:1000
                  ↓
节点2 → 客户端: "张三"  ✅

3️⃣ Smart Client

/**
 * 智能客户端(JedisCluster)
 */
public class RedisClusterClient {
    
    private JedisCluster jedisCluster;
    
    public RedisClusterClient() {
        Set<HostAndPort> nodes = new HashSet<>();
        nodes.add(new HostAndPort("127.0.0.1", 7000));
        nodes.add(new HostAndPort("127.0.0.1", 7001));
        nodes.add(new HostAndPort("127.0.0.1", 7002));
        
        // JedisCluster会自动处理重定向
        this.jedisCluster = new JedisCluster(nodes);
    }
    
    /**
     * 客户端缓存slot映射表
     * 
     * slot映射表:
     * slot 0-5460     → 127.0.0.1:7000
     * slot 5461-10922 → 127.0.0.1:7001
     * slot 10923-16383→ 127.0.0.1:7002
     */
    private Map<Integer, String> slotCache = new ConcurrentHashMap<>();
    
    /**
     * 智能路由
     */
    public String get(String key) {
        // 1. 计算slot
        int slot = SlotCalculator.calculateSlot(key);
        
        // 2. 从缓存获取节点地址
        String nodeAddress = slotCache.get(slot);
        
        if (nodeAddress != null) {
            try {
                // 3. 直接访问目标节点
                return getFromNode(nodeAddress, key);
                
            } catch (MovedException e) {
                // 4. 收到MOVED,更新缓存
                slotCache.put(slot, e.getTargetNode());
                return getFromNode(e.getTargetNode(), key);
            }
        } else {
            // 5. 缓存未命中,随机选择一个节点
            return jedisCluster.get(key);  // 内部会处理重定向
        }
    }
}

🔧 重新分片过程

场景:添加新节点

初始状态:3个节点

节点1: slot 0-5460
节点2: slot 5461-10922
节点3: slot 10923-16383

目标:添加节点4,均衡分配slot

节点1: slot 0-4095       (4096个槽)
节点2: slot 4096-8191    (4096个槽)
节点3: slot 8192-12287   (4096个槽)
节点4: slot 12288-16383  (4096个槽)

步骤详解

1️⃣ 添加新节点

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

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

# 此时节点4加入集群,但没有分配slot

2️⃣ 重新分片

# 开始重新分片
$ redis-cli --cluster reshard 127.0.0.1:7000

# 交互式问答:
How many slots do you want to move? 4096
What is the receiving node ID? [节点4的ID]
Source node: all  # 从所有节点平均迁移

3️⃣ 迁移过程

迁移slot 12288-16383到节点4:

每个slot的迁移流程:

Step 1: 标记slot为迁移中
节点3: CLUSTER SETSLOT 12288 MIGRATING [节点4-ID]
节点4: CLUSTER SETSLOT 12288 IMPORTING [节点3-ID]

Step 2: 获取slot中的所有key
节点3: CLUSTER GETKEYSINSLOT 12288 100

返回:
1) "user:1234"
2) "order:5678"
3) "product:9012"

Step 3: 逐个迁移key
节点3: MIGRATE 127.0.0.1 7003 0 5000 KEYS user:1234 order:5678 product:9012

Step 4: 标记slot迁移完成
节点3: CLUSTER SETSLOT 12288 NODE [节点4-ID]
节点4: CLUSTER SETSLOT 12288 NODE [节点4-ID]

Step 5: 广播更新
所有节点更新slot映射表

代码模拟

/**
 * Slot迁移实现
 */
public class SlotMigrator {
    
    /**
     * 迁移单个slot
     */
    public void migrateSlot(int slot, Jedis sourceNode, Jedis targetNode) {
        String sourceId = sourceNode.clusterMyId();
        String targetId = targetNode.clusterMyId();
        
        // 1. 标记slot为迁移中
        sourceNode.clusterSetSlotMigrating(slot, targetId);
        targetNode.clusterSetSlotImporting(slot, sourceId);
        
        log.info("开始迁移slot {}", slot);
        
        // 2. 迁移所有key
        int migratedCount = 0;
        while (true) {
            // 获取slot中的key(每次100个)
            List<String> keys = sourceNode.clusterGetKeysInSlot(slot, 100);
            
            if (keys.isEmpty()) {
                break;  // 没有key了
            }
            
            // 批量迁移
            for (String key : keys) {
                sourceNode.migrate(
                    targetNode.getClient().getHost(),
                    targetNode.getClient().getPort(),
                    key,
                    0,  // db
                    5000  // timeout
                );
                migratedCount++;
            }
            
            log.info("已迁移{}个key", migratedCount);
        }
        
        // 3. 标记迁移完成
        sourceNode.clusterSetSlotNode(slot, targetId);
        targetNode.clusterSetSlotNode(slot, targetId);
        
        log.info("slot {}迁移完成,共迁移{}个key", slot, migratedCount);
    }
    
    /**
     * 迁移多个slot
     */
    public void migrateSlots(int[] slots, String sourceHost, int sourcePort,
                             String targetHost, int targetPort) {
        
        try (Jedis source = new Jedis(sourceHost, sourcePort);
             Jedis target = new Jedis(targetHost, targetPort)) {
            
            for (int slot : slots) {
                try {
                    migrateSlot(slot, source, target);
                } catch (Exception e) {
                    log.error("迁移slot {}失败", slot, e);
                    // 回滚或重试
                }
            }
        }
    }
}

迁移期间的读写

迁移期间,客户端如何访问数据?

情况1:key还在源节点
客户端 → 源节点: GET user:1000
源节点 → 客户端: "张三"  ✅

情况2:key已迁移到目标节点
客户端 → 源节点: GET user:2000
源节点检查:user:2000 不存在
源节点 → 客户端: ASK 12288 127.0.0.1:7003
客户端 → 目标节点: ASKING
                   GET user:2000
目标节点 → 客户端: "李四"  ✅

情况3:写入新数据
客户端 → 源节点: SET user:3000 "王五"
源节点检查:slot正在迁移
源节点 → 客户端: ASK 12288 127.0.0.1:7003
客户端 → 目标节点: ASKING
                   SET user:3000 "王五"
目标节点 → 客户端: OK  ✅

🎯 最佳实践

1. 节点规划

/**
 * 集群节点规划
 */
public class ClusterPlanning {
    
    /**
     * 推荐配置
     */
    public static final int RECOMMENDED_MASTER_NODES = 3;  // 3个主节点
    public static final int REPLICA_PER_MASTER = 1;        // 每个主1个从
    
    /**
     * 计算每个节点的slot数量
     */
    public static int slotsPerNode(int masterCount) {
        return 16384 / masterCount;
    }
    
    /**
     * 集群规模建议
     */
    public static String getRecommendation(long dataSize, long qps) {
        // 数据量
        int nodesByData = (int) Math.ceil(dataSize / (50.0 * 1024 * 1024 * 1024)); // 每节点50GB
        
        // QPS
        int nodesByQPS = (int) Math.ceil(qps / 100000.0);  // 每节点10万QPS
        
        int masterNodes = Math.max(nodesByData, nodesByQPS);
        masterNodes = Math.max(3, masterNodes);  // 最少3个
        
        return String.format(
            "推荐配置:%d个主节点 + %d个从节点",
            masterNodes,
            masterNodes  // 每个主配1个从
        );
    }
}

2. 批量操作优化

/**
 * 批量操作优化
 */
public class ClusterBatchOperations {
    
    private JedisCluster jedisCluster;
    
    /**
     * 方式1:按slot分组批量操作(推荐)
     */
    public Map<String, String> batchGetOptimized(List<String> keys) {
        // 按slot分组
        Map<Integer, List<String>> slotGroups = keys.stream()
            .collect(Collectors.groupingBy(SlotCalculator::calculateSlot));
        
        // 并行获取
        return slotGroups.entrySet().parallelStream()
            .flatMap(entry -> {
                List<String> slotKeys = entry.getValue();
                
                // 使用pipeline批量获取(同一个节点)
                return mgetFromSameNode(slotKeys).entrySet().stream();
            })
            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }
    
    /**
     * 方式2:使用hash tag
     */
    public void batchSetWithHashTag(Long userId, Map<String, Object> data) {
        // 使用hash tag保证在同一slot
        Map<String, Object> taggedData = new HashMap<>();
        data.forEach((field, value) -> {
            String key = String.format("user:{%d}:%s", userId, field);
            taggedData.put(key, value);
        });
        
        // 现在可以使用mset(在同一个节点)
        jedisCluster.mset(convertToKeyValues(taggedData));
    }
    
    /**
     * 方式3:pipeline(需要自己管理连接)
     */
    public void batchSetWithPipeline(Map<String, String> data) {
        // 按节点分组
        Map<String, Map<String, String>> nodeGroups = groupByNode(data);
        
        // 每个节点使用pipeline
        nodeGroups.forEach((node, nodeData) -> {
            try (Jedis jedis = getJedisByNode(node)) {
                Pipeline pipeline = jedis.pipelined();
                
                nodeData.forEach(pipeline::set);
                
                pipeline.sync();
            }
        });
    }
}

3. 监控指标

/**
 * 集群监控
 */
@Component
public class ClusterMonitor {
    
    @Autowired
    private JedisCluster jedisCluster;
    
    /**
     * 监控slot分布
     */
    @Scheduled(fixedDelay = 60000)  // 每分钟
    public void monitorSlotDistribution() {
        Map<String, ClusterNode> nodes = getClusterNodes();
        
        nodes.forEach((nodeId, node) -> {
            int slotCount = node.getSlots().size();
            long keyCount = getKeyCount(node);
            long memory = getMemoryUsage(node);
            
            log.info("节点 {}: slots={}, keys={}, memory={}MB",
                node.getAddress(), slotCount, keyCount, memory / 1024 / 1024);
            
            // 告警:slot分布不均
            if (slotCount < 4096 || slotCount > 6000) {
                alert("slot分布不均", nodeId);
            }
            
            // 告警:内存使用过高
            if (memory > 50L * 1024 * 1024 * 1024) {  // 50GB
                alert("内存使用过高", nodeId);
            }
        });
    }
    
    /**
     * 监控迁移状态
     */
    public void monitorMigration() {
        String clusterInfo = jedisCluster.clusterInfo();
        
        if (clusterInfo.contains("state:fail")) {
            alert("集群状态异常", clusterInfo);
        }
        
        if (clusterInfo.contains("cluster_state:ok") &&
            clusterInfo.contains("cluster_slots_migrating:")) {
            int migratingSlots = extractMigratingSlots(clusterInfo);
            log.info("正在迁移{}个slot", migratingSlots);
        }
    }
}

💡 常见问题

Q1: 为什么集群模式不支持多数据库?

单机Redis:16个数据库(db0-db15)

集群Redis:只有db0

原因:
1. 数据分散在多个节点
2. 多db会增加slot映射复杂度
3. 多db的使用场景可以通过key前缀替代

Q2: MOVED和ASK的区别?

MOVED:永久重定向
- slot已经完全迁移
- 客户端更新缓存
- 下次直接访问新节点

ASK:临时重定向
- slot正在迁移中
- 客户端不更新缓存
- 只针对当前请求

Q3: 如何避免热点slot?

/**
 * 避免热点slot
 */
public class HotKeyHandler {
    
    /**
     * 方案1:拆分热点key
     */
    public String getHotKey(String hotKey) {
        // 使用多个副本
        int replica = ThreadLocalRandom.current().nextInt(10);
        String replicaKey = hotKey + ":replica:" + replica;
        
        return jedisCluster.get(replicaKey);
    }
    
    /**
     * 方案2:本地缓存
     */
    @Cacheable(value = "localCache", key = "#key")
    public String getWithLocalCache(String key) {
        return jedisCluster.get(key);
    }
}

🎉 总结

核心要点 ✨

  1. 16384个槽位

    • 使用CRC16算法分配
    • 支持Hash Tag
    • 智能路由
  2. 重定向机制

    • MOVED:永久重定向
    • ASK:临时重定向
    • Smart Client缓存映射
  3. 重新分片

    • 在线迁移
    • 不停服
    • 渐进式过程

记忆口诀 📝

Redis集群十六K,
槽位分片数据散。
CRC十六算哈希,
取模得到槽位号。

Hash Tag真神奇,
相关数据同一slot。
批量操作效率高,
Pipeline加持更快!

MOVED永久重定向,
ASK只是临时。
Smart Client很聪明,
缓存映射不用愁!

重新分片不停服,
渐进迁移保可用。
三个节点最常见,
扩展缩容都方便!

愿你的Redis集群永远均衡,数据永不倾斜! 🎰✨