大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Hash 对象:用户资料与做题进度缓存
这一课你将能做到
- 用 Hash 命令对「对象」按字段读写、批量取、原子增减
- 为本站设计一个可局部更新的做题进度缓存结构
- 说清楚 Hash 与「String 存 JSON」各自的适用边界
为什么要学
真实业务里大量数据长着「对象」的样子:一个用户有昵称、头像、等级、连胜天数;一门课的进度有已做几题、正确率、最后学习时间。
如果用 String 存 JSON,改一个字段就得把整个对象读出来、解析、改、序列化、再写回去——一次小改动牵动整个值,并发写还会互相覆盖。Hash 把一个键下的多个「字段-值」分开存放,改昵称只动昵称那个字段,取正确率只取一个字段,字段级的增量更新还是原子的。
本站的做题进度是高频写场景:用户每通过一道题,进度里的 solved 要加一、acRate 要重算、lastActive 要更新时间。Hash 让这三次操作互不干扰、各自极小。
先建立图像
String 存 JSON 的样子:
progress:user:1001:course:7 → "{\"solved\":12,\"total\":50,\"acRate\":0.75,...}"
改 solved?整个值重写。
Hash 的样子:
progress:user:1001:course:7
┌──────────┬────────┐
│ field │ value │
├──────────┼────────┤
│ solved │ 12 │ ← HINCRBY 单独 +1
│ total │ 50 │
│ acRate │ 0.75 │
│ status │ doing │
│ lastActive│ ... │
└──────────┴────────┘
每个字段独立读写,互不影响
跟着做
步骤 1:写入和读取字段
127.0.0.1:6379> HSET user:1001 nickname 小明 level 3 streak 7
(integer) 3
127.0.0.1:6379> HGET user:1001 nickname
"小明"
127.0.0.1:6379> HGET user:1001 vip
(nil)
HSET 一次可写多个字段;字段已存在就是更新,新字段返回新增数量。Redis 4 以后 HSET 已经覆盖了老的 HMSET,统一用 HSET 即可。
判断字段是否存在、看字段总数:
127.0.0.1:6379> HEXISTS user:1001 vip
(integer) 0
127.0.0.1:6379> HLEN user:1001
(integer) 3
步骤 2:批量取与全量取
127.0.0.1:6379> HMGET user:1001 nickname level streak vip
1) "小明"
2) "3"
3) "7"
4) (nil)
127.0.0.1:6379> HGETALL user:1001
1) "nickname"
2) "小明"
3) "level"
4) "3"
5) "streak"
6) "7"
HGETALL 返回「字段、值、字段、值」交替的数组。只想要字段名或值分别用 HKEYS、HVALS。字段特别多的 Hash,用 HSCAN 分批:
127.0.0.1:6379> HSCAN user:1001 0 COUNT 100
步骤 3:字段级原子增减
用户通过一道题:
127.0.0.1:6379> HSET progress:user:1001:course:7 solved 12 total 50 acRate 0.75 status doing
OK
127.0.0.1:6379> HINCRBY progress:user:1001:course:7 solved 1
(integer) 13
127.0.0.1:6379> HINCRBYFLOAT progress:user:1001:course:7 acRate 0.02
"0.77"
和 INCR 一样,HINCRBY 是原子的,多个客户端同时更新也不会错。字段不存在时按 0 起步。
只在字段不存在时初始化(防止覆盖已有进度):
127.0.0.1:6379> HSETNX progress:user:1001:course:7 status doing
(integer) 0
步骤 4:删除字段与整个键
127.0.0.1:6379> HSET user:1001 tempNote rollback
(integer) 1
127.0.0.1:6379> HDEL user:1001 tempNote
(integer) 1
127.0.0.1:6379> DEL user:1001
(integer) 1
HDEL 删的是字段;字段删光后键自动消失。DEL 删的是整个 Hash。
步骤 5:搭建本站做题进度缓存
初始化一个新学员的课程进度(50 道题,刚开始学):
127.0.0.1:6379> HSET progress:user:1001:course:7 solved 0 total 50 acRate 0 wrongCount 0 seconds 0 lastQuestion 0 status doing
(integer) 7
127.0.0.1:6379> EXPIRE progress:user:1001:course:7 2592000
(integer) 1
TTL 设 30 天:学员持续学习会反复触发更新,配合应用层在更新时刷新过期时间(学习中进度一直保留,长期不活跃自动回收)。
模拟一次「通过第 13 题」:
127.0.0.1:6379> HINCRBY progress:user:1001:course:7 solved 1
(integer) 1
127.0.0.1:6379> HSET progress:user:1001:course:7 lastQuestion 13
(integer) 0
127.0.0.1:6379> EXPIRE progress:user:1001:course:7 2592000
(integer) 1
前端打开课程页时,一次 HGETALL 就能渲染进度条、正确率和「继续学习」按钮。
模拟一次提交错误:
127.0.0.1:6379> HINCRBY progress:user:1001:course:7 wrongCount 1
(integer) 1
学完最后一题时更新状态:
127.0.0.1:6379> HSET progress:user:1001:course:7 status done
(integer) 0
步骤 6:查看内部编码,理解省内存
127.0.0.1:6379> OBJECT ENCODING progress:user:1001:course:7
"listpack"
Redis 7 中,字段少且值短时 Hash 使用紧凑的 listpack 编码;字段或值超过阈值(hash-max-listpack-entries、hash-max-listpack-value)才转为哈希表编码。这意味着小对象 Hash 的内存开销极低,适合海量用户各存一个小进度对象。
讲清楚要点
- Hash 解决的是「对象的局部更新」:字段级读写、字段级原子计数,避免整体重写和并发覆盖。
- HGETALL 是双刃剑:小 Hash 用它一把取完最方便;若一个 Hash 塞了上万个字段,HGETALL 会阻塞并撑爆网络,大对象要么拆键要么 HSCAN。
- 字段数量建议控制在百级以内:把 Hash 当成一个紧凑对象,而不是当表用(一个 Hash 存 100 万用户是经典反模式:无法按用户设 TTL、难以迁移、单键过热)。
- 值一律是字符串:数字、小数在 Hash 里也是字符串形态,靠 HINCRBY/HINCRBYFLOAT 做数值运算。
- 小 Hash 有紧凑编码红利:本站「一人一进度」的海量小对象模式,恰好落在 listpack 的最优区间。
你可能会懵的地方
-
HSET 返回 0 是不是没生效? 生效了。返回值是「新增字段数」,更新已存在的字段返回 0。想确认就读回来看看。
-
Hash 和 String 存 JSON 到底怎么选? 经常只改个别字段、要做字段级计数,选 Hash;对象总是整体读写、字段结构复杂有嵌套(数组、子对象),String 存 JSON 更自然,Hash 不支持值的嵌套。
-
能不能用一个大 Hash 存所有用户的进度? 强烈不建议。单键过大导致阻塞、无法给单个用户设过期、迁移和分片困难。正确模式是「一个用户一个键」。
-
HINCRBYFLOAT 算出来小数位很长怎么办? 二进制浮点的正常现象。存储前在应用层四舍五入到需要的精度(比如两位小数),展示类字段也可以直接存格式化后的字符串。
-
HMSET 去哪了? 已废弃但仍可用,HSET 支持一次写多字段,新代码统一写 HSET。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| String JSON 改字段全量重写 | 并发覆盖、浪费带宽 | 用 Hash 按字段更新 |
| 一个 Hash 存全量用户 | 单键过热、无法分 TTL | 一人一键:progress:user:{id}:course:{cid} |
| 对万字段 Hash 用 HGETALL | 阻塞 + 撑爆网络 | HSCAN 或拆键 |
| 字段名写很长 | 每个对象重复浪费 | 字段名短而统一,如 solved 而非 solvedProblemCount |
| 进度键不设 TTL | 废弃学员数据永久占用 | 学习中刷新 TTL,长期不活跃自动过期 |
| Hash 里再塞 JSON 数组 | 失去字段化优势且无法局部改 | 数组类数据用 Set/List 单独的键 |
卡点急救
- HINCRBY 报整数错误:字段值不是整数(比如被 HSET 写过小数)。计数字段和浮点字段分开使用。
- 进度更新互相覆盖:检查是否有人用 HSET 整体重写了本该 HINCRBY 的字段;计数只走增量命令。
- HGETALL 变慢:
HLEN看字段数,字段过多说明键设计有问题,回到「对象要小」的原则拆分。
面试常问
-
Hash 适合什么场景?相比 String 存 JSON 的优势? 适合字段可独立变化的对象:用户资料、进度、配置。优势是局部更新、字段级原子计数、取值只取需要的字段;代价是不支持嵌套结构。
-
为什么不要把 Redis Hash 当数据库表,一个键存几十万条记录? 单键过大使一次操作阻塞主线程;无法对单个元素设 TTL;集群中单键只能落在一个槽,造成热点;迁移成本高。应拆成大量小键。
-
Redis 7 的 Hash 为什么省内存? 小 Hash 采用 listpack 紧凑连续存储,字段少、值短时没有哈希表的指针开销,超过阈值才自动切换编码。
自测三问
- 用户通过一题,只更新进度里的 solved 和 lastQuestion,写出命令。
- 哪三种情况说明你应该用 Hash 而不是 String JSON?
- 「一个大 Hash 存所有用户进度」错在哪?请说出两个问题。
小结一句话
Hash 是键内的字段字典,让对象可以按字段精细、原子地更新,本站「一人一进度」的海量小对象正是它最舒服的姿势。