Skip to content

第十九章:会话管理与登录态 ​

Q59:为什么要把 Session 存到 Redis?分布式 Session 解决什么问题? ​

答:

单机时代,Session 通常存在应用进程内存里(如 Java 的 HttpSession、各框架的 session 容器),服务重启或用户换一台机器访问,登录态就丢了。一旦服务做多实例部署,内存 Session 立刻暴露出问题。

一、多实例部署下面临的问题 ​

text
               负载均衡(Nginx / 网关)────────────────────
               │                      │
请求 A          │                      │          请求 B(同一个用户)
  │             ▼                      ▼             │
实例 1(内存 Session)          实例 2(内存 Session)    │
  ├─ 用户登录成功,token 存这里            ├─ 没有这个 token      │
  └──────────────────────────────────────────────────┘
        当同一个用户的下一次请求被路由到实例 2,
        实例 2 的内存里没有他的 Session,登录态「丢失」

问题本质:Session 存在某个实例的进程内存里,别的实例看不到。负载均衡是随机/轮询分发请求,同一个用户的不同请求可能落在不同实例,导致:

  1. 登录态丢失:用户在 A 实例登录,下一次请求路由到 B 实例,B 不认识他,判断为未登录;
  2. 无法统一管理:登出、踢下线、强制过期等操作,只能在「存有该 Session 的那个实例」上做,全局难以一致;
  3. 实例重启丢会话:某个实例重启,它内存里的所有用户登录态全部丢失。

二、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 按业务规则正常过期,淘汰策略只在内存在达上限的极端情况下兜底,两者都要配。

三、防内存爆的完整清单 ​

  1. 每个 key 必设 TTL:不给 Session 设过期是最常见的「内存泄漏」来源;
  2. 控制 Session 体积:Session 只存必要字段(用户 id、基本信息、权限摘要),不要把大对象塞进去;需要更多详情时按 id 去查数据库/缓存;
  3. 配置 maxmemory + 淘汰策略:给 Redis 设内存上限和 allkeys-lru;
  4. 监控命中率与内存:关注 INFO memory、INFO stats 的 evicted_keys、keyspace_hits/misses;
  5. 定期清理:对业务上已失效但还没过期的 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 → 该用户的所有 tokenuser_sessions:{user_id}(ZSet)
踢人下线删掉该用户所有 token清正向 session + 反向映射
单点登录新登录先踢旧会话登录时先 kick_user 再建新 session
强制失效/批量踢按 user_id 批量删会话遍历 user_ids 调 kick_user
防止再次登录额外的「禁用」标记user:{id}:banned + 登录时校验

七、小结 ​

  • 登录态高级能力的关键是维护「user → token」反向映射(用 ZSet 存 token + 过期时间);
  • 踢人 = 删正向 + 反向;单点登录 = 登录时先踢旧再建新;
  • 强制失效/禁用是独立于 Session 的业务标记,不能只靠删 Session 实现。