Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
1816 字
9 分钟
Redis学习系列 | 对象存 JSON String 还是 Hash?

一个每天都在发生的选择#

上一篇讲 String 的时候留了个尾巴:存对象,到底是序列化成 JSON String 一把梭,还是用 Hash 拆成字段存?

这个问题没有标准答案。每个用 Redis 的人都遇到过——你写 SET user:1 '{"name":"zhangsan","score":1200,"email":"z@test.com"}' 的时候,有没有犹豫过:“要不要用 Hash 存?”

我刚开始的时候全用 String。简单嘛,序列化、反序列化,跟写代码里的对象一样顺手。后来有两个事让我开始反思:

一次是看了下线上 Redis 的内存,发现用户信息那块占了快 2GB。十万用户,每个存了大概二十个字段。直觉告诉我这个数不对。

另一次是写一个积分系统,每次用户完成操作就 +10 分。用 String 的做法是读出来、解析、加 10、序列化、存回去。并发高的时候,两个请求读到了同一个分数,其中一个的更新就被覆盖了。

这两个问题最后都指向同一个结论:大部分时候你应该用 Hash

不绝对。往下看。

两种写法的直观对比#

假设一个用户对象有三个字段:

String 做法:

SET user:1 '{"name":"zhangsan","score":1200,"email":"z@test.com"}'
GET user:1
# → '{"name":"zhangsan","score":1200,"email":"z@test.com"}'

整进整出。要读任何数据,先把整个 JSON 拉出来再解析。要改一个字段,也是拉整个出来、改、再存回去。

Hash 做法:

HSET user:1 name "zhangsan" score 1200 email "z@test.com"
HGET user:1 score
# → "1200"
HINCRBY user:1 score 10
# → 1210

每个字段独立读写。想拿 score?一条 HGET,不用碰 name 和 email。想给 score +10?HINCRBY,原子操作,不会有并发覆盖的问题。

区别不只是写法:

JSON StringHash
读一个字段GET 整个 → 解析 → 取字段HGET,一次网络往返
改一个字段GET → 解析 → 修改 → SETHSET / HINCRBY,一条命令
并发改不同字段互相覆盖(同一把锁)互不干扰
加一个字段改表结构 + 改代码 + 迁移数据HSET 就完了

讲到底,String 把对象当成一个不可分割的 blob;Hash 把对象当成一组独立的字段。如果你的使用模式是”大部分时候只需要操作一两个字段”,Hash 的优势就显出来了。

内存账:为什么 Hash 能省 30%#

回到我前面说的那个问题——十万用户、二十个字段、占了快 2GB。为什么 Hash 能省?

Redis Hash 有一个对使用者透明的优化:小 Hash 用 listpack(Redis 7+)或 ziplist(旧版本)编码,而不是标准的哈希表。

标准哈希表的问题:每个 key-value 对都要维护指针、字典条目、redisObject 元数据。这些”管理开销”在小数据场景下,比数据本身还大。

listpack 的做法:把所有字段和值紧密排列在一块连续内存里,像这样:

[字段1长度][字段1内容][值1长度][值1内容][字段2长度][字段2内容]...

没有指针,没有 redisObject。100 个字段共享一个 redisObject 的元数据开销。

但有两个条件才能用 listpack:

  • 字段数量 ≤ hash-max-listpack-entries(默认 512)
  • 每个字段和值的长度 ≤ hash-max-listpack-value(默认 64 字节)

两个条件都要满足。一旦超了,Redis 自动把 Hash 转成标准哈希表——对你还是透明的,但内存优化就没了。

实际数据#

我之前做的对比测试,100 个短字段(每个 8-16 字节):

编码方式内存占用
100 个独立 String Key~3.8 KB
1 个 String (JSON)~1.2 KB
1 个 Hash (listpack)~0.8 KB
1 个 Hash (hashtable)~1.4 KB

listpack 比 JSON String 省了约 30%,比独立 Key 省了 80%。这就是我当初那个 2GB 问题的答案——如果用 Hash + listpack,同样的数据大概只要 1.3GB。

关于阈值怎么调#

如果你的业务字段比较多,可以调整这两个参数:

hash-max-listpack-entries 1024 # 默认 512
hash-max-listpack-value 128 # 默认 64

但别无脑调大。listpack 是 O(n) 遍历的——如果单个 Hash 塞了几千个字段还强制走 listpack,每次操作都要遍历一遍,反而比标准哈希表慢。保持每个 Hash 100 个字段以内、值不超过 128 字节,是舒适区。

Hash 的两个常见坑#

坑一:HGETALL 大 Hash#

HGETALL 会返回整个 Hash 的所有字段和值。如果 Hash 里有几百上千个字段,一次操作可能阻塞 Redis 的主线程(别忘了,Redis 命令执行的线程只有一个)。

正确的做法是改用 HSCAN 分批读取:

HSCAN user:1 0 COUNT 100
# 每次返回一批,不阻塞

或者干脆在设计上避免单个 Hash 字段太多——超过 50-100 个字段就考虑拆成多个子 Hash。

坑二:Hash 的字段不能单独设过期#

EXPIRE 只能作用于整个 key。你不能让 user:1 里的 score 字段 10 分钟后过期而 name 不过期。

这听起来像废话,但我见过有人写代码想当然地给 Hash 字段设 TTL,结果发现不生效。如果你的场景需要字段级过期,要么拆成独立 String Key,要么在自己代码里处理过期逻辑。

什么时候用 Hash,什么时候用 String#

聊了这么多,给一个简单的判断框架:

用 Hash:

  • 对象字段比较固定(比如用户信息、商品详情、配置项)
  • 经常读写单个或少数几个字段
  • 需要字段级的原子操作(积分增减、状态切换)
  • 字段值和数量都在 listpack 阈值内(省内存)
  • 字段数量 10-200 个左右

用 String (JSON):

  • 存取都是整进整出(页面缓存、API 响应缓存)
  • 数据嵌套很深(多层数组和对象),Hash 摊不开
  • 字段不固定,经常变结构
  • 单个字段值很大(超过几百字节)
  • 需要跨语言可读/调试,JSON 直接看

两可之间:

  • 字段很少(3-5 个),内存差异不大,哪种顺手用哪种
  • Hash 字段数超过 500,但每个值都小 → 调大 listpack 阈值或考虑拆分

没有一刀切的答案。但有个经验法则挺好记:如果读写模式像 UPDATE 某个字段,用 Hash;如果像读缓存快照,用 String。

购物车:一个 Hash 的经典场景#

选 Hash 而不是 String,最典型的例子是购物车。

# 添加商品
HSET cart:user:1001 sku:9988 2 # 2 件
HSET cart:user:1001 sku:7765 1 # 1 件
# 修改数量
HINCRBY cart:user:1001 sku:9988 1 # 加 1 件 → 3 件
# 删除商品
HDEL cart:user:1001 sku:7765
# 查购物车总商品数
HLEN cart:user:1001
# 列出所有商品
HGETALL cart:user:1001

如果用 String,每次加一件商品就得拉出整个购物车 JSON、解析、改、序列化、存回去。两个人同时加商品,后存的会覆盖先存的。Hash 的 HINCRBY 天然原子,每个 SKU 独立操作互不干扰。

购物车恰好满足了 Hash 的全部优势条件:字段数适中(几十个 SKU)、值很小(数量 + 单价)、频繁单字段更新、需要原子增减。

下一篇聊 List——双向队列、阻塞弹出,和什么时候把 Redis 当消息队列用。


这是 Redis 学习系列的第 3 篇。下一篇:Redis学习系列 | 先入先出?还能后进先出?

Redis学习系列 | 对象存 JSON String 还是 Hash?
https://qiandaos.top/posts/redis-learning-series/3-hash/
作者
千岛寒流
发布于
2025-05-22
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00