跳转到内容
123xiao | 无名键客

《Java Web开发中基于Spring Boot与Redis实现分布式登录态管理的实战指南》

字数: 0 阅读时长: 1 分钟

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 开发习惯依赖外部存储,需考虑序列化和过期
无状态 TokenJWT 或自定义 Token扩展性好强制失效、续期、踢人较复杂

本文聚焦第二类:基于 Redis 存储登录态
原因很直接:它对传统 Spring Boot Web 项目最友好,能保留“登录 -> 校验 -> 续期”的典型思路,同时又天然支持多实例共享。


方案对比与取舍分析

从架构角度看,Redis 方案的价值不只是“能共享”,更在于它在一致性、可维护性和业务控制力之间比较平衡。

适合 Redis 登录态的业务场景

  • 后台管理系统
  • 企业内部系统
  • ToB 平台
  • 需要强制下线、单点登录控制、会话续期的系统
  • 已有传统 Session 思维,暂不想全面切 JWT 的项目

不太适合的场景

  • 完全无状态、超大规模公网 API 网关场景
  • 纯前后端分离且更偏 OAuth2 / JWT 生态
  • 对 Redis 依赖不可接受的极简系统

我建议的取舍原则

如果你的系统满足下面两条,Redis 登录态方案通常很合适:

  1. 业务需要服务端控制会话生命周期
  2. 系统是多实例部署,且存在统一认证需求

核心原理

我们先把整体链路讲清楚,再写代码。

整体登录态模型

核心思想很简单:

  1. 用户登录成功后,服务端生成一个随机 token
  2. 把用户登录信息写入 Redis,key = login:token:{token}
  3. token 返回给客户端,通常放在 Cookie 或 Header
  4. 后续每次请求,服务端从请求中取出 token
  5. 去 Redis 查询对应用户信息,校验成功则认为已登录
  6. 如果启用滑动过期,则每次访问时刷新 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
  • 只刷新了 token key,没有刷新 userId -> token key
  • 单端登录映射先过期,导致踢人逻辑异常
  • 时钟不一致、配置热更新误改 TTL

排查重点:

  • TTL login:token:xxx
  • TTL 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 条:

  1. token 随机生成,客户端携带
  2. Redis 存储 token -> 用户会话
  3. 必要时维护 userId -> token 映射
  4. 拦截器统一校验并做滑动续期
  5. 兼顾安全、性能、容量与可运维性

如果你现在正维护一个 Spring Boot 的传统 Web 项目,我建议你按下面顺序落地:

  • 先实现最小闭环:登录、鉴权、退出
  • 再补单端登录和滑动过期
  • 最后做续期优化、监控指标、安全加固

边界上也要说清楚:
如果你的系统未来会走统一身份认证、OAuth2、OpenID Connect,或者需要高度无状态化,那么 Redis 登录态方案可能只是一个阶段性选择;但如果你当前面临的是多实例部署后 Session 失效需要强制下线要快速稳定落地,它依然是非常实用的一条路。

说得直白一点:先把登录态做稳,再谈更复杂的认证体系。
这是很多 Java Web 项目里,性价比很高的一步。


分享到:

上一篇
《Web3 中级实战:基于 EIP-4337 的账户抽象钱包集成与 Gas 代付方案落地指南》
下一篇
《Java Web 开发中基于 Spring Boot + MyBatis 的后台权限管理系统实战:从 RBAC 设计到接口鉴权落地》