Redis Hash 介绍
Redis Hash 介绍
从基础命令、内部编码和使用场景三个维度分析如下:
一、基础命令
Redis Hash 提供了丰富的操作命令,适用于字段(field)级别的增删改查:
-
设置与修改
-
HSET:设置单个字段值(
HSET key field value
)。# 存储用户信息(用户ID=1001) HSET user:1001 name "Alice" age 28 email "alice@example.com" # 返回3(表示成功设置3个字段)
-
HSETNX:仅当字段不存在时设置值,避免覆盖。
# 防止覆盖已存在的字段 HSETNX user:1001 email "new@example.com" # 返回0(字段已存在,未修改) HSETNX user:1001 address "New York" # 返回1(新增字段)
-
HINCRBY:对数值型字段进行增减操作(如计数器场景)。
场景:计数器(库存、销量、点击量),支持原子增减,避免并发问题。
# 商品ID=2001的库存增减 HINCRBY product:2001 stock -5 # 减少库存5(返回新值,如95) HINCRBY product:2001 sales 10 # 增加销量10(返回新值,如150)
-
-
查询与检索
-
HGET:获取单个字段值。
HGET user:1001 name # 返回"Alice"
-
HMGET:批量获取多个字段值。
HMGET user:1001 name age email # 返回["Alice", "28", "alice@example.com"]
-
HGETALL:获取所有字段及值(需谨慎使用,大数据量可能阻塞服务)。
HGETALL user:1001 # 返回所有字段和值(适用于小规模数据)
-
HKEYS/HVALS:分别获取所有字段名或值。
HKEYS user:1001 # 返回["name", "age", "email", "address"] HVALS user:1001 # 返回["Alice", "28", "alice@example.com", "New York"]
-
-
删除与判断
-
HDEL:删除指定字段。
场景:清理过期字段,例如用户注销后删除敏感信息。
-
HEXISTS:判断字段是否存在。
场景:检查字段是否存在,例如权限校验或防止重复操作。
-
HLEN:统计字段数量。
统计对象属性数量,例如监控购物车商品种类数。
-
二、内部编码
Redis Hash 根据数据规模和配置动态选择底层数据结构,以平衡性能与内存效率:
-
ziplist(压缩列表)
- 条件:当字段数 ≤
hash-max-ziplist-entries
(默认 512)且所有字段值 ≤hash-max-ziplist-value
(默认 64 字节)时启用。 - 特点:连续内存存储,无指针开销,内存利用率高,但插入/删除效率随数据量增长而下降。
- 条件:当字段数 ≤
-
hashtable(哈希表)
- 触发条件:超出 ziplist 配置限制时自动转换。
- 特点:通过哈希表实现 O(1) 复杂度的读写操作,适合大规模数据,但内存占用较高。
-
Redis 7.0 及以上版本已弃用 ziplist,改用 listpack 作为默认的紧凑数据结构。
-
数据结构设计对比
特性 ziplist listpack 存储结构 连续内存块,元素通过 prevlen
字段记录前一个元素的长度,形成隐式链表。连续内存块,每个元素独立存储自身长度信息,无前后依赖。 连锁更新风险 存在:插入/删除元素时,可能触发后续元素的 prevlen
字段更新,导致级联内存重分配。不存在:元素长度独立编码,无需依赖前驱元素,彻底避免连锁更新。 内存布局 通过 prevlen
和encoding
字段实现变长编码。通过 element-total-len
字段统一记录元素总长度,结构更简单。安全性 易因 prevlen
计算错误导致内存越界(历史漏洞)。长度信息自包含,访问更安全,减少内存越界风险。 -
性能与内存效率
维度 ziplist listpack 插入/删除效率 平均 O(n),可能触发连锁更新(最坏 O(n^2))。 平均 O(n),无连锁更新,性能更稳定。 随机访问速度 O(n),需遍历查找元素。 O(n),与 ziplist 相同,但遍历更高效(长度解析更快)。 内存占用 较低(但存在 prevlen
字段冗余)。与 ziplist 接近,甚至更优(更紧凑的长度编码)。 并发操作适应性 高并发写入时,连锁更新可能导致性能抖动。 无连锁更新,高并发下性能更稳定。 -
ziplist 的缺陷
- 连锁更新问题:
在中间位置插入元素时,若后续元素的prevlen
字段因长度变化需要扩展(例如从 1 字节变为 5 字节),会触发后续元素的连续内存重分配,导致性能骤降。
示例:插入一个长度超过 254 字节的元素,导致后续元素的prevlen
从0xFE
(1 字节)变为0xFF + 4字节长度
(5 字节)。 - 安全性风险:
prevlen
解析错误可能引发内存越界(如 Redis 早期版本中的 CVE-2018-11213 漏洞)。
- 连锁更新问题:
-
listpack 的改进
-
自包含长度信息:
每个元素头部使用element-total-len
字段记录自身总长度(包含编码类型、数据长度等),解析时无需依赖前驱元素。 -
编码优化:
长度字段采用更紧凑的变长编码(类似 UTF-8),例如:- 长度 ≤ 127:1 字节。
- 长度 ≤ 16383:2 字节。
- 更大长度:5 字节(1 字节标记 + 4 字节长度)。
-
-
使用场景对比
场景 推荐结构 原因 小规模只读数据 ziplist/listpack 内存紧凑,适合存储用户基础信息、配置项等(Redis 7.0+ 优先使用 listpack)。 高频写入/随机修改 listpack 无连锁更新问题,适合计数器、动态元数据等场景(Redis 7.0+ 默认使用 listpack)。 大规模数据存储 hashtable 数据量大时 O(1) 哈希表更高效(无论底层是 ziplist 还是 listpack,超阈值后均转为 hashtable)。 高并发写入场景 listpack 避免 ziplist 的连锁更新抖动,性能更稳定。
-
-
数据结构特性对比
特性 ziplist(压缩列表) hashtable(哈希表) 存储方式 连续内存块存储,无指针开销,通过偏移量访问数据。 散列桶结构,每个键值对通过哈希函数分配到桶中,通过指针链接。 内存占用 内存紧凑,无额外指针开销,内存利用率高。 内存占用较高(存储指针、哈希表扩容时的冗余空间)。 查询复杂度 O(n)(需要遍历字段查找) O(1)(哈希表直接定位) 插入/删除效率 平均 O(n),数据量大时效率低(需移动后续元素,可能触发连锁更新)。 O(1)(哈希表直接操作,扩容时可能触发 rehash 耗时)。 扩容机制 不可动态扩容,超出阈值后直接转换为 hashtable。 动态扩容(负载因子超过阈值时翻倍扩容,缩容时按需减少)。 适用数据规模 字段数少(默认 ≤512)且字段值小(默认 ≤64 字节)。 字段数多或字段值大。 -
适用场景对比
场景 推荐编码 原因 小型对象存储 ziplist 字段数少且值小(如用户基础信息),内存利用率高。 高频写入/随机访问 hashtable 避免 ziplist 的遍历开销和连锁更新问题(如频繁修改的计数器)。 只读或低频修改数据 ziplist 利用内存紧凑特性减少内存占用(如静态配置数据)。 大规模数据存储 hashtable 数据量大时 O(1) 查询效率显著优于 ziplist(如购物车、社交关系链)。 需要遍历所有字段 ziplist(小数据) 连续内存布局遍历更快;大数据量时 hashtable 的 HSCAN
更安全(避免HGETALL
阻塞)。 -
配置调优建议
hash-max-ziplist-entries 512 # 字段数阈值,默认 512 hash-max-ziplist-value 64 # 单个字段值最大字节数,默认 64
- 降低阈值:若需优先保障读写性能,可调小阈值(如
entries 128
),让更多 Hash 使用 hashtable。(空间) - 提高阈值:若内存紧张且数据规模可控,可增大阈值(如
entries 1024
),延长 ziplist 的使用。(时间)
- 降低阈值:若需优先保障读写性能,可调小阈值(如
三、使用场景
案例所需依赖 :
<dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>4.3.1</version></dependency><dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId><version>2.15.2</version></dependency></dependencies>
-
对象属性存储
-
将对象的多个属性(如用户信息、商品详情)聚合存储为一个 Hash,键为对象 ID,字段为属性名,简化键管理并减少内存碎片。
package com.example.redis.hash;/*** 描述: 将对象的多个属性(如用户信息、商品详情)聚合存储为一个 Hash,键为对象 ID,字段为属性名,简化键管理并减少内存碎片** @author ZHOUXIAOYUE* @date 2025/4/17 09:52*/ import redis.clients.jedis.Jedis; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.Map; class User {private String id;private String name;private String age;private String email;private String phone;// 构造器、getter、setter 略public User(String id, String name, String age, String email, String phone) {this.id = id;this.name = name;this.age = age;this.email = email;this.phone = phone;}public String getId() { return id; }public String getName() { return name; }public String getAge() { return age; }public String getEmail() { return email; }public String getPhone() { return phone; } } public class RedisHashExample {public static void main(String[] args) throws Exception {//创建链接Jedis jedis = new Jedis("localhost", 6379);// 定义对象User user = new User("user:1001", "Alice", "30", "alice@example.com", "1234567890");// 使用 Jackson 将对象转换为 Map<String, String>ObjectMapper objectMapper = new ObjectMapper();Map<String, String> userMap = objectMapper.convertValue(user, new TypeReference<Map<String, String>>() {});// 从 Map 中移除 id 字段(如果不需要存储到 Hash 中)userMap.remove("id");// 存储到 Redis 中jedis.hmset(user.getId(), userMap);System.out.println("存储用户信息成功,查询结果为:");Map<String, String> storedUserInfo = jedis.hgetAll(user.getId());storedUserInfo.forEach((field, value) -> System.out.println(field + ": " + value));jedis.close();} }
-
-
部分更新与原子操作
-
支持单个字段的原子更新(如用户年龄修改),避免序列化整个对象。
package com.example.redis.hash;/*** 描述: 支持单个字段的原子更新(如用户年龄修改),避免序列化整个对象。** @author ZHOUXIAOYUE* @date 2025/4/17 10:12*/ import redis.clients.jedis.Jedis; import java.util.Map; import java.util.HashMap; public class RedisHashUpdateExample {public static void main(String[] args) {// 创建 Jedis 连接,连接到本地 Redis 服务Jedis jedis = new Jedis("localhost", 6379);// 假设已经存储了用户信息,键为 "user:1001"String userId = "user:1001";// 如果未事先存储用户数据,可以先初始化对象(仅供测试)if (!jedis.exists(userId)) {Map<String, String> userInfo = new HashMap<>();userInfo.put("name", "Alice");userInfo.put("age", "30");userInfo.put("email", "alice@example.com");userInfo.put("phone", "1234567890");jedis.hmset(userId, userInfo);System.out.println("初始化用户信息成功。");}// 查询更新前的年龄String oldAge = jedis.hget(userId, "age");System.out.println("更新前用户年龄: " + oldAge);// 对单个字段进行原子更新,这里更新年龄为 31jedis.hset(userId, "age", "31");// 查询更新后的年龄String newAge = jedis.hget(userId, "age");System.out.println("更新后用户年龄: " + newAge);// 关闭 Jedis 连接jedis.close();} }
-
-
计数器与统计
-
利用
HINCRBY
实现实时计数(如商品销量、页面访问量)。package com.example.redis.hash;/*** 描述: 利用 HINCRBY 实现实时计数(如商品销量、页面访问量)** @author ZHOUXIAOYUE* @date 2025/4/17 10:15*/ import redis.clients.jedis.Jedis; public class RedisCounterExample {public static void main(String[] args) {// 连接 Redis 服务器Jedis jedis = new Jedis("localhost", 6379);// 示例1:统计商品销量// 商品 ID 为 product:2001,对应的销量字段为 "sales"String productKey = "product:2001";// 初始化销量字段,注意如果该字段不存在,则 hincrby 会默认其值为 0// jedis.hset(productKey, "sales", "0");// 模拟一次商品销售,使用 HINCRBY 将销量加 1long salesCount = jedis.hincrBy(productKey, "sales", 1);System.out.println("商品 " + productKey + " 当前销量为:" + salesCount);// 示例2:统计页面访问量// 页面标识为 page:homepage,对应的访问量字段为 "views"String pageKey = "page:homepage";// 初始化访问量字段,若不存在则 hincrby 默认从 0 开始计数// jedis.hset(pageKey, "views", "0");// 模拟页面访问,使用 HINCRBY 将访问量加上 5(比如一次批量更新)long viewCount = jedis.hincrBy(pageKey, "views", 5);System.out.println("页面 " + pageKey + " 当前访问量为:" + viewCount);// 关闭 Jedis 连接jedis.close();} }
-
-
购物车实现
-
以用户 ID 为键,商品 ID 为字段,数量为值,便于动态增删商品。
package com.example.redis.hash;/*** 描述: 以用户 ID 为键,商品 ID 为字段,数量为值,便于动态增删商品。** @author ZHOUXIAOYUE* @date 2025/4/17 10:18*/ import redis.clients.jedis.Jedis; import java.util.Map; public class ShoppingCartDemo {public static void main(String[] args) {// 创建 Jedis 连接,连接到本地 Redis 服务,默认端口 6379Jedis jedis = new Jedis("localhost", 6379);// 以用户ID构建购物车的 key(建议加上 cart 前缀以示区分)String cartKey = "cart:user:1001";// -------------------------------// 示例1:添加商品到购物车// -------------------------------// 将商品 product:2001 添加至购物车,数量 2jedis.hset(cartKey, "product:2001", "2");System.out.println("添加商品 product:2001 数量 2 到购物车 " + cartKey);// 添加另一个商品 product:2002,数量 3jedis.hset(cartKey, "product:2002", "3");System.out.println("添加商品 product:2002 数量 3 到购物车 " + cartKey);printCart(jedis, cartKey);// -------------------------------// 示例2:动态增加已有商品的数量// -------------------------------// 将商品 product:2001 的数量原子性增加 1jedis.hincrBy(cartKey, "product:2001", 1);System.out.println("将商品 product:2001 的数量增加 1");printCart(jedis, cartKey);// -------------------------------// 示例3:删除商品// -------------------------------// 从购物车中删除商品 product:2002jedis.hdel(cartKey, "product:2002");System.out.println("从购物车中删除商品 product:2002");printCart(jedis, cartKey);// 关闭 Jedis 连接jedis.close();}// 打印购物车中所有商品信息private static void printCart(Jedis jedis, String cartKey) {System.out.println("购物车 " + cartKey + " 当前内容:");Map<String, String> cartItems = jedis.hgetAll(cartKey);if(cartItems.isEmpty()){System.out.println("购物车为空。");} else {cartItems.forEach((productId, quantity) ->System.out.println("商品ID:" + productId + ", 数量:" + quantity));}System.out.println("--------------------------------------------------");} }
-
-
分布式锁与缓存
-
通过
HSETNX
实现轻量级锁,或缓存计算结果减少重复计算。package com.example.redis.hash;/*** 描述: 通过 HSETNX 实现轻量级锁,或缓存计算结果减少重复计算* 连接 Redis 后,使用哈希 key "cache:calc" 保存缓存数据和锁信息。* 首先通过 hget 检查是否已经有计算结果存入缓存,如果存在则直接返回。* 如果未命中缓存,通过 HSETNX 尝试对字段 "lock" 加锁。如果返回 1,则表示当前线程获得锁,然后执行耗时计算,将结果存入 "result" 字段,最后释放锁(删除 "lock" 字段)。* 若 HSETNX 返回 0,则表示已有线程在计算,当前线程等待一段时间后轮询 "result" 字段,直到获取到已缓存的计算结果。\* @author ZHOUXIAOYUE* @date 2025/4/17 10:26*/ import redis.clients.jedis.Jedis; public class CacheLockExample {// 模拟一个耗时计算,例如求 1~1000000 的和private static long doExpensiveComputation() {long sum = 0;for (int i = 1; i <= 1_000_000; i++) {sum += i;}// 模拟延迟try {Thread.sleep(1000);} catch (InterruptedException e) {// 忽略异常处理}return sum;}public static void main(String[] args) {// 连接 Redis 服务Jedis jedis = new Jedis("localhost", 6379);// 使用哈希结构保存缓存与锁信息String hashKey = "cache:calc";String lockField = "lock";String resultField = "result";// 判断缓存中是否已有计算结果String cachedResult = jedis.hget(hashKey, resultField);if (cachedResult != null) {System.out.println("直接从缓存中获取结果: " + cachedResult);jedis.close();return;}// 尝试通过 HSETNX 获取轻量级锁(lockField 不存在时设置锁)long lockAcquired = jedis.hsetnx(hashKey, lockField, String.valueOf(System.currentTimeMillis()));if (lockAcquired == 1) {// 获取锁成功,执行耗时计算System.out.println("获取锁成功,开始计算...");long computedValue = doExpensiveComputation();// 将计算结果缓存到 resultField 中jedis.hset(hashKey, resultField, String.valueOf(computedValue));// 释放锁(删除 lockField)jedis.hdel(hashKey, lockField);System.out.println("计算完成并缓存结果: " + computedValue);} else {// 获取锁失败,其他线程正在计算,等待缓存结果System.out.println("其他线程正在计算,等待获取结果...");int maxWaitTime = 10; // 最长等待时间(单位:循环次数,每次 100 毫秒)int waited = 0;while (waited < maxWaitTime) {try {Thread.sleep(100); // 每次等待100毫秒} catch (InterruptedException e) {// 忽略异常}cachedResult = jedis.hget(hashKey, resultField);if (cachedResult != null) {System.out.println("等待期间缓存中获取结果: " + cachedResult);break;}waited++;}if (cachedResult == null) {System.out.println("等待超时,还未获取到计算结果。");}}jedis.close();} }
-
总结
Redis Hash 通过灵活的字段操作、高效的内存管理和多样化的应用场景,成为存储结构化数据的理想选择。