📃个人主页:island1314
⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏 💞 💞 💞
- 生活总是不会一帆风顺,前进的道路也不会永远一马平川,如何面对挫折影响人生走向 – 《人民日报》
🔥 目录
一、前言:深入理解 Redis 哈希类型
几乎所有的主流编程语言都提供了哈希类型,它们通常被称为哈希、字典、关联数组或映射。在 Redis 中,哈希类型(Hash)是一种特殊的值类型,它 以键值对的形式存储一个又一个的字段(field)和对应的值(value)。
您可以将 Redis 的哈希类型想象成一个**“内嵌的键值对集合”**:
- Redis 键(key):对应一个独立的哈希表。
- 哈希表:包含多个字段-值对(field-value pairs)。
举个例子,一个 Redis 键 user:1
可以是一个哈希表,其中包含 name
字段对应 James
,age
字段对应 28
,email
字段对应 James@example.com
。
字符串 Vs 哈希
🔥 为了更好地理解哈希类型的优势,我们来对比一下使用 Redis 字符串(String) 和 哈希(Hash) 存储同一个用户对象(UID 为 1,姓名 James,年龄 28)的示例:
1. String类型存储
user:1:name James
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 键值对 和 哈希类型二者的关系可以通过图示表示,如下:
二、Hash 命令下详解
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 |
1. HSET & HGET
HSET
- 描述:设置 hash 中 指定的字段(field)的值(value,只能是 字符串)
- 语法:
HSET key field value [field value ...]
- 时间复杂度:O(N),N 为 field 的个数
- 返回值:添加的字段的个数。
- 字段唯一性 :同一个 Hash 中,字段是唯一的,重复设置相同字段会覆盖旧值
HGET
- 描述:获取 hash 中指定字段的值
- 语法:
HGET key field
- 时间复杂度:O(1)
- 返回值:字段对应的值或者 nil
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)
2. HEXISTS & HDEL
HEXISTS
- 描述:判断 Hash 中是否 存在 指定字段
- 语法:
HEXISTS key field
- 时间复杂度:O(1)
- 返回值:1 表示存在,0 表示不存在
注意:HEXISTS
不支持一次性查询多个字段,每次只能查询一个
HDEL
- 描述:删除 Hash 中指定字段
- 语法:
HDEL key field[field]
- 时间复杂度:O(N),N 为 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
3. HKEYS & HVALS
HKEY
- 描述:获取 hash 中的 所有 field
- 语法:
HKEYS key
- 时间复杂度:O(N),N 为 field 的个数
- 返回值:字段名列表
HVALS
- 描述:获取 hash 中的 所有 value
- 语法:
HVALS key
- 时间复杂度:O(N),N 为 field 的个数
- 返回值:值列表
案例:
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"
4. HGETALL & HMGET
HGETALL
- 描述:获取 hash 中的所有 field 以及对应的 value
- 语法:
HGETALL key
- 时间复杂度:O(N),N 为 field 的个数
- 返回值:字段和对应的值。
HMGET
- 描述:一次获取 hash 中多个 field 的值
- 语法:
HMGET key field[field]
- 时间复杂度:O(N),N 为 查询元素个数
- 返回值:字段对应的值列表,如果字段不存在则对应位置返回
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
命令进行 渐进式遍历,避免一次性加载过多数据造成的阻塞。
5. HLEN & HSETNX
HLEN
- 描述:获取 hash 中的所有 field 的个数
- 语法:
HLEN key
- 时间复杂度:O(1)
- 返回值:字段数
HSETNX
- 描述:在字段 不存在的情况下,设置 hash 中的字段和值
- 语法:
HSETNX key field value
- 时间复杂度: O(1)
- 返回值:1 表示设置成功,0 表示失败
案例:
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"
6. HINCRBY & HINCRBYFLOAT
HINCRBY
- 描述:将 hash 中 field 对应的数值添加指定的值
- 语法:
HINCRBY key field increment
- 时间复杂度:O(1)
- 返回值:该字段变化之后的值
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(压缩列表):
-
特点: 一种紧凑的、连续存储的数据结构,用于节省内存。
-
适用条件: 当哈希类型满足以下两个条件时,Redis 会使用
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;
}
哈希类型 vs. 关系型数据库:异同点
虽然哈希类型在某种程度上可以模拟关系型数据库中的行,但两者仍存在本质区别:
- 稀疏性 vs. 结构化:
- 哈希类型是稀疏的: 每个键下的哈希表可以拥有不同的字段集合,实现的是对一个类的高内聚。例如,用户A有
name
和age
字段,用户B可能除了name
和age
,还有email
字段。 - 关系型数据库是完全结构化的: 一旦添加新的列,所有行都必须为其设置值(即使为
NULL
)。这体现了对信息的标准化格式化。
- 哈希类型是稀疏的: 每个键下的哈希表可以拥有不同的字段集合,实现的是对一个类的高内聚。例如,用户A有
- 查询能力:
- 关系型数据库: 擅长进行复杂的关系查询(如联表查询、聚合查询、条件过滤等)。
- Redis 哈希类型: 不适合模拟复杂的查询。Redis 主要设计用于快速键值查找,进行联表查询、聚合查询等操作几乎不可能,并且会带来高昂的维护成本。
总结: 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"
优点:
- 实现简单: 每个属性独立存储,易于理解。
- 灵活的局部变更: 对单个属性的修改非常灵活,直接操作对应的键即可。
缺点:
- 键空间占用大: 一个用户对象就需要占用多个 Redis 键,随着用户数量增加,键空间会变得非常庞大且难以管理。
- 内存占用较高: 每个键都会有额外的内存开销(如键名本身)。
- 缺乏内聚性: 用户信息分散在多个键中,缺乏整体性,不利于维护和整体操作(例如获取一个用户的完整信息需要多次网络请求)。
结论: 这种方案在实际生产环境中基本没有实用性,尤其对于大量用户数据。
② 序列化字符串类型 ⸺ 整个对象序列化为 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\"}"
优点:
- 整体操作: 适用于总是以整体作为操作单元的信息,例如一次性获取或更新整个用户对象。
- 编程简单: 应用程序端将对象序列化为字符串存储,反之亦然。
- 内存使用效率高: 如果选择合适的序列化方案(如 Protobuf、MessagePack 等),内存占用可以非常紧凑。
缺点:
- 序列化/反序列化开销: 每次存取都需要进行序列化和反序列化操作,这会带来一定的计算开销。
- 局部操作不灵活: 如果只需要更新或获取对象中的某个属性,也需要先获取整个字符串,反序列化后修改再序列化存回,效率低下且不灵活。
结论: 适用于对象数据总是以整体存取的场景。
③ 哈希类型 ⸺ 字段-值对集合
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
)。 - 高内聚性: 将一个对象的所有属性集中存储在一个 Redis 键下,提高了数据的内聚性。
- 减少网络开销: 通过
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
结论: 哈希类型是存储对象数据,且需要频繁进行局部读写操作的场景的理想选择。在设计时需要注意数据量和访问模式,以避免内部编码转换带来的潜在内存问题,并明确其不适用于复杂查询的局限性。