Learn
Redis/04-hash

Hash:字段级操作的对象存储

用 String 缓存对象时,改一个字段就要整体读出再写回。Hash 把一个 key 内部再分成多个 field-value 对,支持字段级的读写与自增,是存储"对象"的更自然选择。

1. 数据模型

key: user:1001
+----------+------------------+
| field    | value            |
+----------+------------------+
| name     | Ada              |
| age      | 30               |
| city     | Shanghai         |
| balance  | 999              |
+----------+------------------+

一个 Hash 最多可容纳 2^32 - 1 个 field,但实践中应远小于这个数(后面讲大 key 风险)。

2. 核心命令实操

2.1 写入与读取

Hash 写入与读取
# 返回值是新建的 field 数量
HSET user:1001 name "Ada" age 30 city "Shanghai"
HGET user:1001 name
HMGET user:1001 name age nonexist
HGETALL user:1001
HEXISTS user:1001 age
HDEL user:1001 city
HLEN user:1001
HKEYS user:1001
HVALS user:1001

Redis 4 之后 HSET 支持一次写多个 field,HMSET 已废弃。

2.2 存在性、删除与遍历

127.0.0.1:6379> HEXISTS user:1001 age
(integer) 1
127.0.0.1:6379> HDEL user:1001 city
(integer) 1
127.0.0.1:6379> HLEN user:1001
(integer) 2
127.0.0.1:6379> HKEYS user:1001
1) "name"
2) "age"
127.0.0.1:6379> HVALS user:1001
1) "Ada"
2) "30"

2.3 字段级自增

字段级自增
HSET user:1001 name "Ada" age 30
HINCRBY user:1001 balance 100
HINCRBY user:1001 balance -30
HINCRBYFLOAT user:1001 score 0.5
# field 已存在时 HSETNX 不覆盖,返回 0
HSETNX user:1001 name "Bob"
HGETALL user:1001

一个 key 管理一组相关计数器(如商品的浏览数/收藏数/销量),比一堆独立 String key 更好维护。

2.4 HSETNX 与 HRANDFIELD

127.0.0.1:6379> HSETNX user:1001 name "Bob"
(integer) 0             # field 已存在,不覆盖
127.0.0.1:6379> HRANDFIELD user:1001 2 WITHVALUES
1) "age"
2) "30"
3) "name"
4) "Ada"

2.5 HSCAN:安全遍历大 Hash

HGETALL 会一次返回全部 field,对上万 field 的 Hash 是危险操作。用 HSCAN 分批:

127.0.0.1:6379> HSCAN user:1001 0 MATCH * COUNT 100
1) "0"
2) 1) "name"
   2) "Ada"
   3) "age"
   4) "30"
   5) "balance"
   6) "70"

用法与 SCAN 相同:游标从 0 开始,返回 0 结束。

3. 底层编码:listpack 与 hashtable

listpack 转 hashtable 是单向的
HSET user:1001 name "Ada" age 30
OBJECT ENCODING user:1001
# 写入一个超过 64 字节的 value,触发编码升级
HSET user:1001 bio "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OBJECT ENCODING user:1001
# 删掉长字段也不会降回 listpack
HDEL user:1001 bio
OBJECT ENCODING user:1001
编码条件结构与代价
listpackfield 数 ≤ 128 且 每个 field/value ≤ 64 字节连续内存紧凑排列,省内存;查找 O(n)
hashtable任一条件超出真正的哈希表;查找 O(1),内存开销大数倍

阈值可配置:

hash-max-listpack-entries 128
hash-max-listpack-value 64
listpack(连续内存,顺序扫描):
+------+-----+------+-----+------+------+
| name | Ada | age  | 30  | city | ...  |
+------+-----+------+-----+------+------+
 
hashtable(桶数组 + 链表):
bucket[0] -> (age, 30)
bucket[1] -> (name, Ada) -> (city, Shanghai)
bucket[2] -> ...

Redis 7 用 listpack 全面替换了旧的 ziplist:两者思路一致(连续内存省空间),但 listpack 每个元素只记录自身长度,修复了 ziplist 级联更新的隐患。编码转换是单向的:一旦升级为 hashtable,即使删到只剩 1 个 field 也不会降回 listpack。

小对象用 listpack 有多省内存?存 100 万个"含 3 个短字段的对象",Hash+listpack 通常只需要 String+JSON 方案 60% 左右的内存——这是 Hash 的隐藏优势。

4. 场景:对象缓存的两种方案对比

维度String + JSONHash
读整个对象GET,一次搞定HGETALL
读单个字段取整串再解析,浪费HGET,精准
改单个字段读-改-写,非原子HSET/HINCRBY,原子
嵌套结构天然支持只支持一层平铺
给单个字段设 TTL不支持同样不支持(TTL 是 key 级的)
内存序列化有冗余listpack 编码更省

经验法则:字段会被单独读写、或需要字段级原子自增,用 Hash;对象整存整取、含嵌套结构,用 String + JSON。

用 Hash 实现购物车
# field = 商品 ID,value = 数量
HSET cart:u1001 sku:2001 1 sku:2002 3
# 商品数量 +1
HINCRBY cart:u1001 sku:2001 1
# 移除商品
HDEL cart:u1001 sku:2002
# 结算时取整车
HGETALL cart:u1001
HLEN cart:u1001

5. 大 key 风险

Hash 很容易被滥用成"把一整类数据塞进一个 key",比如把全站 1000 万用户都放进 all:users 一个 Hash:

  • 读写放大:HGETALL 返回几百 MB,打爆网络与客户端内存;
  • 阻塞:对它执行 DEL、HGETALL 会卡主线程;
  • 无法分片:Cluster 按 key 分片,单个巨型 key 无法拆到多个节点,造成数据倾斜;
  • 过期集中:整个 Hash 只有一个 TTL,无法按 field 过期。
⚠️大 key 判定的经验值

Hash/Set/ZSet/List 的元素数超过 5000,或单 key 序列化体积超过 1 MB,就应视为大 key。治理方法是拆分:如把 all:users 按用户 ID 取模拆成 users:0 到 users:99 共 100 个 Hash。定位大 key 用 redis-cli --bigkeys(第 19 章详解)。

ℹ️field 没有独立 TTL

TTL 只能设在整个 key 上。若业务需要"每个字段独立过期"(如各用户的验证码),应该用独立的 String key 而不是共享一个 Hash。Redis 7.4 开始提供 HEXPIRE 支持 field 级过期,但使用前需确认你的服务端版本。

小结

  • Hash 提供字段级读写与原子自增,适合购物车、对象属性、计数器组
  • 编码两段式:小而短用 listpack(省内存),超阈值转 hashtable(O(1) 查找),单向不可逆
  • HGETALL 只用于确认较小的 Hash,大 Hash 遍历一律 HSCAN
  • 大 key 是 Hash 的头号陷阱:元素超 5000 就要考虑拆分
  • 下一章 List:队列与时间线 →
🎯练习
  1. 创建一个 Hash,先写 100 个短 field 确认编码为 listpack,再写入一个 100 字节的 value,验证编码转为 hashtable 且不可逆。
  2. 用 Hash 实现购物车:加购、改数量、删商品、结算取全量,写出完整命令序列。
  3. 假设要缓存 500 万用户的资料(每人 5 个字段),设计拆分方案避免大 key,并说明如何根据用户 ID 定位到对应的 key。