Redis的Hash解析
Redis Hash数据结构概述
Redis的Hash数据类型是一种键值对集合,特别适合用于存储对象。它将多个字段-值对(field-value pairs)存储在一个Redis键下,每个字段对应一个值。从外部使用视角看,Hash类似于编程语言中的字典或Map数据结构,可以将一个对象的所有属性作为字段存储在一个键中。
在实际应用中,Redis Hash常用于以下几种场景:
- 存储对象信息:如用户信息、商品信息、配置项等,可以将一个对象的所有属性存储在单个Hash中
- 结构化数据缓存:相比将对象的每个属性存储为单独的String类型,Hash能更有效地组织和管理相关数据
- 聚合统计数据:可以方便地对字段进行增量操作,如计数器、统计指标等
与将对象属性存储为多个String键相比,Hash具有以下显著优势:
优势 | 说明 |
---|---|
内存效率 | 小字段的Hash编码非常紧凑,比多个String键占用更少内存 |
操作原子性 | 可以原子性地操作多个字段,而无需使用事务 |
数据局部性 | 相关数据存储在同一个键中,可以一次性获取所有字段 |
访问效率 | 不需要为每个属性单独建立键,减少网络往返时间 |
Redis Hash的底层实现
Redis Hash数据结构的内部采用两种编码方式:ziplist(压缩列表)和hashtable(哈希表)。Redis会根据一定的条件自动选择和使用这两种编码。
ziplist编码
ziplist是Redis为了节约内存而设计的一种特殊紧凑的连续内存数据结构。当Hash中的元素数量较少时,Redis会使用ziplist作为底层实现。
ziplist的结构如下图所示:
[zlbytes][zltail][zllen][entry1][entry2]...[entryN][zlend]
- zlbytes:4字节,表示整个ziplist占用的内存字节数
- zltail:4字节,记录到最后一个节点的偏移量,便于快速定位到尾部
- zllen:2字节,表示ziplist中节点的数量
- entryX:不固定,ziplist中的各个节点
- zlend:1字节,固定值0xFF,表示ziplist的结束
每个entry(节点)的结构为:[previous_entry_length][encoding][content]
。其中previous_entry_length
记录前一个节点的长度,以实现从后向前的遍历;encoding
表示内容编码;content
是实际保存的数据。
在ziplist编码的Hash中,字段和值依次相邻存放。例如,执行HSET user:1 name Tom age 25
后,ziplist中的布局为:[field1][value1][field2][value2]...
,即字段名和字段值交替存储。
hashtable编码
当Hash规模较大时,Redis会自动转换为hashtable编码。hashtable是一种标准的哈希表实现,使用数组加链表来解决哈希冲突。
Redis的hashtable实现涉及三个核心结构体:
- dict:表示字典,包含两个哈希表(用于渐进式rehash)和其他管理信息
- dictht:哈希表结构,包含桶数组、大小、掩码和使用数量
- dictEntry:哈希表节点,保存键值对及下一个节点的指针(形成链表)
hashtable的结构如下图所示:
+-----------------------+
| dict |
| ht[0] | ht[1] |
| rehashidx| ... |
+-----------------------+| |v v
+----------+ +----------+
| dictht | | dictht |
| table | | table |
| size | | size |
| sizemask | | sizemask |
| used | | used |
+----------+ +----------+|v
+------------------+
| dictEntry* array |
+------------------+|v
+------------------+ +------------------+
| dictEntry | -> | dictEntry |
| key: "name" | | key: "age" |
| val: "Tom" | | val: "25" |
| next: pointer | | next: NULL |
+------------------+ +------------------+
转换条件
Redis根据以下两个条件自动决定使用ziplist还是hashtable编码:
- 元素数量:Hash中的字段数量小于
hash-max-ziplist-entries
配置值(默认512) - 值大小:所有字段值和字段名的字符串长度都小于
hash-max-ziplist-value
配置值(默认64字节)
当同时满足以上两个条件时,使用ziplist编码;只要任一条件不满足,则转换为hashtable编码。
示例
# 初始时,所有字段和值都小于64字节,使用ziplist编码
127.0.0.1:6379> HSET user:1 name "Tom" age 25 career "Programmer"
(integer) 3
127.0.0.1:6379> OBJECT encoding user:1
"ziplist"# 当添加一个长字符串字段(超过64字节)后,自动转换为hashtable编码
127.0.0.1:6379> HSET user:1 desc "Programmer 11111112121v121kl lldklakdkalgam fsfdslkgkskgsklgklsklgklsklgsdkgkskgdsklmvm,,vm,vm,,maafaklglkaklsfakslkf"
(integer) 1
127.0.0.1:6379> OBJECT encoding user:1
"hashtable"
注意
从ziplist转换为hashtable的过程是不可逆的。即使之后删除了导致转换的大字段,Hash也不会恢复为ziplist编码。
Redis Hash操作命令
Redis提供了丰富的命令来操作Hash数据类型,这些命令可以分为以下几类:
基本操作命令
-
HSET key field value:设置Hash中指定字段的值
127.0.0.1:6379> HSET user:1 name "Tom" (integer) 1
-
HGET key field:获取Hash中指定字段的值
127.0.0.1:6379> HGET user:1 name "Tom"
-
HDEL key field [field …]:删除Hash中的一个或多个字段
127.0.0.1:6379> HDEL user:1 age (integer) 1
-
HEXISTS key field:判断指定字段是否存在于Hash中
127.0.0.1:6379> HEXISTS user:1 name (integer) 1
-
HLEN key:获取Hash中字段的数量
127.0.0.1:6379> HLEN user:1 (integer) 2
批量操作命令
-
HMSET key field value [field value …]:同时设置多个字段-值对
127.0.0.1:6379> HMSET user:1 name "Tom" age 25 city "Shanghai" OK
-
HMGET key field [field …]:获取多个字段的值
127.0.0.1:6379> HMGET user:1 name age city 1) "Tom" 2) "25" 3) "Shanghai"
-
HGETALL key:获取Hash中所有字段和值
127.0.0.1:6379> HGETALL user:1 1) "name" 2) "Tom" 3) "age" 4) "25" 5) "city" 6) "Shanghai"
-
HKEYS key:获取Hash中的所有字段名
127.0.0.1:6379> HKEYS user:1 1) "name" 2) "age" 3) "city"
-
HVALS key:获取Hash中的所有值
127.0.0.1:6379> HVALS user:1 1) "Tom" 2) "25" 3) "Shanghai"
数字操作命令
-
HINCRBY key field increment:将指定字段的值增加整数增量
127.0.0.1:6379> HINCRBY user:1 age 1 (integer) 26
-
HINCRBYFLOAT key field increment:将指定字段的值增加浮点数增量
127.0.0.1:6379> HINCRBYFLOAT user:1 score 0.5 "0.5"
高级操作命令
-
HSCAN key cursor [MATCH pattern] [COUNT count]:增量式迭代Hash中的字段
127.0.0.1:6379> HSCAN user:1 0 MATCH "n*" 1) "0" 2) 1) "name"2) "Tom"
-
HSETNX key field value:只在字段不存在时设置其值
127.0.0.1:6379> HSETNX user:1 name "Mike" (integer) 0 # 返回0表示字段已存在,设置失败
-
HSTRLEN key field:获取指定字段值的字符串长度(Redis 3.2+)
127.0.0.1:6379> HSTRLEN user:1 name (integer) 3
Java操作Redis Hash
StringRedisTemplate与RedisTemplate区别
- StringRedisTemplate:专门用于处理字符串类型的数据,使用String序列化器,键和值都序列化为可读的字符串
- RedisTemplate:可以处理任意类型的对象,默认使用JDK序列化器,在Redis中存储为二进制数据
基本配置
首先需要配置StringRedisTemplate
Bean:
@Configuration
public class RedisConfig {@Beanpublic StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {StringRedisTemplate template = new StringRedisTemplate();template.setConnectionFactory(redisConnectionFactory);// 设置Hash值的序列化器GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();template.setHashValueSerializer(jackson2JsonRedisSerializer);return template;}
}
代码示例
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.util.HashMap;
import java.util.Map;@Component
public class UserHashService {@Autowiredprivate StringRedisTemplate stringRedisTemplate;// 获取Hash操作接口private HashOperations<String, String, Object> hashOps() {return stringRedisTemplate.opsForHash();}// 添加单个字段public void addUserField(String userId, String field, String value) {hashOps().put(userKey(userId), field, value);}// 添加整个用户对象public void addUser(String userId, Map<String, Object> userData) {hashOps().putAll(userKey(userId), userData);}// 获取用户字段public Object getUserField(String userId, String field) {return hashOps().get(userKey(userId), field);}// 获取整个用户信息public Map<String, Object> getUser(String userId) {return hashOps().entries(userKey(userId));}// 更新用户年龄(数字操作)public long incrementUserAge(String userId, long increment) {return hashOps().increment(userKey(userId), "age", increment);}// 删除用户字段public void deleteUserField(String userId, String field) {hashOps().delete(userKey(userId), field);}// 检查字段是否存在public boolean hasUserField(String userId, String field) {return hashOps().hasKey(userKey(userId), field);}// 获取用户所有字段名public Set<String> getUserFields(String userId) {return hashOps().keys(userKey(userId));}// 获取用户字段数量public long getUserFieldCount(String userId) {return hashOps().size(userKey(userId));}private String userKey(String userId) {return "user:" + userId;}// 使用示例public void userOperationsExample() {String userId = "123";// 添加用户Map<String, Object> userData = new HashMap<>();userData.put("name", "Tom");userData.put("age", 25);userData.put("city", "Shanghai");addUser(userId, userData);// 获取用户姓名String name = (String) getUserField(userId, "name");System.out.println("User name: " + name);// 增加年龄incrementUserAge(userId, 1);// 获取完整用户信息Map<String, Object> user = getUser(userId);System.out.println("User: " + user);}
}
序列化策略
在Redis中存储对象时,选择合适的序列化策略很重要:
- StringRedisSerializer:用于键和字段名的序列化,保证可读性
- Jackson2JsonRedisSerializer:用于值的序列化,将对象序列化为JSON格式,便于不同语言间共享数据
- JdkSerializationRedisSerializer:默认序列化器,将对象序列化为二进制,但不同语言间不兼容
对于Hash值的序列化,可以针对不同的需求选择不同的策略:
@Configuration
public class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(redisConnectionFactory);// 设置键的序列化器template.setKeySerializer(new StringRedisSerializer());template.setHashKeySerializer(new StringRedisSerializer());// 设置值的序列化器Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class);ObjectMapper objectMapper = new ObjectMapper();objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);objectMapper.activateDefaultTyping(LazySerializationizationParser.DefaultTyping.NON_FINAL);jsonSerializer.setObjectMapper(objectMapper);template.setValueSerializer(jsonSerializer);template.setHashValueSerializer(jsonSerializer);return template;}
}
Redis Hash的渐进式扩容机制
需要渐进式Rehash的原因
Redis的哈希表在数据量增加时需要扩容,以避免哈希冲突导致的性能下降。传统的Rehash操作需要一次性将所有键从旧表迁移到新表,当哈希表很大时,这会阻塞Redis主线程较长时间,影响服务可用性。
为了解决这个问题,Redis设计了渐进式Rehash机制,将大规模的数据迁移分多次、渐进式地完成,避免长时间阻塞。
触发Rehash的条件
Rehash操作在以下两种情况下触发:
扩容条件
- 当哈希表的负载因子(元素数量/哈希桶数量)超过1,且Redis没有执行BGSAVE或BGREWRITEAOF命令时
- 当负载因子超过5,无论是否在执行持久化操作,都会强制扩容
缩容条件:
- 当哈希表的负载因子低于0.1时,触发缩容以节省内存
需要注意的是,在Redis执行BGSAVE或BGREWRITEAOF时,正常情况下会尽量避免扩容以减少内存页的过多分离(Copy On Write),但如果负载因子超过5,说明冲突已经很严重,会强制扩容。
执行流程
渐进式Rehash的整个过程可以分为以下几个步骤:
-
准备阶段
- 为
ht[1]
分配空间:- 扩容时:大小为第一个大于等于
ht[0].used * 2
的2的n次幂 - 缩容时:大小为第一个大于等于
ht[0].used
的2的n次幂
- 扩容时:大小为第一个大于等于
- 设置rehashidx = 0,表示Rehash正式开始
- 为
-
渐进迁移阶段
- 每次对字典执行增删改查操作时,Redis除了执行指定操作外,还会将
ht[0]
在rehashidx索引上的整个桶中的所有键值对迁移到ht[1]
- 迁移完成后,rehashidx值加1
- 在Rehash期间,新添加的键值对会直接保存到
ht[1]
,而ht[0]
不再进行任何添加操作
- 每次对字典执行增删改查操作时,Redis除了执行指定操作外,还会将
-
完成阶段
- 当
ht[0]
的所有桶都迁移完成后,rehashidx设置为-1 - 释放
ht[0]
的空间,将ht[1]
设置为新的ht[0]
,并在ht[1]
创建一个新的空哈希表为下一次Rehash做准备
- 当
Rehash期间的访问规则
在渐进式Rehash期间,字典同时持有两个哈希表,访问规则如下:
- 查找操作:先在
ht[0]
中查找,如果没找到再到ht[1]
中查找 - 插入操作:新键值对直接插入到
ht[1]
中 - 删除和更新操作:需要在
ht[0]
和ht[1]
上同时进行
以下是在Rehash期间查找操作的伪代码示例:
def get(key):if rehashing:# 先查ht[0]idx = dict_rehashidx(d)bucket = &d->ht[0].table[idx]while bucket->used > 0:if key matches:return valuebucket++# 如果ht[0]未找到,转向ht[1]return lookup_in_ht1(key)else:return lookup_in_ht0(key)
示例
为了更好地理解渐进式Rehash的过程,以下是一个简化的示例:
-
初始状态:
ht[0]: 大小为4,有3个元素 ht[1]: 为空 rehashidx: -1
-
开始Rehash:
ht[0]: 大小为4,有3个元素 ht[1]: 大小为8(扩容为2倍) rehashidx: 0
-
第一次迁移后:
ht[0]: 索引0的桶已迁移,剩余2个元素 ht[1]: 已接收索引0的桶中元素 rehashidx: 1
-
完成Rehash:
ht[0]: 已全部迁移,将被释放 ht[1]: 包含所有3个元素 rehashidx: -1
优缺点
优点:
- 非阻塞:将庞大的Rehash操作分摊到多个请求上,避免长时间阻塞服务
- 平滑过渡:在Rehash期间,Redis仍能正常处理读写请求,保证高可用性
缺点:
- 迁移期间性能略有下降:每个操作需要额外检查两个哈希表
- 内存占用翻倍:在Rehash期间,需要同时存储两个哈希表,内存占用增加