小 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) 。

2.4 三个核心概念
| 概念 |
说明 |
类比 |
| Token(凭证) |
随机字符串,客户端持有,用于标识会话 |
自助储物柜的手牌 |
| Session 数据 |
存在 Redis 里的会话状态(uid、权限、设备、IP) |
储物柜里存的物品 |
| TTL(过期时间) |
Redis key 的剩余存活时间,到期自动清除 |
手牌的有效期 |
2.5 认证会话 vs 资源访问
会话在系统里有两个层面,不要混淆:
| 层面 |
说明 |
例子 |
| 认证会话 |
用户登录后建立的"登录态" |
登录后 7 天内不用重新登录 |
| 资源访问会话 |
单次请求的上下文 |
本次请求的用户 id、权限、链路追踪 |
本文主要讲认证会话(登录态管理)。资源访问会话通常用 ThreadLocal 在请求内传递,请求结束即清除。
三、阐述背景:单机 Session 为何失效
3.1 单机 Session 的工作方式
在传统的单机 Servlet 容器(如 Tomcat)里,Session 的工作流程是这样的:
- 用户首次请求,Tomcat 在内存里创建一个
HttpSession 对象,生成一个 JSESSIONID
- 通过
Set-Cookie: JSESSIONID=xxx 把 id 写到浏览器
- 后续请求浏览器自动带上这个 Cookie,Tomcat 凭 id 找到对应 Session
- 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 和存储依赖 |
粘性会话为什么是"治标不治本":
- 机器下线即丢会话:某台机器重启或宕机,钉在它身上的所有用户全部掉线
- 扩容不均衡:
ip_hash 的分布固定,新增机器分不到老用户的流量,要等新用户进来才被分配
- 滚动发布受阻:一台台重启时,被重启机器的用户全被踢,无法做到用户无感发布
- 流量不均:某些 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));
这套方案的致命问题:
- Token 可预测——userId 是自增整数,攻击者算出自己的 Token 后,枚举几个 id 就能拿到别人的 Token
- 同一用户永远同一个 Token——无法支持"多端登录""踢旧设备",因为换设备登录后 Token 没变,旧设备的 Token 依然有效
- 无法主动失效——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 生成策略深入
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 |
无(靠清理逻辑维护) |

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;
}
}
未完待续……