Redis的Hash类型非常适合存储对象属性或者映射关系,比如用户画像、商品规格、会话缓存。但一旦往一个Hash里无节制地塞数据,几十万甚至上百万个field堆在同一条key下面,大key问题就会暴露出来:单分片内存倾斜、命令耗时飙升、集群迁移时源节点长时间阻塞。分槽存储就是针对这个问题的解法,核心思路是把逻辑上的一个大Hash,物理上拆成多个小Hash,让它们分布在不同的槽位上。

为什么Hash容易成为大key
首先要理解Redis对Hash的两种底层编码。当Hash的field数量少于hash-max-ziplist-entries(默认128)且所有值都小于hash-max-ziplist-value(默认64字节)时,使用ziplist编码,整个Hash是一块连续内存,读写效率高,内存占用小。一旦超过阈值,编码会转换为hashtable,此时每个field都会产生额外的指针开销和内存碎片。
问题在于很多业务在初始设计时数据量很小,比如一个活动页面的配置Hash,上线时只有几十个field,后来运营不断追加,最终膨胀到几十万元素。此时执行一次hgetall命令,Redis需要一次性申请大块内存来组装响应,可能达到几百MB,网络传输和客户端解析都会被拖垮。更危险的是删除操作,del一个百万元素的Hash,释放内存的时间可能以秒计,直接阻塞主线程,造成慢查询和连接超时。
在Cluster集群模式下还有一层影响:一个大Hash必然只落在一个槽位、一个分片上,所有针对这批数据的读写压力都集中到单个节点,无法通过水平扩容分摊。这就是分槽存储要解决的根本问题。
分槽拆分的具体实现方案
分槽拆分的核心公式很简单:对field做一次hash运算,取模后得到桶编号,然后把field写入对应的小Hash。拆分后的key命名一般采用业务前缀:实体ID:桶编号的格式。
/**
* 将大Hash按桶拆分
* @param baseKey 业务基础key,如 product:1001
* @param field 原始field名
* @param buckets 拆分桶数量,建议为2的幂
* @return 拆分后的实际key
*/
public String shardKey(String baseKey, String field, int buckets) {
// 用CRC16取模,与Redis Cluster的槽位计算保持风格一致
int bucket = Math.abs(crc16(field.getBytes()) % buckets);
return baseKey + ":" + bucket;
}
// 写入示例:原来写 product:1001 -> field sku_9527
// 拆分后写 product:1001:37 -> field sku_9527
String key = shardKey("product:1001", "sku_9527", 128);
jedis.hset(key, "sku_9527", "998.00");桶数量的评估需要结合两个维度。第一是数据规模:如果总field数预计是10万,拆成128个桶,每个桶约780个field,已经远离大key阈值。第二是集群规模:理想情况下桶数量应该是分片数的整数倍,这样拆出来的key能均匀映射到各个槽位。比如6个分片的集群,桶数取96或192,都能保证负载均衡。不建议一开始拆得太细,桶太多会增加key的管理成本和客户端的遍历开销。
读取单个field时,客户端必须用同样的hash算法先算出桶编号,再拼接key执行hget。这里有个容易踩的坑:拆分用的hash函数必须固定,不能升级代码时更换算法,否则新算法算出的桶编号和写入时不一致,直接导致读不到数据。建议把hash算法封装在公共库里统一维护。
读写命令的配套改造
拆分后最大的代价是批量读取。原来的hmget product:1001 f1 f2 f3一条命令搞定,现在这些field可能分散在不同桶里,需要先按桶分组,再用pipeline批量执行。
// 按桶分组后再pipeline批量读取
public Map<String, String> batchGet(Jedis jedis, String baseKey,
List<String> fields, int buckets) {
// 第一步:field按桶分组
Map<String, List<String> grouped = new HashMap<>();
for (String f : fields) {
grouped.computeIfAbsent(shardKey(baseKey, f, buckets),
k -> new ArrayList<>()).add(f);
}
// 第二步:pipeline并行请求各桶
Map<String, String> result = new HashMap<>();
Pipeline pipe = jedis.pipelined();
Map<String, Response<List<String>>> responses = new HashMap<>();
for (Map.Entry<String, List<String> e : grouped.entrySet()) {
responses.put(e.getKey(),
pipe.hmget(e.getKey(), e.getValue().toArray(new String[0])));
}
pipe.sync();
// 第三步:合并结果
for (Map.Entry<String, Response<List<String>>> e : responses.entrySet()) {
List<String> vals = e.getValue().get();
List<String> fs = grouped.get(e.getKey());
for (int i = 0; i < fs.size(); i++) {
result.put(fs.get(i), vals.get(i));
}
}
return result;
}原来的hgetall要替换成对全部桶的轮询。由于拆分规则是确定的,客户端知道桶的完整列表,直接遍历baseKey:0到baseKey:127逐个执行hgetall,每个桶数据量小,单次耗时可控。同样建议配合pipeline,把128次往返压缩成一两次网络IO。
过期策略也需要调整。如果原来对整个Hash设置统一的TTL,拆分后要对每个桶分别设置,或者封装一个expireAll方法循环处理。注意不要漏桶,否则部分数据会变成僵尸key长期占用内存。删除同理,用pipeline逐桶del代替一次删除大Hash,把删除耗时摊薄到毫秒级。
存量数据迁移与验证
对已经存在的大Hash,迁移推荐用hscan渐进式扫描,避免hkeys一次性拉全量把Redis打挂。扫描出数据后按新规则写入小Hash,验证无误后再删除原key。
#!/bin/bash
# 渐进式迁移大Hash到128个分桶
BASE_KEY="product:1001"
BUCKETS=128
CURSOR="0"
while true; do
# 每次扫描500条,hscan不会阻塞Redis
OUTPUT=$(redis-cli HSCAN "$BASE_KEY" "$CURSOR" COUNT 500)
CURSOR=$(echo "$OUTPUT" | sed -n '1p')
# 解析field和value,逐条写入分桶(实际项目建议用客户端程序处理)
echo "$OUTPUT" | tail -n +2
if [ "$CURSOR" = "0" ]; then
break
fi
done
# 校验通过后删除原大key
redis-cli UNLINK "$BASE_KEY"验证环节建议做双向核对:用hlen统计所有分桶的元素总数,与原Hash的hlen对比;抽样若干field,用新规则读取,确认值与旧数据一致。确认无误后删除原key时务必用unlink而不是del,unlink会把内存释放工作交给后台线程,不会阻塞主线程。
最后要建立防复发机制。可以在运维侧部署大key巡检,通过redis-cli --bigkeys或RDB分析工具定期扫描,一旦发现单key元素数超过阈值(比如1万)就告警,从流程上杜绝新的大Hash产生。分槽拆分本身不难,难的是拆分规则的全局一致性和命令改造的完整性,把hash算法、桶数量、key命名这三点固化成团队规范,这套方案就能稳定支撑百万级field的存储场景。
Redis Hash分槽大key集群拆分修改时间:2026-09-16 05:48:38