【Redis 初阶】Hash 类型深度解析:结构化数据存储的最优解


🔥草莓熊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 数量非常多,hkeyshvals 会长时间遍历,阻塞 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 有两个阈值,都可以通过配置文件修改:

  1. 字段数量阈值hash-max-ziplist-entries,默认 512。当字段数超过 512 个时,转成 hashtable。
  2. 值长度阈值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 像数据库里的一行记录,但两者有本质区别:

  1. 稀疏性不同 Hash 是稀疏的:每个 key 可以有完全不同的 field,不需要的字段根本不存,不占空间。 关系型数据库是结构化的:一旦加了新列,所有行都要有这个字段,哪怕值是 null。 这也让 Hash 非常适合属性不固定的对象存储。
  2. 查询能力不同 关系型数据库支持复杂的条件查询、联表查询、聚合统计。 Redis Hash 只能按 key 和 field 做单点查询,做复杂统计非常困难,开发成本极高。

所以两者是互补关系:数据库存全量数据、做复杂查询;Redis 做缓存、扛高频单点读写。

4.3 工程实践细节

有个很有意思的细节:用户 id 已经在 key 里了(比如 user:1),那 hash 里还要不要存 uid 字段? 从节省空间的角度,确实没必要。但在实际工程中,大多数团队都会选择冗余存一份。原因很简单:代码里拿到 hash 的数据后,可以直接转成对象使用,不用再从 key 里解析 id,开发更方便,代码也更简洁。这点空间开销换开发效率,通常是划算的。

核心考点总结

最后梳理一下 Hash 类型的核心考点,基本覆盖面试和工作的高频问题:

  1. 数据结构:value 本身是 field-value 键值对,适合存储结构化对象。
  2. 核心命令:hset/hget/hdel/hexists/hlen 的用法与时间复杂度;hgetall、hkeys 的生产风险与替代方案 hscan。
  3. 底层编码:ziplist 与 hashtable 两种编码,各自的优缺点与切换条件。
  4. 设计思想:小数据量省空间、大数据量保性能的时空权衡;渐进式操作化整为零,避免阻塞。
  5. 方案对比:三种对象缓存方案的优缺点,Hash 相比 string+JSON 的优势。
  6. 与数据库区别:稀疏性与结构化的差异,各自的适用边界。
  7. 源码原理:ziplist 连续内存的设计、dict 渐进式 rehash 的思路。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:Hash 是 Redis 里非常实用的一种数据结构,尤其适合对象类的缓存场景,比 string+JSON 更灵活,比多 string 方案更内聚。理解它的底层编码和使用边界,能帮你在业务里做出更合理的技术选型。下一篇我们会继续深入 List 类型,看看它的命令、底层实现与典型业务场景。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
jieshenai1 小时前
Shell 脚本如何定位项目根目录——从一个 8 行的小脚本讲起
linux·运维
caimouse1 小时前
ReactOS 窗口系统分析(31):TextOutW 文本输出全链路 — 从用户函数到显示缓冲区的旅程
网络·人工智能·计算机视觉
nvd111 小时前
K3s + ArgoCD 中的密码管理
数据库·oracle·argocd
迷途之人不知返1 小时前
自制第一个Linux小程序——进度条
linux
无忧.芙桃1 小时前
Linux系统之网络基础概念
网络
zbyyd1 小时前
深入理解 Linux 文件 IO 与目录 IO:系统调用与库函数的本质区别
linux·c语言
XUEYUAN52121 小时前
IP 池调度算法实战:代理池负载均衡、健康检查与失效剔除机制(2026)
tcp/ip·算法·负载均衡
Wang's Blog2 小时前
PostgreSQL笔记62: 分区表维护最佳实践——默认分区、锁策略与性能调优
数据库·笔记·postgresql
qetfw2 小时前
Debian OpenLDAP 目录服务配置:DN、LDIF、ldapsearch 与认证验证
linux·运维·debian