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

《Java Web开发实战:基于Spring Boot与Redis实现高并发登录鉴权与会话管理优化》

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

Java Web开发实战:基于Spring Boot与Redis实现高并发登录鉴权与会话管理优化

在 Java Web 项目里,登录鉴权几乎是“每个系统都要做,但真正做稳不容易”的模块。功能上看似简单:用户登录、发 token、后续请求校验身份;但一旦到了并发量上来、服务实例变多、用户端同时多设备登录、风控和安全要求提高时,问题就开始冒出来了。

这篇文章我不打算只讲“Spring Boot + Redis 能做什么”,而是从架构设计、实现细节、常见坑、性能与安全取舍几个角度,把一个中级开发者在实际项目里会遇到的问题捋清楚,并给出一套可以直接落地的实现。


背景与问题

传统的 Java Web 登录方案,常见有两类:

  1. 基于 HttpSession

    • 会话保存在应用服务器内存
    • 单机时很方便
    • 一旦集群部署,需要 Session 共享或粘性会话
  2. 基于 JWT 自包含 token

    • 服务端无状态
    • 水平扩展很好
    • 但 token 一旦签发,很难主动失效
    • 做“踢人下线、单点登录、动态权限变更”时会很别扭

对于高并发业务,典型问题通常是这些:

  • 登录接口被瞬时打爆,数据库密码校验成为瓶颈
  • 多实例部署下,会话状态不一致
  • 用户退出登录后,旧 token 仍可使用
  • 同账号多端登录策略难以控制
  • Redis 键设计不合理,导致扫描、过期、内存膨胀问题
  • 鉴权逻辑散落在 Controller、Interceptor、Filter 里,维护困难

如果把目标说得更明确一点,我们希望得到的是:

  • 高并发可扩展
  • 支持主动失效
  • 支持多端会话控制
  • 访问校验足够轻量
  • 对 Spring Boot 应用侵入性低
  • 出现 Redis 抖动时具备可降级能力

方案选型与架构取舍

在这个场景下,我更推荐一种折中且实用的方案:

Token 负责身份凭证,Redis 负责会话状态与权限快照。

也就是:

  • 登录成功后生成一个随机 token
  • token 与用户会话信息映射,存入 Redis
  • 请求到达时,从请求头取 token,去 Redis 查询会话
  • Redis 中会话存在且未过期,则认为登录有效
  • 退出登录、踢下线、修改权限时,直接操作 Redis 即可生效

为什么不完全依赖 JWT?

JWT 最大的优点是无状态,但这套方案的问题也很明显:

  • 无法优雅地让已经签发的 token 立即失效
  • 权限变更无法实时生效
  • 黑名单机制复杂且最终还是要引入 Redis

所以在很多“高并发但又需要可控会话”的系统里,纯 JWT 并不是最优解
如果你需要的是“能撤销、可控、多端管理”,Redis 会话模型更务实。

本文采用的架构

  • Spring Boot:承载 Web 接口与拦截器
  • Redis:存储登录态、用户会话、版本号
  • MySQL:存储用户账号与密码哈希
  • BCrypt:密码哈希校验
  • HandlerInterceptor:统一鉴权入口

整体架构图

flowchart LR
    A[客户端] --> B[Spring Boot 应用]
    B --> C[Redis 会话存储]
    B --> D[MySQL 用户数据]

    A -->|登录请求| B
    B -->|校验账号密码| D
    B -->|写入 token 与 session| C
    A -->|携带 token 访问接口| B
    B -->|校验 token 是否存在| C
    C -->|返回 session| B
    B -->|返回业务数据| A

核心原理

1. 登录态设计

推荐将 token 设计为随机字符串,而不是可推导结构。比如:

  • UUID
  • SecureRandom 生成 32 字节后 Base64/Hex 编码

Redis 中可以这样存:

  • login:token:{token} -> 会话详情
  • login:user:{userId} -> 当前活跃 token 集合或主 token

会话详情可以包括:

  • userId
  • username
  • roles
  • loginTime
  • expireAt
  • device
  • tokenVersion

2. 鉴权流程

请求进入服务后:

  1. Authorization 请求头提取 token
  2. 查询 Redis 中 login:token:{token}
  3. 若不存在,返回未登录
  4. 若存在,解析会话并放入 ThreadLocal 或 request attribute
  5. Controller / Service 中获取当前登录用户

3. 为什么 Redis 适合做高并发会话管理

Redis 的几个关键优势非常适合登录态:

  • 内存级访问,QPS 高
  • 支持过期时间,天然适合会话
  • 支持 Hash / Set / String 等结构,便于多端管理
  • 单线程模型避免很多并发锁问题
  • 可通过主从、哨兵、集群扩展可用性与容量

4. 单点登录与多端登录控制

这里有两种常见策略:

策略 A:单端登录

同一用户再次登录时,踢掉旧 token。

适合:

  • 后台管理系统
  • 风控较严格的业务

策略 B:多端并存

按设备维度保存 token,如:

  • Web
  • iOS
  • Android

适合:

  • To C 应用
  • 允许用户多终端在线

我建议一开始就把“登录策略”做成可配置的,不要把它硬编码在 Controller 里,否则后面业务一变更,改动会很痛苦。


登录与访问时序图

sequenceDiagram
    participant Client as 客户端
    participant App as Spring Boot
    participant DB as MySQL
    participant Redis as Redis

    Client->>App: POST /login (username, password)
    App->>DB: 查询用户信息
    DB-->>App: 用户+密码哈希
    App->>App: BCrypt 校验密码
    App->>Redis: SET login:token:{token} session EX 1800
    App->>Redis: SET login:user:{userId} {token} EX 1800
    App-->>Client: 返回 token

    Client->>App: GET /profile + Authorization: Bearer token
    App->>Redis: GET login:token:{token}
    Redis-->>App: session
    App-->>Client: 返回用户信息

数据模型与 Key 设计

高并发场景里,Redis key 设计不是小事。设计不合理,后期会非常难维护。

推荐 Key 设计

login:token:{token}                 -> String/JSON,会话详情
login:user:{userId}:tokens          -> Set,用户所有有效 token
login:user:{userId}:version         -> String,用户 token 版本号
login:blacklist:{token}             -> String,已注销 token(可选)

为什么要有 version?

假设用户修改密码,理论上应该让历史会话全部失效。
这时有两种做法:

  • 遍历删除这个用户所有 token
  • 维护一个 version,旧会话中的 version 不匹配则视为失效

第二种在某些场景更稳,尤其是“批量失效所有旧 token”时很方便。

Session 示例结构

{
  "userId": 1001,
  "username": "alice",
  "roles": ["ADMIN"],
  "device": "WEB",
  "loginTime": 1694188800000,
  "tokenVersion": 3
}

实战代码(可运行)

下面给出一个可运行的简化版示例,重点放在登录鉴权主链路。

1. Maven 依赖

<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-auth-demo</artifactId>
    <version>1.0.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.15</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>org.springframework.security</groupId>
            <artifactId>spring-security-crypto</artifactId>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>
</project>

2. 配置文件

server:
  port: 8080

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 3000

3. 启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RedisAuthDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisAuthDemoApplication.class, args);
    }
}

4. 用户会话模型

package com.example.demo.model;

import java.io.Serializable;
import java.util.List;

public class LoginSession implements Serializable {
    private Long userId;
    private String username;
    private List<String> roles;
    private String device;
    private Long loginTime;
    private Integer tokenVersion;

    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 List<String> getRoles() {
        return roles;
    }

    public void setRoles(List<String> roles) {
        this.roles = roles;
    }

    public String getDevice() {
        return device;
    }

    public void setDevice(String device) {
        this.device = device;
    }

    public Long getLoginTime() {
        return loginTime;
    }

    public void setLoginTime(Long loginTime) {
        this.loginTime = loginTime;
    }

    public Integer getTokenVersion() {
        return tokenVersion;
    }

    public void setTokenVersion(Integer tokenVersion) {
        this.tokenVersion = tokenVersion;
    }
}

5. 当前用户上下文

package com.example.demo.support;

import com.example.demo.model.LoginSession;

public class UserContext {
    private static final ThreadLocal<LoginSession> LOCAL = new ThreadLocal<>();

    public static void set(LoginSession session) {
        LOCAL.set(session);
    }

    public static LoginSession get() {
        return LOCAL.get();
    }

    public static void clear() {
        LOCAL.remove();
    }
}

6. Redis 会话服务

package com.example.demo.service;

import com.example.demo.model.LoginSession;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Set;
import java.util.UUID;

@Service
public class TokenService {

    private static final String TOKEN_KEY_PREFIX = "login:token:";
    private static final String USER_TOKENS_KEY_PREFIX = "login:user:";
    private static final Duration SESSION_TTL = Duration.ofMinutes(30);

    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public TokenService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public String createToken(LoginSession session) {
        String token = UUID.randomUUID().toString().replace("-", "");
        String tokenKey = TOKEN_KEY_PREFIX + token;
        String userTokensKey = USER_TOKENS_KEY_PREFIX + session.getUserId() + ":tokens";
        try {
            String sessionJson = objectMapper.writeValueAsString(session);
            stringRedisTemplate.opsForValue().set(tokenKey, sessionJson, SESSION_TTL);
            stringRedisTemplate.opsForSet().add(userTokensKey, token);
            stringRedisTemplate.expire(userTokensKey, SESSION_TTL);
            return token;
        } catch (JsonProcessingException e) {
            throw new RuntimeException("序列化 session 失败", e);
        }
    }

    public LoginSession getSession(String token) {
        String tokenKey = TOKEN_KEY_PREFIX + token;
        String sessionJson = stringRedisTemplate.opsForValue().get(tokenKey);
        if (sessionJson == null) {
            return null;
        }
        try {
            return objectMapper.readValue(sessionJson, LoginSession.class);
        } catch (Exception e) {
            throw new RuntimeException("反序列化 session 失败", e);
        }
    }

    public void refreshToken(String token) {
        String tokenKey = TOKEN_KEY_PREFIX + token;
        Boolean exists = stringRedisTemplate.hasKey(tokenKey);
        if (Boolean.TRUE.equals(exists)) {
            stringRedisTemplate.expire(tokenKey, SESSION_TTL);
        }
    }

    public void logout(String token) {
        LoginSession session = getSession(token);
        if (session != null) {
            String userTokensKey = USER_TOKENS_KEY_PREFIX + session.getUserId() + ":tokens";
            stringRedisTemplate.opsForSet().remove(userTokensKey, token);
        }
        stringRedisTemplate.delete(TOKEN_KEY_PREFIX + token);
    }

    public void logoutAll(Long userId) {
        String userTokensKey = USER_TOKENS_KEY_PREFIX + userId + ":tokens";
        Set<String> tokens = stringRedisTemplate.opsForSet().members(userTokensKey);
        if (tokens != null) {
            for (String token : tokens) {
                stringRedisTemplate.delete(TOKEN_KEY_PREFIX + token);
            }
        }
        stringRedisTemplate.delete(userTokensKey);
    }
}

7. 登录服务

这里为了让示例可直接运行,我用内存模拟用户数据。实际项目应从 MySQL 查询。

package com.example.demo.service;

import com.example.demo.model.LoginSession;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

@Service
public class AuthService {

    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    private final TokenService tokenService;

    private static final Map<String, MockUser> USERS = new HashMap<>();

    static {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        USERS.put("admin", new MockUser(1L, "admin", encoder.encode("123456"), Arrays.asList("ADMIN")));
        USERS.put("user", new MockUser(2L, "user", encoder.encode("123456"), Arrays.asList("USER")));
    }

    public AuthService(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    public String login(String username, String password, String device) {
        MockUser user = USERS.get(username);
        if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
            throw new RuntimeException("用户名或密码错误");
        }

        LoginSession session = new LoginSession();
        session.setUserId(user.getUserId());
        session.setUsername(user.getUsername());
        session.setRoles(user.getRoles());
        session.setDevice(device == null ? "WEB" : device);
        session.setLoginTime(System.currentTimeMillis());
        session.setTokenVersion(1);

        return tokenService.createToken(session);
    }

    public static class MockUser {
        private Long userId;
        private String username;
        private String passwordHash;
        private java.util.List<String> roles;

        public MockUser(Long userId, String username, String passwordHash, java.util.List<String> roles) {
            this.userId = userId;
            this.username = username;
            this.passwordHash = passwordHash;
            this.roles = roles;
        }

        public Long getUserId() {
            return userId;
        }

        public String getUsername() {
            return username;
        }

        public String getPasswordHash() {
            return passwordHash;
        }

        public java.util.List<String> getRoles() {
            return roles;
        }
    }
}

8. 鉴权拦截器

package com.example.demo.interceptor;

import com.example.demo.model.LoginSession;
import com.example.demo.service.TokenService;
import com.example.demo.support.UserContext;
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 TokenService tokenService;

    public LoginInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String auth = request.getHeader("Authorization");
        if (auth == null || !auth.startsWith("Bearer ")) {
            response.setStatus(401);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"message\":\"未登录\"}");
            return false;
        }

        String token = auth.substring(7);
        LoginSession session = tokenService.getSession(token);
        if (session == null) {
            response.setStatus(401);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"message\":\"登录已过期\"}");
            return false;
        }

        UserContext.set(session);
        tokenService.refreshToken(token);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear();
    }
}

9. Web 配置

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 WebConfig implements WebMvcConfigurer {

    private final LoginInterceptor loginInterceptor;

    public WebConfig(LoginInterceptor loginInterceptor) {
        this.loginInterceptor = loginInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/auth/login");
    }
}

10. 控制器

package com.example.demo.controller;

import com.example.demo.model.LoginSession;
import com.example.demo.service.AuthService;
import com.example.demo.service.TokenService;
import com.example.demo.support.UserContext;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
public class AuthController {

    private final AuthService authService;
    private final TokenService tokenService;

    public AuthController(AuthService authService, TokenService tokenService) {
        this.authService = authService;
        this.tokenService = tokenService;
    }

    @PostMapping("/auth/login")
    public Map<String, Object> login(@RequestBody Map<String, String> body) {
        String token = authService.login(
                body.get("username"),
                body.get("password"),
                body.get("device")
        );
        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        return result;
    }

    @PostMapping("/api/logout")
    public Map<String, Object> logout(@RequestHeader("Authorization") String auth) {
        String token = auth.substring(7);
        tokenService.logout(token);
        Map<String, Object> result = new HashMap<>();
        result.put("message", "退出成功");
        return result;
    }

    @GetMapping("/api/me")
    public LoginSession me() {
        return UserContext.get();
    }
}

11. 测试方式

登录:

curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"123456","device":"WEB"}'

获取个人信息:

curl -X GET 'http://localhost:8080/api/me' \
-H 'Authorization: Bearer 你的token'

退出登录:

curl -X POST 'http://localhost:8080/api/logout' \
-H 'Authorization: Bearer 你的token'

会话状态图

如果你要和产品、测试、运维对齐逻辑,这种状态图非常有用。

stateDiagram-v2
    [*] --> 未登录
    未登录 --> 已登录: 登录成功
    已登录 --> 已登录: 正常访问/续期
    已登录 --> 已过期: TTL 到期
    已登录 --> 已注销: 主动退出
    已登录 --> 已失效: 管理员踢下线/密码修改
    已过期 --> [*]
    已注销 --> [*]
    已失效 --> [*]

容量估算与架构取舍

架构类文章不能只讲“能跑”,还得讲“跑起来后会怎样”。

1. Redis 内存估算

假设:

  • 每个 session JSON 平均 400B
  • Redis 实际存储考虑对象元数据、key 长度、过期信息,粗略按 1KB 估算
  • 在线会话数 100 万

则仅 token session 大约需要:

100万 * 1KB ≈ 1GB

再加上用户 token 集合、版本号、碎片率,实际建议按 2GB ~ 3GB 预留。

2. 续期策略取舍

常见有两种:

固定过期

  • 登录后 30 分钟固定失效
  • 简单
  • 用户活跃时也可能突然掉线

滑动过期

  • 每次访问时刷新 TTL
  • 用户体验好
  • Redis 写压力会增加

本文示例里用了滑动过期。
但在超高 QPS 场景下,我建议做优化:不是每次请求都刷新 TTL,而是剩余 TTL 小于阈值时再刷新。这样能显著降低 Redis 写放大。

3. 单 Redis 还是 Redis Cluster?

  • 小中型后台系统:单 Redis + 哨兵,够用
  • 大型互联网系统:Redis Cluster 更适合横向扩容
  • 如果鉴权是核心链路,建议把登录态 Redis 与业务缓存 Redis 做隔离

这点非常重要。我见过把排行榜缓存、大对象缓存、登录态全塞进一个 Redis 的项目,最后某个热点 key 把整个鉴权链路拖慢,排查起来相当痛苦。


常见坑与排查

这一节我尽量写得实战一点,因为很多问题不是“不会写代码”,而是“线上为什么时好时坏”。

1. token 明明存在,却频繁 401

可能原因:

  • 请求头格式不一致,如少了 Bearer
  • 网关层把 Authorization 丢了
  • Redis key TTL 太短
  • 多服务实例使用了不同的 token 解析逻辑

排查建议:

  1. 打印原始请求头
  2. 在 Redis 中手工查看 key 是否存在
  3. 检查是否有重复登录后旧 token 被覆盖
  4. 核对网关/反向代理是否透传鉴权头

Redis 检查示例:

redis-cli
GET login:token:your_token
TTL login:token:your_token

2. 用户明明退出了,旧 token 还能访问

这通常是以下几类问题:

  • 退出时只删除了本地状态,没有删 Redis
  • 网关层做了用户信息缓存
  • 使用 JWT 但没做服务端黑名单
  • 多副本部署下某节点做了本地缓存且未失效

如果你做了本地缓存,一定要想清楚: 缓存命中带来的性能收益,是否值得牺牲退出实时性。

对后台管理系统来说,我一般不建议把登录态放本地缓存太久。

3. Redis CPU 不高,但延迟突然上升

可能是:

  • 网络抖动
  • 大 key
  • 频繁序列化/反序列化大对象
  • 热点用户频繁刷新 token TTL
  • Redis 持久化抖动(AOF rewrite / RDB save)

排查路径:

  1. 看应用端 Redis RT
  2. 看 Redis INFO、慢查询
  3. 看是否存在大量 KEYSSMEMBERS、大集合操作
  4. 看序列化对象是否过大

4. ThreadLocal 用户信息串号

这个坑在使用线程池、异步任务时很常见。

如果你在拦截器里用 ThreadLocal 保存当前用户,却忘了清理,那么线程复用后可能出现“用户 A 请求读到用户 B 上下文”的严重问题。

所以一定要:

  • afterCompletion 中清理
  • 异步任务不要直接依赖请求线程上下文
  • 需要传递用户信息时,显式传参

这个问题我自己就踩过一次,定位时非常折磨,因为本地很难复现,线上偶发且影响极其诡异。

5. 使用 KEYS login:* 排查线上问题

千万别在生产 Redis 上习惯性用 KEYS
数据量大时,它会阻塞 Redis。

建议改用:

SCAN 0 MATCH login:token:* COUNT 100

安全最佳实践

登录鉴权不是只要“能用”就行,安全边界必须清楚。

1. 密码必须使用强哈希

不要:

  • 明文存储
  • MD5/SHA1 直接哈希

应该:

  • 使用 BCrypt / Argon2 / PBKDF2
  • 为每个密码生成独立 salt

本文示例用了 BCrypt,这是 Spring 生态里非常常见且靠谱的选择。

2. token 必须足够随机

不要把 token 生成为:

  • 用户 ID
  • 时间戳拼接
  • 可预测规则字符串

应该使用高熵随机值。
否则会有被撞库、枚举、伪造的风险。

3. 敏感接口要结合风控

Redis 会话管理解决的是“身份识别”,不是全部安全问题。
对于这些接口,建议额外做二次校验:

  • 修改密码
  • 绑定手机号
  • 提现
  • 删除关键数据

可以引入:

  • 短信验证码
  • 图形验证码
  • 设备指纹
  • 异地登录检测
  • 操作二次确认

4. 防止暴力破解

登录接口高并发时,很容易成为攻击入口。
建议加:

  • IP 维度限流
  • 用户名维度失败次数限制
  • 失败锁定时间窗口
  • 验证码策略动态触发

例如可以在 Redis 记录:

login:fail:ip:{ip}
login:fail:user:{username}

达到阈值后拒绝登录或要求验证码。

5. 全链路 HTTPS

token 一旦被中间人窃取,后果和密码泄漏差别不大。
因此:

  • 外网必须 HTTPS
  • 内网跨服务访问也应考虑 mTLS 或网关隔离
  • 不要在日志中完整打印 token

6. 最小化会话信息

Redis 中保存什么,不只是技术问题,也是安全问题。
建议会话里只保留:

  • 用户 ID
  • 用户名
  • 角色/权限快照
  • 设备信息
  • 版本号

不要存:

  • 明文手机号
  • 明文身份证
  • 密码哈希
  • 过多隐私信息

性能最佳实践

1. 读多写少地设计会话访问

鉴权是高频读操作,所以要尽量让读取简单:

  • key 短小明确
  • session 结构扁平
  • 反序列化成本低

如果权限字段非常大,可以拆分成:

  • 基础 session
  • 权限缓存

不要把几十 KB 的权限树塞进每个 token session 里。

2. 控制续期频率

我比较推荐这种做法:

  • token 默认 TTL 30 分钟
  • 当剩余 TTL 小于 10 分钟时才续期

这样可以减少每次请求都触发 EXPIRE 的写操作。

3. Redis 与数据库职责分离

不要每次鉴权都回查数据库。
正确做法是:

  • 登录时查库
  • 请求时查 Redis
  • 用户信息变更时,按策略刷新 session 或提升 version

4. 使用批量失效代替逐条扫描

如果业务需要“某用户所有会话失效”,优先考虑:

  • 用户 token 集合维护
  • token version 机制

不要通过模糊匹配去扫全库。

5. 监控指标要补齐

至少要监控这些:

  • 登录接口 QPS / RT / 错误率
  • Redis GET / SET RT
  • token 校验失败率
  • 会话总量
  • 过期率
  • 被踢下线次数
  • 登录失败次数

很多团队登录模块出问题,不是代码写得差,而是没有监控,出问题时完全盲飞


可扩展方向

如果你准备把这套方案真正用到生产,我建议继续往这几个方向演进。

1. 接入 Spring Security

本文为了突出核心逻辑,用了 HandlerInterceptor
如果你的项目安全要求更复杂,比如:

  • 细粒度角色控制
  • 方法级权限校验
  • 统一认证授权体系

那么可以把 Redis 会话校验接入 Spring Security Filter 链。

2. 增加设备维度会话管理

例如:

  • 同账号 Web 只允许 1 个在线
  • App 允许 2 台设备在线
  • 后台登录强制挤掉历史会话

这时可以把用户 token 集合进一步细分为:

login:user:{userId}:device:{device}

3. 引入消息通知做会话同步

当管理员踢人、用户改密码、角色变化时,可以通过:

  • Redis Pub/Sub
  • MQ
  • 配置中心事件

通知各服务节点清理本地缓存或刷新权限快照。

4. 灰度降级策略

当 Redis 短暂故障时,是否允许:

  • 核心只读接口短时间放行
  • 已登录用户使用本地短缓存兜底 30 秒
  • 登录接口直接限流保护

这类降级策略一定要提前设计,不要等线上故障时才临时拍脑袋。


总结

基于 Spring Boot 与 Redis 实现高并发登录鉴权,本质上是在解决两个问题:

  1. 如何高效识别用户身份
  2. 如何可控地管理会话生命周期

相比传统 HttpSession,它更适合集群与高并发;
相比纯 JWT,它又更容易做到主动失效、单点登录、多端控制和权限实时生效。

如果你准备在项目里落地,我建议按这个顺序推进:

  1. 先实现 随机 token + Redis session
  2. 再补 统一拦截器鉴权
  3. 接着加 退出登录、踢下线、多端控制
  4. 然后完善 限流、失败次数限制、监控告警
  5. 最后根据规模决定是否接入 Spring Security、Redis Cluster、本地短缓存

边界条件也要说清楚:

  • 如果你是简单单体后台,用户量不大,别一上来就做过度复杂的集群方案
  • 如果你对“主动失效、踢下线、权限实时变更”要求很高,别迷信纯 JWT
  • 如果登录链路已经是系统核心流量入口,Redis 必须做高可用与容量评估,不能只靠“先上再说”

一句话总结这套方案:

让 token 保持轻,真正的会话控制交给 Redis,你会更容易把登录鉴权做稳、做快、也做得可运维。


分享到:

上一篇
《从原型到上线:中级开发者如何构建可落地的 RAG 智能问答系统》
下一篇
《从 0 理解Docker 容器日志治理实战:从 json-file 到集中采集的性能、容量与排障优化:原理、流程与实战》