几乎所有的主流编程语言都提供了哈希类型,它们通常被称为哈希、字典、关联数组或映射。在 Redis 中,哈希类型(Hash)是一种特殊的值类型,它 以键值对的形式存储一个又一个的字段(field)和对应的值(value)。
您可以将 Redis 的哈希类型想象成一个**“内嵌的键值对集合”**:
举个例子,一个 Redis 键 user:1
可以是一个哈希表,其中包含 name
字段对应 James
,age
字段对应 28
,email
字段对应 James@example.com
。
字符串 Vs 哈希
🔥 为了更好地理解哈希类型的优势,我们来对比一下使用 Redis 字符串(String) 和 哈希(Hash) 存储同一个用户对象(UID 为 1,姓名 James,年龄 28)的示例:
1. String类型存储
user:1:name Jame
user:1:age 28
这种方式需要为每个用户属性创建一个独立的 Redis 键
2. Hash类型存储
HSET user:1 name "James" age 28 email "James@example.com"
通过一个 user:1
的哈希键,将用户的所有属性集中存储在一个哈希表中。
关键概念:Field-Value
⛵️ 在哈希类型中,我们使用 field-value 来描述哈希表内部的映射关系,以区别于 Redis 整体的 key-value。这里的 value 指的是 field
对应的值,而不是 Redis key
对应的值。简单来说,Redis 中的 value 的类型就是 Hash 类型的数据,这意味着在 Hash 内部又存储了一层键值对。
Redis 键值对 和 哈希类型二者的关系可以通过图示表示,如下:
Redis 提供了丰富的哈希操作命令,下表汇总了常用命令的效果,供开发者参考并结合业务需求和数据大小选择合适的命令。
命令 | 说明 | 示例 |
---|---|---|
HSET key field value | 设置字段值 | HSET user:1000 name "Alice" |
HGET key field | 获取字段值 | HGET user:1000 name |
HMSET key field1 value1 [field2 value2 ...] | 批量设置字段值 | HMSET user:1000 name "Alice" age 25 |
HMGET key field1 [field2 ...] | 批量获取字段值 | HMGET user:1000 name age |
HDEL key field1 [field2 ...] | 删除字段 | HDEL user:1000 age |
HGETALL key | 获取所有字段和值 | HGETALL user:1000 |
HKEYS key | 获取所有字段名 | HKEYS user:1000 |
HVALS key | 获取所有值 | HVALS user:1000 |
HINCRBY/HINCRBYFLOAT key field increment | 对字段值递增 | HINCRBY user:1000 age 1 |
HEXISTS key field | 判断字段是否存在 | HEXISTS user:1000 name |
hlen key | 计算 field 个数 | HLEN user |
HSET
HSET key field value [field value ...]
HGET
HGET key field
127.0.0.1:6379> HSET myhash field1 "Hello"
(integer) 1
127.0.0.1:6379> HGET myhash field1
"Hello"
127.0.0.1:6379> HGET myhash filed1 # 注意:这里是键不存在,所以会返回nil
(nil)
HEXISTS
HEXISTS key field
注意:HEXISTS
不支持一次性查询多个字段,每次只能查询一个
HDEL
HDEL key field[field]
案例:
127.0.0.1:6379> HEXISTS myhash field1
(integer) 1
127.0.0.1:6379> HDEL myhash field1
(integer) 1
127.0.0.1:6379> HEXISTS myhash field1
(integer) 0
HKEY
HKEYS key
HVALS
HVALS key
案例:
127.0.0.1:6379> HSET myhash f1 "Hello" f2 "World"
(integer) 2
127.0.0.1:6379> HKEYS myhash
1) "f1"
2) "f2"
127.0.0.1:6379> HVALS myhash
1) "Hello"
2) "World"
HGETALL
HGETALL key
HMGET
HMGET key field[field]
nil
案例:
127.0.0.1:6379> HSET myhash f1 "Hello" f2 "Island"
(integer) 2
127.0.0.1:6379> HGETALL myhash
1) "f1"
2) "Hello"
3) "f2"
4) "Island"
127.0.0.1:6379> HMGET myhash f1 f2 f3
1) "Hello"
2) "Island"
3) (nil)
注意: 当哈希元素个数较多时,使用 HGETALL
可能会阻塞 Redis。如果只需要获取部分字段,建议使用 HMGET
。如果确实需要获取全部字段且数据量大,可以考虑使用 HSCAN
命令进行 渐进式遍历,避免一次性加载过多数据造成的阻塞。
HLEN
HLEN key
HSETNX
HSETNX key field value
案例:
127.0.0.1:6379> HSETNX myhash field "Hello"
(integer) 1
127.0.0.1:6379> HSETNX myhash field "World" # field已经存在,设置失败
(integer) 0
127.0.0.1:6379> HGET myhash field
"Hello"
HINCRBY
HINCRBY key field increment
HINCRBYFLOAT(HINCRBY
的浮点数版本)
案例:
127.0.0.1:6379> HSET myhash f 5
(integer) 1
127.0.0.1:6379> HINCRBY myhash f 2
(integer) 7
127.0.0.1:6379> HINCRBY myhash f 0.1 # 报错,因为0.1不是整数
(error) ERR value is not an integer or out of range
127.0.0.1:6379> HINCRBYFLOAT myhash f 0.1
"7.1"
127.0.0.1:6379> HINCRBYFLOAT myhash f 2.0e2 # 2.0e2 即 200
"207.10000000000000001"
应用场景: 常用于用户积分、点赞数、商品库存等需要原子性递增或递减的场景
Redis 哈希的内部编码有两种:ziplist
(压缩列表)和 hashtable
(哈希表)。Redis 会根据哈希中存储的数据量和数据大小自动选择最优的内部编码,以平衡内存使用和读写效率
① ziplist(压缩列表):
ziplist
作为内部实现:
hash-max-ziplist-entries
配置(默认 512 个)。
hash-max-ziplist-value
配置(默认 64 字节)。
ziplist
在节省内存方面比 hashtable
更加优秀。
ziplist
的读写效率会下降,因为查找需要遍历。
hashtable(哈希表)
ziplist
的条件时,Redis 会自动切换到使用 hashtable
作为内部实现。hashtable
也能保证 O(1) 的高效访问。ziplist
,哈希表在内存使用上相对较多,特别是在存储小数据集时,内存开销更为显著。下面的示例演示了哈希类型的内部编码如何根据数据量和大小进行动态转换:
ziplist
1)当field个数比较少且没有大的value时,内部编码为 ziplist
127.0.0.1:6379> hmset hashkey f1 v1 f2 v2
OK
127.0.0.1:6379> object encoding hashkey
"ziplist"
2)当有value大于64字节时,内部编码会转换为 hashtable
127.0.0.1:6379> hset hashkey f3 "one string is bigger than 64 bytes ... 省略..."
OK
127.0.0.1:6379> object encoding hashkey
"hashtable"
3)当field个数超过512时,内部编码也会转换为 hashtable
# (假设已经添加了512个字段)
127.0.0.1:6379> HMSET hashkey f1 v1 ... f512 v512 f513 v513
OK
127.0.0.1:6379> OBJECT ENCODING hashkey
"hashtable"
知识点补充: hash-max-ziplist-entries
和 hash-max-ziplist-value
这两个配置项可以在 redis.conf
文件中进行调整。理解这种优化思想比记忆具体数值更重要:Redis 会根据数据特征选择最合适的存储方式,以兼顾内存效率和访问速度。
对于压缩算法理解:
关系型数据表保存用户信息:哈希类型非常适合用于存储对象数据,例如用户信息。
假设我们有一个关系型数据库表,存储用户信息的结构如下:
列名 | 类型 | 描述 |
---|---|---|
uid | BIGINT | 用户ID |
name | VARCHAR | 用户姓名 |
age | INT | 用户年龄 |
city | VARCHAR | 用户城市 |
图解如下:
映射关系表示用户信息,可以将每个用户的完整信息映射为一个 Redis 哈希类型,其中 uid
作为 Redis 键的后缀,而用户的各个属性(name
、age
、city
)则作为哈希的字段
Redis Key: user:{uid}
Hash Fields: {name: "James", age: 28, email: "James@example.com", city: "Beijing"}
图解如下:
应用优势:直观与灵活的更新
相比于将整个用户信息序列化为 JSON 字符串进行缓存,哈希类型具有显著的优势:
用户信息缓存流程伪代码(将每个用户的 id 定义为键后缀,多对 field-value 对应用户的各个属性)
UserInfo getUserInfo(long uid) {
// 1. 根据 uid 构造 Redis 键
String key = "user:" + uid;
// 2. 尝试从 Redis 中获取对应的哈希表
Map<String, String> userInfoMap = redisTemplate.opsForHash().entries(key); // 等同于 Redis 命令: HGETALL key;
// 3. 如果缓存命中(hit)
if (userInfoMap != null && !userInfoMap.isEmpty()) {
// 将映射关系还原为对象形式
UserInfo userInfo = convertMapToUserInfoObject(userInfoMap); // 假设存在一个转换方法
System.out.println("缓存命中,从 Redis 获取用户信息: " + userInfo);
return userInfo;
}
// 4. 如果缓存未命中(miss)
System.out.println("缓存未命中,从数据库查询用户信息...");
// 从数据库中,根据 uid 获取用户信息
UserInfo userInfo = mysqlService.getUserInfoByUid(uid); // 假设存在一个数据库查询服务
// 5. 如果数据库中没有 uid 对应的用户信息
if (userInfo == null) {
System.out.println("数据库中未找到用户信息。");
// 可以返回一个表示用户不存在的响应,例如抛出异常或返回 null
return null; // 响应 404
}
// 6. 将用户信息以哈希类型保存到 Redis 缓存
// 这里使用 HMSET 批量设置多个字段,效率更高
redisTemplate.opsForHash().putAll(key, convertUserInfoObjectToMap(userInfo)); // 假设存在一个转换方法
System.out.println("用户信息已写入 Redis 缓存: " + userInfo);
// 7. 设置缓存过期时间,防止数据腐烂(rot)
// 写入缓存,设置过期时间为 1 小时(3600 秒)
redisTemplate.expire(key, 3600, TimeUnit.SECONDS);
// 8. 返回用户信息
return userInfo;
}
虽然哈希类型在某种程度上可以模拟关系型数据库中的行,但两者仍存在本质区别:
name
和age
字段,用户B可能除了name
和age
,还有email
字段。NULL
)。这体现了对信息的标准化格式化。总结: Redis 哈希更适用于**“对象缓存”或“轻量级属性存储”**,不应将其作为关系型数据库的替代品来处理复杂的业务逻辑和数据关联。
截至目前,我们已经探讨了三种常见的 Redis 缓存用户信息的方式。下面将对这三种方案进行详细的优缺点分析,帮助您根据业务场景做出最佳选择。
① 原生字符串类型 ⸺ 使用字符串类型,每个属性一个键。
127.0.0.1:6379> set user:1:name James
127.0.0.1:6379> set user:1:age 23
127.0.0.1:6379> set user:1:city Beijing
127.0.0.1:6379> get user:1:name
"James"
优点:
缺点:
结论: 这种方案在实际生产环境中基本没有实用性,尤其对于大量用户数据。
② 序列化字符串类型 ⸺ 整个对象序列化为 JSON 等格式
# 假设经过序列化后的用户对象字符串(例如 JSON)
127.0.0.1:6379> SET user:1 "{\"name\":\"James\",\"age\":23,\"city\":\"Beijing\"}"
OK
127.0.0.1:6379> GET user:1
"{\"name\":\"James\",\"age\":23,\"city\":\"Beijing\"}"
优点:
缺点:
结论: 适用于对象数据总是以整体存取的场景。
③ 哈希类型 ⸺ 字段-值对集合
127.0.0.1:6379> hmset user:1 name "James" age 23 city "Beijing"
OK
127.0.0.1:6379> hgetall user:1
1) "name"
2) "James"
3) "age"
4) "23"
5) "city"
6) "Beijing"
优点:
HGET
、HSET
、HINCRBY
)。HMGET
、HMSET
可以批量操作多个字段,减少客户端与 Redis 之间的网络往返次数。缺点:
ziplist
和 hashtable
两种内部编码之间的转换。当数据量超出 ziplist
阈值时,转换为 hashtable
会带来额外的内存开销。Redis哈希对象常常用来缓存一些对象信息,如用户信息、商品信息、配置信息等。
我们以用户信息为例,它在关系型数据库中的结构是这样的
uid | name | age |
---|---|---|
1 | Tom | 15 |
2 | Jerry | 13 |
而使用Redis Hash存储其结构如下图:
此外,我们曾经在做配置中心系统的时候,使用Hash来缓存每个应用的配置信息,其在数据库中的数据结构大致如下表
AppId | SettingKey | SettingValue |
---|---|---|
10001 | AppName | myblog |
10001 | Version | 1.0 |
10002 | AppName | admin site |
在使用Redis Hash进行存储的时候
新增或更新一个配置项
Copy127.0.0.1:6379> HSET 10001 AppName myblog
(integer) 1
获取一个配置项
Copy127.0.0.1:6379> HGET 10001 AppName
"myblog"
删除一个配置项
Copy127.0.0.1:6379> HDEL 10001 AppName
(integer) 1
很多电商网站都会使用 cookie实现购物车,也就是将整个购物车都存储到 cookie里面。这种做法的一大优点:无须对数据库进行写入就可以实现购物车功能,这种方式大大提高了购物车的性能,而缺点 则是程序需要重新解析和验证( validate) cookie,确保cookie的格式正确,并且包含的商品都是真正可购买的商品。cookie购物车还有一个缺点:因为浏览器每次发送请求都会连 cookie一起发送,所以如果购物车cookie的体积比较大,那么请求发送和处理的速度可能会有所降低。
购物车的定义非常简单:我们以每个用户的用户ID(或者CookieId)作为Redis的Key,每个用户的购物车都是一个哈希表,这个哈希表存储了商品ID与商品订购数量之间的映射。在商品的订购数量出现变化时,我们操作Redis哈希对购物车进行更新:
如果用户订购某件商品的数量大于0,那么程序会将这件商品的ID以及用户订购该商品的数量添加到散列里面。
Copy//用户1 商品1 数量1
127.0.0.1:6379> HSET uid:1 pid:1 1
(integer) 1 //返回值0代表改field在哈希表中不存在,为新增的field
如果用户购买的商品已经存在于散列里面,那么新的订购数量会覆盖已有的订购数量;
Copy//用户1 商品1 数量5
127.0.0.1:6379> HSET uid:1 pid:1 5
(integer) 0 //返回值0代表改field在哈希表中已经存在
相反地,如果用户订购某件商品的数量不大于0,那么程序将从散列里面移除该条目。
Copy//用户1 商品1
127.0.0.1:6379> HDEL uid:1 pid:2
(integer) 1
Redis 哈希表作为计数器的使用也非常广泛。它常常被用在记录网站每一天、一月、一年的访问数量。每一次访问,我们在对应的field上自增1
Copy//记录我的
127.0.0.1:6379> HINCRBY MyBlog 202001 1
(integer) 1
127.0.0.1:6379> HINCRBY MyBlog 202001 1
(integer) 2
127.0.0.1:6379> HINCRBY MyBlog 202002 1
(integer) 1
127.0.0.1:6379> HINCRBY MyBlog 202002 1
(integer) 2
也经常被用在记录商品的好评数量,差评数量上
Copy127.0.0.1:6379> HINCRBY pid:1 Good 1
(integer) 1
127.0.0.1:6379> HINCRBY pid:1 Good 1
(integer) 2
127.0.0.1:6379> HINCRBY pid:1 bad 1
(integer) 1
也可以实时记录当天的在线的人数。
Copy//有人登陆
127.0.0.1:6379> HINCRBY MySite 20200310 1
(integer) 1
//有人登陆
127.0.0.1:6379> HINCRBY MySite 20200310 1
(integer) 2
//有人登出
127.0.0.1:6379> HINCRBY MySite 20200310 -1
(integer) 1
结论: 哈希类型是存储对象数据,且需要频繁进行局部读写操作的场景的理想选择。在设计时需要注意数据量和访问模式,以避免内部编码转换带来的潜在内存问题,并明确其不适用于复杂查询的局限性。