4. 会话存储的读写路径设计

会话存储的读写路径,本质上是把“用户身份”在 Redis 中做成一条可验证、可续期、可撤销的数据链路。登录时写入,鉴权时读取,续期时更新,登出时删除——四个动作对应四种不同的读写模式,而 String、Hash、JSON 序列化与过期策略的选择,会直接影响这条链路的性能、可维护性和一致性。

一、先确定会话数据的结构边界

一个典型的会话对象至少包含:会话标识(sessionId)、用户标识(userId)、角色或权限标签、登录时间、最后活跃时间、客户端指纹(IP/UA 摘要)、以及可选的业务上下文(如租户 ID、门店 ID)。这些字段中,有些是鉴权必须的“热字段”,有些是审计或风控用的“冷字段”。

如果全部塞进一个 JSON 字符串,读取时虽然一次 GET 就能拿到全部,但每次鉴权都要反序列化整个对象;如果拆成 Hash,可以用 HGET 只取 userId 和 role,用 HGETALL 取全量,字段级读写更灵活。工程上常见的取舍是:鉴权路径只依赖少量字段时用 Hash,会话对象整体较小且需要频繁整体读取时用 JSON String。

二、登录写入:SET 与 HSET 的路径差异

登录成功后,服务端生成 sessionId(通常用 UUID 或加密随机数),然后写入 Redis。以 String + JSON 为例:

SET session:8f3a2c91d4e5 '{"userId":"u1024","role":"admin","loginAt":1710000000,"lastActive":1710000000}' EX 1800

这条命令一次性完成写入和过期设置,原子性好,适合会话对象整体读写的场景。如果改用 Hash:

HSET session:8f3a2c91d4e5 userId u1024 role admin loginAt 1710000000 lastActive 1710000000
EXPIRE session:8f3a2c91d4e5 1800

Hash 的写入需要两条命令,但后续可以只更新 lastActive 而不触碰其他字段。注意:EXPIRE 必须显式设置,Hash 不会因为 HSET 自动续期。如果希望每次鉴权都顺带续期,可以在读取后调用 EXPIRE 刷新 TTL,这就是“滑动过期”的实现方式。

会话读写路径:登录 → 鉴权 → 续期 → 登出 客户端 携带 sessionId 应用服务 鉴权中间件 / 会话管理器 Redis session:<id> 键空间 请求 读写 ① 登录写入 SET session:id JSON EX 1800 或 HSET + EXPIRE 写入成功 → 返回 sessionId TTL 初始值 = 1800s ② 鉴权读取 GET / HGETALL session:id 校验 userId、role、TTL 命中 → 放行;未命中 → 401 读取不改变 TTL ③ 续期更新 EXPIRE session:id 1800 或 HSET lastActive + EXPIRE 滑动过期:每次活跃刷新 绝对过期:不刷新,到点即失效 ④ 登出删除 DEL session:id 或加入黑名单短 TTL 删除后立即失效 黑名单用于防重放 关键工程取舍 String + JSON:一次 GET 取全量,适合会话对象小、整体读取频繁的场景;缺点是字段级更新需反序列化再写回。 Hash:HGET 只取鉴权字段,HSET 只更新 lastActive;字段多、读写粒度细时更省带宽,但需额外 EXPIRE。 过期策略:滑动过期提升体验但延长风险窗口;绝对过期更安全但用户可能被强制重登。可组合:TTL 上限 + 活跃刷新。 登出:DEL 立即失效;若需防重放,可将 sessionId 写入黑名单并设置短 TTL,鉴权时先查黑名单。

三、鉴权读取:命中、未命中与降级

鉴权中间件拿到 sessionId 后,第一步是查 Redis。以 Hash 为例,可以只取必要字段:

HGET session:8f3a2c91d4e5 userId
HGET session:8f3a2c91d4e5 role
TTL session:8f3a2c91d4e5

如果 userId 存在且 TTL 大于 0,说明会话有效;如果返回 nil,说明会话不存在或已过期,应返回 401 并引导重新登录。这里有一个容易忽略的点:TTL 返回 -2 表示键不存在,-1 表示键存在但没有过期时间。如果发现 -1,说明某次写入漏掉了 EXPIRE,需要立即补上或直接判定为异常会话。

在高并发场景下,鉴权路径应尽量避免多次往返。可以用 Lua 脚本把“读取字段 + 校验 TTL + 刷新 TTL”合并为一次原子操作,减少网络 RTT 和竞态。例如:

local uid = redis.call('HGET', KEYS[1], 'userId')
if not uid then return nil end
local ttl = redis.call('TTL', KEYS[1])
if ttl <= 0 then return nil end
redis.call('EXPIRE', KEYS[1], ARGV[1])
return uid

这段脚本在鉴权成功的同时完成续期,适合滑动过期策略。注意 ARGV[1] 是新的 TTL 秒数,示例中可设为 1800。

四、续期策略:滑动过期与绝对过期的组合

滑动过期的含义是:只要用户在 TTL 内有过请求,就刷新过期时间。它的优点是用户体验好,不会在操作过程中突然掉线;缺点是如果 sessionId 泄露,攻击者可以不断刷新 TTL,使会话长期有效。绝对过期则是从登录时刻起算一个硬上限,例如 8 小时,无论中间是否活跃,到点必须重新登录。

工程上更稳妥的做法是两者组合:每次鉴权刷新 TTL,但 TTL 不超过绝对上限。实现方式是在会话对象中记录 loginAt,鉴权时计算 now - loginAt,如果超过 8 小时则拒绝并删除会话;否则将 TTL 设置为 min(1800, 绝对上限剩余秒数)。这样既保留了滑动过期的体验,又限制了最长生命周期。

五、登出:删除与黑名单的取舍

最简单的登出就是 DEL session:<id>,删除后该 sessionId 立即失效。但在分布式环境中,如果客户端在登出前已经发出了一个请求,该请求可能仍在网络中传输,到达服务端时会话已被删除,这属于正常情况。真正需要防范的是“已登出的 sessionId 被重放”——如果攻击者已经拿到了 sessionId,即使服务端删除了键,攻击者仍可能用旧 ID 尝试访问。

此时可以引入黑名单:登出时不仅 DEL 会话键,还将 sessionId 写入 blacklist:<id>,TTL 设置为原会话剩余时间或一个固定短时间(如 300 秒)。鉴权时先查黑名单,命中则直接拒绝。黑名单的代价是多一次读取,但能有效防止重放。如果业务对安全性要求不高,可以只用 DEL,依靠 sessionId 的随机性和短 TTL 来降低风险。

六、序列化格式的工程影响

JSON 序列化的优点是跨语言、可读、易调试;缺点是体积比二进制大,反序列化有 CPU 开销。如果会话对象字段固定且对性能敏感,可以考虑 MessagePack 或 Protobuf,但会增加调试成本。对于大多数会话场景,JSON 已经足够,因为会话对象通常只有几百字节,Redis 的网络和内存开销远大于序列化本身。

需要特别注意的是:不要把整个用户对象塞进会话。会话只应保存鉴权必需的标识和少量上下文,用户详情、权限列表等应通过 userId 回查数据库或缓存。这样既能控制会话体积,也能避免用户信息变更后会话数据不一致。

会话键命名建议统一为 session:{sessionId},黑名单为 blacklist:{sessionId},便于用 SCAN 按前缀统计和清理。TTL 初始值建议 1800 秒,绝对上限 8 小时,黑名单 TTL 300 秒,这些参数应根据业务安全等级调整。

把登录、鉴权、续期、登出四个动作串起来看,核心就是一条“写入—读取—刷新—删除”的键生命周期。String 与 Hash 的选择取决于读写粒度,JSON 与二进制取决于性能与可维护性的平衡,过期策略则决定了安全与体验的边界。设计时先明确会话字段边界,再选结构,最后用 Lua 或组合命令保证原子性,这条链路就能稳定支撑后续的实时状态平台。