找回密码
立即注册
搜索
发回帖 发新帖

4808

积分

0

好友

618

主题
发表于 2 小时前 | 查看: 7| 回复: 0

小 A 把应用从单机部署改成双机部署后,第二天就被用户投诉:明明刚登录,刷新一下就掉线了。一番排查后他才发现,请求被负载均衡打到另一台机器上,而那台机器的内存里根本没有这个用户的 session。多实例部署这道坎,绕不开分布式 Session。

注:此文为上篇

一、引言

小 A 负责的后台系统一直跑得很稳。直到有一天,运营反馈高峰期偶尔卡顿,运维决定加一台机器做负载均衡。小 A 改了改 Nginx 配置,把 upstream 加了一台,测了测接口,一切正常,便放心下班了。

第二天上午,客服群里炸了:

"我登录了三次了,点一下又让我登录!"

"刚进后台,点个菜单就跳回登录页了。"

小 A 一头雾水,自己测试明明没问题。他打开浏览器看 Cookie,sessionid 还在;登录一次,刷新页面——正常;再刷新——正常。可换成手机热点访问,问题就复现了。

折腾了一上午,他才反应过来:自己测试时一直命中同一台机器,而用户的两次请求可能被分别打到两台不同的机器上。第二台机器的内存里没有这个 sessionid,自然认为用户没登录,把他踢回了登录页。

这就是 session 默认存在应用进程内存里、多实例之间不共享带来的经典问题。解决它的路不止一条,最常见、也最实用的,就是把 session 搬到 Redis 里,做成分布式 Session。

本文要聊的,正是小 A 这一路上踩过的坑、想明白的设计,以及最终那套能直接抄走的自研 Redis 分布式 Session 方案——从 Token 生成、双索引存储、登录鉴权、续期策略、多端互踢,到 Lua 原子操作、源码分析、单元测试、高可用降级,一层层拆透。

二、概念定义

2.1 什么是 Session

定义:Session(会话)是服务器端用于在多次请求之间保持用户状态的机制。服务器为每个客户端分配一个唯一标识(通常是 JSESSIONID),并在服务器内存中维护与该标识对应的状态数据。

说人话:HTTP 是无状态协议——服务器处理完一个请求就忘了你是谁。但很多业务(购物车、登录态)需要"记住"用户。Session 的做法是:服务器给你发一张写着编号的小纸条(sessionid),你下次来带上这张纸条,服务器凭编号在自己的小本子(内存)里翻出你的信息。

2.2 什么是分布式 Session

定义:分布式 Session 是指将原本存放在单机内存中的会话数据,转移到独立的共享存储(如 Redis)中,使得多个应用实例都能访问同一份会话数据,从而支持水平扩展。

说人话:单机时服务器把小本子放在自己兜里,别人看不到;分布式时把小本子放在一个大家都够得着的公共柜子(Redis)里,谁来都能翻。

2.3 Session 与 Token 的关系

很多新手会纠结"Session 和 Token 到底什么区别"。其实它们并不是对立的概念,而是从不同角度描述会话管理:

维度 Session Token
角度 服务端状态保持 客户端持有的凭证
状态位置 服务端内存/共享存储 客户端(Cookie/Header)
校验方式 凭 id 查服务端数据 自包含或查服务端
典型实现 Servlet Session、Redis Session JWT、Opaque Token

关键认知:在自研 Redis 分布式 Session 的方案里,"Token" 就是那个 sessionid——一个随机字符串,它的真正状态(用户 id、权限、过期时间)存在 Redis 里。这与 JWT 那种"状态全在 Token 里、服务端不存"的思路恰恰相反。

想深入对比 JWT 的自包含思路,可以看上篇 | JWT 深度解析与实战,结构详解,双Token机制(Access & Refresh) 。

Token 认证架构图:客户端与服务端共享 Redis

2.4 三个核心概念

概念 说明 类比
Token(凭证) 随机字符串,客户端持有,用于标识会话 自助储物柜的手牌
Session 数据 存在 Redis 里的会话状态(uid、权限、设备、IP) 储物柜里存的物品
TTL(过期时间) Redis key 的剩余存活时间,到期自动清除 手牌的有效期

2.5 认证会话 vs 资源访问

会话在系统里有两个层面,不要混淆:

层面 说明 例子
认证会话 用户登录后建立的"登录态" 登录后 7 天内不用重新登录
资源访问会话 单次请求的上下文 本次请求的用户 id、权限、链路追踪

本文主要讲认证会话(登录态管理)。资源访问会话通常用 ThreadLocal 在请求内传递,请求结束即清除。

三、阐述背景:单机 Session 为何失效

3.1 单机 Session 的工作方式

在传统的单机 Servlet 容器(如 Tomcat)里,Session 的工作流程是这样的:

  1. 用户首次请求,Tomcat 在内存里创建一个 HttpSession 对象,生成一个 JSESSIONID
  2. 通过 Set-Cookie: JSESSIONID=xxx 把 id 写到浏览器
  3. 后续请求浏览器自动带上这个 Cookie,Tomcat 凭 id 找到对应 Session
  4. Session 默认 30 分钟不活动则过期

这套机制在单机下完美运行——因为那个 HttpSession 对象就躺在 Tomcat 进程的堆内存里。

Tomcat Session 管理器源码层面:Tomcat 的 StandardManager(或 PersistentManager)维护一个 ConcurrentHashMap<String, Session>,JSESSIONID 为 key,Session 对象为 value。请求进来时 Request.getSession() 凭 JSESSIONID 从这个 Map 取。这个 Map 是进程内的——换一台机器,Map 是空的。想深入 Tomcat 源码解析 可以进一步了解。

3.2 多实例下的断裂

一旦前面挂了负载均衡(Nginx/网关),请求被分散到多台机器:

多实例会话断裂序列图:负载均衡导致登录状态丢失

问题根因:会话状态绑死在某一台机器的进程内存里,与负载均衡的"无差别转发"冲突。

3.3 两条解决路径

业界有两条主流路径:

路径 做法 优缺点
粘性会话(Sticky Session) 负载均衡按 sessionid 哈希,让同一用户始终打到同一台 实现简单;但机器下线会丢失该机所有 session,无法平滑扩缩容,违背无状态初衷
共享存储(Shared Storage) 把 session 搬到 Redis 等外部存储,所有实例共享 任意实例都能处理任意请求,真正水平扩展;引入一次网络 IO 和存储依赖

粘性会话为什么是"治标不治本":

  1. 机器下线即丢会话:某台机器重启或宕机,钉在它身上的所有用户全部掉线
  2. 扩容不均衡:ip_hash 的分布固定,新增机器分不到老用户的流量,要等新用户进来才被分配
  3. 滚动发布受阻:一台台重启时,被重启机器的用户全被踢,无法做到用户无感发布
  4. 流量不均:某些 IP 段流量大,对应机器过载,其他机器空闲

所以生产环境几乎都选共享存储,而 Redis 是最常用的存储。

3.4 为什么是 Redis

会话数据的访问特征是:高频读、低频写、要求低延迟、可接受短暂丢失。Redis 作为纯内存数据库,恰好契合:

  • 单次访问亚毫秒级,对鉴权链路几乎无感
  • 丰富的过期机制(EXPIRE/PERSIST),天然适合 TTL 管理
  • 数据结构丰富,String/Hash/Set 都能用来组织会话数据
  • 高可用方案成熟(主从、哨兵、Cluster),可避免单点

用 MySQL 存 session 行不行?技术上可行,但每次请求都查库的代价太高,高峰期数据库会成为鉴权链路的瓶颈。MySQL 适合做"持久化兜底",不适合做高频会话存储。

Redis 与其他内存存储的对比:

存储 适合做 Session? 原因
Redis ✅ 首选 内存级、TTL、数据结构丰富、高可用成熟
Memcached ⚠️ 可用 内存级但无持久化、无丰富数据结构、无 TTL 自动回收优化
数据库 ❌ 不推荐 磁盘 IO 慢,扛不住每请求读写
本地缓存(Caffeine) ⚠️ 仅做一级缓存 不跨实例,只能配合 Redis 做"二级缓存"

四、类比:自助储物柜

理解了概念,用一个生活类比把整套机制串起来。

把整套系统想象成一家大型水上乐园:

现实世界 系统对应
顾客 用户
前台 登录接口
储物柜 Redis
手牌(写着一个随机编号) Token
柜子里存的物品(钥匙、手环、押金记录) Session 数据
手牌有效期 TTL
多个寄存处 多个应用实例

顾客到任意一个寄存处办手续(登录),前台给他一个手牌(Token),并在对应的储物柜(Redis key)里放上他的物品信息。之后顾客去乐园里任何一个寄存处(任意实例),只要出示手牌,前台都能从同一个储物柜区(Redis)里查到他的信息。

几个关键设计点都对应得上:

  • 手牌编号必须是随机的——如果是"顾客手机号+1"这种可预测编号,别人能猜出来冒领
  • 储物柜有有效期——30 天不来取就清空,腾出柜子给别人
  • 一个顾客可以领多个手牌——分别对应手机端、电脑端,互不干扰
  • 前台能强制作废某个手牌——发现异常登录,直接把柜子清空,手牌立刻失效

五、正反例子

5.1 反例:裸用 Servlet Session 上多机

小 A 最开始的"修复"尝试,是直接在 Nginx 配置了 ip_hash(粘性会话):

upstream backend {
    ip_hash;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
}

上线当天没出问题,第二天运维滚动重启升级 JVM,重启到第一台时,该台所有在线用户的 session 全没了——因为他们被 ip_hash 钉死在这台机器上,机器一重启,内存清空,全部被踢下线。更糟的是,新增的第三台机器几乎分不到流量,因为 ip_hash 的分布是固定的。

问题:粘性会话把"水平扩展"变成了"水平堆叠"——机器数变了,但单机故障的影响范围没变。

5.2 反例:用 MD5(用户id) 当 Token

小 A 见过有人图省事,直接用 MD5(userId) 生成 Token:

// 反例
String token = DigestUtils.md5Hex(String.valueOf(userId));

这套方案的致命问题:

  1. Token 可预测——userId 是自增整数,攻击者算出自己的 Token 后,枚举几个 id 就能拿到别人的 Token
  2. 同一用户永远同一个 Token——无法支持"多端登录""踢旧设备",因为换设备登录后 Token 没变,旧设备的 Token 依然有效
  3. 无法主动失效——Token 是 userId 算出来的,除非改 userId,否则 Token 永远有效

5.3 反例:先写 token 再写 uid 集合(非原子)

小 A 早期的登录代码:

// 反例:两步写,中间崩溃会留下脏数据
redisson.getBucket("login:token:" + token).set(session, TTL);
// 若此处崩溃,token 已写但 uid 集合没维护
redisson.getSet("login:uid:" + uid).add(token);

问题:两步非原子,中间崩溃会导致 token 能用但 uid 集合找不到它——踢人时漏踢,多端管理失准。正确做法用 Lua 脚本保证原子性(见 7.3)。

5.4 正例:随机 Token + Redis 双索引 + Lua 原子写入

正确的做法是:Token 用密码学安全的随机数生成,会话数据存 Redis,并维护 token→数据 和 uid→token列表 两套索引,用 Lua 脚本保证两套索引原子写入:

登录Token生成与Lua原子写入流程图

这套设计支持主动踢人、多端共存、滑动过期、原子一致,是后面要详细展开的方案。

六、核心一:Token 生成策略深入

6.1 Token 的三个要求

Token 必须满足:不可预测、全局唯一、长度适中。

要求 原因
不可预测 Token 等同于密码,可预测就能被枚举冒充
全局唯一 两个用户拿到相同 Token 会串号
长度适中 太短易碰撞,太长占带宽和存储

6.2 方案对比

方案 实现 是否推荐
MD5(userId) 可预测,不安全 ❌
UUID.randomUUID() JDK 内部用 SecureRandom,128 位 ✅ 可用,但含 - 需处理
SecureRandom 生成字节 + Hex 密码学安全随机源 ✅✅ 推荐
雪花算法 id 有序但可预测,且暴露规模 ❌ 不适合做 Token
Math.random() / Random 线性同余,可预测 ❌

6.3 推荐实现:SecureRandom + Hex

import java.security.SecureRandom;

public class TokenGenerator {
    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
    private static final char[] HEX = "0123456789abcdef".toCharArray();

    /**
     * 生成 32 字符的随机 Token(128 位熵)
     */
    public static String generate() {
        byte[] bytes = new byte[16]; // 16 字节 = 128 位
        SECURE_RANDOM.nextBytes(bytes);
        char[] chars = new char[bytes.length * 2];
        for (int i = 0; i < bytes.length; i++) {
            int v = bytes[i] & 0xFF;
            chars[i * 2] = HEX[v >>> 4];
            chars[i * 2 + 1] = HEX[v & 0x0F];
        }
        return new String(chars);
    }
}

为什么不用 Math.random() 或 Random?

Random 用线性同余算法,给定种子就能预测后续序列;Math.random() 内部也是 Random。Token 是会话凭证,一旦可预测,攻击者就能枚举 Token 冒充任意用户。SecureRandom 使用系统熵源(如 /dev/urandom),密码学上不可预测,是生成 Token 的正确选择。

6.4 熵与暴力穷举分析

128 位熵意味着什么:即便攻击者每秒尝试 1 万亿次(10^12),穷举 128 位空间需要 2^128 / 10^12 ≈ 3.4 × 10^26 秒,约 10^19 年——远超宇宙年龄(约 1.4 × 10^10 年)。32 位 hex 字符串的 Token 在安全性上完全够用。

对比 64 位 Token:64 位空间 2^64 ≈ 1.8 × 10^19,每秒 10^12 次尝试需约 585 年。看似够,但分布式攻击 + 多年持续,64 位有理论风险。生产实践通常 128 位起步。

Token 长度与碰撞概率:32 字符 hex(128 位)下,按生日悖论,要约 2^64 ≈ 1.8 × 10^19 个 Token 才有 50% 碰撞概率——任何系统都到不了这个量级,所以全局唯一性有保障。

小知识:UUID v4 也是 122 位随机位,与本文方案安全性相当。但 UUID 含 -(如 550e8400-e29b-...),作为 Token 要么去掉 -,要么直接用 Hex。两者都行,SecureRandom + Hex 更紧凑可控。

七、核心二:Redis 存储结构设计

这是整套方案最关键的设计决策。

7.1 Key 组织

Key 类型 内容 TTL
login:token:{token} Hash uid、device、loginIp、loginTime、权限快照、absoluteExpireAt 会话过期时间(如 7 天)
login:uid:{uid} Set 该用户所有有效 token 无(靠清理逻辑维护)

Redis 数据结构设计:token 到数据和 uid 到 token 集合

7.2 为什么用 Hash 而不是 String(JSON)

维度 Hash String(JSON)
局部更新 HSET 单字段,省 读出整个 JSON 反序列化改完再写回
单字段查询 HGET 省带宽 GET 取整个 JSON
整体读改写 不能原子 可整体 CAS
内存占用 字段少时 ziplist 压缩,省 JSON 字符串原样存

会话场景字段固定(5-6 个),局部更新需求存在(如续期时改 absoluteExpireAt、异常检测时更新 IP),Hash 更合适。

7.3 Lua 脚本保证原子写入

登录时要同时写 token→会话和 uid→token 集合,两步必须原子——否则中间崩溃会留下脏数据。用 Lua 脚本:

private static final String LOGIN_LUA = """
    redis.call('HSET', KEYS[1], 'uid', ARGV[1], 'device', ARGV[2],
               'loginIp', ARGV[3], 'loginTime', ARGV[4],
               'perms', ARGV[5], 'absoluteExpireAt', ARGV[6])
    redis.call('EXPIRE', KEYS[1], ARGV[7])
    redis.call('SADD', KEYS[2], ARGV[8])
    return 1
    """;

public String login(Long uid, String device, String loginIp, Set<String> permissions) {
    String token = TokenGenerator.generate();
    String tokenKey = TOKEN_PREFIX + token;
    String uidKey = UID_PREFIX + uid;
    long ttlSeconds = SESSION_TTL.toSeconds();
    long absoluteExpireAt = Instant.now().plus(ABSOLUTE_TTL).getEpochSecond();

    RScript script = redisson.getScript();
    script.eval(RScript.Mode.READ_WRITE, LOGIN_LUA, RScript.ReturnType.INTEGER,
        List.of(tokenKey, uidKey),
        String.valueOf(uid), device, loginIp,
        String.valueOf(Instant.now().getEpochSecond()),
        String.join(",", permissions),
        String.valueOf(absoluteExpireAt),
        String.valueOf(ttlSeconds),
        token);
    return token;
}

Lua 原子性的原理:Redis 单线程执行命令,Lua 脚本在执行期间不会被其他命令打断——脚本里的多条命令要么全执行,要么都不执行,中间不会插入其他客户端的请求。这是保证"两套索引一致"的关键。

7.4 权限快照要不要存进 Session

这是个有争议的设计点:

方案 优点 缺点
存权限快照 校验不查库,性能好 权限变更不立即生效
不存,每次查库 权限实时 每请求查库,压力大
存快照 + 变更时刷新 性能好且近实时 实现复杂

折中方案:权限存 Session 做"热路径",但权限变更时主动刷新该用户所有会话的权限字段(遍历 uid→token 集合,逐个 HSET 更新 perms)。

7.5 为什么需要 uid → token 集合反向索引

  • 实现"踢人下线":知道 uid 就能找到他所有 token,逐个删除
  • 实现"限制登录设备数":登录前查集合,超限就踢掉最早的
  • 实现"用户视角的会话列表":展示"当前在哪些设备登录"
  • 权限变更时刷新所有会话:遍历集合批量更新

八、核心三:登录核心代码(完整版)

import org.redisson.api.*;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;

@Service
public class LoginService {

    public static final Duration SESSION_TTL = Duration.ofDays(7);      // 滑动过期 7 天
    public static final Duration ABSOLUTE_TTL = Duration.ofDays(30);    // 绝对过期 30 天
    private static final String TOKEN_PREFIX = "login:token:";
    private static final String UID_PREFIX = "login:uid:";
    private static final int MAX_DEVICES = 5;

    private static final String LOGIN_LUA = """
        redis.call('HSET', KEYS[1], 'uid', ARGV[1], 'device', ARGV[2],
                   'loginIp', ARGV[3], 'loginTime', ARGV[4],
                   'perms', ARGV[5], 'absoluteExpireAt', ARGV[6],
                   'token', ARGV[8])
        redis.call('EXPIRE', KEYS[1], ARGV[7])
        redis.call('SADD', KEYS[2], ARGV[8])
        return 1
        """;

    private final RedissonClient redisson;

    public LoginService(RedissonClient redisson) {
        this.redisson = redisson;
    }

    /** 登录:校验账号密码,创建会话,返回 Token */
    public String login(Long uid, String device, String loginIp, Set<String> permissions) {
        String token = TokenGenerator.generate();
        String tokenKey = TOKEN_PREFIX + token;
        String uidKey = UID_PREFIX + uid;

        long absoluteExpireAt = Instant.now().plus(ABSOLUTE_TTL).getEpochSecond();

        redisson.getScript().eval(RScript.Mode.READ_WRITE, LOGIN_LUA,
            RScript.ReturnType.INTEGER,
            List.of(tokenKey, uidKey),
            String.valueOf(uid), device, loginIp,
            String.valueOf(Instant.now().getEpochSecond()),
            String.join(",", permissions),
            String.valueOf(absoluteExpireAt),
            String.valueOf(SESSION_TTL.toSeconds()),
            token);

        // 限制最大设备数,踢掉最早的
        evictIfExceed(uid, MAX_DEVICES);
        return token;
    }

    /** 超过最大设备数时,淘汰最早登录的 Token */
    public void evictIfExceed(Long uid, int max) {
        RSet<String> uidSet = redisson.getSet(UID_PREFIX + uid);
        if (uidSet.size() <= max) return;

        List<SessionInfo> sessions = uidSet.readAll().stream()
            .map(t -> redisson.<SessionInfo>getBucket(TOKEN_PREFIX + t).get())
            .filter(Objects::nonNull)
            .sorted(Comparator.comparing(SessionInfo::getLoginTime))
            .toList();

        int toEvict = sessions.size() - max;
        for (int i = 0; i < toEvict; i++) {
            logout(uid, sessions.get(i).getToken());
        }
    }

    /** 登出:删除指定 Token 的会话 */
    public void logout(Long uid, String token) {
        // 用 Lua 保证两步原子
        redisson.getScript().eval(RScript.Mode.READ_WRITE,
            "redis.call('DEL', KEYS[1]) redis.call('SREM', KEYS[2], ARGV[1]) return 1",
            RScript.ReturnType.INTEGER,
            List.of(TOKEN_PREFIX + token, UID_PREFIX + uid),
            token);
    }

    /** 踢人下线:删除该用户所有会话 */
    public void kickOut(Long uid) {
        RSet<String> uidSet = redisson.getSet(UID_PREFIX + uid);
        Set<String> tokens = uidSet.readAll();
        for (String token : tokens) {
            redisson.getBucket(TOKEN_PREFIX + token).delete();
        }
        uidSet.delete();
    }

    /** 刷新会话(滑动续期) */
    public boolean refresh(String token) {
        RBucket<SessionInfo> bucket = redisson.getBucket(TOKEN_PREFIX + token);
        SessionInfo session = bucket.get();
        if (session == null) return false;

        Instant now = Instant.now();
        if (now.isAfter(session.getAbsoluteExpireAt())) {
            bucket.delete(); // 超绝对过期,强制失效
            return false;
        }
        Duration remaining = Duration.between(now, session.getAbsoluteExpireAt());
        Duration ttl = remaining.compareTo(SESSION_TTL) < 0 ? remaining : SESSION_TTL;
        bucket.expire(ttl);
        return true;
    }
}

未完待续……




上一篇:抓包分析与网络调试面试自测:10道高频考题详解
下一篇:OAuth2四角色、PKCE、JWT与SSO怎么考?10道认证授权面试题全解
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-4 06:10 , Processed in 0.075475 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表