← [ BACK_TO_NOTES ]

BodySense Redis 第 2 课:String、Hash、Set 与 Session 建模

用 BodySense 的 session:<sid> 与 user_sessions:<uid> 学习 String、Hash、Set,并建立按访问模式选择数据结构与手工维护 secondary index 的思维。

#course #growing #backend #redis #bodysense

[!info] related notes

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:s1 TTL 到期,但 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

能够解释:

  1. 为什么“对象”不等于“Hash”;
  2. 为什么 user_sessions: 是 secondary index;
  3. 为什么 secondary index 可以短时间 stale,但不能成为认证 authority;
  4. 为什么 TTL 自动过期不能简单靠同一段 Lua 同步清理 Set member。

本课结论

选择 Redis 数据结构,看访问模式和需要的原子操作,而不是看数据“长得像什么”。

创建于 2026/9/18 更新于 2026/9/18
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本