Java Web开发中基于Spring Boot与Redis实现分布式登录态管理的实战指南
在单体应用时代,登录态管理往往很简单:用户登录后,把 Session 放在应用服务器内存里就行。但一旦系统走向多实例部署、网关转发、容器化、弹性扩缩容,问题就来了:用户到底登录在哪台机器上?另一台机器怎么知道?
这篇文章我想从“架构设计 + 实战落地”的角度,带你用 Spring Boot + Redis 做一套比较实用的分布式登录态管理方案。它不是最炫的方案,但在很多中后台、业务平台、传统 Java Web 系统里,非常稳、非常常见,也很适合中级开发者真正拿去落地。
背景与问题
为什么单机 Session 在分布式环境下会失效
先看一个常见部署形态:
flowchart LR
U[用户浏览器] --> LB[负载均衡]
LB --> A[应用实例 A]
LB --> B[应用实例 B]
LB --> C[应用实例 C]
如果用户第一次请求打到实例 A,登录信息保存在 A 的 JVM 内存中;第二次请求可能被负载均衡转发到实例 B,这时 B 根本不知道这个用户已经登录过,于是就会出现这些经典问题:
- 登录后刷新又变成未登录
- 某些接口偶发 401
- 多实例部署后登录态不一致
- 应用重启后全部用户被强制下线
常见解决方案有哪些
在实际项目里,分布式登录态管理一般有三类思路:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 粘性会话 | 请求固定转发到同一台机器 | 改造小 | 扩缩容差,故障迁移差 |
| Session 共享 | 使用 Redis / JDBC / Spring Session 存 Session | 保留 Session 开发习惯 | 依赖外部存储,需考虑序列化和过期 |
| 无状态 Token | JWT 或自定义 Token | 扩展性好 | 强制失效、续期、踢人较复杂 |
本文聚焦第二类:基于 Redis 存储登录态。
原因很直接:它对传统 Spring Boot Web 项目最友好,能保留“登录 -> 校验 -> 续期”的典型思路,同时又天然支持多实例共享。
方案对比与取舍分析
从架构角度看,Redis 方案的价值不只是“能共享”,更在于它在一致性、可维护性和业务控制力之间比较平衡。
适合 Redis 登录态的业务场景
- 后台管理系统
- 企业内部系统
- ToB 平台
- 需要强制下线、单点登录控制、会话续期的系统
- 已有传统 Session 思维,暂不想全面切 JWT 的项目
不太适合的场景
- 完全无状态、超大规模公网 API 网关场景
- 纯前后端分离且更偏 OAuth2 / JWT 生态
- 对 Redis 依赖不可接受的极简系统
我建议的取舍原则
如果你的系统满足下面两条,Redis 登录态方案通常很合适:
- 业务需要服务端控制会话生命周期
- 系统是多实例部署,且存在统一认证需求
核心原理
我们先把整体链路讲清楚,再写代码。
整体登录态模型
核心思想很简单:
- 用户登录成功后,服务端生成一个随机
token - 把用户登录信息写入 Redis,
key = login:token:{token} - 把
token返回给客户端,通常放在 Cookie 或 Header - 后续每次请求,服务端从请求中取出
token - 去 Redis 查询对应用户信息,校验成功则认为已登录
- 如果启用滑动过期,则每次访问时刷新 TTL
请求时序图
sequenceDiagram
participant Client as 浏览器/客户端
participant App as Spring Boot应用
participant Redis as Redis
Client->>App: POST /login 用户名+密码
App->>App: 校验账号密码
App->>Redis: SETEX login:token:{token} userInfo ttl
Redis-->>App: OK
App-->>Client: 返回token
Client->>App: GET /api/profile + token
App->>Redis: GET login:token:{token}
Redis-->>App: userInfo
App->>Redis: EXPIRE login:token:{token} ttl(可选)
App-->>Client: 返回业务数据
Redis 中的数据设计
建议不要一上来就把所有信息胡乱塞进去,至少先把 key 设计好。
设计一:token -> 用户会话
login:token:8f3c...a91 => {"userId":1,"username":"admin","loginTime":...}
这是最基础的设计,支持:
- 通过 token 查用户
- 会话过期自动删除
- 多实例共享
设计二:userId -> 当前 token
login:user:1 => 8f3c...a91
如果要支持这些功能,建议加这层映射:
- 单端登录
- 登录挤下线
- 主动踢人
- 查询当前在线 token
登录态生命周期
stateDiagram-v2
[*] --> UnLogin
UnLogin --> LoggedIn: 登录成功
LoggedIn --> Active: 请求访问并续期
Active --> Active: 持续访问
Active --> Expired: 超过TTL
Active --> KickedOut: 管理员踢下线
Expired --> [*]
KickedOut --> [*]
架构设计与容量估算
很多人做登录态只盯着“能不能用”,但上线后最容易出问题的,其实是容量和过期策略。
一个简单容量估算
假设:
- 在线用户数:10 万
- 每个登录态 JSON:约 512B
- Redis 附加元数据和 key 成本:保守按 1KB 估算
- 每用户还维护一个
userId -> token映射:约 100B ~ 200B
粗略估算:
- 主会话:
100000 * 1KB ≈ 100MB - 用户映射:
100000 * 0.2KB ≈ 20MB - 再预留碎片和扩容空间,建议至少
300MB ~ 500MB
如果在线量再高,就要进一步考虑:
- Redis 集群
- 过期热点
- 大量同时续期带来的写放大
- 热 key 与批量失效问题
TTL 怎么定
经验上可以这样分层:
- 管理后台:30 分钟 ~ 2 小时
- 内部平台:1 小时左右
- 高安全系统:15 分钟 + 活跃续期
- 移动端长登录:可考虑 Refresh Token 机制,而不是无限延长单一 token
我个人不建议一开始就把 TTL 设成 7 天、30 天,问题不在“能不能存”,而在于风险窗口过大。
实战代码(可运行)
下面给出一个简化但可运行的 Spring Boot 示例,演示:
- 登录
- Redis 保存登录态
- 拦截器校验 token
- 滑动过期
- 单端登录控制
为了保持文章聚焦,这里采用自定义实现,不引入 Spring Session。这样你更容易看懂底层思路。
项目依赖
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>redis-login-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
</parent>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
配置文件
application.yml
server:
port: 8080
spring:
redis:
host: localhost
port: 6379
timeout: 3000ms
login:
token-header: X-Token
expire-seconds: 1800
single-login: true
登录用户对象
LoginUser.java
package com.example.demo.model;
import java.io.Serializable;
public class LoginUser implements Serializable {
private Long userId;
private String username;
private Long loginTime;
public LoginUser() {
}
public LoginUser(Long userId, String username, Long loginTime) {
this.userId = userId;
this.username = username;
this.loginTime = loginTime;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Long getLoginTime() {
return loginTime;
}
public void setLoginTime(Long loginTime) {
this.loginTime = loginTime;
}
}
Redis 配置
这里显式指定 RedisTemplate 的序列化器,避免默认 JDK 序列化导致 key 可读性差、跨语言不方便。
RedisConfig.java
package com.example.demo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
template.setKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
登录服务实现
LoginService.java
package com.example.demo.service;
import com.example.demo.model.LoginUser;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class LoginService {
private final RedisTemplate<String, Object> redisTemplate;
@Value("${login.expire-seconds}")
private long expireSeconds;
@Value("${login.single-login}")
private boolean singleLogin;
public LoginService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public String login(String username, String password) {
// 示例中直接写死,实际项目请查数据库并校验密码哈希
if (!"admin".equals(username) || !"123456".equals(password)) {
throw new RuntimeException("用户名或密码错误");
}
Long userId = 1L;
String token = UUID.randomUUID().toString().replace("-", "");
if (singleLogin) {
String oldTokenKey = buildUserTokenKey(userId);
Object oldTokenObj = redisTemplate.opsForValue().get(oldTokenKey);
if (oldTokenObj != null) {
String oldToken = String.valueOf(oldTokenObj);
redisTemplate.delete(buildTokenKey(oldToken));
}
}
LoginUser loginUser = new LoginUser(userId, username, System.currentTimeMillis());
redisTemplate.opsForValue().set(
buildTokenKey(token),
loginUser,
expireSeconds,
TimeUnit.SECONDS
);
redisTemplate.opsForValue().set(
buildUserTokenKey(userId),
token,
expireSeconds,
TimeUnit.SECONDS
);
return token;
}
public LoginUser getLoginUser(String token) {
Object obj = redisTemplate.opsForValue().get(buildTokenKey(token));
if (obj == null) {
return null;
}
return (LoginUser) obj;
}
public void refreshToken(String token, Long userId) {
redisTemplate.expire(buildTokenKey(token), expireSeconds, TimeUnit.SECONDS);
redisTemplate.expire(buildUserTokenKey(userId), expireSeconds, TimeUnit.SECONDS);
}
public void logout(String token) {
LoginUser loginUser = getLoginUser(token);
if (loginUser != null) {
redisTemplate.delete(buildUserTokenKey(loginUser.getUserId()));
}
redisTemplate.delete(buildTokenKey(token));
}
private String buildTokenKey(String token) {
return "login:token:" + token;
}
private String buildUserTokenKey(Long userId) {
return "login:user:" + userId;
}
}
登录拦截器
LoginInterceptor.java
package com.example.demo.interceptor;
import com.example.demo.model.LoginUser;
import com.example.demo.service.LoginService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class LoginInterceptor implements HandlerInterceptor {
private final LoginService loginService;
@Value("${login.token-header}")
private String tokenHeader;
public LoginInterceptor(LoginService loginService) {
this.loginService = loginService;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String uri = request.getRequestURI();
if ("/login".equals(uri)) {
return true;
}
String token = request.getHeader(tokenHeader);
if (token == null || token.trim().isEmpty()) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"未登录,缺少token\"}");
return false;
}
LoginUser loginUser = loginService.getLoginUser(token);
if (loginUser == null) {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\":\"登录态已失效\"}");
return false;
}
loginService.refreshToken(token, loginUser.getUserId());
request.setAttribute("loginUser", loginUser);
return true;
}
}
注册拦截器
WebMvcConfig.java
package com.example.demo.config;
import com.example.demo.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final LoginInterceptor loginInterceptor;
public WebMvcConfig(LoginInterceptor loginInterceptor) {
this.loginInterceptor = loginInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/error");
}
}
控制器
AuthController.java
package com.example.demo.controller;
import com.example.demo.model.LoginUser;
import com.example.demo.service.LoginService;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@RestController
public class AuthController {
private final LoginService loginService;
public AuthController(LoginService loginService) {
this.loginService = loginService;
}
@PostMapping("/login")
public Map<String, Object> login(@RequestParam String username,
@RequestParam String password) {
String token = loginService.login(username, password);
Map<String, Object> result = new HashMap<>();
result.put("token", token);
return result;
}
@GetMapping("/profile")
public Map<String, Object> profile(HttpServletRequest request) {
LoginUser loginUser = (LoginUser) request.getAttribute("loginUser");
Map<String, Object> result = new HashMap<>();
result.put("userId", loginUser.getUserId());
result.put("username", loginUser.getUsername());
result.put("loginTime", loginUser.getLoginTime());
return result;
}
@PostMapping("/logout")
public Map<String, Object> logout(@RequestHeader("X-Token") String token) {
loginService.logout(token);
Map<String, Object> result = new HashMap<>();
result.put("message", "退出成功");
return result;
}
}
启动类
DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
运行与验证
1. 启动 Redis
如果你本地有 Docker,可以直接这样启动:
docker run -d --name redis-demo -p 6379:6379 redis:6.2
2. 启动 Spring Boot 项目
mvn spring-boot:run
3. 登录获取 token
curl -X POST "http://localhost:8080/login?username=admin&password=123456"
返回示例:
{
"token": "0c8f4c4b1d2b4f32a9f3c6a7b8d9e0ab"
}
4. 带 token 访问受保护接口
curl -H "X-Token: 0c8f4c4b1d2b4f32a9f3c6a7b8d9e0ab" \
"http://localhost:8080/profile"
5. 查看 Redis 数据
redis-cli
keys login:*
你会看到类似:
login:token:0c8f4c4b1d2b4f32a9f3c6a7b8d9e0ab
login:user:1
核心流程图
flowchart TD
A[用户提交用户名密码] --> B[服务端校验账号密码]
B --> C[生成随机Token]
C --> D[写入Redis: login:token:token]
D --> E[写入Redis: login:user:userId]
E --> F[返回Token给客户端]
F --> G[客户端后续请求携带Token]
G --> H[拦截器读取Token]
H --> I[查询Redis]
I --> J{是否存在}
J -- 是 --> K[放行并刷新TTL]
J -- 否 --> L[返回401]
常见坑与排查
这一节我想重点讲几个非常常见、而且很多人上线后才发现的问题。我自己就踩过不止一次。
1. Redis 里明明有 key,但程序取不到
常见原因:
- key 前缀拼错
- 使用了不同的数据库索引
- 序列化器不一致
- 一个地方用
StringRedisTemplate,另一个地方用RedisTemplate<Object, Object>
排查建议:
redis-cli
select 0
keys login:*
get login:user:1
ttl login:token:xxxx
如果 value 看起来像乱码,通常是 JDK 序列化导致的。
建议统一:
- key 用
StringRedisSerializer - value 用 JSON 序列化
2. 登录后一会儿就失效
常见原因:
- 没有正确设置 TTL
- 只刷新了
tokenkey,没有刷新userId -> tokenkey - 单端登录映射先过期,导致踢人逻辑异常
- 时钟不一致、配置热更新误改 TTL
排查重点:
TTL login:token:xxxTTL login:user:1- 请求命中时是否执行了
expire
3. 多端登录互相挤掉
如果你启用了单端登录,那么同一个用户再次登录时,旧 token 被删除是符合预期的。
但如果你的业务要求:
- PC 端和移动端可以同时在线
- 同一浏览器多标签页共用登录态
- 同一账号允许 N 台设备在线
那就不能简单设计成 userId -> token 单值映射。
更合适的方式是:
userId -> token集合- token 中附带设备类型
- 登录策略按端区分
4. 应用重启后接口偶发反序列化错误
原因通常是:
- 存储对象结构变更
- 类字段调整后老数据反序列化失败
- 使用 JDK 序列化对类版本非常敏感
建议:
- 登录态只存必要字段
- 优先存 JSON 结构
- 给登录态对象做向后兼容设计
5. 高并发下续期导致 Redis 写压力上升
滑动过期虽然好用,但每次请求都 expire 一次,在高 QPS 场景会产生额外写流量。
优化思路:
- 只有剩余 TTL 小于阈值时才续期
- 比如 TTL 剩余不足 1/3 再刷新
- 对只读接口批量限频续期
这个优化在用户量上来后非常有价值。
安全最佳实践
登录态不是“能用就行”,它本质上是安全边界的一部分。
1. token 不要用可预测值
错误示例:
- 用户 ID
- 用户名拼时间戳
- 简单递增字符串
正确做法:
- 使用高随机性 UUID 或安全随机数
- 长度足够,难以猜测
2. 不要把敏感信息直接存进 Redis 登录态
建议只保存这些基础信息:
- userId
- username
- role / permissionVersion
- loginTime
不建议存:
- 明文密码
- 手机号、身份证等敏感隐私
- 过大的权限明细对象
3. 密码校验必须使用哈希
本文示例为了演示方便写死了账号密码,实际项目里必须做到:
- 数据库存储哈希密码
- 使用 BCrypt / PBKDF2 等强哈希方案
- 禁止明文落库
4. token 尽量走 HTTPS
否则 token 被抓包后就等于登录态被盗用。特别是:
- 公网系统
- 管理后台
- 运营系统
这点不能省。
5. 支持主动失效机制
安全事件发生时,需要能快速:
- 踢掉某个用户
- 让某个 token 立即失效
- 批量让高风险会话下线
Redis 方案在这方面天然比纯 JWT 更容易控制。
性能最佳实践
1. 登录态对象尽量轻量
Redis 是高性能,但不是无限免费。会话对象越大:
- 网络开销越大
- 序列化越慢
- 内存占用越高
建议把登录态对象控制在“够用”范围内。
2. 减少无意义续期
前面提过一次,这里再强调一下:
不是每个请求都必须 expire。可以改成“接近过期再续期”。
伪代码如下:
Long ttl = redisTemplate.getExpire("login:token:" + token, TimeUnit.SECONDS);
if (ttl != null && ttl > 0 && ttl < 600) {
redisTemplate.expire("login:token:" + token, 1800, TimeUnit.SECONDS);
}
3. 热点接口注意缓存与登录态解耦
有些接口同时具备:
- 超高访问频率
- 登录校验
- 大量用户在线
这时候要关注 Redis 是否既承担登录态查询,又承担业务缓存,避免互相影响。生产环境中我通常建议:
- 登录态 Redis 与业务缓存 Redis 逻辑隔离
- 至少不同库,最好不同实例
4. 监控关键指标
上线后建议至少看这些指标:
- Redis QPS
- 命中率
- 内存使用率
- key 过期速率
- 网络带宽
- 401 比例
- 登录成功率 / 失败率
很多登录问题不是代码错,而是“Redis 抖了、网络抖了、连接池满了”。
可扩展方向
如果你的系统继续演进,可以在当前方案上逐步增强:
1. 加设备维度
把 key 改成:
login:token:{deviceType}:{token}
login:user:{userId}:{deviceType}
可以支持:
- PC 单登
- App 单登
- 多端并存
2. 加权限版本控制
登录态中记录 permissionVersion,当后台角色权限变更时:
- 比对版本号
- 不一致则强制重新拉取权限或重新登录
3. 接入网关统一校验
当系统拆成多个服务后,登录态校验可前移到:
- API Gateway
- 统一认证中心
- SSO 服务
业务服务只消费用户身份,不重复处理 token 逻辑。
总结
基于 Spring Boot + Redis 实现分布式登录态管理,本质上是在解决一个核心问题:
让多实例应用共享、可控地维护用户会话。
这套方案的关键点可以浓缩成 5 条:
- token 随机生成,客户端携带
- Redis 存储 token -> 用户会话
- 必要时维护 userId -> token 映射
- 拦截器统一校验并做滑动续期
- 兼顾安全、性能、容量与可运维性
如果你现在正维护一个 Spring Boot 的传统 Web 项目,我建议你按下面顺序落地:
- 先实现最小闭环:登录、鉴权、退出
- 再补单端登录和滑动过期
- 最后做续期优化、监控指标、安全加固
边界上也要说清楚:
如果你的系统未来会走统一身份认证、OAuth2、OpenID Connect,或者需要高度无状态化,那么 Redis 登录态方案可能只是一个阶段性选择;但如果你当前面临的是多实例部署后 Session 失效、需要强制下线、要快速稳定落地,它依然是非常实用的一条路。
说得直白一点:先把登录态做稳,再谈更复杂的认证体系。
这是很多 Java Web 项目里,性价比很高的一步。