BodySense Redis 第 2 课:String、Hash、Set 与 Session 建模
用 BodySense 的 session:<sid> 与 user_sessions:<uid> 学习 String、Hash、Set,并建立按访问模式选择数据结构与手工维护 secondary index 的思维。
[!info] related notes
- 课程 MOC:BodySense Redis 从零到一课程 MOC
- 前置课程:第 1 课
- 对应知识笔记:Redis、Redis Session Authority
- 实践:Redis 在 Go 后端的应用
BodySense Redis 第 2 课:String、Hash、Set 与 Session 建模
[!abstract] 学习进度 第 2 / 12 课。目标是从“数据要放 Redis”继续推进到“应该使用什么数据结构,以及为什么”。
核心原则:Key 只是入口,Value 才有数据结构
Redis 表面:
key → value
更准确:
key → Redis data structure
例如:
session:s1
→ String
user_sessions:u1
→ Set
设计时不要只问 Key 名字,而要问:
未来要对这份数据执行哪些操作?
先建立一个更准确的模型:Redis Key 有类型
上一课我们说:
key → Redis data structure
这和 JavaScript Object / Go map 有一个重要区别:
Redis Server 自己知道某个 Key 当前是什么数据类型,并提供与这个类型对应的服务器端命令。
例如:
SET user:name Arlon
TYPE user:name
会得到 String。
而:
SADD user:roles admin
TYPE user:roles
会得到 Set。
如果你对一个 String Key 硬执行 Set 命令族:
SMEMBERS user:name
Redis 不会“帮你猜”,而会返回类型错误。
这件事说明:
选择数据结构不是代码风格,而是在决定 Redis Server 能替你提供哪些操作语义。
为什么这一课先学 String / Hash / Set
Redis 的数据结构不止三个。现代 Redis 还包括 List、Sorted Set、Stream,以及 JSON、Time Series、Vector Set 等能力。
但 BodySense 当前认证链最能直接用三个基础结构建立直觉:
String
→ 单值 / counter / flag / serialized object
Hash
→ 一个 Key 下多个 field
Set
→ 唯一成员集合 / 反向索引
后面再进入:
Sorted Set → 滑动窗口 / 排名 / 时间 score
Stream → retained event / consumer group
Pub/Sub → 实时通知(它甚至不是持久数据结构)
所以这一课的目标不是背命令大全,而是建立:
先看访问模式,再选服务器原生数据结构。
Redis Key 本身只是名字,命名也属于数据模型
常见命名形状:
<domain>:<entity>:<id>
例如 BodySense 当前真实 Key:
session:<sid>
user_sessions:<userID>
user_auth_revoked:<uid>
refresh_token:<sha256-digest>
refresh_replay:<sha256-digest>
refresh_family:<sessionID>
冒号没有神奇语义,Redis 不会自动把它变成目录;它只是人类和运维工具容易理解的命名约定。
一个好 Key 名至少应该让你看出:
它属于哪个 domain?
它代表 authority 还是 index?
它用什么 identity 定位?
同时不要把原始 secret/PII 随意放进 Key。BodySense 的 Refresh Token Key 使用 SHA-256 digest 派生名,就是为了避免 Redis key dump 直接暴露 bearer token。
String:最基础,但绝不只是“文字字符串”
Redis String 是最基础的数据类型。官方定义可以理解成:
一段 binary-safe bytes。
所以 String 可以保存:
普通文本
整数/浮点数字面值
JSON
序列化后的 protobuf/msgpack
JWT / opaque token(是否应该存是另一回事)
图片等二进制片段(通常不是最佳用途)
状态 flag
counter
最基本 SET / GET
SET name "Arlon"
GET name
得到:
Arlon
一次 SET 同时表达 TTL
SET verification:abc user-42 EX 300
这一个命令同时表达:
写 Value
+
生命周期 300 秒
比:
SET
↓
EXPIRE
更少一个失败窗口。第 4 课会把这个原则推广到原子状态转换。
String 为什么可以 INCR
你可能会疑惑:
既然叫 String,为什么能做数字加法?
因为 Value 在存储层可以是一串表示整数的 bytes,而 INCR 命令会按整数语义解析、增加,再保存结果。
例如:
SET login_failed 4
INCR login_failed
GET login_failed
结果是:
5
这不意味着 Redis 出现了另一个“Integer 类型”;从 Redis data type 视角,它仍属于 String。
String 作为 Counter 的关键优势:服务器端原子增加
不要:
GET counter
→ 应用 +1
→ SET counter
因为多个客户端会发生 lost update。
直接:
INCR counter
把“读当前数字 + 加一 + 保存”收敛成服务器的一条原子命令。
String 保存整个对象
可以:
SET demo_session:s1 '{"user_id":"u1","status":"active"}' EX 1800
Redis 只把它当一整段 bytes:
GET
→ application deserialize
→ Go struct
优点:
- 模型简单;
- 一次完整读写;
- 应用可以使用自己的 schema/serializer。
代价:
- Redis 不知道 JSON 内部 field;
- 修改一个小字段通常仍要整块读/反序列化/写回;
- 如果多个客户端分别改不同字段,也需要考虑整个对象的并发更新。
[!important] BodySense 当前真实 Session 不是这里的 JSON 示例 当前
UserSessionCache把session:<sid>作为存在性 authority flag,真实脚本写的是 String 值'1'+ TTL。认证只需要知道这个 sid 是否仍存在,并不需要每次把完整 Session struct 存进去。
Hash:一个 Redis Key 下面有多个 Field
Hash 可以把它想成 Redis Server 内部的一张小型 field-value map:
user:42
├─ name → Arlon
├─ age → 22
└─ role → user
写入:
HSET user:42 name Arlon age 22 role user
读取单字段:
HGET user:42 age
更新一个字段:
HSET user:42 age 23
读取全部:
HGETALL user:42
Hash 和“JSON Object”不是一回事
表面看起来都像:
{
name: Arlon,
age: 22
}
但真正差异在谁理解内部结构。
String JSON:
Redis
→ 只知道一块 bytes
Application
→ 理解 JSON field
Hash:
Redis
→ 直接理解 field/value
→ 可以 HGET/HSET/HINCRBY
所以不要因为“这是一个对象”就自动选 Hash。
应该问:
我是否经常只读一个 field?
是否经常独立更新一个 field?
是否需要 field-level numeric operation?
是否每次本来就要读取整个对象?
一个典型候选:Job Runtime State
假设存在:
job:123
├─ status
├─ progress
├─ worker_id
└─ heartbeat_at
如果这些 field 经常独立变化:
HSET job:123 progress 60
HSET job:123 heartbeat_at 123456
Hash 比“每次 GET 整块 JSON → 修改 → SET”更符合访问模式。
但注意:这只是数据结构建模示例,不代表当前 BodySense JobRuntime 应迁到 Redis。当前 BodySense Job authority 在 PostgreSQL,第 10 课会专门讨论 Queue/Worker 边界。
Set:无序、成员唯一的集合
Set 最重要的不是“能放很多值”,而是两个语义:
member unique
+
membership operation
例如:
SADD user:roles admin
SADD user:roles doctor
SADD user:roles admin
最终 Set 里 admin 只有一个。
常见操作:
SADD key member # 添加
SREM key member # 删除
SISMEMBER key member # 判断是否存在
SCARD key # 成员数
SMEMBERS key # 取全部成员
为什么不是 List
如果需求是:
Session ID 不能重复
不关心先后顺序
经常判断/删除某个 sid
Set 的语义天然匹配。
List 更强调有序序列、头尾 push/pop;如果你只为了“存很多 sid”使用 List,还要自己处理重复成员和 membership。
Set 不是关系数据库的自动 Index
这一点对后面非常重要。
PostgreSQL:
CREATE INDEX ...
之后数据库自己维护索引和表的一致性。
Redis 中:
session:<sid>
+
user_sessions:<uid> Set
是应用自己定义的两份 Key。
Redis 不知道:
user_sessions:u1 里的 s1
应该指向 session:s1
所以一致性也必须由应用负责。
为什么 BodySense 同时需要 session: 与 user_sessions:
假设用户 u1 三个设备登录:
session:s1
session:s2
session:s3
通过 sid 找 Session 很容易:
sid → session:<sid>
但“退出所有设备”从 user_id 出发,需要反向找到全部 sid。
Redis 没有关系数据库式:
SELECT * FROM sessions WHERE user_id = 'u1';
于是应用自己维护:
user_sessions:u1
→ Set {s1, s2, s3}
这本质上是:
应用自己维护的 secondary index。
两种查询方向:
sid → Session
uid → [sid...]
为什么这里是 Set 而不是 List
需求:
- Session ID 不应重复;
- 快速判断成员关系;
- 删除任意 Session;
- 不关心插入顺序。
这正是 Set。
Secondary Index 带来一致性问题
创建 Session:
SET session:s1 ...
SADD user_sessions:u1 s1
如果第一步成功后进程崩溃:
session:s1 exists
user_sessions:u1 missing s1
删除时也可能产生 dangling reference。
所以:
数据结构选择解决查询问题,但多个 Key 又引入一致性边界。
这会在第 4 课进入 Lua / atomicity。
TTL 与 Secondary Index 的一个陷阱
假设:
session:s1
→ TTL 到期自动消失
但:
user_sessions:u1
→ Set 仍包含 s1
普通 Set 的单个 member 没有独立 TTL。
于是:
user_sessions:u1 = {s1, s2, s3}
session:s1 → nil
session:s2 → exists
session:s3 → exists
s1 是 stale index entry。
为什么这通常不破坏认证安全
正确 authority:
session:<sid>
= Session 是否有效的权威
辅助索引:
user_sessions:<uid>
= 只负责从用户找到候选 sid
不能:
sid 在 Set 里
→ 就认为 Session 有效
而应该:
Set 找候选 sid
→ 再查 session:<sid>
→ 最终决定
Lazy Cleanup
读取用户 Session 列表:
SMEMBERS
→ [s1,s2,s3]
GET s1 → nil
GET s2 → exists
GET s3 → exists
发现 s1 已失效:
SREM user_sessions:u1 s1
这是 opportunistic / lazy cleanup。
[!important] 为什么 Lua 不能自动解决 TTL 残留 Lua 能保护“应用主动发起”的多 Key 状态转换,但 TTL 到期是 Redis 自己发生的生命周期事件,不会自动调用你的 Session revoke Lua。
Prediction
[!question] 为以下场景选择主要数据结构,并先写理由
A. 登录失败次数,只做
+1
B. Job 的status/progress/worker_id/heartbeat经常独立更新
C. 用户角色:admin/doctor/researcher,不重复并需快速 membership check
D. 每次鉴权只需要判断整个 Session authority 是否存在
E.session:s1TTL 到期,但user_sessions:u1仍有s1,这意味着什么?
F. 一个对象长得像 JSON,是否就应该用 Hash?
[!success]- Answer / 解析 A → String + INCR。 Counter 不需要 Hash;
INCR已经提供原子数字增加。B → Hash 是一个合理候选。 多个 field 独立读写是 Hash 最典型的访问模式。不过是否应该 Redis-owned 还要先判断 Job 的 ownership;当前 BodySense Job authority 仍是 PostgreSQL。
C → Set。 需要唯一成员和快速 membership,顺序不是核心。
D → String / existence flag 很合适。 当前 BodySense
session:<sid>实际就是 String 值'1'+ TTL;鉴权关心的是 Key 是否存在,而不是读取完整 Session JSON。E → secondary index stale。 Session authority 已不存在,所以 s1 已无效;Set 残留只会影响“列出用户全部 Session”之类的反向查询,不能用 Set membership 判断认证有效。
F → 不一定。 选 String JSON 还是 Hash 要看访问模式:整体读写偏 String;field 独立读写偏 Hash。
Explain-back / L4 Gate
能够解释:
- 为什么“对象”不等于“Hash”;
- 为什么 user_sessions:
是 secondary index; - 为什么 secondary index 可以短时间 stale,但不能成为认证 authority;
- 为什么 TTL 自动过期不能简单靠同一段 Lua 同步清理 Set member。
本课结论
选择 Redis 数据结构,看访问模式和需要的原子操作,而不是看数据“长得像什么”。