一个每天都在发生的选择
上一篇讲 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 String | Hash | |
|---|---|---|
| 读一个字段 | GET 整个 → 解析 → 取字段 | HGET,一次网络往返 |
| 改一个字段 | GET → 解析 → 修改 → SET | HSET / 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 # 默认 512hash-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学习系列 | 先入先出?还能后进先出?
部分信息可能已经过时





