在Redis的实际应用中,存储对象是最常见的需求之一。比如用户信息、商品详情、配置项等等,本质上都是一个对象的多个字段。Redis提供了多种方式来存储这类数据,其中哈希类型是最贴合对象存储场景的结构。一个Hash可以看作一个Java里的Map,或者PHP里的关联数组,外层是一个key,内部是field-value的映射。这篇文章就来详细聊聊Hash类型的存储原理、常用命令,以及它和String存储对象方案的对比,帮你在实际项目中做出正确的选择。

一、Hash类型的基本概念与存储原理
Hash类型是一个键值对集合,其中每个键值对的键叫field,值叫value。它和Redis最外层的key-value不一样:最外层的key是字符串,而Hash内部的field-value都是字符串类型,且同一个Hash内部不允许存在重复的field。用一个形象的比喻,Redis最外层是一个大Map,Hash类型相当于在大Map的某个key下面又嵌套了一个小Map。
从底层实现来看,Hash类型的存储结构在Redis 7.0之后主要有两种编码方式:listpack和hashtable。当一个Hash的field数量较少且所有field和value的长度都较短时,Redis使用listpack编码,这是一种紧凑的顺序存储结构,所有数据在内存中连续排列,节省了大量指针开销。当满足一定条件后,比如field数量超过hash-max-listpack-entries配置的值(默认128),或者任意一个field或value的长度超过hash-max-listpack-value配置的值(默认64),Redis会把这个Hash从listpack转换为hashtable编码。
转换为hashtable之后,查找某个field的时间复杂度是O(1),但内存开销会明显上升,因为每个entry都需要额外的指针维护。这种设计思路是典型的空间换时间的权衡:小对象用紧凑结构省内存,大对象用哈希表保性能。可以通过下面的命令查看一个Hash的编码方式:
127.0.0.1:6379> HSET user:1 name zhangsan age 25 (integer) 2 127.0.0.1:6379> OBJECT ENCODING user:1 "listpack" 127.0.0.1:6379> CONFIG GET hash-max-listpack-entries 1) "hash-max-listpack-entries" 2) "128"
二、Hash类型的常用命令详解
掌握Hash的命令是使用它的基础。最核心的一组命令是HSET和HGET,分别用于设置和获取单个field的值。HSET支持一次设置多个field-value对,这是后来版本合并了HMSET功能的结果,官方已经不推荐使用HMSET。还有一个非常实用的HSETNX命令,只有当field不存在时才设置值,适合做初始化操作。
批量读取方面,HMGET可以一次获取多个field的值,HGETALL则返回整个Hash的所有field和value。需要注意的是,如果Hash里的字段非常多,HGETALL是一个危险的命令,它会一次性返回全部数据,可能造成Redis阻塞。生产环境中对于大Hash,建议使用HSCAN渐进式遍历,避免一次性拉取带来的性能问题。
计数相关的命令有HINCRBY和HINCRBYFLOAT,它们可以对某个field的值做原子自增,前提是该值必须能被解析为整数或浮点数。这个特性在做计数器、库存扣减、积分累计时特别有用。下面用一段代码演示这些常用命令:
# 设置多个字段 HSET user:1001 name "lisi" age 28 city "beijing" # 获取单个字段 HGET user:1001 name # 返回 "lisi" # 批量获取 HMGET user:1001 name age city # 获取所有字段 HGETALL user:1001 # 删除指定字段 HDEL user:1001 city # 字段原子自增 HINCRBY user:1001 age 1 # age 变为 29 # 判断字段是否存在 HEXISTS user:1001 age # 返回 1 # 只获取所有字段名或字段值 HKEYS user:1001 HVALS user:1001 # 获取字段数量 HLEN user:1001
另外还有HSTRLEN命令可以获取指定field值的字符串长度,HINCRBYFLOAT用于浮点数自增。这些命令组合起来基本能覆盖对象读写的所有场景。
三、Hash存储对象与String存储对象的方案对比
用Redis存储对象其实有三种常见方案。第一种是原生String,直接把对象序列化成JSON字符串后存储;第二种是拆分成多个String的key,每个字段一个key;第三种就是使用Hash。三种方案各有优劣,需要根据读写模式来选择。
先说JSON序列化方案,它的优点是读写整个对象很方便,一次GET就能拿到全部数据,但缺点也很明显:只要修改一个字段,就必须把整个对象读出来、反序列化、修改后再序列化写回,在并发场景下还容易出现互相覆盖的问题。拆分成多个String key的方案虽然可以单独修改某个字段,但会产生大量碎片化的key,占用更多内存,而且无法集中管理,删除一个对象需要删除多个key,事务和过期控制都很麻烦。
Hash方案则取了两者的长处:既能像JSON方案一样集中在一个key下管理整个对象,又能通过field单独读写某个字段,修改年龄不需要动到名字,天然支持字段级别的原子操作。内存方面,处于listpack编码下的小Hash非常省空间。我们用表格对比一下三种方案:
| 对比项 | JSON存String | 拆分多个String | Hash类型 |
|---|---|---|---|
| 整体读写 | 方便,一次GET | 需多次GET或MGET | HGETALL |
| 单独修改字段 | 需读出整个对象改后写回 | 支持,单独SET | 支持,HSET即可 |
| 内存占用 | 较低 | 较高,key碎片多 | 小Hash时很低 |
| 字段级原子计数 | 不支持 | 支持INCR | 支持HINCRBY |
| 过期控制 | 整个对象统一过期 | 各key独立过期 | 只能整个key过期 |
需要特别指出Hash的一个限制:过期时间只能设置在key级别,不能针对单个field设置过期。如果你的业务需要每个字段有独立的TTL,那Hash就无能为力了,可以考虑拆分key或者等新版本的相关特性。另外,Hash也不能像RedisJSON那样支持嵌套结构,field的value只能是字符串,复杂对象仍然需要序列化后存入field。
四、实战场景:用Hash实现购物车
购物车是Hash的经典应用场景。一辆购物车对应一个key,比如cart:用户ID,购物车里的每个商品对应一个field,商品数量作为value。这样添加商品、修改数量、删除商品、查询整个购物车都天然对应Hash的操作命令,逻辑非常清晰。下面是一段Java风格的示例代码:
// 添加商品到购物车,商品1001加入2件
jedis.hset("cart:1001", "goods:5001", "2");
// 商品数量加1,原子操作,无需担心并发问题
jedis.hincrBy("cart:1001", "goods:5001", 1);
// 获取某个商品的数量
String num = jedis.hget("cart:1001", "goods:5001");
// 移除商品
jedis.hdel("cart:1001", "goods:5001");
// 获取购物车中商品总数
long count = jedis.hlen("cart:1001");
// 获取整个购物车
Map<String, String> cart = jedis.hgetAll("cart:1001");除了购物车,用户资料缓存也是Hash的典型用法。把用户的昵称、头像、等级等字段存进user:ID这个Hash里,页面展示时用HMGET只取需要的几个字段,避免了JSON方案下必须反序列化整个对象的开销。字段有更新时直接HSET对应field即可,缓存和数据库的同步维护也简单得多。
五、使用Hash的注意事项与坑点
第一个坑是bigkey问题。如果一个Hash的field数量达到几十万甚至上百万,HGETALL、HKEYS这类全量命令会严重阻塞Redis主线程。规范的做法是控制单个Hash的field数量,把大对象按一定规则拆分成多个小Hash,比如按用户ID取模分桶存储,每个桶控制在几千个field以内。
第二个坑是遍历时的错误做法。很多新手习惯用HKEYS去遍历大Hash,这在生产环境是明令禁止的。正确的做法是使用HSCAN配合游标渐进式获取,每次只拉取一小部分数据。需要注意的是,HSCAN保证的是遍历期间完整返回所有存在的元素,但如果遍历过程中有数据被修改,可能会返回重复或遗漏,业务侧需要做好幂等处理。
第三个坑是编码转换的性能抖动。当Hash从listpack转换为hashtable时会发生一次性的内存重分配,如果业务上Hash的字段数量刚好在阈值附近来回波动,可能造成频繁转换。这种情况可以通过调大hash-max-listpack-entries来缓解,但要权衡内存增长,或者直接在业务上控制字段数量的稳定性。理解了这些原理和边界,Hash类型就能在你的系统里发挥出最大的价值。
Redis Hash哈希类型存储对象修改时间:2026-09-07 18:54:56