
🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:

文章目录
- 前言:
- [一. 哈希类型基本介绍](#一. 哈希类型基本介绍)
- [二. Hash 核心命令全解](#二. Hash 核心命令全解)
-
- [2.1 基础读写删:hset /hget/hexists /hdel](#2.1 基础读写删:hset /hget/hexists /hdel)
- [2.2 全量遍历:hkeys /hvals](#2.2 全量遍历:hkeys /hvals)
- [2.3 批量操作:hgetall /hmget](#2.3 批量操作:hgetall /hmget)
- [2.4 辅助命令:hlen /hsetnx/hincrby /hincrbyfloat](#2.4 辅助命令:hlen /hsetnx/hincrby /hincrbyfloat)
- [2.5 命令小结](#2.5 命令小结)
- [三. Hash 底层编码实现](#三. Hash 底层编码实现)
-
- [3.1 两种编码方式](#3.1 两种编码方式)
- [3.2 编码切换条件](#3.2 编码切换条件)
- [3.3 设计思想:时空权衡的经典体现](#3.3 设计思想:时空权衡的经典体现)
- [四. Hash 典型应用场景:对象缓存](#四. Hash 典型应用场景:对象缓存)
-
- [4.1 三种对象缓存方案对比](#4.1 三种对象缓存方案对比)
- [4.2 Hash 与关系型数据库的区别](#4.2 Hash 与关系型数据库的区别)
- [4.3 工程实践细节](#4.3 工程实践细节)
- 结尾:
前言:
上一篇我们把 String 类型从命令到底层编码再到业务场景彻底讲透了,很多业务用 string + JSON 序列化就能完成缓存。但如果你的数据是结构化对象,比如用户信息、商品详情,经常需要读写单个字段,每次都全量读取、反序列化、修改、再写回,既笨重又浪费性能。这时候 Hash 类型就是更贴合场景的选择。很多初学者对 Hash 的认知停留在 "值里还能嵌套键值对",但对命令的生产风险、底层编码的切换逻辑、和其他存储方案的优劣对比一知半解。本文从基础概念讲起,逐个拆解 Hash 核心命令的用法与踩坑点,深入 ziplist 和 hashtable 两种底层编码的设计思想,再结合对象缓存场景做完整的方案对比,帮你把 Hash 类型从 "会用" 吃透到 "用好"。

一. 哈希类型基本介绍
哈希表是开发中最常用的数据结构之一,不同语言里叫法不同:C++ 里是 unordered_map,Java 里是 HashMap,本质都是键值对映射。
Redis 本身就是一个大的键值对存储,而 Hash 类型的特殊之处在于:它的 value 本身又是一个键值对结构 。为了和外层的 key-value 区分,内层的键我们一般叫 field,对应的值叫 value,整体是 key -> { field1: value1, field2: value2 } 的结构。
举个最直观的例子:存储一个用户的信息。 如果用 string 类型,需要拆成多个独立的 key:
Plain
user:1:name -> James
user:1:age -> 28
user:1:city -> Beijing
如果用 hash 类型,一个 key 就能把所有属性装进去:
Plain
user:1 -> {
name -> James
age -> 28
city -> Beijing
}
很明显,Hash 类型更适合存储结构化的对象数据,相关的属性聚合在同一个 key 里,内聚性更好。


二. Hash 核心命令全解
2.1 基础读写删:hset /hget/hexists /hdel
这四个是 Hash 最基础的增删改查命令,时间复杂度都是 O (1)。
hset:设置字段值
bash
HSET key field value [field value ...]
支持一次设置一个或多个 field-value 对,返回值是本次成功新增的字段个数。如果字段已经存在,会覆盖旧值,但不计入返回的新增计数。
bash
# 单个字段设置
127.0.0.1:6379> hset user:1 name James
(integer) 1
# 批量设置多个字段
127.0.0.1:6379> hset user:1 age 28 city Beijing
(integer) 2
hget:读取字段值
bash
HGET key field
根据 key 和 field 获取对应的值。如果 key 或 field 不存在,返回 nil;如果 key 的类型不是 hash,会报错。
bash
127.0.0.1:6379> hget user:1 name
"James"
127.0.0.1:6379> hget user:1 gender
(nil)

hexists:判断字段是否存在
bash
HEXISTS key field
判断指定 field 是否存在于 hash 中,存在返回 1,不存在返回 0。
bash
127.0.0.1:6379> hexists user:1 name
(integer) 1
127.0.0.1:6379> hexists user:1 gender
(integer) 0

hdel:删除指定字段
bash
HDEL key field [field ...]
删除 hash 中一个或多个字段,返回值是成功删除的字段个数 。 注意区分:del 删除的是整个 key,hdel 删除的是 key 下的 field,不要搞混。
bash
127.0.0.1:6379> hdel user:1 age city
(integer) 2

2.2 全量遍历:hkeys /hvals
这两个命令用来获取 hash 中所有的 field 或者所有的 value,时间复杂度为 O (N),N 是当前 hash 中 field 的数量。
bash
# 获取所有字段名
127.0.0.1:6379> hkeys user:1
1) "name"
# 获取所有字段值
127.0.0.1:6379> hvals user:1
1) "James"
这里必须强调一个生产风险:和 keys * 类似,如果某个 hash 里的 field 数量非常多,hkeys、hvals 会长时间遍历,阻塞 Redis 主线程。不要因为它只操作一个 key 就掉以轻心,大 hash 的全量遍历同样是高危操作。



2.3 批量操作:hgetall /hmget
hgetall:获取全部字段与值
bash
HGETALL key
返回 hash 中所有的 field 和 value,输出格式是 field 和 value 交替排列。
bash
127.0.0.1:6379> hgetall user:1
1) "name"
2) "James"
3) "age"
4) "28"
这个命令的风险比 hkeys 更高,因为它要返回所有字段名和字段值,数据量更大,阻塞时间更长。生产环境的大 hash 绝对禁止直接使用 hgetall。
hmget:批量读取多个字段
bash
HMGET key field [field ...]
和 string 的 mget 类似,一次获取多个 field 的值,返回结果的顺序和输入的 field 顺序一一对应。通过批量操作减少网络 IO 次数,是非常实用的优化手段。
bash
127.0.0.1:6379> hmget user:1 name age city
1) "James"
2) "28"
3) (nil)
很多人会问有没有 hmset?其实是有的,但现在的 Redis 版本里,hset 本身已经支持批量设置多个字段了,hmset 基本被替代,日常开发直接用 hset 即可。
更安全的替代方案:hscan
针对大 hash 的遍历需求,Redis 提供了 hscan 命令,属于渐进式遍历。它不会一次遍历完所有字段,而是每次调用只返回一小部分,多次调用完成全量遍历。 这种 "化整为零" 的思路,把一次长时间阻塞拆成了多次短时间操作,不会卡住主线程,是生产环境遍历大集合的标准做法。这个思想和哈希表的渐进式 rehash 异曲同工,后面讲源码的时候会再提到。



2.4 辅助命令:hlen /hsetnx/hincrby /hincrbyfloat
hlen:获取字段总数
bash
HLEN key
返回 hash 中 field 的总个数,时间复杂度 O (1)。 很多人会疑惑为什么不用遍历就能拿到总数?原理很简单:底层结构里专门有一个变量记录元素个数,直接读取即可,不需要遍历。这也是典型的 "用少量空间换时间" 的设计。
hsetnx:字段不存在才设置
bash
HSETNX key field value
和 string 的 setnx 逻辑一致:只有当 field 不存在时,设置才会成功;如果 field 已经存在,直接失败。原子性操作,适合做字段级别的幂等控制。
hincrby /hincrbyfloat:字段数值增减
bash
HINCRBY key field increment
HINCRBYFLOAT key field increment
和 string 的 incr 家族类似,对指定字段的数值做增减操作,整数用 hincrby,浮点数用 hincrbyfloat。同样是原子操作,单线程下不会有并发问题。 Redis 没有提供 hdecrby,传负数即可实现减法。


2.5 命令小结
| 命令 | 作用 | 时间复杂度 |
|---|---|---|
| hset key field value field ... | 设置一个或多个字段值 | O (k),k 为字段数 |
| hget key field | 获取指定字段值 | O(1) |
| hexists key field | 判断字段是否存在 | O(1) |
| hdel key field field ... | 删除一个或多个字段 | O (k),k 为字段数 |
| hlen key | 获取字段总数 | O(1) |
| hkeys key | 获取所有字段名 | O (N),N 为字段总数 |
| hvals key | 获取所有字段值 | O (N),N 为字段总数 |
| hgetall key | 获取所有字段与值 | O (N),N 为字段总数 |
| hmget key field field ... | 批量获取多个字段值 | O (k),k 为字段数 |
| hsetnx key field value | 字段不存在时才设置 | O(1) |
| hincrby key field n | 字段整数值增减 | O(1) |
| hincrbyfloat key field n | 字段浮点数值增减 | O(1) |
| hstrlen key field | 获取字段值的字节长度 | O(1) |
学习 Redis 命令不用追求死记硬背,用的时候多查官方文档、多动手敲,自然就熟了。核心是理解每个命令的时间复杂度和生产风险。
三. Hash 底层编码实现
和 string 类型一样,Hash 对外接口一致,但底层会根据数据量自动选择编码方式,在空间和时间之间做权衡。Hash 一共有两种底层编码:ziplist(压缩列表) 和 hashtable(哈希表)。
3.1 两种编码方式
ziplist(压缩列表)
当 hash 中元素数量少、每个值的长度都很短时,Redis 会使用 ziplist 作为底层实现。 ziplist 是一块连续的内存,所有元素紧挨着排列,没有指针、没有空槽位,空间利用率非常高。和普通哈希表相比,它省去了哈希数组的空间开销和指针的额外占用,内存占用小很多。
代价也很明显:读写元素需要遍历,插入删除需要移动后续的内存数据,性能会随着元素增多而下降。但在元素数量少的前提下,这点性能差异完全可以忽略,换来的内存收益非常可观。
hashtable(哈希表)
当数据量超过阈值后,ziplist 的读写效率不足以支撑,就会自动转成 hashtable 编码,也就是我们熟悉的标准哈希表实现。它通过哈希函数定位元素,读写时间复杂度 O (1),保证操作性能。代价是需要额外的哈希数组和指针开销,内存占用更高。



3.2 编码切换条件
触发 ziplist 转 hashtable 有两个阈值,都可以通过配置文件修改:
- 字段数量阈值 :
hash-max-ziplist-entries,默认 512。当字段数超过 512 个时,转成 hashtable。 - 值长度阈值 :
hash-max-ziplist-value,默认 64 字节。当任意一个字段的值长度超过 64 字节时,转成 hashtable。
两个条件满足任意一个,就会触发编码转换。我们可以通过 OBJECT encoding 命令查看实际编码:
bash
# 小数据量,默认ziplist
127.0.0.1:6379> hset user:1 name James age 28
(integer) 2
127.0.0.1:6379> OBJECT encoding user:1
"ziplist"
# 插入一个很长的值,触发转换
127.0.0.1:6379> hset user:1 desc "非常长的描述内容..."
(integer) 1
127.0.0.1:6379> OBJECT encoding user:1
"hashtable"
3.3 设计思想:时空权衡的经典体现
很多人会问:什么是 "压缩"?通用压缩算法是 zip、gzip 那类,而 ziplist 的 "压缩" 本质是针对数据结构的紧凑编码------ 去掉所有不必要的空间开销,让数据尽可能紧密排列。
普通哈希表为了 O (1) 的查询性能,需要维持一个有空闲槽位的数组,还要存指针,本身就有空间浪费;而 ziplist 完全放弃了哈希索引,用连续内存紧凑存储,用少量的性能损失换取了大幅的空间节省。
这就是 Redis 编码优化的核心逻辑:小数据量优先省内存,大数据量优先保性能。Redis 里几乎所有数据类型的编码切换,都遵循这个思路。
源码视角:从 ziplist 到 dict 的工程智慧
站在 C/C++ 开发的视角看,这两种编码的设计非常有代表性,背后是两种经典的内存管理思路。
ziplist:极致的空间优化
ziplist 的本质是一个连续分配的字节数组,头部记录了总长度、尾部偏移、元素个数,后面紧跟着一个个数据项。每个数据项里记录了前一项的长度、当前项的编码和内容。
- 优点:没有任何内存碎片和指针开销,内存利用率拉满;对 CPU 缓存非常友好。
- 缺点:插入、删除元素都需要做内存拷贝,元素越多开销越大。
所以它只适合小数据量场景,这也是为什么阈值设为 512 个元素 ------ 在这个量级下,内存拷贝的代价完全可控,而空间收益最大。
dict:哈希表与渐进式 rehash
Hash 类型的 hashtable 编码,和 Redis 全局 key 的存储结构一样,都是用 dict(字典)实现的,也就是标准的数组 + 链表哈希表,链地址法解决哈希冲突。
哈希表有个经典问题:扩容时需要把所有元素重新哈希到新数组里,如果数据量很大,这个过程会非常耗时,导致服务卡顿。Redis 的解决方案是渐进式 rehash:
- 同时保留新旧两个哈希数组;
- 不一次性搬完所有数据,而是每次操作哈希表的时候,顺便搬几个元素;
- 慢慢把旧数组的数据全部迁移到新数组,最后释放旧数组。
这和 hscan 的 "化整为零" 思想完全一致:把一次大的耗时操作,拆分成很多次小操作,分散到平时的请求里,避免长时间阻塞主线程。Java 的 ConcurrentHashMap 扩容也用了类似的思路,是高并发系统里非常经典的优化手段。
四. Hash 典型应用场景:对象缓存
Hash 最核心的应用场景,就是存储结构化的对象数据,最典型的就是用户信息、商品信息这类和数据库表行对应的实体。
4.1 三种对象缓存方案对比
同样是缓存用户信息,有三种常见实现方式,我们来完整对比一下优缺点。
方案一:每个属性一个 string key
bash
set user:1:name James
set user:1:age 28
set user:1:city Beijing
- 优点:实现简单,单属性修改灵活。
- 缺点:key 数量太多,内存占用大;同一个对象的属性分散在各处,内聚性极差,不好维护。
- 结论:基本没有实用价值。
这里提一下 "高内聚、低耦合":高内聚就是把相关的东西放在一起,好找好管理;低耦合就是模块之间关联要弱,改一处不会牵连另一处。第一种方案把同一个用户的属性拆得七零八落,就是典型的低内聚,代码写起来乱,出问题也不好排查。
方案二:string + JSON 序列化
bash
set user:1 '{"name":"James","age":28,"city":"Beijing"}'
- 优点:结构清晰,一个 key 搞定,适合整体读写的场景。
- 缺点:如果只修改单个字段,需要把整个 JSON 读出来、反序列化、修改、再序列化写回去,开销大,性能差。
方案三:hash 类型存储
bash
hset user:1 name James age 28 city Beijing
- 优点:结构直观,和数据库表一一对应;可以单独读写任意一个字段,灵活高效;数据内聚性好,便于管理。
- 缺点:内存占用比 JSON 字符串略高,需要注意编码切换的阈值,避免大对象带来的问题。
综合来看,对于需要频繁操作单个属性的结构化对象,Hash 类型是最合适的选择。
4.2 Hash 与关系型数据库的区别
很多人觉得 Hash 像数据库里的一行记录,但两者有本质区别:
- 稀疏性不同 Hash 是稀疏的:每个 key 可以有完全不同的 field,不需要的字段根本不存,不占空间。 关系型数据库是结构化的:一旦加了新列,所有行都要有这个字段,哪怕值是 null。 这也让 Hash 非常适合属性不固定的对象存储。
- 查询能力不同 关系型数据库支持复杂的条件查询、联表查询、聚合统计。 Redis Hash 只能按 key 和 field 做单点查询,做复杂统计非常困难,开发成本极高。
所以两者是互补关系:数据库存全量数据、做复杂查询;Redis 做缓存、扛高频单点读写。






4.3 工程实践细节
有个很有意思的细节:用户 id 已经在 key 里了(比如 user:1),那 hash 里还要不要存 uid 字段? 从节省空间的角度,确实没必要。但在实际工程中,大多数团队都会选择冗余存一份。原因很简单:代码里拿到 hash 的数据后,可以直接转成对象使用,不用再从 key 里解析 id,开发更方便,代码也更简洁。这点空间开销换开发效率,通常是划算的。
核心考点总结
最后梳理一下 Hash 类型的核心考点,基本覆盖面试和工作的高频问题:
- 数据结构:value 本身是 field-value 键值对,适合存储结构化对象。
- 核心命令:hset/hget/hdel/hexists/hlen 的用法与时间复杂度;hgetall、hkeys 的生产风险与替代方案 hscan。
- 底层编码:ziplist 与 hashtable 两种编码,各自的优缺点与切换条件。
- 设计思想:小数据量省空间、大数据量保性能的时空权衡;渐进式操作化整为零,避免阻塞。
- 方案对比:三种对象缓存方案的优缺点,Hash 相比 string+JSON 的优势。
- 与数据库区别:稀疏性与结构化的差异,各自的适用边界。
- 源码原理:ziplist 连续内存的设计、dict 渐进式 rehash 的思路。
结尾:
html
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!
结语:Hash 是 Redis 里非常实用的一种数据结构,尤其适合对象类的缓存场景,比 string+JSON 更灵活,比多 string 方案更内聚。理解它的底层编码和使用边界,能帮你在业务里做出更合理的技术选型。下一篇我们会继续深入 List 类型,看看它的命令、底层实现与典型业务场景。
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど


