上一篇学习了 Redis 中最常用的数据类型:
String
我们知道,String 不只能保存普通文字,还可以保存:
数字
验证码
Token
JSON
计数器
二进制数据
例如,将一个用户对象转换成 JSON 后保存:
SET ark:user:info:1001 "{\"id\":1001,\"name\":\"张三\",\"age\":30}"
Redis 中的数据可以理解为:
ark:user:info:1001
↓
{"id":1001,"name":"张三","age":30}
这种方式非常常见。
但是,如果现在只想修改用户年龄:
30 → 31
使用 String JSON 时,通常需要:
读取完整 JSON
→ 反序列化成 User
→ 修改 age
→ 重新序列化
→ 覆盖整个 JSON
这时就会产生一个问题:
Redis 有没有一种数据类型,可以把一个对象拆成多个字段保存,并单独读取或修改某个字段?
答案就是:
Hash
这一篇重点讲清楚:
Redis Hash 到底是什么
Hash 的 Key、Field、Value 分别代表什么
HSET、HGET、HMGET、HGETALL 如何使用
Hash 怎样保存一个用户对象
如何单独修改对象中的一个字段
Hash 和 String JSON 有什么区别
Hash 能不能设置过期时间
新版 Redis 如何给单独 Field 设置 TTL
Spring Boot 如何操作 Hash
用户缓存到底应该选择 JSON 还是 Hash
一、Redis Hash 是什么
Redis Hash 是一个由多个 Field-Value 组成的数据结构。
可以把它表示为:
Redis Key
├── Field 1 → Value 1
├── Field 2 → Value 2
├── Field 3 → Value 3
└── Field 4 → Value 4
例如保存用户 1001:
ark:user:info:1001
├── id → 1001
├── name → 张三
├── age → 30
└── phone → 13800000000
这里有三层概念:
ark:user:info:1001
→ Redis Key
name、age、phone
→ Hash Field
张三、30、13800000000
→ Hash Value
Redis 官方将 Hash 描述为一种由 Field-Value 对组成的记录结构,适合表示基础对象,也可以用于保存一组相关计数器。
二、Hash 和 Java Map 很像
Redis Hash 可以类比成 Java 中嵌套的一层 Map:
Map<String, Map<String, String>> redis = new HashMap<>();
其中外层 Map 的 Key 是 Redis Key:
ark:user:info:1001
内层 Map 保存用户字段:
Map<String, String> userFields = new HashMap<>();
userFields.put("id", "1001");
userFields.put("name", "张三");
userFields.put("age", "30");
userFields.put("phone", "13800000000");
整体可以理解为:
redis.put(
"ark:user:info:1001",
userFields
);
读取姓名:
String name = redis
.get("ark:user:info:1001")
.get("name");
Redis 中对应:
HGET ark:user:info:1001 name
虽然使用方式类似,但仍然要记住:
Java Map
→ 位于当前 JVM 进程
Redis Hash
→ 位于 Redis Server 进程
Java 必须通过 Redis 客户端发送命令,才能读取或修改 Hash。
三、Hash 中的 Key 和 Field 不要混淆
假设执行:
HSET ark:user:info:1001 name 张三
这里:
ark:user:info:1001
→ Redis Key
name
→ Hash Field
张三
→ Field 对应的 Value
很多初学者容易把:
name
也称为 Redis Key。
更准确的说法是:
ark:user:info:1001
→ Key
name
→ Field,也可以叫 Hash Key
在 Spring Data Redis 中,泛型名称有时会写成:
hashKey
它指的就是 Hash 内部的 Field,而不是最外层 Redis Key。
完整结构是:
Redis Key
↓
Hash
├── Field → Value
├── Field → Value
└── Field → Value
四、HSET:向 Hash 中写入字段
Redis Hash 最基础的写入命令是:
HSET key field value
例如:
HSET ark:user:info:1001 name 张三
此时 Redis 中形成:
ark:user:info:1001
└── name → 张三
继续写入年龄:
HSET ark:user:info:1001 age 30
现在变成:
ark:user:info:1001
├── name → 张三
└── age → 30
继续写入手机号:
HSET ark:user:info:1001 phone 13800000000
结果:
ark:user:info:1001
├── name → 张三
├── age → 30
└── phone → 13800000000
Redis 的 HSET 可以一次写入一个或多个 Field;当指定 Field 已经存在时,新值会覆盖旧值。
五、一次写入多个字段
不需要为每个字段分别发送一次命令。
可以一次写入多个 Field:
HSET ark:user:info:1001 \
id 1001 \
name 张三 \
age 30 \
phone 13800000000
整理后就是:
HSET key
field1 value1
field2 value2
field3 value3
一次命令写入多个字段,可以减少客户端和 Redis 之间的网络往返。
如果分别执行:
HSET ark:user:info:1001 id 1001
HSET ark:user:info:1001 name 张三
HSET ark:user:info:1001 age 30
HSET ark:user:info:1001 phone 13800000000
就产生了四次 Redis 请求。
一次执行:
HSET ark:user:info:1001 \
id 1001 \
name 张三 \
age 30 \
phone 13800000000
通常只需要一次请求。
六、HGET:读取一个字段
读取 Hash 中的单个字段:
HGET key field
例如:
HGET ark:user:info:1001 name
返回:
张三
读取年龄:
HGET ark:user:info:1001 age
返回:
30
读取不存在的 Field:
HGET ark:user:info:1001 address
返回空结果。
这时可能存在两种情况:
整个 Redis Key 不存在
或者
Redis Key 存在,但 address Field 不存在
单纯通过 HGET 返回空,通常不能区分这两种情况。
七、HMGET:一次读取多个字段
如果同时需要姓名和年龄,不建议分别执行:
HGET ark:user:info:1001 name
HGET ark:user:info:1001 age
可以使用:
HMGET ark:user:info:1001 name age
返回结果会按照 Field 参数的顺序排列:
1) 张三
2) 30
如果其中一个 Field 不存在:
HMGET ark:user:info:1001 name address age
可能返回:
1) 张三
2) nil
3) 30
HMGET 可以在一次命令中读取多个字段,从而减少多次单字段查询带来的网络往返。
八、HGETALL:读取整个 Hash
读取全部 Field 和 Value:
HGETALL ark:user:info:1001
返回内容类似:
1) id
2) 1001
3) name
4) 张三
5) age
6) 30
7) phone
8) 13800000000
可以整理成:
id → 1001
name → 张三
age → 30
phone → 13800000000
在 Spring Boot 中,通常会转换成:
Map<Object, Object>
或者:
Map<String, String>
HGETALL 很适合读取字段数量较少的对象。
但不能看到 Hash 就无脑使用 HGETALL。
九、为什么大 Hash 要谨慎使用 HGETALL
假设一个 Hash 中只有:
4 个字段
执行 HGETALL 没有什么明显问题。
但如果某个 Hash 中有:
10 万个 Field
执行:
HGETALL huge:hash
会一次性返回大量数据。
这可能带来:
Redis 处理时间变长
网络传输数据过大
客户端内存占用增加
其他 Redis 请求受到影响
因此:
小对象
→ 可以使用 HGETALL
大型 Hash
→ 优先按需 HGET、HMGET,或者使用 HSCAN 分批遍历
不要把大量无边界增长的数据全部塞进一个 Hash,再频繁执行 HGETALL。
十、HEXISTS:判断 Field 是否存在
判断 Hash 中是否存在某个 Field:
HEXISTS ark:user:info:1001 phone
如果存在,返回:
1
不存在则返回:
0
需要注意:
EXISTS
→ 判断最外层 Redis Key 是否存在
HEXISTS
→ 判断 Hash 内部 Field 是否存在
例如:
EXISTS ark:user:info:1001
判断:
整个用户 Hash 是否存在
而:
HEXISTS ark:user:info:1001 phone
判断:
用户 Hash 中是否有 phone 字段
十一、HDEL:删除一个或多个字段
删除手机号字段:
HDEL ark:user:info:1001 phone
执行前:
ark:user:info:1001
├── id → 1001
├── name → 张三
├── age → 30
└── phone → 13800000000
执行后:
ark:user:info:1001
├── id → 1001
├── name → 张三
└── age → 30
也可以一次删除多个字段:
HDEL ark:user:info:1001 phone age
这里删除的是 Hash 内部字段,不是整个 Redis Key。
删除整个 Hash 仍然使用:
DEL ark:user:info:1001
需要区分:
HDEL
→ 删除 Hash 内部一个或多个 Field
DEL
→ 删除整个 Redis Key,包括整个 Hash
十二、Hash 中所有 Field 删除后会怎样
假设一个 Hash 只有一个字段:
ark:test:hash
└── name → 张三
执行:
HDEL ark:test:hash name
最后一个 Field 被删除后,这个 Hash Key 也会随之消失。
此时执行:
EXISTS ark:test:hash
会返回:
0
因为 Redis 不会保留一个完全为空的 Hash。
可以理解为:
Hash 中还有 Field
→ Key 存在
最后一个 Field 被删除
→ 整个 Key 消失
十三、HLEN:查看 Hash 有多少字段
查看用户 Hash 中的字段数量:
HLEN ark:user:info:1001
如果当前有:
id
name
age
phone
返回:
4
HLEN 统计的是:
Field 数量
而不是:
Value 字符数量
或者
Redis Key 数量
例如:
ark:user:info:1001
├── id
├── name
├── age
└── phone
HLEN 返回 4。
十四、HKEYS 和 HVALS
获取所有 Field:
HKEYS ark:user:info:1001
可能返回:
id
name
age
phone
获取所有 Value:
HVALS ark:user:info:1001
可能返回:
1001
张三
30
13800000000
一般来说:
HKEYS
→ 只需要字段名时使用
HVALS
→ 只需要字段值时使用
HGETALL
→ Field 和 Value 都需要时使用
如果 Hash 很大,这几个全量读取命令都要谨慎。
十五、HINCRBY:修改 Hash 中的数字字段
Hash Field 的 Value 也可以保存数字。
例如用户统计信息:
ark:user:stats:1001
├── loginCount → 10
├── score → 80
└── failCount → 2
写入:
HSET ark:user:stats:1001 \
loginCount 10 \
score 80 \
failCount 2
登录次数加一:
HINCRBY ark:user:stats:1001 loginCount 1
结果:
loginCount → 11
积分增加 20:
HINCRBY ark:user:stats:1001 score 20
结果:
score → 100
减少也可以传负数:
HINCRBY ark:user:stats:1001 score -10
结果:
score → 90
Redis Hash 支持通过 HINCRBY 原子修改数字 Field,因此一个 Hash 也很适合保存一组相关计数器。
十六、HINCRBY 遇到不存在的 Field
假设 Hash 中没有:
shareCount
执行:
HINCRBY ark:user:stats:1001 shareCount 1
Redis 会将不存在的 Field 当作初始值 0:
0 + 1 = 1
然后创建:
shareCount → 1
因此不需要提前:
HSET ark:user:stats:1001 shareCount 0
可以直接执行 HINCRBY。
十七、HINCRBY 遇到非数字 Value
假设:
HSET ark:user:info:1001 name 张三
然后执行:
HINCRBY ark:user:info:1001 name 1
Redis 无法把"张三"解析成整数,会返回错误。
所以:
HINCRBY
→ 只能操作能够表示整数的 Field Value
例如:
"10"
→ 可以
"-5"
→ 可以
"张三"
→ 不可以
"{\"count\":10}"
→ 不可以
十八、用 Hash 保存一个用户对象
假设 Java 用户对象是:
public record User(
Long id,
String name,
Integer age,
String phone
) {
}
使用 Hash 保存时,可以设计:
Redis Key:
ark:user:info:1001
字段:
id → 1001
name → 张三
age → 30
phone → 13800000000
Redis 命令:
HSET ark:user:info:1001 \
id 1001 \
name 张三 \
age 30 \
phone 13800000000
读取姓名:
HGET ark:user:info:1001 name
修改年龄:
HSET ark:user:info:1001 age 31
删除手机号:
HDEL ark:user:info:1001 phone
读取全部:
HGETALL ark:user:info:1001
这就是 Hash 保存对象的基本方式。
十九、同一个对象也可以保存成 String JSON
用户对象也可以转换成 JSON:
{
"id": 1001,
"name": "张三",
"age": 30,
"phone": "13800000000"
}
然后使用 String 保存:
SET ark:user:info:1001 "{\"id\":1001,\"name\":\"张三\",\"age\":30,\"phone\":\"13800000000\"}"
Redis 中的数据是:
ark:user:info:1001
↓
一整段 JSON 字符串
读取:
GET ark:user:info:1001
此时 Redis 不理解 JSON 内部的:
id
name
age
phone
对于 Redis 来说,它只是一整段 String Value。
二十、String JSON 和 Hash 的结构区别
String JSON
ark:user:info:1001
↓
{"id":1001,"name":"张三","age":30}
整个对象是一个 Value。
Hash
ark:user:info:1001
├── id → 1001
├── name → 张三
└── age → 30
对象被拆成多个 Field-Value。
最直接的区别是:
String JSON
→ Redis 只看到一整段数据
Hash
→ Redis 能单独操作每个字段
二十一、修改一个字段时有什么区别
假设要把年龄:
30
修改为:
31
String JSON 的修改过程
通常需要:
GET 完整 JSON
↓
反序列化成 User
↓
修改 age
↓
重新序列化成 JSON
↓
SET 覆盖完整 Value
伪代码:
String json = stringRedisTemplate
.opsForValue()
.get(key);
User user = objectMapper.readValue(
json,
User.class
);
User updatedUser = new User(
user.id(),
user.name(),
31,
user.phone()
);
String newJson = objectMapper
.writeValueAsString(updatedUser);
stringRedisTemplate.opsForValue()
.set(key, newJson);
Hash 的修改过程
直接执行:
HSET ark:user:info:1001 age 31
Spring Boot 中:
stringRedisTemplate.opsForHash()
.put(
key,
"age",
"31"
);
所以:
经常单独修改字段
→ Hash 更方便
二十二、读取完整对象时有什么区别
String JSON
只需要一次 GET:
GET ark:user:info:1001
Java 得到 JSON 后,直接反序列化成 User。
Redis GET
→ 得到完整 JSON
→ 转换成 User
对象结构、嵌套关系和数据类型都可以由 JSON 表达。
Hash
执行:
HGETALL ark:user:info:1001
Java 得到:
Map<String, String>
然后还要手动转换:
"id" → Long
"age" → Integer
"name" → String
"phone" → String
因此:
每次都读取完整对象
→ String JSON 通常更直接
二十三、复杂嵌套对象更适合哪一种
假设用户对象中还有地址:
{
"id": 1001,
"name": "张三",
"address": {
"province": "江苏省",
"city": "苏州市",
"detail": "工业园区"
}
}
String JSON 可以直接保存整个结构:
用户
├── id
├── name
└── address
├── province
├── city
└── detail
Hash 本身不支持无限嵌套的 Hash 结构。
Hash 的 Field 对应的 Value 仍然是一段字符串或字节数据。
如果想使用 Hash 保存地址,可能需要:
方案一:地址再次转 JSON
ark:user:info:1001
├── id → 1001
├── name → 张三
└── address → {"province":"江苏省","city":"苏州市"}
方案二:展开字段
ark:user:info:1001
├── id → 1001
├── name → 张三
├── address:province → 江苏省
├── address:city → 苏州市
└── address:detail → 工业园区
方案三:拆成另一个 Hash
ark:user:info:1001
├── id
└── name
ark:user:address:1001
├── province
├── city
└── detail
对象结构越复杂,String JSON 通常越自然。
二十四、JSON 能保留字段类型,Hash 可以吗
JSON 中可以表达:
{
"id": 1001,
"enabled": true,
"score": 95.5,
"name": "张三"
}
反序列化时可以恢复成:
id → Long
enabled → Boolean
score → Double
name → String
而使用 StringRedisTemplate 操作 Hash 时,Field 和 Value 通常都是字符串:
id → "1001"
enabled → "true"
score → "95.5"
name → "张三"
读取后需要根据业务字段自己转换类型:
Long id = Long.valueOf(
values.get("id")
);
Boolean enabled = Boolean.valueOf(
values.get("enabled")
);
因此对于标准 Java 对象缓存:
String JSON
→ 对象转换通常更统一
Hash
→ 字段级操作更方便,但类型转换更分散
二十五、Hash 能不能设置 TTL
当然可以。
最常见的方式是给整个 Hash Key 设置 TTL:
EXPIRE ark:user:info:1001 1800
表示:
整个用户 Hash
→ 30 分钟后过期
查看:
TTL ark:user:info:1001
可能返回:
1768
当 Key 过期后,整个 Hash 消失:
id
name
age
phone
都会一起失效。
传统 Redis 使用方式中,TTL 主要设置在最外层 Key 上。
二十六、HSET 会不会清除整个 Hash 的 TTL
这一点和普通 String 的 SET 不一样。
假设:
HSET ark:user:info:1001 name 张三 age 30
EXPIRE ark:user:info:1001 1800
此时整个 Hash 有 1800 秒 TTL。
接着更新年龄:
HSET ark:user:info:1001 age 31
通常情况下,Hash Key 原来的 TTL 会继续保留。
也就是说:
HSET 修改 Hash 内部字段
→ 不会像普通 SET 覆盖整个 Key 那样自动清除 Key TTL
但如果先执行:
DEL ark:user:info:1001
然后重新创建 Hash,原来的 TTL 自然也不存在了。
二十七、Hash 的每个 Field 能单独设置 TTL 吗
这个问题需要根据 Redis 版本回答。
传统版本的 Redis
过去常见的规则是:
TTL 只能设置在整个 Hash Key 上
不能直接给单个 Field 设置 TTL
例如:
ark:user:info:1001
├── name → 张三
├── age → 30
└── phone → 13800000000
设置:
EXPIRE ark:user:info:1001 1800
代表所有 Field 一起过期。
Redis 7.4 及以后
Redis 7.4 开始支持 Hash Field 级别的过期时间,可以通过 HEXPIRE、HPEXPIRE 等命令给一个或多个 Field 单独设置 TTL。Field 到期后会从 Hash 中自动删除。
例如:
HSET ark:user:info:1001 \
name 张三 \
tempCode 9527
只给 tempCode 设置 300 秒 TTL:
HEXPIRE ark:user:info:1001 300 \
FIELDS 1 tempCode
查看 Field TTL:
HTTL ark:user:info:1001 \
FIELDS 1 tempCode
300 秒后:
tempCode Field 消失
name Field 仍然存在
因此,文章或面试中不要再绝对地说:
Redis Hash 永远不能给单独 Field 设置 TTL
更准确的表达是:
传统 Redis 版本主要支持 Key 级 TTL;Redis 7.4 开始增加了 Hash Field 级过期能力,但实际项目能否使用,还要看服务端版本和客户端支持情况。
二十八、Field TTL 是否适合普通用户缓存
虽然新版 Redis 支持单独 Field TTL,但普通用户对象缓存通常没有必要让每个字段分别过期。
例如:
name 30 分钟
age 20 分钟
phone 40 分钟
address 10 分钟
这种设计会让数据生命周期变得复杂。
读取完整用户时,可能出现:
id 存在
name 存在
phone 已过期
address 已过期
最终得到一个字段残缺的用户对象。
对于普通对象缓存,更常见的做法仍然是:
整个对象统一缓存
整个 Key 统一过期
Field TTL 更适合一些字段本身具有独立生命周期的场景,例如:
会话中的临时属性
按字段保存的短期状态
事件或传感器数据
独立过期的计数信息
不能因为 Redis 支持,就给每个普通对象字段都设计一套 TTL。
二十九、Hash 和 String JSON 对比
| 对比项 | String JSON | Hash |
|---|---|---|
| 数据形式 | 整个对象一段 JSON | 对象拆成 Field-Value |
| 读取完整对象 | 一个 GET | HGETALL 或 HMGET |
| 读取单个字段 | 通常要读取完整 JSON | HGET |
| 修改单个字段 | 通常重新写完整 JSON | HSET |
| Java 对象转换 | JSON 序列化较自然 | 通常需要 Map 转对象 |
| 嵌套对象 | 表达方便 | 需要 JSON、展开或拆 Key |
| 字段类型 | JSON 可以表达类型 | 常按字符串处理 |
| 整体 TTL | 支持 | 支持 |
| Field TTL | 不适用 | Redis 7.4+ 支持 |
| 适合场景 | 完整对象缓存 | 字段独立读写、统计数据 |
三十、什么情况下优先选择 String JSON
以下场景更适合 String JSON:
1. 每次都读取完整对象
例如用户详情接口:
GET /user/1001
接口需要返回完整用户信息。
流程:
GET Redis JSON
→ 反序列化成 User
→ 返回
2. 对象结构比较复杂
例如对象中存在:
嵌套对象
List
Map
地址
权限列表
角色列表
JSON 表达更加自然。
3. 业务通常整体更新对象
例如:
数据库用户信息更新
→ 删除整个缓存
→ 下次重新加载完整对象
这种 Cache Aside 模式,不需要在 Redis 中频繁单独修改字段。
4. 多个服务之间已经统一 JSON 格式
Java、Node.js、Python 都可以理解相同 JSON。
因此完整对象缓存中,String JSON 是非常常见的方案。
三十一、什么情况下优先选择 Hash
以下场景更适合 Hash:
1. 经常只读取单个字段
例如只查询用户昵称:
HGET ark:user:info:1001 name
不用获取整个对象。
2. 经常单独更新字段
例如:
登录次数
积分
状态
最后访问时间
失败次数
可以直接:
HSET ark:user:info:1001 status 1
或者:
HINCRBY ark:user:stats:1001 loginCount 1
3. 数据天然就是一组 Field-Value
例如运行状态:
ark:device:status:2001
├── online → 1
├── battery → 80
├── temperature → 32
└── mode → working
设备的多个状态字段可能独立更新。
4. 保存一组相关计数器
例如:
ark:article:stats:2001
├── view → 1000
├── like → 80
├── comment → 30
└── favorite → 20
可以分别使用 HINCRBY 更新。
三十二、用户信息到底选 JSON 还是 Hash
没有绝对答案。
要看实际访问方式。
方案一:用户完整信息缓存
如果业务通常是:
按用户 ID 查询完整对象
数据库更新后删除缓存
下一次重新加载
推荐:
String + JSON
例如:
ark:user:info:1001
→ 完整用户 JSON
原因是:
对象序列化简单
读取完整对象方便
嵌套结构容易表达
缓存删除和重建清晰
方案二:用户运行状态或统计
如果业务经常独立更新:
登录次数
登录失败次数
在线状态
最后操作时间
积分
推荐单独设计 Hash:
ark:user:stats:1001
├── loginCount
├── failCount
├── score
└── lastActiveTime
原因是:
字段可以独立修改
数字字段可以原子自增
不用反复读取完整 JSON
实际项目可以同时使用:
ark:user:info:1001
→ String JSON,缓存完整用户信息
ark:user:stats:1001
→ Hash,保存动态统计字段
不是必须二选一。
三十三、不要把数据库表机械地复制成 Hash
假设 MySQL 有一张 user 表:
id
name
phone
password
status
create_time
update_time
deleted
不能因为 Hash 能保存字段,就把所有数据库字段全部复制到 Redis:
ark:user:info:1001
├── id
├── name
├── phone
├── password
├── status
├── create_time
├── update_time
└── deleted
需要考虑:
哪些字段真的需要缓存
哪些字段存在安全风险
哪些字段业务根本不会读取
哪些字段变化频繁
哪些字段适合放在一起
尤其不能把:
密码
敏感密钥
不必要的个人隐私
随意保存进缓存。
Redis 数据设计不是数据库表的简单复制。
三十四、一个 Hash 不要保存无限增长的数据
例如将所有用户放进同一个 Hash:
ark:all:users
├── 1001 → 张三
├── 1002 → 李四
├── 1003 → 王五
├── ...
└── 几百万用户
这种设计可能形成一个超大 Hash。
问题包括:
单个 Key 数据量过大
迁移和备份成本增加
全量读取风险增大
删除和扫描成本增加
集群中一个 Key 只能落在一个槽位
更常见的是:
一个用户一个 Key
例如:
ark:user:info:1001
ark:user:info:1002
ark:user:info:1003
或者根据明确业务合理分组。
不要为了减少 Key 数量,就把所有数据无边界地塞入一个 Hash。
三十五、Spring Boot 如何写入一个 Hash 字段
使用:
stringRedisTemplate.opsForHash()
写入单个字段:
@Service
public class UserRedisService {
private final StringRedisTemplate stringRedisTemplate;
public UserRedisService(
StringRedisTemplate stringRedisTemplate
) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void saveUserName(
Long userId,
String name
) {
String key = "ark:user:info:" + userId;
stringRedisTemplate.opsForHash()
.put(
key,
"name",
name
);
}
}
底层对应:
HSET ark:user:info:1001 name 张三
三十六、Spring Boot 如何一次写入多个字段
准备 Map:
public void saveUser(User user) {
String key = "ark:user:info:" + user.id();
Map<String, String> values = new HashMap<>();
values.put(
"id",
String.valueOf(user.id())
);
values.put(
"name",
user.name()
);
values.put(
"age",
String.valueOf(user.age())
);
values.put(
"phone",
user.phone()
);
stringRedisTemplate.opsForHash()
.putAll(key, values);
}
底层效果类似:
HSET ark:user:info:1001 \
id 1001 \
name 张三 \
age 30 \
phone 13800000000
Spring Data Redis 的 HashOperations 提供了读取、写入、删除、计数、获取全部 Entry 和扫描等 Hash 操作封装。
三十七、写入 Hash 后设置过期时间
public void saveUser(
User user,
long timeout,
TimeUnit unit
) {
String key = "ark:user:info:" + user.id();
Map<String, String> values = new HashMap<>();
values.put("id", String.valueOf(user.id()));
values.put("name", user.name());
values.put("age", String.valueOf(user.age()));
values.put("phone", user.phone());
stringRedisTemplate.opsForHash()
.putAll(key, values);
stringRedisTemplate.expire(
key,
timeout,
unit
);
}
调用:
saveUser(
user,
30,
TimeUnit.MINUTES
);
这里需要注意:
putAll
和
expire
仍然是两次 Redis 操作。
如果第一步成功、第二步失败,Hash 可能没有 TTL。
对于普通缓存,可以通过事务、Pipeline、Lua,或者统一的缓存封装进一步处理。
当前阶段先理解:
Hash 写入字段
→ HSET
整个 Hash 设置 TTL
→ EXPIRE
三十八、Spring Boot 如何读取一个字段
public String getUserName(Long userId) {
String key = "ark:user:info:" + userId;
Object value = stringRedisTemplate
.opsForHash()
.get(
key,
"name"
);
return value == null
? null
: value.toString();
}
底层对应:
HGET ark:user:info:1001 name
三十九、Spring Boot 如何读取多个字段
public List<Object> getUserBaseInfo(
Long userId
) {
String key = "ark:user:info:" + userId;
List<Object> fields = List.of(
"name",
"age",
"phone"
);
return stringRedisTemplate.opsForHash()
.multiGet(
key,
fields
);
}
底层类似:
HMGET ark:user:info:1001 name age phone
返回顺序与传入 Field 顺序一致。
四十、Spring Boot 如何读取整个 Hash
public Map<Object, Object> getUserHash(
Long userId
) {
String key = "ark:user:info:" + userId;
return stringRedisTemplate.opsForHash()
.entries(key);
}
返回:
{
id=1001,
name=张三,
age=30,
phone=13800000000
}
然后可以转换成 User:
public User getUser(Long userId) {
Map<Object, Object> values =
getUserHash(userId);
if (values.isEmpty()) {
return null;
}
return new User(
Long.valueOf(
values.get("id").toString()
),
values.get("name").toString(),
Integer.valueOf(
values.get("age").toString()
),
values.get("phone").toString()
);
}
这种手动转换适合帮助理解。
正式项目可以进一步封装映射逻辑,避免各个业务类重复转换。
四十一、Spring Boot 如何修改一个字段
public void updateUserAge(
Long userId,
Integer age
) {
String key = "ark:user:info:" + userId;
stringRedisTemplate.opsForHash()
.put(
key,
"age",
String.valueOf(age)
);
}
底层对应:
HSET ark:user:info:1001 age 31
这里不会读取和重写其他字段。
四十二、Spring Boot 如何删除字段
public long deleteUserFields(
Long userId,
String... fields
) {
String key = "ark:user:info:" + userId;
Long deleted = stringRedisTemplate
.opsForHash()
.delete(
key,
(Object[]) fields
);
return deleted == null ? 0L : deleted;
}
调用:
deleteUserFields(
1001L,
"phone",
"age"
);
底层类似:
HDEL ark:user:info:1001 phone age
四十三、Spring Boot 如何进行 Hash 自增
用户积分增加 10:
public long increaseScore(
Long userId,
long delta
) {
String key = "ark:user:stats:" + userId;
Long result = stringRedisTemplate
.opsForHash()
.increment(
key,
"score",
delta
);
return result == null ? 0L : result;
}
底层对应:
HINCRBY ark:user:stats:1001 score 10
这种方式比:
HGET score
→ Java 加 10
→ HSET score
更加安全,因为 Redis 在服务端完成原子自增。
四十四、封装 Hash Key
继续使用统一 Key 构建类:
public final class RedisKeys {
private static final String PROJECT = "ark";
private RedisKeys() {
}
public static String userInfo(Long userId) {
return PROJECT
+ ":user:info:"
+ userId;
}
public static String userStats(Long userId) {
return PROJECT
+ ":user:stats:"
+ userId;
}
}
使用:
String key = RedisKeys.userInfo(userId);
不要在各个 Service 中到处手写:
"ark:user:info:" + userId
统一封装可以减少:
拼写错误
前缀不一致
模块命名混乱
后期批量修改困难
四十五、常见错误一:Key 和 Field 顺序写反
正确:
HSET ark:user:info:1001 name 张三
结构:
HSET key field value
错误理解可能写成:
HSET name ark:user:info:1001 张三
这样 Redis 会把:
name
当成外层 Redis Key。
结果形成:
name
└── ark:user:info:1001 → 张三
虽然命令可能执行成功,但数据结构已经完全错误。
四十六、常见错误二:把 Hash 当成嵌套对象
Hash 只有一层 Field-Value:
Key
├── Field → Value
└── Field → Value
它不是:
Key
└── Field
└── 子 Field
└── 更深层 Field
如果 Value 是 JSON:
address → {"province":"江苏省"}
Redis 默认只把它当成一整段 Value,不会自动理解 JSON 内部结构。
四十七、常见错误三:频繁 HGETALL 大 Hash
如果只需要:
name
和
age
不要为了方便总是执行:
HGETALL ark:user:info:1001
更适合:
HMGET ark:user:info:1001 name age
原则是:
需要什么字段
→ 获取什么字段
避免无意义地传输整个大 Hash。
四十八、常见错误四:把所有用户放进一个 Hash
不推荐:
ark:users
├── 1001 → 用户 JSON
├── 1002 → 用户 JSON
├── 1003 → 用户 JSON
└── 无限增长
更常见:
ark:user:info:1001
ark:user:info:1002
ark:user:info:1003
一个超大 Hash 会增加维护、迁移和故障处理难度。
四十九、常见错误五:看到 Hash 就认为一定比 JSON 省内存
Hash 和 String JSON 谁更节省内存,没有一个脱离数据规模、字段数量、Redis 编码方式和 Value 内容的绝对结论。
不能简单记成:
Hash 一定更省内存
或者:
JSON 一定更省内存
实际设计首先考虑:
访问模式
更新方式
对象复杂度
开发成本
数据规模
再通过压测和内存分析验证。
五十、常见错误六:缓存对象时直接修改 Hash,却不更新 MySQL
假设:
HSET ark:user:info:1001 name 李四
只修改了 Redis。
MySQL 中仍然是:
张三
以后缓存过期,重新查询 MySQL:
名字又变回张三
因为 Redis 通常只是缓存,MySQL 才是正式数据源。
正常业务通常是:
客户端提交修改
↓
更新 MySQL
↓
删除 Redis 缓存
↓
下一次重新加载
而不是只修改 Redis 中的用户缓存。
Hash 可以方便地修改字段,不代表缓存应该成为正式数据源。
五十一、Hash 更适合动态状态,而不一定适合正式对象缓存
例如设备状态:
ark:device:status:2001
├── online → 1
├── battery → 82
├── mode → working
├── temperature → 31
└── updateTime → 1720000000
设备可能频繁上报某个字段:
HSET ark:device:status:2001 battery 81
或者:
HSET ark:device:status:2001 online 0
这种场景 Hash 很自然,因为:
字段独立变化
经常只读取部分状态
数据本身就是扁平属性集合
对你未来的机器人、IoT 项目来说,Hash 可以用于:
设备在线状态
设备实时参数
任务执行状态
机器人运行统计
告警计数
简化的设备影子
不过高频实时数据是否都应写 Redis,还需要结合数据量、写入频率和业务需求设计。
五十二、本篇动手实践
进入 redis-cli,依次执行。
1. 创建用户 Hash
HSET ark:user:info:1001 \
id 1001 \
name 张三 \
age 30 \
phone 13800000000
2. 读取单个字段
HGET ark:user:info:1001 name
HGET ark:user:info:1001 age
3. 一次读取多个字段
HMGET ark:user:info:1001 name age phone
4. 读取完整 Hash
HGETALL ark:user:info:1001
5. 判断字段是否存在
HEXISTS ark:user:info:1001 phone
HEXISTS ark:user:info:1001 address
6. 查看字段数量
HLEN ark:user:info:1001
7. 修改年龄
HSET ark:user:info:1001 age 31
HGET ark:user:info:1001 age
8. 删除手机号
HDEL ark:user:info:1001 phone
HGETALL ark:user:info:1001
9. 设置整个 Hash 的 TTL
EXPIRE ark:user:info:1001 300
TTL ark:user:info:1001
10. 测试 HSET 后 TTL 是否保留
HSET ark:user:info:1001 name 李四
TTL ark:user:info:1001
观察 TTL 是否继续存在。
11. 创建统计 Hash
HSET ark:user:stats:1001 \
loginCount 0 \
score 100 \
failCount 0
12. 原子自增
HINCRBY ark:user:stats:1001 loginCount 1
HINCRBY ark:user:stats:1001 score 10
HINCRBY ark:user:stats:1001 failCount 1
HGETALL ark:user:stats:1001
13. Redis 7.4+ 测试 Field TTL
先确认 Redis 版本支持后执行:
HSET ark:test:field-ttl \
normalField normal \
tempField temporary
给 tempField 设置 60 秒过期:
HEXPIRE ark:test:field-ttl 60 \
FIELDS 1 tempField
查看:
HTTL ark:test:field-ttl \
FIELDS 1 tempField
如果命令不支持,说明当前 Redis 版本较低,继续使用整个 Key 的 TTL 即可。
五十三、本篇总结
Redis Hash 是一种由多个 Field-Value 组成的数据类型。
整体结构是:
Redis Key
├── Field → Value
├── Field → Value
└── Field → Value
例如:
ark:user:info:1001
├── id → 1001
├── name → 张三
├── age → 30
└── phone → 13800000000
常用命令包括:
HSET
HGET
HMGET
HGETALL
HEXISTS
HDEL
HLEN
HKEYS
HVALS
HINCRBY
String JSON 和 Hash 各有适用场景:
完整对象读取
复杂嵌套结构
统一序列化
→ 更适合 String JSON
单字段读取
单字段修改
一组相关计数器
扁平动态状态
→ 更适合 Hash
普通对象缓存通常仍然可以优先使用:
String + JSON
而用户统计、设备状态等字段独立变化的数据,可以使用:
Hash
传统 Redis 主要给整个 Hash Key 设置 TTL;Redis 7.4 开始支持 Hash Field 级别的过期能力,但实际使用前要确认 Redis Server 和客户端版本。
最终可以用一句话概括:
Redis Hash 可以理解成 Redis 中的一张小型 Field-Value 属性表,它适合保存结构扁平、字段需要独立读取或修改的对象状态;而完整、复杂的 Java 对象缓存,通常使用 String JSON 会更加直接。
下一篇预告
下一篇继续学习 Redis 的集合类数据结构:
《Redis List、Set、ZSet 分别解决什么问题?》
下一篇会重点讲清楚:
List 为什么可以表示队列
LPUSH、RPUSH、LPOP、RPOP 如何使用
Set 为什么能够自动去重
SADD、SREM、SISMEMBER 如何使用
Set 如何实现点赞用户和共同关注
ZSet 中的 Score 是什么
ZADD、ZRANGE、ZRANK 如何使用
ZSet 如何实现排行榜
List、Set、ZSet 和 Java 集合有什么区别
简单消息队列为什么不一定要使用 Redis List
真实项目中应该怎样选择数据结构
学完下一篇,Redis 最常用的五种基础数据类型就会形成完整认知。