副标题:深入理解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);
}
}
🎉 总结
核心要点 ✨
-
16384个槽位:
- 使用CRC16算法分配
- 支持Hash Tag
- 智能路由
-
重定向机制:
- MOVED:永久重定向
- ASK:临时重定向
- Smart Client缓存映射
-
重新分片:
- 在线迁移
- 不停服
- 渐进式过程
记忆口诀 📝
Redis集群十六K,
槽位分片数据散。
CRC十六算哈希,
取模得到槽位号。
Hash Tag真神奇,
相关数据同一slot。
批量操作效率高,
Pipeline加持更快!
MOVED永久重定向,
ASK只是临时。
Smart Client很聪明,
缓存映射不用愁!
重新分片不停服,
渐进迁移保可用。
三个节点最常见,
扩展缩容都方便!
愿你的Redis集群永远均衡,数据永不倾斜! 🎰✨