Redis的Hash类型因为可以方便地存储对象属性,被大量用于缓存用户信息、商品详情、配置项集合等场景。但随着业务数据不断增长,一个Hash内部可能积累几十万甚至上百万个field,形成典型的大key。大key不仅拖慢单次命令的响应时间,还可能在过期删除时引发Redis主线程阻塞。分槽(也叫分桶)存储是解决这个问题的主流方案,本文将系统讲解其原理与落地细节。

什么是大key,大key会带来哪些危害
大key并没有一个绝对严格的官方定义,社区通用的经验标准是:String类型的value超过10KB,集合类型(Hash、List、Set、ZSet)的元素数量超过5000个,或者单个key整体内存占用超过1MB,就可以认为存在大key风险。当然这个阈值要结合业务场景灵活判断,如果一个Hash里存了10万个field,即使每个field只有几个字节,整体也属于大key。
大key的危害主要体现在四个方面。第一,单命令操作耗时高。对大Hash执行HGETALL或者HDEL多个field时,命令执行时间可能达到几百毫秒,期间Redis主线程被占用,其他请求只能排队。第二,集群模式下数据倾斜。Redis Cluster按key做哈希分片,一个大key只能落在某一个分片上,导致该分片的内存和QPS远高于其他分片。第三,删除和过期代价高。Redis 4.0之前删除大key是同步操作,4.0之后虽然可以用UNLINK异步删除,但过期触发的主动清理依然可能造成卡顿。第四,主从同步和持久化压力增大,RDB生成和AOF重写时,大key的序列化会占用更多CPU时间。
排查线上大key可以借助以下几种方式:使用redis-cli --bigkeys做采样扫描;通过MEMORY USAGE key查看具体key的内存占用;使用HSCAN遍历Hash统计field数量;或者分析RDB文件工具如rdb-tools做离线全量分析。定位到大key之后,就需要考虑分槽改造。
Hash分槽存储的原理与实现
分槽的核心思想很简单:把原本一个Hash中的N个field,按照某种哈希规则分散到M个小Hash中。每个小Hash叫一个桶(bucket),桶的数量通常是2的幂次,方便用位运算取模。比如把一个拥有100万元素的Hash拆成1024个桶,每个桶平均只有约1000个元素,单次HGETALL的开销就大幅降低了。
具体拆分规则为:对field的key做一致性哈希(这里可以直接用CRC32、MurmurHash或者简单的hashCode % bucketCount),得到桶编号,然后拼接出真实的Redis key。例如用户维度的大Hash原键为user:10086:profile,分桶后变为user:10086:profile:0到user:10086:profile:1023。读写时先计算field落在哪个桶,再操作对应的小Hash。下面是一段Java实现示例:
public class HashSharding {
private static final int BUCKET_COUNT = 1024; // 桶数量,2的幂
/**
* 计算 field 所属的桶编号
*/
public static int bucketOf(String field) {
int h = field.hashCode();
// hash值与 (bucketCount - 1) 做与运算,等价于取模
return (h ^ (h >>> 16)) & (BUCKET_COUNT - 1);
}
/**
* 拼接分桶后的真实 Redis key
*/
public static String realKey(String baseKey, String field) {
return baseKey + ":" + bucketOf(field);
}
/**
* 分槽写入:把大 Hash 的一个 field 写入对应的小 Hash
*/
public static void hset(Jedis jedis, String baseKey,
String field, String value) {
jedis.hset(realKey(baseKey, field), field, value);
}
/**
* 分槽读取:根据 field 定位桶再读取
*/
public static String hget(Jedis jedis, String baseKey, String field) {
return jedis.hget(realKey(baseKey, field), field);
}
}桶数量的选择需要慎重。桶太少,拆分效果不明显,每个桶仍然偏大;桶太多,则会增加key的数量,浪费内存(每个key都有元数据开销,Redis 7.0之前每个key约占90字节左右),同时批量读取时需要请求更多的小Hash。一般建议按照预估数据量除以单桶目标元素数(比如1000到5000)来计算,再向上取2的幂。例如预计100万元素,目标每桶2000个左右,可以取512或1024个桶。
还有一个容易被忽略的细节:分桶的哈希函数一旦确定就不能轻易变更,否则同一个field会算出不同的桶编号,导致读不到旧数据。如果确实需要调整桶数量,建议通过双写加灰度切换的方式迁移,切换期间新旧桶同时写入,读旧桶为主,逐步验证数据一致后再切到新桶。
分槽后的批量操作与性能优化
分槽解决了单key过大的问题,但也带来了新的挑战:原本一次HMGET能完成的批量读取,现在field可能分散在多个桶中,需要多次网络往返。解决这个问题的主要手段是Pipeline和Lua脚本。
使用Pipeline时,客户端先按桶编号对field分组,同一个桶的field合并成一条HMGET命令,然后把多条命令打包发送。这样网络往返次数从field个数降低为桶个数(实际上只涉及有field的桶)。示例代码如下:
public static Map<String, String> batchHget(Jedis jedis,
String baseKey,
List<String> fields) {
// 1. 按桶分组
Map<Integer, List<String>> bucketMap = new HashMap<>();
for (String field : fields) {
bucketMap.computeIfAbsent(bucketOf(field), k -> new ArrayList<>())
.add(field);
}
Map<String, String> result = new HashMap<>();
Pipeline pipeline = jedis.pipelined();
List<Response<List<String>>> responses = new ArrayList<>();
// 2. 每个桶一条 HMGET 命令
for (Map.Entry<Integer, List<String>> entry : bucketMap.entrySet()) {
String realKey = baseKey + ":" + entry.getKey();
responses.add(pipeline.hmget(realKey,
entry.getValue().toArray(new String[0])));
}
pipeline.sync();
// 3. 汇总结果
int i = 0;
for (Map.Entry<Integer, List<String>> entry : bucketMap.entrySet()) {
List<String> values = responses.get(i++).get();
List<String> bucketFields = entry.getValue();
for (int j = 0; j < bucketFields.size(); j++) {
if (values.get(j) != null) {
result.put(bucketFields.get(j), values.get(j));
}
}
}
return result;
}需要注意Redis Cluster环境下的一个限制:Pipeline要求所有命令的key落在同一个slot。如果分桶后的key没有被hash tag包裹,多个桶的key大概率分布在不同节点,Pipeline会直接报错。解决方式有两种:一是使用类似user:10086:{profile}:0这样的hash tag写法,让大括号内的内容参与slot计算,这样所有桶都落在同一个slot上;二是客户端按节点分组,分别向不同节点发送Pipeline。前者实现简单但会破坏集群负载均衡,后者更合理但实现复杂度高,需要根据集群规模权衡。
除了批量读取,还有两点性能建议。第一,分桶后仍然要避免无边界增长,每个桶也应设置合理的TTL和容量上限,超过上限时考虑淘汰策略或进一步二次分桶。第二,监控层面要持续跟踪每个桶的元素数量,可以在写入时顺带执行HLEN采样,或者定期用HSCAN统计,及时发现新的倾斜。
存量数据迁移方案与方案对比
对于已经存在的大Hash,直接切换到分槽读写会导致新旧数据不一致,必须做数据迁移。稳妥的迁移流程分四步:第一步,上线双写逻辑,所有写操作同时写旧Hash和新的分桶Hash;第二步,用HSCAN增量遍历旧Hash,把历史数据回填到分桶结构,注意每次HSCAN的COUNT参数控制在1000以内,避免阻塞;第三步,做数据校验,抽样对比新旧结构的读取结果;第四步,灰度切换读流量到分桶结构,观察无误后下线旧Hash的写入,最终用UNLINK异步删除旧key。
// 迁移核心逻辑:HSCAN 增量扫描旧 Hash 并写入分桶结构
public static void migrate(Jedis jedis, String oldKey, String baseKey) {
String cursor = "0";
ScanParams params = new ScanParams().count(500);
do {
ScanResult<Map.Entry<String, String>> result =
jedis.hscan(oldKey, cursor, params);
List<Map.Entry<String, String>> entries = result.getResult();
if (!entries.isEmpty()) {
Pipeline pipeline = jedis.pipelined();
for (Map.Entry<String, String> entry : entries) {
pipeline.hset(realKey(baseKey, entry.getKey()),
entry.getKey(), entry.getValue());
}
pipeline.sync();
}
cursor = result.getCursor();
} while (!"0".equals(cursor));
}最后对比一下几种应对大key的方案。分槽存储适合field可以独立读写的场景,改造成本中等,效果稳定;如果业务只是需要整体读取整个Hash,可以考虑改用多个String key加本地缓存;如果field本身结构复杂且量级可控,也可以评估换用SSDB、Tair等支持更大数据结构的存储。综合来看,分槽是在Redis体系内解决大Hash问题最通用、迁移路径最清晰的方式,配合Pipeline、hash tag和完善的监控,能够在生产环境长期稳定运行。
Redis Hash分槽大keyRedis性能优化修改时间:2026-08-31 04:52:46