Redis 数据分片策略详解
数据分片是 Redis 水平扩展的核心技术。如何将数据合理分布到多个节点,直接影响系统性能和扩展性。本文将深入解析各种分片策略及其应用场景。
一、分片基础概念
1.1 为什么需要分片
分片的必要性:
单节点限制:
┌─────────────────────────────────────┐
│ 内存限制:单机内存有限(通常<64GB) │
│ CPU 限制:单线程处理能力的瓶颈 │
│ 网络限制:单节点网络带宽限制 │
│ 并发限制:单节点连接数限制 │
└─────────────────────────────────────┘
分片优势:
┌─────────────────────────────────────┐
│ 存储容量:多节点内存叠加 │
│ 处理能力:并行处理提升 QPS │
│ 可用性:节点故障不影响全局 │
│ 扩展性:按需添加节点 │
└─────────────────────────────────────┘
1.2 分片目标
- 数据均匀:各节点数据量均衡
- 负载均衡:各节点请求量均衡
- 最小迁移:节点变化时数据迁移最少
- 高效路由:快速定位数据所在节点
二、哈希分片
2.1 原理
哈希分片流程:
key → hash(key) → hash % N → 节点编号
示例:
key = "user:1001"
hash(key) = CRC16("user:1001") = 12345
节点数 N = 3
节点编号 = 12345 % 3 = 0 → 节点 0
2.2 Java 实现
import redis.clients.jedis.Jedis;
import java.util.*;
public class HashSharding {
private List<Jedis> shards;
private int shardCount;
public HashSharding(List<Jedis> shards) {
this.shards = shards;
this.shardCount = shards.size();
}
/**
* 计算 key 应该路由到哪个分片
*/
private int getShardIndex(String key) {
int hash = Math.abs(key.hashCode());
return hash % shardCount;
}
/**
* 获取分片
*/
private Jedis getShard(String key) {
int index = getShardIndex(key);
return shards.get(index);
}
/**
* 设置值
*/
public void set(String key, String value) {
Jedis shard = getShard(key);
shard.set(key, value);
}
/**
* 获取值
*/
public String get(String key) {
Jedis shard = getShard(key);
return shard.get(key);
}
/**
* 删除
*/
public void delete(String key) {
Jedis shard = getShard(key);
shard.del(key);
}
/**
* 批量设置(跨分片)
*/
public void mset(Map<String, String> data) {
// 按分片分组
Map<Integer, Map<String, String>> grouped = new HashMap<>();
for (Map.Entry<String, String> entry : data.entrySet()) {
int shardIndex = getShardIndex(entry.getKey());
grouped.computeIfAbsent(shardIndex, k -> new HashMap<>())
.put(entry.getKey(), entry.getValue());
}
// 并行执行
for (Map.Entry<Integer, Map<String, String>> entry : grouped.entrySet()) {
int shardIndex = entry.getKey();
Map<String, String> shardData = entry.getValue();
new Thread(() -> {
Jedis shard = shards.get(shardIndex);
String[] args = new String[shardData.size() * 2];
int i = 0;
for (Map.Entry<String, String> e : shardData.entrySet()) {
args[i++] = e.getKey();
args[i++] = e.getValue();
}
shard.mset(args);
}).start();
}
}
}
2.3 优缺点
优点:
- 实现简单
- 数据分布均匀
- 路由快速
缺点:
- 节点增减时数据迁移量大
- 需要重新计算所有 key 的路由
三、范围分片
3.1 原理
范围分片流程:
key → 按范围划分 → 节点
示例(按用户 ID 范围):
┌─────────────┐
│ 节点 1 │ user:1 - user:10000
│ 节点 2 │ user:10001 - user:20000
│ 节点 3 │ user:20001 - user:30000
└─────────────┘
3.2 Java 实现
import redis.clients.jedis.Jedis;
import java.util.*;
public class RangeSharding {
private List<ShardRange> shards;
public RangeSharding() {
shards = new ArrayList<>();
// 初始化分片范围
shards.add(new ShardRange(1, 10000, getJedis(1)));
shards.add(new ShardRange(10001, 20000, getJedis(2)));
shards.add(new ShardRange(20001, 30000, getJedis(3)));
}
/**
* 分片范围
*/
static class ShardRange {
int start;
int end;
Jedis jedis;
ShardRange(int start, int end, Jedis jedis) {
this.start = start;
this.end = end;
this.jedis = jedis;
}
boolean inRange(int id) {
return id >= start && id <= end;
}
}
/**
* 从 key 中提取 ID
*/
private int extractId(String key) {
// 假设 key 格式:user:12345
String[] parts = key.split(":");
if (parts.length >= 2) {
return Integer.parseInt(parts[1]);
}
throw new IllegalArgumentException("Invalid key format: " + key);
}
/**
* 获取分片
*/
private Jedis getShard(String key) {
int id = extractId(key);
for (ShardRange shard : shards) {
if (shard.inRange(id)) {
return shard.jedis;
}
}
throw new RuntimeException("No shard found for id: " + id);
}
/**
* 设置值
*/
public void set(String key, String value) {
Jedis shard = getShard(key);
shard.set(key, value);
}
/**
* 获取值
*/
public String get(String key) {
Jedis shard = getShard(key);
return shard.get(key);
}
/**
* 获取某个范围内的所有数据
*/
public Map<String, String> getRange(int startId, int endId) {
Map<String, String> result = new HashMap<>();
for (ShardRange shard : shards) {
// 判断是否有重叠
if (shard.start <= endId && shard.end >= startId) {
// 扫描该分片
String pattern = "user:*";
String cursor = ScanParams.SCAN_POINTER_START;
ScanParams scanParams = new ScanParams();
scanParams.match(pattern);
scanParams.count(1000);
while (true) {
ScanResult<String> scanResult = shard.jedis.scan(cursor, scanParams);
cursor = scanResult.getCursor();
for (String key : scanResult.getResult()) {
int id = extractId(key);
if (id >= startId && id <= endId) {
result.put(key, shard.jedis.get(key));
}
}
if (ScanParams.SCAN_POINTER_START.equals(cursor)) {
break;
}
}
}
}
return result;
}
private Jedis getJedis(int shardId) {
// 创建连接
return new Jedis("192.168.1." + shardId, 6379);
}
}
3.3 优缺点
优点:
- 范围查询高效
- 便于批量操作
- 支持顺序扫描
缺点:
- 数据分布可能不均
- 范围边界需要预先定义
- 扩容时需要调整范围
四、一致性哈希
4.1 原理
一致性哈希环:
┌─────────────────┐
│ 节点 A │
│ (hash: 1000) │
│ ● │
│ │
●────┤ ├────●
节点 C │ ○ key1 │ 节点 B
(4000) │ │ (2000)
│ ● │
│ 节点 D │
│ (hash: 3000) │
└─────────────────┘
路由规则:
1. 计算 key 的 hash 值
2. 顺时针找到第一个节点
3. 该节点负责存储
4.2 虚拟节点
带虚拟节点的一致性哈希:
物理节点:A, B, C
虚拟节点:A1, A2, B1, B2, C1, C2
┌─────────────────────────────────────┐
│ A1 B1 C1 │
│ ● ● ● │
│ │
│ A2 B2 C2 │
│ ● ● ● │
└─────────────────────────────────────┘
优势:
- 数据分布更均匀
- 减少节点变化的影响
4.3 Java 实现
import redis.clients.jedis.Jedis;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.ConcurrentSkipListMap;
public class ConsistentHashing {
private final ConcurrentSkipListMap<Long, Jedis> circle = new ConcurrentSkipListMap<>();
private final List<Jedis> shards;
private final int virtualNodes;
public ConsistentHashing(List<Jedis> shards, int virtualNodes) {
this.shards = shards;
this.virtualNodes = virtualNodes;
initCircle();
}
/**
* 初始化哈希环
*/
private void initCircle() {
for (Jedis shard : shards) {
for (int i = 0; i < virtualNodes; i++) {
String virtualNodeKey = shard.getPool().getHostAndPort() + "#" + i;
long hash = hash(virtualNodeKey);
circle.put(hash, shard);
}
}
}
/**
* 获取 key 对应的节点
*/
public Jedis getShard(String key) {
long hash = hash(key);
// 找到大于等于 hash 的第一个节点
Map.Entry<Long, Jedis> entry = circle.ceilingEntry(hash);
// 如果没找到,返回环首的节点
if (entry == null) {
entry = circle.firstEntry();
}
return entry.getValue();
}
/**
* 计算 hash 值(MD5 算法)
*/
private long hash(String key) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(key.getBytes(StandardCharsets.UTF_8));
// 取前 8 个字节作为 hash 值
long hash = 0;
for (int i = 0; i < 8; i++) {
hash = (hash << 8) | (digest[i] & 0xFF);
}
return hash;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 algorithm not found", e);
}
}
/**
* 设置值
*/
public void set(String key, String value) {
Jedis shard = getShard(key);
shard.set(key, value);
}
/**
* 获取值
*/
public String get(String key) {
Jedis shard = getShard(key);
return shard.get(key);
}
/**
* 添加节点
*/
public void addNode(Jedis newShard) {
shards.add(newShard);
// 添加虚拟节点到环
for (int i = 0; i < virtualNodes; i++) {
String virtualNodeKey = newShard.getPool().getHostAndPort() + "#" + i;
long hash = hash(virtualNodeKey);
circle.put(hash, newShard);
}
}
/**
* 移除节点
*/
public void removeNode(Jedis shard) {
shards.remove(shard);
// 从环中移除虚拟节点
for (int i = 0; i < virtualNodes; i++) {
String virtualNodeKey = shard.getPool().getHostAndPort() + "#" + i;
long hash = hash(virtualNodeKey);
circle.remove(hash);
}
}
/**
* 数据迁移(添加节点时)
*/
public void rebalance() {
// 实际项目中需要实现数据迁移逻辑
// 1. 扫描所有节点的数据
// 2. 重新计算每个 key 的目标节点
// 3. 迁移需要移动的 key
System.out.println("数据重新平衡...");
}
}
4.4 使用示例
public class ConsistentHashingDemo {
public static void main(String[] args) {
// 创建分片
List<Jedis> shards = Arrays.asList(
new Jedis("192.168.1.10", 6379),
new Jedis("192.168.1.11", 6379),
new Jedis("192.168.1.12", 6379)
);
// 创建一致性哈希环(每个节点 100 个虚拟节点)
ConsistentHashing ch = new ConsistentHashing(shards, 100);
// 存储数据
for (int i = 1; i <= 1000; i++) {
ch.set("user:" + i, "user_data_" + i);
}
// 添加新节点
Jedis newShard = new Jedis("192.168.1.13", 6379);
ch.addNode(newShard);
// 数据重新平衡(只迁移部分数据)
ch.rebalance();
// 验证数据
String value = ch.get("user:100");
System.out.println("user:100 = " + value);
}
}
4.5 优缺点
优点:
- 节点增减时数据迁移量最小
- 数据分布均匀(使用虚拟节点)
- 扩展性好
缺点:
- 实现复杂
- 虚拟节点需要合理配置
- 数据均匀性依赖虚拟节点数量
五、Redis Cluster 分片
5.1 槽位机制
Redis Cluster 使用 16384 个槽位:
槽位分配:
┌─────────────────────────────────────┐
│ 节点 1: 槽位 0 - 5460 │
│ 节点 2: 槽位 5461 - 10922 │
│ 节点 3: 槽位 10923 - 16383 │
└─────────────────────────────────────┘
Key 路由:
CRC16(key) % 16384 → 槽位编号 → 节点
5.2 哈希标签
使用哈希标签控制 key 的分布:
# 这些 key 会被路由到同一个槽位
user:1001:profile
user:1001:orders
user:1001:cart
# 哈希标签 {user:1001} 内的内容用于计算槽位
# 确保相关数据在同一个节点
5.3 Java 使用
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;
import java.util.HashSet;
import java.util.Set;
public class ClusterShardingExample {
public static void main(String[] args) {
Set<HostAndPort> nodes = new HashSet<>();
nodes.add(new HostAndPort("192.168.1.10", 7000));
nodes.add(new HostAndPort("192.168.1.11", 7001));
nodes.add(new HostAndPort("192.168.1.12", 7002));
JedisCluster cluster = new JedisCluster(nodes);
// 自动路由
cluster.set("user:1001:name", "张三");
cluster.set("user:1001:age", "25");
// 哈希标签确保在同一个节点
cluster.set("{user:1001}:profile", "profile_data");
cluster.set("{user:1001}:orders", "orders_data");
// 批量操作(必须在同一个槽位)
cluster.mset(
"{user:1001}:name", "张三",
"{user:1001}:age", "25",
"{user:1001}:email", "test@example.com"
);
cluster.close();
}
}
六、分片策略对比
6.1 对比表
| 策略 | 均匀性 | 扩展性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 哈希分片 | 高 | 低 | 低 | 简单应用 |
| 范围分片 | 中 | 中 | 中 | 范围查询 |
| 一致性哈希 | 高 | 高 | 高 | 大规模分布式 |
| Cluster | 高 | 高 | 中 | 生产环境 |
6.2 选择建议
选择分片策略决策树:
1. 是否使用 Redis Cluster?
├── 是 → 使用 Cluster 内置分片
└── 否 → 2
2. 是否需要频繁扩缩容?
├── 是 → 一致性哈希
└── 否 → 3
3. 是否有范围查询需求?
├── 是 → 范围分片
└── 否 → 哈希分片
七、总结
7.1 核心要点
- 哈希分片:简单直接,适合固定节点数
- 范围分片:适合有序数据和范围查询
- 一致性哈希:扩展性最好,节点变化影响最小
- Cluster 分片:生产环境首选,自动化程度高
7.2 最佳实践
- 提前规划:根据业务增长预估分片数量
- 预留空间:分片数量应多于当前需求
- 监控均衡:定期检查各分片数据量和负载
- 平滑迁移:扩缩容时保证服务不中断
参考资料
- Redis Cluster 规范
- 一致性哈希算法
- 《分布式系统原理与范型》第 5 章