主题
第十九章:会话管理与登录态
Q59:为什么要把 Session 存到 Redis?分布式 Session 解决什么问题?
答:
单机时代,Session 通常存在应用进程内存里(如 Java 的 HttpSession、各框架的 session 容器),服务重启或用户换一台机器访问,登录态就丢了。一旦服务做多实例部署,内存 Session 立刻暴露出问题。
一、多实例部署下面临的问题
text
负载均衡(Nginx / 网关)────────────────────
│ │
请求 A │ │ 请求 B(同一个用户)
│ ▼ ▼ │
实例 1(内存 Session) 实例 2(内存 Session) │
├─ 用户登录成功,token 存这里 ├─ 没有这个 token │
└──────────────────────────────────────────────────┘
当同一个用户的下一次请求被路由到实例 2,
实例 2 的内存里没有他的 Session,登录态「丢失」问题本质:Session 存在某个实例的进程内存里,别的实例看不到。负载均衡是随机/轮询分发请求,同一个用户的不同请求可能落在不同实例,导致:
- 登录态丢失:用户在 A 实例登录,下一次请求路由到 B 实例,B 不认识他,判断为未登录;
- 无法统一管理:登出、踢下线、强制过期等操作,只能在「存有该 Session 的那个实例」上做,全局难以一致;
- 实例重启丢会话:某个实例重启,它内存里的所有用户登录态全部丢失。
二、Redis 作为「共享 Session 存储」的价值
把 Session 从「进程内存」搬到 Redis,让所有实例都读写同一份 Session,问题就解决了:
text
实例 1 ──┐
├──► Redis(共享 Session)
实例 2 ──┤ ▲
│ └─ 所有实例读写的都是这一份
实例 3 ──┘无论请求被路由到哪个实例,都去 Redis 里查同一个 key,登录态全局一致。这就是分布式 Session:Session 数据集中存储在共享的 Redis 中,实现跨实例、跨服务、跨机器的登录态统一。
三、Redis 存 Session 的优势
| 能力 | 说明 |
|---|---|
| 跨实例共享 | 所有实例读写同一份 Session,登录态一致 |
| 天然有过期时间 | SET ... EX 直接实现会话超时,无需手动清理 |
| 高性能 | 内存读写,远快于把 Session 存数据库 |
| 统一管理 | 登出、踢人、强制下线只需操作 Redis 里的 key,全局生效 |
| 高可用 | Redis 主从/集群保证 Session 服务不单点 |
四、典型实现
登录成功后生成一个随机 token,把用户信息(或用户 id + 关键字段)写入 Redis 并设置过期:
python
import uuid
import json
def create_session(user_id: str, user_info: dict, ttl_seconds: int = 7 * 24 * 3600):
token = uuid.uuid4().hex
redis_client.set(
f"session:{token}",
json.dumps({"user_id": user_id, **user_info}),
ex=ttl_seconds,
)
return token后续请求带 token 来,各实例都去 Redis 查:
python
def get_session(token: str):
raw = redis_client.get(f"session:{token}")
return json.loads(raw) if raw else None
def logout(token: str):
redis_client.delete(f"session:{token}")关于过期续期:滑动过期(用户每次活跃就顺延 TTL)可以在获取 Session 时重新 EXPIRE;也可以在用户每次操作后刷新。这个根据业务「固定超时」还是「滑动超时」决定。
Q60:Session 存在 Redis,内存会爆吗?如何做会话的过期与淘汰?
答:
Session 存 Redis 后,一个现实问题是:用户量一大,Session key 会非常多,如果不做管理,确实可能把 Redis 内存撑爆。解决思路分「正常过期」和「兜底淘汰」两层。
一、用 TTL 保证「正常过期」
每个 Session 都必须设置过期时间,这是第一道防线:
python
# 固定时长:登录后 7 天过期
redis_client.set(f"session:{token}", payload, ex=7 * 24 * 3600)Session 过期方式分两种:
| 方式 | 含义 | 实现 |
|---|---|---|
| 固定过期(绝对) | 登录后固定 N 秒过期,不管用户是否活跃 | 登录时设 EX,之后不再刷新 |
| 滑动过期(相对) | 用户每次活跃都顺延,超时时间是「最后活跃 + N 秒」 | 每次访问 Session 时重新 EXPIRE |
滑动过期更符合「会话」直觉(活跃用户不断续期、不活跃用户自动登出),但每次访问都要多一次 EXPIRE,开销略大。可以在更新 Session 或某个固定频率下顺延,而不是每次请求都刷。
二、用内存淘汰策略做「兜底」
即使每个 key 都有 TTL,极端情况下(活跃用户爆发、恶意刷 Session)仍可能触及内存上限。所以要给 Redis 配置内存上限 + 淘汰策略。
conf
# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru| 淘汰策略 | 含义 | 适用 |
|---|---|---|
allkeys-lru | 全库范围内,淘汰「最近最少使用」的 key | 通用缓存 / Session 首选 |
volatile-lru | 只从「设置了过期时间」的 key 里淘汰 LRU | 希望不丢永久数据的场景 |
allkeys-lfu | 全库范围内,淘汰「最不常用」的 key | 关注访问频率的场景 |
volatile-ttl | 只从设了 TTL 的 key 里淘汰「即将过期」的 | 希望优先清理快过期的 |
noeviction | 达到上限后拒绝写入 | 不适合 Session(会导致登录失败) |
Session 场景的推荐:allkeys-lru 或 volatile-lru。Session 本身就是「可以重建、有 TTL、丢了最多让用户重新登录」的数据,最适合被淘汰。
关键认知:淘汰策略是「兜底」不是「替代 TTL」。TTL 保证 Session 按业务规则正常过期,淘汰策略只在内存在达上限的极端情况下兜底,两者都要配。
三、防内存爆的完整清单
- 每个 key 必设 TTL:不给 Session 设过期是最常见的「内存泄漏」来源;
- 控制 Session 体积:Session 只存必要字段(用户 id、基本信息、权限摘要),不要把大对象塞进去;需要更多详情时按 id 去查数据库/缓存;
- 配置 maxmemory + 淘汰策略:给 Redis 设内存上限和
allkeys-lru; - 监控命中率与内存:关注
INFO memory、INFO stats的 evicted_keys、keyspace_hits/misses; - 定期清理:对业务上已失效但还没过期的 Session(如用户已登出),主动
DEL。
四、小结
- Session 内存管理 = TTL(正常过期)+ maxmemory 淘汰策略(兜底)+ 体积控制 + 监控 四件事;
- 推荐
allkeys-lru/volatile-lru; - Session 是可重建数据,被淘汰的代价只是「用户重新登录」,可接受。
Q61:用户登录态里,「踢人下线」「单点登录」「强制失效」这些能力怎么做?
答:
登录态管理不只是「存 token + 查 token」,还有一类「反查 + 状态控制」的高级需求:把某个用户踢下线、实现单点登录(一个账号只能同时在一个设备登录)、管理员强制让某用户失效。这些都需要在「token → user」之外,维护一份「user → token」的映射。
一、核心问题:只知道 token,不知道「用户有哪些登录态」
普通方案里,Session 的 key 是 token,value 是用户信息。要「踢用户 X 下线」,你只有 user_id,却不知道他此刻的 token 是哪个,无从下手。所以必须多维护一份反向映射:
text
正向(查会话):session:{token} → user_id + 用户信息
反向(查该用户所有会话):user:{user_id}:sessions → { token1, token2, ... }用 Set 或 Hash 记录「每个用户当前有哪些 token」。
二、实现映射关系
python
SESSION_PREFIX = "session:"
USER_SESSIONS_PREFIX = "user_sessions:"
def create_session(user_id: str, user_info: dict, ttl_seconds: int = 7 * 24 * 3600):
token = uuid.uuid4().hex
# 正向:token → 用户信息
redis_client.set(f"{SESSION_PREFIX}{token}", json.dumps({"user_id": user_id, **user_info}), ex=ttl_seconds)
# 反向:用户 → 他的所有 token(value 里同时存 token 对应过期时间,便于清理)
redis_client.zadd(f"{USER_SESSIONS_PREFIX}{user_id}", {token: time.time() + ttl_seconds})
redis_client.expire(f"{USER_SESSIONS_PREFIX}{user_id}", ttl_seconds)
return token反向映射用 ZSet(member=token,score=过期时间戳)而不是 Set,好处是能方便地「按过期时间清理已失效的 token」。
三、踢人下线
python
def kick_user(user_id: str):
# 1. 拿到该用户的所有 token
tokens = redis_client.zrange(f"{USER_SESSIONS_PREFIX}{user_id}", 0, -1)
# 2. 删除所有正向 session + 反向映射
if tokens:
redis_client.delete(*[f"{SESSION_PREFIX}{t}" for t in tokens])
redis_client.delete(f"{USER_SESSIONS_PREFIX}{user_id}")后续该用户带着旧 token 访问时,session:{token} 已不存在,判断为未登录,即「被踢下线」。如果要给用户明确提示「你已被踢下线」,可以额外维护一个「黑名单 token」或「该用户已失效」的标记,查询时区分「token 不存在」和「被强制踢出」。
四、单点登录(一个账号只有一个有效会话)
单点登录的思路是:新登录成功后,把该用户之前的会话全部作废。
python
def login_single_device(user_id: str, user_info: dict, ttl_seconds: int = 7 * 24 * 3600):
# 1. 先把该用户旧会话全部踢掉
kick_user(user_id)
# 2. 再创建新会话
return create_session(user_id, user_info, ttl_seconds)这样同一账号在设备 B 登录后,设备 A 的旧 token 被删,A 访问时判定为未登录,实现了「一个账号同时只有一个有效登录态」。
注意:严格的单点登录要处理「多端并存」的边界(比如要不要允许 Web 和 App 同时在线),这属于业务规则。实现层面无非是「登录时踢旧会话」的程度不同:可以全踢,也可以按设备类型踢。
五、强制失效 / 管理员操作
管理员可能需要让某个用户(或一批用户)立即失效,本质就是「批量踢人」:
python
def force_logout_many(user_ids: list[str]):
for uid in user_ids:
kick_user(uid)如果还要「禁止该用户再次登录」,则是另一回事——需要在用户登录校验时再判断一个「是否被禁用」的标记(如 user:{id}:banned),而不只是删 Session。
六、设计要点汇总
| 能力 | 关键 | 实现 |
|---|---|---|
| 查会话 | token → 用户信息 | session:{token} |
| 反向映射 | user_id → 该用户的所有 token | user_sessions:{user_id}(ZSet) |
| 踢人下线 | 删掉该用户所有 token | 清正向 session + 反向映射 |
| 单点登录 | 新登录先踢旧会话 | 登录时先 kick_user 再建新 session |
| 强制失效/批量踢 | 按 user_id 批量删会话 | 遍历 user_ids 调 kick_user |
| 防止再次登录 | 额外的「禁用」标记 | user:{id}:banned + 登录时校验 |
七、小结
- 登录态高级能力的关键是维护「user → token」反向映射(用 ZSet 存 token + 过期时间);
- 踢人 = 删正向 + 反向;单点登录 = 登录时先踢旧再建新;
- 强制失效/禁用是独立于 Session 的业务标记,不能只靠删 Session 实现。