Redis Hash 介绍

发布于:2025-04-21 ⋅ 阅读:(67) ⋅ 点赞:(0)

Redis Hash 介绍

从基础命令、内部编码和使用场景三个维度分析如下:


一、基础命令

Redis Hash 提供了丰富的操作命令,适用于字段(field)级别的增删改查:

  1. 设置与修改

    • 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)  
      
  2. 查询与检索

    • 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"]  
      
  3. 删除与判断

    • HDEL:删除指定字段。

      场景:清理过期字段,例如用户注销后删除敏感信息。

    • HEXISTS:判断字段是否存在。

      场景:检查字段是否存在,例如权限校验或防止重复操作。

    • HLEN:统计字段数量。

      统计对象属性数量,例如监控购物车商品种类数。


二、内部编码

Redis Hash 根据数据规模和配置动态选择底层数据结构,以平衡性能与内存效率:

  1. ziplist(压缩列表)

    • 条件:当字段数 ≤ hash-max-ziplist-entries​(默认 512)且所有字段值 ≤ hash-max-ziplist-value​(默认 64 字节)时启用。
    • 特点:连续内存存储,无指针开销,内存利用率高,但插入/删除效率随数据量增长而下降。
  2. hashtable(哈希表)

    • 触发条件:超出 ziplist 配置限制时自动转换。
    • 特点:通过哈希表实现 O(1) 复杂度的读写操作,适合大规模数据,但内存占用较高。
  3. Redis 7.0 及以上版本已弃用 ziplist,改用 listpack 作为默认的紧凑数据结构。

    1. 数据结构设计对比

      特性 ziplist listpack
      存储结构 连续内存块,元素通过prevlen​字段记录前一个元素的长度,形成隐式链表 连续内存块,每个元素独立存储自身长度信息,无前后依赖。
      连锁更新风险 存在:插入/删除元素时,可能触发后续元素的prevlen​字段更新,导致级联内存重分配。 不存在:元素长度独立编码,无需依赖前驱元素,彻底避免连锁更新。
      内存布局 通过prevlen​和encoding​字段实现变长编码。 通过element-total-len​字段统一记录元素总长度,结构更简单。
      安全性 易因prevlen​计算错误导致内存越界(历史漏洞)。 长度信息自包含,访问更安全,减少内存越界风险。
    2. 性能与内存效率

      维度 ziplist listpack
      插入/删除效率 平均 O(n),可能触发连锁更新(最坏 O(n^2))。 平均 O(n),无连锁更新,性能更稳定。
      随机访问速度 O(n),需遍历查找元素。 O(n),与 ziplist 相同,但遍历更高效(长度解析更快)。
      内存占用 较低(但存在prevlen​字段冗余)。 与 ziplist 接近,甚至更优(更紧凑的长度编码)。
      并发操作适应性 高并发写入时,连锁更新可能导致性能抖动。 无连锁更新,高并发下性能更稳定。
    3. ziplist 的缺陷

      • 连锁更新问题
        在中间位置插入元素时,若后续元素的 prevlen​ 字段因长度变化需要扩展(例如从 1 字节变为 5 字节),会触发后续元素的连续内存重分配,导致性能骤降。
        示例:插入一个长度超过 254 字节的元素,导致后续元素的 prevlen​ 从 0xFE​(1 字节)变为 0xFF + 4字节长度​(5 字节)。
      • 安全性风险
        prevlen​ 解析错误可能引发内存越界(如 Redis 早期版本中的 CVE-2018-11213 漏洞)。
    4. listpack 的改进

      • 自包含长度信息
        每个元素头部使用 element-total-len​ 字段记录自身总长度(包含编码类型、数据长度等),解析时无需依赖前驱元素。

      • 编码优化
        长度字段采用更紧凑的变长编码(类似 UTF-8),例如:

        • 长度 ≤ 127:1 字节。
        • 长度 ≤ 16383:2 字节。
        • 更大长度:5 字节(1 字节标记 + 4 字节长度)。
    5. 使用场景对比

      场景 推荐结构 原因
      小规模只读数据 ziplist/listpack 内存紧凑,适合存储用户基础信息、配置项等(Redis 7.0+ 优先使用 listpack)。
      高频写入/随机修改 listpack 无连锁更新问题,适合计数器、动态元数据等场景(Redis 7.0+ 默认使用 listpack)。
      大规模数据存储 hashtable 数据量大时 O(1) 哈希表更高效(无论底层是 ziplist 还是 listpack,超阈值后均转为 hashtable)。
      高并发写入场景 listpack 避免 ziplist 的连锁更新抖动,性能更稳定。
  4. 数据结构特性对比

    特性 ziplist(压缩列表) hashtable(哈希表)
    存储方式 连续内存块存储,无指针开销,通过偏移量访问数据。 散列桶结构,每个键值对通过哈希函数分配到桶中,通过指针链接。
    内存占用 内存紧凑,无额外指针开销,内存利用率高。 内存占用较高(存储指针、哈希表扩容时的冗余空间)。
    查询复杂度 O(n)(需要遍历字段查找 O(1)(哈希表直接定位)
    插入/删除效率 平均 O(n),数据量大时效率低(需移动后续元素,可能触发连锁更新)。 O(1)(哈希表直接操作,扩容时可能触发 rehash 耗时)。
    扩容机制 不可动态扩容,超出阈值后直接转换为 hashtable。 动态扩容(负载因子超过阈值时翻倍扩容,缩容时按需减少)。
    适用数据规模 字段数少(默认 ≤512)且字段值小(默认 ≤64 字节)。 字段数多或字段值大。
  5. 适用场景对比

    场景 推荐编码 原因
    小型对象存储 ziplist 字段数少且值小(如用户基础信息),内存利用率高。
    高频写入/随机访问 hashtable 避免 ziplist 的遍历开销和连锁更新问题(如频繁修改的计数器)。
    只读或低频修改数据 ziplist 利用内存紧凑特性减少内存占用(如静态配置数据)。
    大规模数据存储 hashtable 数据量大时 O(1) 查询效率显著优于 ziplist(如购物车、社交关系链)。
    需要遍历所有字段 ziplist(小数据) 连续内存布局遍历更快;大数据量时 hashtable 的HSCAN​更安全(避免HGETALL​阻塞)。
  6. 配置调优建议

    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>
  1. 对象属性存储

    • 将对象的多个属性(如用户信息、商品详情)聚合存储为一个 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();
          }
      }
      
  2. 部分更新与原子操作

    • 支持单个字段的原子更新(如用户年龄修改),避免序列化整个对象。

      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);
              
              // 对单个字段进行原子更新,这里更新年龄为 31
              jedis.hset(userId, "age", "31");
      
              // 查询更新后的年龄
              String newAge = jedis.hget(userId, "age");
              System.out.println("更新后用户年龄: " + newAge);
      
              // 关闭 Jedis 连接
              jedis.close();
          }
      }
      
  3. 计数器与统计

    • 利用 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 将销量加 1
              long 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();
          }
      }
      
  4. 购物车实现

    • 以用户 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 服务,默认端口 6379
              Jedis jedis = new Jedis("localhost", 6379);
      
              // 以用户ID构建购物车的 key(建议加上 cart 前缀以示区分)
              String cartKey = "cart:user:1001";
      
              // -------------------------------
              // 示例1:添加商品到购物车
              // -------------------------------
              // 将商品 product:2001 添加至购物车,数量 2
              jedis.hset(cartKey, "product:2001", "2");
              System.out.println("添加商品 product:2001 数量 2 到购物车 " + cartKey);
      
              // 添加另一个商品 product:2002,数量 3
              jedis.hset(cartKey, "product:2002", "3");
              System.out.println("添加商品 product:2002 数量 3 到购物车 " + cartKey);
              printCart(jedis, cartKey);
              // -------------------------------
              // 示例2:动态增加已有商品的数量
              // -------------------------------
              // 将商品 product:2001 的数量原子性增加 1
              jedis.hincrBy(cartKey, "product:2001", 1);
              System.out.println("将商品 product:2001 的数量增加 1");
              printCart(jedis, cartKey);
              // -------------------------------
              // 示例3:删除商品
              // -------------------------------
              // 从购物车中删除商品 product:2002
              jedis.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("--------------------------------------------------");
          }
      }
      
  5. 分布式锁与缓存

    • 通过 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 通过灵活的字段操作、高效的内存管理和多样化的应用场景,成为存储结构化数据的理想选择。


网站公告

今日签到

点亮在社区的每一天
去签到