文章目录
- [第一章:Redis 哈希(Hash)核心机制与命令全解](#第一章:Redis 哈希(Hash)核心机制与命令全解)
-
- [1.1 基础单字段读写与判定](#1.1 基础单字段读写与判定)
-
- [HSET 命令](#HSET 命令)
- [HGET 命令](#HGET 命令)
- [HEXISTS 命令](#HEXISTS 命令)
- [HDEL 命令](#HDEL 命令)
- [1.2 批量操作与全量获取的高危陷阱](#1.2 批量操作与全量获取的高危陷阱)
-
- [HMGET 与 HMSET 命令](#HMGET 与 HMSET 命令)
- [HKEYS、HVALS 与 HGETALL 命令](#HKEYS、HVALS 与 HGETALL 命令)
- [1.3 渐进式遍历 HSCAN 与扩容机制](#1.3 渐进式遍历 HSCAN 与扩容机制)
-
- [HSCAN 命令](#HSCAN 命令)
- [1.4 长度统计、存在性判定与数值计算](#1.4 长度统计、存在性判定与数值计算)
-
- [HLEN 命令](#HLEN 命令)
- [HSETNX 命令](#HSETNX 命令)
- [HINCRBY 与 HINCRBYFLOAT 命令](#HINCRBY 与 HINCRBYFLOAT 命令)
- [HSTRLEN 命令](#HSTRLEN 命令)
- [第二章:Redis 哈希的底层编码与内存优化](#第二章:Redis 哈希的底层编码与内存优化)
-
- [2.1 压缩的本质:重新编码与内存节约](#2.1 压缩的本质:重新编码与内存节约)
- [2.2 内存与性能的权衡:ziplist 与 hashtable 之争](#2.2 内存与性能的权衡:ziplist 与 hashtable 之争)
- [2.3 触发编码转换的两个核心条件](#2.3 触发编码转换的两个核心条件)
- [2.4 关键配置参数:redis.conf 中的调控开关](#2.4 关键配置参数:redis.conf 中的调控开关)
- 第三章:哈希存储设计与工程实践原则
-
- [3.1 结构化数据存储:字符串(JSON)与哈希的选型对比](#3.1 结构化数据存储:字符串(JSON)与哈希的选型对比)
- [3.2 数据模型演进:原生字符串的"低内聚"困境](#3.2 数据模型演进:原生字符串的“低内聚”困境)
- [3.3 软件工程心法:高内聚与低耦合的哲学](#3.3 软件工程心法:高内聚与低耦合的哲学)
- [3.4 哈希存储的最佳实践:关于 uid 字段的思考](#3.4 哈希存储的最佳实践:关于 uid 字段的思考)
第一章:Redis 哈希(Hash)核心机制与命令全解
Redis 自身在底层就是一个键值对(key-value)结构,而它自身的键值对正是通过哈希的方式来组织的。
在日常开发与技术面试中,哈希(Hash)结构属于高频考察点,也是使用频率极高的数据结构之一。
在 Redis 中,哈希类型是指 value 本身又是一个键值对结构 ,形如 key = "key",value = { { field1, value1 }, ..., { fieldN, valueN } }。也就是说,把 key 这一层组织完毕后,到了 value 这一层,value 的具体类型还可以再是一个哈希。
这里需要明确一个术语区分:哈希类型中的映射关系通常称为 field-value(字段-值),用于区分 Redis 整体的键值对(key-value)。这里的 value 是指 field 对应的值,不是 key 对应的值,在不同上下文中请留意 value 的作用范围。
1.1 基础单字段读写与判定
HSET 命令
-
语法 :
HSET key field value [field value ...] -
功能:设置 hash 中指定的字段(field)的值(value)。
-
时间复杂度 :插入一组 field 为 O(1),插入 N 组 field 为 O(N)
-
返回值 :添加的字段的个数。
-
示例 :
redisredis> HSET myhash field1 "Hello" (integer) 1 redis> HGET myhash field1 "Hello"
HGET 命令
-
语法 :
HGET key field -
功能:获取 hash 中指定字段的值。
-
时间复杂度 :O(1)
-
返回值 :字段对应的值或者 nil。
-
示例 :
redisredis> HSET myhash field1 "foo" (integer) 1 redis> HGET myhash field1 "foo" redis> HGET myhash field2 (nil)
HEXISTS 命令
- 语法 :
HEXISTS key field - 功能:判断 hash 中是否有指定的字段。
- 时间复杂度 :O(1)
- 返回值 :1 表示存在,0 表示不存在。
- ⚠️ 风险警示 :这个操作存在一定的风险,类似于字符串章节提到的
KEYS *命令。主要问题在于我们无法预知这个 hash 中是否会存在大量的 field。如果存在大量的 field,这个操作可能会带来不可预知的性能阻塞,在性能敏感的场景下务必谨慎使用。
HDEL 命令
-
语法 :
HDEL key field [field ...] -
功能 :删除 hash 中指定的字段。需要严格区分的是,
DEL删除的是整个 key,而HDEL删除的是哈希内部的 field。 -
时间复杂度 :删除一个元素为 O(1),删除 N 个元素为 O(N)。
-
返回值 :本次操作删除的字段个数。
-
示例 :
redisredis> HSET myhash field1 "foo" (integer) 1 redis> HDEL myhash field1 (integer) 1 redis> HDEL myhash field2 (integer) 0
1.2 批量操作与全量获取的高危陷阱
HMGET 与 HMSET 命令
- HMGET 语法 :
HMGET key field [field ...] - 功能 :一次获取 hash 中多个字段的值。类似于字符串类型的
MGET,HMGET可以一次查询多个 field,而HGET一次只能查询一个 field 。注意 :返回的多个 value 的顺序和请求的 field 的顺序是完全匹配的。 - HMSET 语法 :
HMSET key field value [field value ...] - 功能 :早期用于一次设置多个 field 和 value。现在已不需要使用,因为
HSET已经完全支持一次设置多个 field 和 value。 - 时间复杂度 :只查询一个元素为 O(1),查询多个元素为 O(N)(N 为查询元素个数)。
- 返回值 :字段对应的值或者 nil。
bash
# 使用 HMSET 批量设置字段(早期用法)
redis> HMSET user:1 name James age 28 city Beijing
OK
# 使用 HMGET 批量获取字段,顺序与请求一致
redis> HMGET user:1 name age city nofield
1) "James"
2) "28"
3) "Beijing"
4) (nil)
HKEYS、HVALS 与 HGETALL 命令
- HKEYS 语法 :
HKEYS key,获取 hash 中的所有字段。返回字段列表。 - HVALS 语法 :
HVALS key,获取 hash 中的所有的值。返回所有的值。 - HGETALL 语法 :
HGETALL key,获取 hash 中的所有字段以及对应的值。返回字段和对应的值。 - 时间复杂度 :O(N),N 为 field 的个数。
- ⚠️ 性能风险 :H 系列的命令必须保证 key 对应的 value 是哈希类型的,否则会报错。上述
HKEYS、HVALS、HGETALL操作的时间复杂度都是 O(N)。如果哈希非常大,这个操作就可能导致 Redis 服务器被阻塞。上述这样的操作,开销是比较大的。多数情况下,我们不需要查询所有的 field,可能只需要查询其中的几个 field。
bash
redis> HSET myhash field1 "Hello"
(integer) 1
redis> HSET myhash field2 "World"
(integer) 1
# 获取所有字段名
redis> HKEYS myhash
1) "field1"
2) "field2"
# 获取所有值
redis> HVALS myhash
1) "Hello"
2) "World"
# 获取所有字段和值,按 field1, value1, field2, value2 顺序返回
redis> HGETALL myhash
1) "field1"
2) "Hello"
3) "field2"
4) "World"
1.3 渐进式遍历 HSCAN 与扩容机制
HSCAN 命令
- 语法 :
HSCAN key cursor [MATCH pattern] [COUNT count]- 语法中 key 表示要扫描的 Hash 键名,cursor 表示游标位置 (第一次扫描传入 0,之后使用 Redis 返回的新游标继续扫描,直到返回 0 表示结束);MATCH pattern 是可选参数,用于按照匹配规则过滤字段名,例如 MATCH user:* 只返回符合规则的 field;COUNT count 也是可选参数,用于提示 Redis 每次扫描返回的大致数量,例如 COUNT 10 表示希望每次获取约 10 个字段 。完整流程是:第一次执行 HSCAN key 0 获取部分 field-value 和新的游标,随后不断使用新游标执行 HSCAN key cursor,
直到游标重新变为 0,完成整个 Hash 的遍历。
- 语法中 key 表示要扫描的 Hash 键名,cursor 表示游标位置 (第一次扫描传入 0,之后使用 Redis 返回的新游标继续扫描,直到返回 0 表示结束);MATCH pattern 是可选参数,用于按照匹配规则过滤字段名,例如 MATCH user:* 只返回符合规则的 field;COUNT count 也是可选参数,用于提示 Redis 每次扫描返回的大致数量,例如 COUNT 10 表示希望每次获取约 10 个字段 。完整流程是:第一次执行 HSCAN key 0 获取部分 field-value 和新的游标,随后不断使用新游标执行 HSCAN key cursor,
- 功能 :因为
HKEYS、HVALS、HGETALL都存在一定的风险,如果 hash 的元素个数太多,执行耗时会比较长,从而阻塞 Redis。HSCAN用于遍历 Redis 的 hash,它属于"渐进式遍历"。 - 机制 :
每次调用HSCAN,只遍历 hash 的一小部分;再调用一次,再遍历一小部分...... 连续执行多次,就可以完成整个遍历过程。这种化整为零的方式,使得时间就是可控的。
bash
# 创建 Hash 数据
HSET user:1001 name Tom age 20 city Beijing email tom@test.com job engineer
# 第一次扫描,从 cursor=0 开始
HSCAN user:1001 0 COUNT 2
# 假设返回:
# 1) "4" <-游标,不计算数量
# 2) name Tom age 20 <-COUNT 只影响 Redis 希望返回多少个 field-value 数据,不影响 cursor。
# 使用返回的 cursor 继续扫描
HSCAN user:1001 4 COUNT 2
# 假设返回:
# 1) "8"
# 2) city Beijing email tom@test.com
# 继续扫描
HSCAN user:1001 8 COUNT 2
# 假设返回:
# 1) "0" <- cursor 为 0,遍历结束
# 2) job engineer
# 使用 MATCH 过滤字段
HSCAN user:1001 0 MATCH name* COUNT 10
# 只返回 field 名匹配 name* 的数据
1.4 长度统计、存在性判定与数值计算
HLEN 命令
- 语法 :
HLEN key - 功能:获取 hash 中的所有字段的个数。
- 时间复杂度 :O(1)。不需要遍历,直接获取。
- 返回值 :字段个数。
bash
redis> HSET user:1 name James age 28
(integer) 2
redis> HLEN user:1
(integer) 2
HSETNX 命令
- 语法 :
HSETNX key field value - 功能 :在字段不存在的情况下,设置 hash 中的字段和值。类似于
SETNX,存在则设置失败。 - 时间复杂度 :O(1)
- 返回值 :1 表示设置成功,0 表示失败。
bash
# 字段不存在,设置成功
redis> HSETNX user:1 email "james@example.com"
(integer) 1
# 字段已存在,设置失败
redis> HSETNX user:1 name "Tom"
(integer) 0
# 查看结果,name 仍然是原值
redis> HGET user:1 name
"James"
HINCRBY 与 HINCRBYFLOAT 命令
- HINCRBY 语法 :
HINCRBY key field increment - HINCRBYFLOAT 语法 :
HINCRBYFLOAT key field increment - 功能 :hash 这里的 value,也可以当做数字来处理。
HINCRBY可以加/减整数,HINCRBYFLOAT可以加/减小数。 - 时间复杂度 :O(1)
- 返回值 :该字段变化之后的值。
- 注意事项 :使用频率不算很高。Redis 没有提供类似于
HINCR、HDECR这样专门用于自增自减的命令,而是通过传入负数来实现减法操作。
bash
# HINCRBY 整数加减
redis> HSET stats count 10
(integer) 1
redis> HINCRBY stats count 5
(integer) 15
redis> HINCRBY stats count -3
(integer) 12
# HINCRBYFLOAT 浮点数加减
redis> HSET stats price 10.50
(integer) 1
redis> HINCRBYFLOAT stats price 0.1
"10.6"
redis> HINCRBYFLOAT stats price -5
"5.6"
HSTRLEN 命令
- 语法 :
HSTRLEN key field - 功能:计算 value 的字符串长度。
- 时间复杂度 :O(1)
- 返回值 :字符串长度。
bash
redis> HSET user:1 name James
(integer) 1
redis> HSTRLEN user:1 name
(integer) 5
第二章:Redis 哈希的底层编码与内存优化
在业务中使用哈希时,往往容易忽略 Redis 底层为了平衡内存与性能所做出的精妙设计。本章深入底层,剖析哈希的两种核心编码方式:ziplist 与 hashtable,揭示其空间与时间的博弈。
2.1 压缩的本质:重新编码与内存节约
压缩的本质,是对数据针对性的重新编码。例如,将包含大量重复字符的字符串 abcd000...000efgh 重新编码为 abcd0[100]efgh,即可大幅缩短长度。常见的 rar、zip、gzip 等算法皆是如此。
ziplist(压缩列表)也是同理。Redis 内部的数据结构经过精心设计,其核心目的就是节省内存空间。
一个普通的哈希表首先是一个数组,数组中必然存在空槽位,这会带来内存浪费;而
ziplist通过紧凑的连续内存布局如:[field1, value1, field2, value2, ..., fieldN, valueN],极大减少了这种浪费。
2.2 内存与性能的权衡:ziplist 与 hashtable 之争
ziplist 节省内存的代价是:读写元素的速度较慢 。因为需要遍历或重新分配连续内存 。元素少时这种慢并不明显;元素太多时,慢就会雪上加霜,导致 Redis 耗费大量 CPU 时间甚至阻塞。
因此,Redis 必须在内存和性能之间寻找平衡点。
当数据量小、值短时,优先使用ziplist节省内存;当数据量大、值长时,必须转换为hashtable保证读写性能。
2.3 触发编码转换的两个核心条件
Redis 会在满足以下任一条件时,自动将哈希编码从 ziplist 转换为 hashtable:
- 元素个数维度:哈希中的元素个数超过阈值。
- 值长度维度:哈希中任何一个 value 的长度超过阈值。
只要触发其中一条,Redis 就会果断放弃 ziplist,转而使用 hashtable,防止过高的时间开销拖垮整个服务。

2.4 关键配置参数:redis.conf 中的调控开关
上述临界点通过配置文件明确定义,可直接写入 redis.conf:
hash-max-ziplist-entries:允许的最大元素个数。默认配置为 512 个。超过则转换为hashtable。hash-max-ziplist-value:允许的最大单值长度。默认配置为 64 字节 。超过则转换为hashtable。
第三章:哈希存储设计与工程实践原则
在实际的业务开发中,我们经常面临数据模型的选型。Redis 的字符串(String)虽然也能作为缓存使用,但如果是存储结构化的数据,使用哈希(Hash)类型会更加合适。这类似于数据库中表的结构,哈希能够将对象的不同属性聚合在一起,实现高效的读写。
3.1 结构化数据存储:字符串(JSON)与哈希的选型对比
假设我们需要存储一个用户对象(UserInfo),包含 uid、name、age、city 等字段。
如果使用 String(JSON 格式) 来表示 UserInfo,当我们只需要获取或者修改其中某个 field(比如只修改 age)时,整个流程会非常繁琐:需要把整个 JSON 字符串读取出来,解析成对象,修改字段,然后再重新序列化为 JSON 字符串写回去。这显然带来了极大的 CPU 和网络开销。
而如果使用 Hash 的方式来表示 UserInfo,我们就可以使用 field 表示对象的每个属性(类似数据表的每一列),此时就可以非常方便地修改/获取任意一个属性的值。这种读写方式更直观、更高效。

然而,天下没有免费的午餐。使用哈希确实读写更高效,但付出的代价是空间代价 。我们需要严格控制在 ziplist 和 hashtable 两种内部编码之间的转换,因为一旦转换不当,可能会造成内存的较大消耗。因此,数据的合理拆分与组织变得尤为关键。
3.2 数据模型演进:原生字符串的"低内聚"困境
面对对象属性存储,如果不使用哈希,还有一种常见的做法是使用原生字符串类型,将每个属性作为一个独立的 key:
text
set user:1:name James
set user:1:age 28
set user:1:city Beijing
这种做法本质上相当于把同一个数据对象的各个属性,给完全分散开表示了。这就是典型的低内聚。
低内聚有什么坏处?我们可以用生活中的场景类比:
就好比你的衣服,如果随意乱扔,会出现很多地方------比如床上、床头柜上、沙发上、椅子上、地上、晾衣架上......唯独不会出现在衣柜里。这种"低内聚"的情况,导致想找某个衣服或者找一个小的物件时,就会变得非常困难,就需要把各个角落都遍历一遍。如果把这些衣服都收在衣柜里,找起来就会非常方便。
在软件工程中,我们追求的恰恰相反:把有关联的东西放在一起,放在最能发挥它们作用的地方(高内聚),这才是好的代码设计。
3.3 软件工程心法:高内聚与低耦合的哲学
高内聚往往伴随着低耦合。这里的"耦合",指的是两个模块/代码之间的关联关系。关联关系越大,越是相互影响,认为是耦合越大;反之则越小。
"高内聚、低耦合"是软件工程中追求的核心目标。这可以通过两个生动的案例来理解:
- 拔智齿(低耦合的案例) :以前有朋友生病了,去医院拔阻生智齿(通常需要进行复杂的切口、切骨头、缝合)。当时他推掉了所有的课,去医院住院,全程照顾。手术仅仅是把智齿拔掉,如果非要说影响,总不可能把整个脑子也切掉吧?这个拔牙手术,仅仅是局部的操作,不应该影响其他器官的正常运作。这就是低耦合------牵一发而不动全身。
- 前任(高耦合的案例) :交往了一年,分开后偶然遇到,突然找你聊天。这种重新建立联系的"模块调用",往往带有很强的目的性(比如借钱、结婚要份子钱、推销微商产品等)。这种一旦联系,必然会严重影响你当前的生活状态,这就是高耦合------牵一发而动全身。
我们在写代码时,最怕的就是"牵一发而动全身",这边改一个 Bug,结果影响了其他模块,导致了更严重的问题。因此,在 Redis 的数据结构设计上,Hash 是"高内聚"的绝佳体现,因为它将同一个对象的数据封装在了一个 key 内部,修改某个字段不会影响其他字段,也不会影响其他 key。
3.4 哈希存储的最佳实践:关于 uid 字段的思考
在使用 Hash 存储对象时,我们会面临一个工程细节的考量:既然 key 已经是 user:1 了,那么 Hash 内部的 field 还需要存一个 uid 吗?如上图
从理论上讲,uid 确实没有必要存,我们可以直接使用 key 中的 id 来进行区分,这样是不是能存储空间进一步节省了呢?
如果确实想不存这个 uid 也可以,但是在真实的工程实践中,一般还是会把 uid 在 value 中再存一份。虽然这会多消耗一点点存储空间,但这其实是"高内聚"的体现------它保证了 value 本身是一份完整的、自包含的数据。
从工程实用的角度来看,多存一份 uid,后续写相关的代码,用起来会比较方便。例如,当后台管理系统需要批量扫描所有的用户 key,并提取出用户信息进行展示或导出时,如果 value 中包含了 uid,代码就不需要去解析 key 的字符串来截取 uid,直接读取 field 即可,提升了代码的可读性和可维护性。这种冗余是值得的。