Redis分布式Session生产环境配置方案

3 阅读18分钟

Redis 分布式 Session 生产环境完整配置方案

该方案适配Spring Boot 2.x/3.x(兼容 Spring Cloud 微服务),一站式实现序列化优化、多端登录管理、强制注销、Redis 宕机本地容灾兜底,同时包含Cookie 安全配置、会话动态刷新、Nacos 配置热更新、防攻击加固等生产必备能力,所有代码可直接复制使用,仅需根据业务微调配置项。

核心设计原则:无侵入业务代码(完全兼容原生HttpSession)、高可用(Redis 宕机不丢登录态)、可管理(精细化会话控制)、高安全(防 XSS/CSRF/Session 固定攻击)。

一、前置核心依赖(Maven)

统一引入依赖,区分 Spring Boot 2.x/3.x(核心差异是 Servlet 包从javax改为jakarta),生产建议锁定版本。

<!-- 核心:Spring Session + Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- Redis客户端(Lettuce,Spring Boot默认,生产推荐) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 本地容灾兜底缓存(Caffeine,性能优于Guava) -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>
<!-- Nacos配置中心(会话规则热更新,可选替换为Apollo) -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 工具类(简化字符串/集合操作) -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.23</version>
</dependency>
<!-- 序列化依赖(Jackson,解决复杂对象序列化) -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
<!-- 可选:Redisson(增强Redis操作,简化分布式锁) -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.3</version>
</dependency>

版本兼容说明

Spring Boot 版本核心包差异适配 JDK
2.x依赖javax.servlet-api8/11
3.x依赖jakarta.servlet-api(Spring 自动引入)17+

二、基础环境配置(application.yml)

包含Redis 高可用配置、Spring Session 核心配置、Cookie 安全配置、Nacos 动态配置,生产优先使用Redis Cluster / 主从 + 哨兵,避免单点故障。

2.1 核心配置(主配置)

spring:
  application:
    name: session-demo # 服务名,与Nacos配置对应
  # 1. Redis配置(生产推荐Cluster,单机/主从可直接替换)
  redis:
    cluster:
      nodes: redis://192.168.1.100:7001,redis://192.168.1.100:7002,redis://192.168.1.100:7003
    password: prod_redis_123 # 生产必须设置密码
    database: 1 # 会话专用库,与业务库隔离(避免键冲突)
    lettuce:
      pool: # 连接池优化,按QPS调整
        max-active: 50 # 最大连接数
        max-idle: 20   # 最大空闲连接
        min-idle: 5    # 最小空闲连接
        max-wait: 3000ms # 连接超时(生产建议3s内)
      shutdown-timeout: 100ms # 关闭超时
    timeout: 2000ms # Redis操作超时(避免阻塞服务)
  # 2. Spring Session核心配置(核心)
  session:
    store-type: redis # 会话存储到Redis(核心)
    timeout: 1800s    # 会话默认有效期30分钟(可通过Nacos热更新)
    redis:
      namespace: spring:session:prod # 键前缀,区分环境(dev/test/prod)
      flush-mode: on_save # 刷新模式:保存时刷新(性能优),可选immediate(实时刷新)
      cleanup-cron: 0 * * * * ? # 过期会话清理定时任务(每分钟)
  # 3. Cookie安全配置(生产必须,防XSS/CSRF)
  web:
    session:
      cookie:
        http-only: true # 禁止前端JS读取Cookie,防XSS
        secure: true    # 仅HTTPS传输(生产必须开启,测试环境可关闭)
        same-site: Lax  # 防CSRF:Lax(允许正常跳转)/Strict(严格禁止跨域)
        path: /         # Cookie全局有效
        max-age: 1800s  # Cookie有效期与会话一致
  # 4. Nacos配置中心(会话规则热更新,可选)
  cloud:
    nacos:
      config:
        server-addr: 192.168.1.101:8848 # Nacos地址
        namespace: prod # 环境隔离
        group: DEFAULT_GROUP
        file-extension: yml
        shared-configs: # 加载全局会话配置文件
          - data-id: spring-session-rules.yml
            group: DEFAULT_GROUP
            refresh: true # 开启热更新(核心)

2.2 Nacos 动态配置(spring-session-rules.yml,可选)

实现会话超时时间、本地兜底缓存规则热更新,无需重启服务:

# 会话动态配置规则
session:
  dynamic:
    timeout: 1800s # 会话有效期(秒),与application.yml一致,可动态修改
    local: # 本地容灾兜底配置
      enable: true # 开启本地兜底
      expire: 1800s # 本地缓存有效期与Redis一致
      max-size: 10000 # 本地缓存最大会话数(按服务实例内存调整)

三、核心配置类(序列化优化 + Redis 模板)

3.1 序列化优化配置(RedisConfig.java)

替换 Spring Session 默认的 JDK 序列化(痛点:体积大、需实现Serializable、兼容差),使用Jackson2JsonRedisSerializer实现 JSON 序列化,优势:体积小、无需序列化接口、跨语言兼容、易调试

同时优化RedisTemplate,解决默认序列化乱码问题。

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
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.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

/**
 * Redis配置 + Spring Session开启 + 序列化优化
 * @EnableRedisHttpSession:开启Redis分布式会话,核心注解
 */
@Configuration
// maxInactiveIntervalInSeconds:默认会话超时,优先级低于配置文件
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class RedisConfig {

    /**
     * 优化RedisTemplate:解决默认JDK序列化乱码、体积大问题
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 1. String序列化器(key/HashKey)
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        // 2. JSON序列化器(value/HashValue):支持泛型和复杂对象
        GenericJackson2JsonRedisSerializer jsonSerializer = getGenericJackson2JsonRedisSerializer();

        // 配置序列化规则
        template.setKeySerializer(stringSerializer);
        template.setValueSerializer(jsonSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }

    /**
     * 自定义GenericJackson2JsonRedisSerializer:支持类型解析,避免反序列化类型丢失
     */
    private GenericJackson2JsonRedisSerializer getGenericJackson2JsonRedisSerializer() {
        ObjectMapper om = new ObjectMapper();
        // 开启所有字段的序列化(包括private)
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 开启类型信息:序列化时记录对象类型,反序列化时自动解析(核心)
        om.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL
        );
        return new GenericJackson2JsonRedisSerializer(om);
    }

    /**
     * 配置Spring Session的序列化器(核心:替换默认JDK序列化)
     * 与RedisTemplate共用序列化规则,保证一致性
     */
    @Bean
    public Jackson2JsonRedisSerializer<Object> springSessionRedisSerializer() {
        return new Jackson2JsonRedisSerializer<>(getGenericJackson2JsonRedisSerializer().getObjectMapper(), Object.class);
    }
}

3.2 动态配置映射类(SessionDynamicProperties.java)

映射 Nacos 中的动态会话规则,结合@RefreshScope实现热更新:

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import java.time.Duration;

/**
 * 会话动态配置(Nacos热更新)
 */
@Data
@Component
@RefreshScope // 开启配置热更新(核心)
@ConfigurationProperties(prefix = "session.dynamic")
public class SessionDynamicProperties {
    private Duration timeout = Duration.ofSeconds(1800); // 会话有效期
    private LocalConfig local; // 本地容灾配置

    @Data
    public static class LocalConfig {
        private boolean enable = true; // 是否开启本地兜底
        private Duration expire = Duration.ofSeconds(1800); // 本地缓存有效期
        private int maxSize = 10000; // 本地缓存最大会话数
    }
}

四、核心能力 1:多端登录管理 + 强制注销

4.1 核心设计思路

  1. 维护用户 - 会话映射:用户登录成功后,将用户IDSessionID绑定,存储到 Redis 的Hash结构(key: user:session:{userId}, field: sessionId, value: 登录端信息);
  2. 多端信息存储:记录登录端类型(PC/Android/IOS/WeChat)、登录 IP、登录时间、设备信息,方便前端展示;
  3. 强制注销:通过用户ID+SessionID从 Redis 中删除指定会话,同时清理用户 - 会话映射;
  4. 自动清理:监听 Spring Session 的SessionDestroyedEvent事件,会话过期 / 销毁时自动清理映射关系,避免脏数据。

4.2 登录端信息 DTO(LoginDeviceInfo.java)

封装多端登录的设备信息,序列化到 Redis:

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 多端登录设备信息
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginDeviceInfo implements Serializable {
    private String sessionId; // 会话ID
    private String deviceType; // 设备类型:PC/Android/IOS/WeChat/Other
    private String loginIp; // 登录IP
    private String deviceInfo; // 设备信息(如浏览器/手机型号)
    private LocalDateTime loginTime; // 登录时间
}

4.3 会话管理核心工具类(SessionManagerUtil.java)

生产通用工具类,封装多端会话查询、强制注销单个 / 所有会话、会话注册、登出等能力,兼容 Redis / 本地兜底双存储。

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.net.IpUtil;
import cn.hutool.core.util.StrUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * 分布式会话管理工具类(核心)
 * 支持:多端登录查询、强制注销、会话注册、容灾兜底
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionManagerUtil {
    // Spring Session原生会话仓库(操作Redis会话)
    private final SessionRepository<? extends Session> sessionRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final SessionDynamicProperties sessionDynamicProperties;

    // Redis键前缀:用户-会话映射
    private static final String USER_SESSION_KEY_PREFIX = "user:session:";
    // Caffeine本地缓存(Redis宕机时兜底)
    private Cache<String, Object> localSessionCache;
    // Redis Hash操作器
    private HashOperations<String, String, LoginDeviceInfo> hashOps;

    // 初始化:项目启动时加载本地缓存和Redis操作器
    {
        initLocalCache();
        this.hashOps = redisTemplate.opsForHash();
    }

    /**
     * 初始化本地容灾缓存(Caffeine)
     */
    private void initLocalCache() {
        SessionDynamicProperties.LocalConfig localConfig = sessionDynamicProperties.getLocal();
        this.localSessionCache = Caffeine.newBuilder()
                .maximumSize(localConfig.getMaxSize()) // 最大缓存数
                .expireAfterWrite(localConfig.getExpire().toSeconds(), TimeUnit.SECONDS) // 过期时间
                .build();
        log.info("本地会话兜底缓存初始化完成,最大容量:{},过期时间:{}s",
                localConfig.getMaxSize(), localConfig.getExpire().toSeconds());
    }

    // ---------------------- 基础方法:获取当前请求/会话/用户ID ----------------------
    /**
     * 获取当前HttpServletRequest
     */
    private HttpServletRequest getCurrentRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attributes == null ? null : attributes.getRequest();
    }

    /**
     * 获取当前会话ID
     */
    public String getCurrentSessionId() {
        HttpServletRequest request = getCurrentRequest();
        return request == null ? null : request.getSession().getId();
    }

    /**
     * 获取当前会话对象
     */
    public HttpSession getCurrentSession() {
        HttpServletRequest request = getCurrentRequest();
        return request == null ? null : request.getSession(false); // false:无会话不创建
    }

    // ---------------------- 核心1:用户登录时注册会话(绑定用户ID与SessionID) ----------------------
    /**
     * 注册用户会话(用户登录成功后调用)
     * @param userId 用户唯一标识(如用户ID/手机号)
     * @param deviceInfo 设备信息
     */
    public void registerUserSession(String userId, LoginDeviceInfo deviceInfo) {
        if (StrUtil.isBlank(userId) || deviceInfo == null) {
            log.warn("用户会话注册失败:参数为空,userId={}", userId);
            return;
        }
        try {
            String redisKey = USER_SESSION_KEY_PREFIX + userId;
            // 1. 存储用户-会话映射到Redis(Hash结构)
            hashOps.put(redisKey, deviceInfo.getSessionId(), deviceInfo);
            // 2. 设置Redis键过期时间(与会话有效期一致,避免内存泄漏)
            redisTemplate.expire(redisKey, sessionDynamicProperties.getTimeout(), TimeUnit.SECONDS);
            // 3. 本地缓存同步(兜底用)
            localSessionCache.put(redisKey + ":" + deviceInfo.getSessionId(), deviceInfo);
            log.info("用户会话注册成功,userId={},sessionId={},设备={}",
                    userId, deviceInfo.getSessionId(), deviceInfo.getDeviceType());
        } catch (Exception e) {
            log.error("用户会话注册失败,userId={}", userId, e);
            // Redis异常时,仅存储到本地缓存(兜底)
            if (sessionDynamicProperties.getLocal().isEnable()) {
                String localKey = USER_SESSION_KEY_PREFIX + userId + ":" + deviceInfo.getSessionId();
                localSessionCache.put(localKey, deviceInfo);
                log.warn("Redis异常,用户会话已写入本地兜底缓存,userId={}", userId);
            }
        }
    }

    // ---------------------- 核心2:查询用户所有多端登录会话 ----------------------
    /**
     * 查询用户所有登录会话(多端展示)
     * @param userId 用户ID
     * @return 多端登录信息列表
     */
    public List<LoginDeviceInfo> listUserSessions(String userId) {
        if (StrUtil.isBlank(userId)) {
            return Collections.emptyList();
        }
        try {
            String redisKey = USER_SESSION_KEY_PREFIX + userId;
            // 1. 从Redis查询
            Map<String, LoginDeviceInfo> sessionMap = hashOps.entries(redisKey);
            if (CollUtil.isNotEmpty(sessionMap)) {
                return new ArrayList<>(sessionMap.values());
            }
        } catch (Exception e) {
            log.error("Redis查询用户会话失败,userId={}", userId, e);
        }
        // 2. Redis异常/无数据,从本地缓存查询(兜底)
        if (sessionDynamicProperties.getLocal().isEnable()) {
            List<LoginDeviceInfo> localList = new ArrayList<>();
            localSessionCache.asMap().forEach((k, v) -> {
                if (k.startsWith(USER_SESSION_KEY_PREFIX + userId + ":")) {
                    localList.add((LoginDeviceInfo) v);
                }
            });
            return localList;
        }
        return Collections.emptyList();
    }

    // ---------------------- 核心3:强制注销(单个会话/踢人) ----------------------
    /**
     * 强制注销指定用户的指定会话(后台踢人)
     * @param userId 用户ID
     * @param sessionId 要注销的会话ID
     * @return true=注销成功,false=失败
     */
    public boolean forceLogout(String userId, String sessionId) {
        if (StrUtil.isBlank(userId) || StrUtil.isBlank(sessionId)) {
            log.warn("强制注销失败:参数为空,userId={},sessionId={}", userId, sessionId);
            return false;
        }
        try {
            // 1. 从Spring Session仓库删除会话(核心:让会话失效)
            sessionRepository.deleteById(sessionId);
            // 2. 从Redis删除用户-会话映射
            String redisKey = USER_SESSION_KEY_PREFIX + userId;
            hashOps.delete(redisKey, sessionId);
            // 3. 清理本地缓存
            localSessionCache.invalidate(redisKey + ":" + sessionId);
            log.info("用户会话强制注销成功,userId={},sessionId={}", userId, sessionId);
            return true;
        } catch (Exception e) {
            log.error("强制注销用户会话失败,userId={},sessionId={}", userId, sessionId, e);
            // Redis异常,仅清理本地缓存
            if (sessionDynamicProperties.getLocal().isEnable()) {
                localSessionCache.invalidate(USER_SESSION_KEY_PREFIX + userId + ":" + sessionId);
            }
            return false;
        }
    }

    // ---------------------- 核心4:强制注销用户所有会话(踢下线所有端) ----------------------
    /**
     * 强制注销用户所有登录会话(所有端踢下线)
     * @param userId 用户ID
     * @return 注销成功的会话数
     */
    public int forceLogoutAll(String userId) {
        if (StrUtil.isBlank(userId)) {
            return 0;
        }
        int successCount = 0;
        try {
            // 1. 查询用户所有会话
            List<LoginDeviceInfo> sessionList = listUserSessions(userId);
            if (CollUtil.isEmpty(sessionList)) {
                return 0;
            }
            // 2. 逐个注销会话
            String redisKey = USER_SESSION_KEY_PREFIX + userId;
            for (LoginDeviceInfo info : sessionList) {
                sessionRepository.deleteById(info.getSessionId());
                hashOps.delete(redisKey, info.getSessionId());
                localSessionCache.invalidate(redisKey + ":" + info.getSessionId());
                successCount++;
            }
            log.info("用户所有会话强制注销成功,userId={},注销数量={}", userId, successCount);
        } catch (Exception e) {
            log.error("强制注销用户所有会话失败,userId={}", userId, e);
        }
        return successCount;
    }

    // ---------------------- 核心5:当前用户登出(销毁自身会话) ----------------------
    /**
     * 当前用户登出(销毁会话+清理映射)
     * @param userId 当前用户ID
     * @return true=登出成功
     */
    public boolean logout(String userId) {
        String sessionId = getCurrentSessionId();
        if (StrUtil.isBlank(sessionId)) {
            log.warn("用户登出失败:当前无有效会话,userId={}", userId);
            return false;
        }
        // 调用强制注销方法
        boolean result = forceLogout(userId, sessionId);
        // 销毁当前HttpSession(兜底)
        HttpSession session = getCurrentSession();
        if (session != null) {
            session.invalidate();
        }
        return result;
    }

    // ---------------------- 辅助方法:刷新会话有效期(用户活跃时调用) ----------------------
    /**
     * 刷新会话有效期(用户操作时自动延长登录态)
     * @param sessionId 会话ID
     */
    public void refreshSessionTimeout(String sessionId) {
        if (StrUtil.isBlank(sessionId)) {
            return;
        }
        try {
            Session session = sessionRepository.findById(sessionId);
            if (session != null) {
                // 重置会话过期时间(核心)
                session.setLastAccessedTime(new Date());
                sessionRepository.save(session);
                log.debug("会话有效期刷新成功,sessionId={}", sessionId);
            }
        } catch (Exception e) {
            log.error("刷新会话有效期失败,sessionId={}", sessionId, e);
        }
    }

    // ---------------------- 辅助方法:获取当前登录用户IP ----------------------
    public String getCurrentLoginIp() {
        HttpServletRequest request = getCurrentRequest();
        return request == null ? "unknown" : IpUtil.getClientIp(request);
    }
}

4.4 会话事件监听器(SessionEventListener.java)

监听 Spring Session 的创建 / 销毁 / 过期事件,自动清理用户 - 会话映射的脏数据(如会话过期后,自动删除 Redis 中的映射关系),避免内存泄漏。

import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.session.Session;
import org.springframework.session.events.SessionCreatedEvent;
import org.springframework.session.events.SessionDestroyedEvent;
import org.springframework.stereotype.Component;
import org.springframework.context.ApplicationListener;

import java.util.Optional;

/**
 * Spring Session事件监听器:清理脏数据、监控会话生命周期
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionEventListener implements
        ApplicationListener<SessionCreatedEvent>,
        ApplicationListener<SessionDestroyedEvent> {

    private final RedisTemplate<String, Object> redisTemplate;
    // 从Session中获取用户ID的key(需与业务一致,如session.setAttribute("loginUserId", userId))
    private static final String SESSION_USER_ID_KEY = "loginUserId";
    private static final String USER_SESSION_KEY_PREFIX = "user:session:";

    /**
     * 监听会话创建事件
     */
    @Override
    public void onApplicationEvent(SessionCreatedEvent event) {
        Session session = event.getSession();
        log.debug("会话创建成功,sessionId={}", session.getId());
        // 可添加会话创建监控埋点
    }

    /**
     * 监听会话销毁/过期事件(核心:清理用户-会话映射)
     */
    @Override
    public void onApplicationEvent(SessionDestroyedEvent event) {
        Session session = event.getSession();
        String sessionId = session.getId();
        log.info("会话销毁/过期,开始清理映射关系,sessionId={}", sessionId);

        try {
            // 1. 从会话中获取用户ID
            String userId = Optional.ofNullable(session.getAttribute(SESSION_USER_ID_KEY))
                    .map(Object::toString)
                    .orElse(null);
            if (StrUtil.isBlank(userId)) {
                log.debug("会话无绑定用户ID,无需清理映射,sessionId={}", sessionId);
                return;
            }

            // 2. 从Redis删除用户-会话映射
            String redisKey = USER_SESSION_KEY_PREFIX + userId;
            HashOperations<String, String, ?> hashOps = redisTemplate.opsForHash();
            hashOps.delete(redisKey, sessionId);
            log.info("用户-会话映射清理成功,userId={},sessionId={}", userId, sessionId);
        } catch (Exception e) {
            log.error("清理用户-会话映射失败,sessionId={}", sessionId, e);
        }
    }
}

五、核心能力 2:Redis 宕机容灾兜底(本地缓存)

5.1 兜底设计原则

  1. 自动降级:Redis 操作抛出异常时,自动切换到Caffeine 本地内存缓存存储会话相关数据(用户 - 会话映射、会话属性);
  2. 临时兜底:本地缓存仅作为Redis 宕机时的临时方案,Redis 恢复后需手动同步(小型项目可忽略,大型项目可添加定时同步任务);
  3. 隔离性:本地缓存为服务实例独享,分布式环境下各实例本地缓存不互通,因此仅适合临时兜底,Redis 恢复后需立即切回;
  4. 配置开关:可通过 Nacos 动态开启 / 关闭本地兜底,生产默认开启。

5.2 兜底实现说明

本模板的容灾兜底已整合到 SessionManagerUtil.java中,核心实现点:

  1. 所有 Redis 操作均包裹try-catch,异常时执行本地缓存操作;
  2. 本地缓存使用 Caffeine,配置最大容量、过期时间(与 Redis 一致),避免内存溢出;
  3. 会话注册、查询、注销均支持Redis 优先,本地兜底的双存储策略;
  4. 可通过session.dynamic.local.enable动态开关控制是否开启兜底。

5.3 进阶:本地会话属性兜底(可选)

若需要对Session.getAttribute/setAttribute做全量兜底(Redis 宕机时本地 Session 也能使用),可自定义HttpSessionWrapper,替换原生 Session,实现双存储。以下是简化版实现:

import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionWrapper;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.RequiredArgsConstructor;

import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.Vector;

/**
 * 自定义Session包装类:实现Redis/本地双存储兜底
 */
@RequiredArgsConstructor
public class LocalCacheSessionWrapper extends HttpSessionWrapper {
    private final HttpSession originalSession;
    private final Cache<String, Object> localSessionCache;
    private final String sessionId;
    private static final String SESSION_ATTR_KEY = "session:attr:";

    @Override
    public Object getAttribute(String name) {
        try {
            // 优先从原生Redis Session获取
            return originalSession.getAttribute(name);
        } catch (Exception e) {
            // Redis异常,从本地缓存获取
            return localSessionCache.getIfPresent(SESSION_ATTR_KEY + sessionId + ":" + name);
        }
    }

    @Override
    public void setAttribute(String name, Object value) {
        try {
            // 优先写入Redis Session
            originalSession.setAttribute(name, value);
        } catch (Exception e) {
            // Redis异常,写入本地缓存
            localSessionCache.put(SESSION_ATTR_KEY + sessionId + ":" + name, value);
        }
    }

    @Override
    public void removeAttribute(String name) {
        try {
            originalSession.removeAttribute(name);
        } catch (Exception e) {
            localSessionCache.invalidate(SESSION_ATTR_KEY + sessionId + ":" + name);
        }
    }

    @Override
    public Enumeration<String> getAttributeNames() {
        try {
            return originalSession.getAttributeNames();
        } catch (Exception e) {
            Map<String, Object> attrMap = new HashMap<>();
            localSessionCache.asMap().forEach((k, v) -> {
                if (k.startsWith(SESSION_ATTR_KEY + sessionId + ":")) {
                    attrMap.put(k.substring((SESSION_ATTR_KEY + sessionId + ":").length()), v);
                }
            });
            return new Vector<>(attrMap.keySet()).elements();
        }
    }
}

使用方式:自定义 Filter,拦截所有请求,替换原生 Session(生产按需使用,简单项目可忽略)。

六、业务层整合示例(生产通用)

6.1 统一返回结果(Result.java)

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 统一返回结果
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
    private int code;
    private String msg;
    private T data;

    // 成功
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "success", data);
    }

    // 失败
    public static <T> Result<T> fail(int code, String msg) {
        return new Result<>(code, msg, null);
    }
}

6.2 登录 / 登出 / 多端管理接口(UserSessionController.java)

import cn.hutool.core.util.StrUtil;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 用户会话管理接口(登录/登出/多端管理/强制注销)
 * 生产可结合Spring Security/Sa-Token做权限控制
 */
@RestController
@RequestMapping("/api/session")
@RequiredArgsConstructor
public class UserSessionController {
    private final SessionManagerUtil sessionManagerUtil;

    // 会话中存储用户ID的key(与SessionEventListener一致)
    private static final String SESSION_USER_ID_KEY = "loginUserId";

    /**
     * 1. 用户登录接口(核心:绑定Session与用户ID,注册多端会话)
     * @param userId 用户ID(生产需从登录参数中解析,此处简化)
     * @param deviceType 设备类型
     * @param deviceInfo 设备信息
     * @param session 原生HttpSession(无侵入)
     */
    @PostMapping("/login")
    public Result<String> login(@RequestParam String userId,
                                @RequestParam String deviceType,
                                @RequestParam(required = false) String deviceInfo,
                                HttpSession session) {
        if (StrUtil.isBlank(userId)) {
            return Result.fail(400, "用户ID不能为空");
        }
        // 1. 绑定用户ID到Session(核心:与事件监听器联动)
        session.setAttribute(SESSION_USER_ID_KEY, userId);
        String sessionId = session.getId();
        // 2. 组装多端登录信息
        LoginDeviceInfo deviceInfo = new LoginDeviceInfo(
                sessionId,
                StrUtil.isBlank(deviceType) ? "Other" : deviceType,
                sessionManagerUtil.getCurrentLoginIp(),
                StrUtil.isBlank(deviceInfo) ? "unknown" : deviceInfo,
                LocalDateTime.now()
        );
        // 3. 注册用户会话(绑定用户ID与SessionID)
        sessionManagerUtil.registerUserSession(userId, deviceInfo);
        // 4. 刷新会话有效期
        sessionManagerUtil.refreshSessionTimeout(sessionId);
        return Result.success("登录成功,sessionId=" + sessionId);
    }

    /**
     * 2. 当前用户登出
     * @param userId 当前用户ID
     */
    @PostMapping("/logout")
    public Result<Boolean> logout(@RequestParam String userId) {
        boolean result = sessionManagerUtil.logout(userId);
        return result ? Result.success(true) : Result.fail(500, "登出失败");
    }

    /**
     * 3. 查询用户多端登录信息
     * @param userId 用户ID
     */
    @GetMapping("/list")
    public Result<List<LoginDeviceInfo>> listUserSessions(@RequestParam String userId) {
        List<LoginDeviceInfo> list = sessionManagerUtil.listUserSessions(userId);
        return Result.success(list);
    }

    /**
     * 4. 强制注销指定会话(后台踢人)
     * @param userId 用户ID
     * @param sessionId 要注销的会话ID
     */
    @PostMapping("/force/logout")
    public Result<Boolean> forceLogout(@RequestParam String userId,
                                       @RequestParam String sessionId) {
        boolean result = sessionManagerUtil.forceLogout(userId, sessionId);
        return result ? Result.success(true) : Result.fail(500, "强制注销失败");
    }

    /**
     * 5. 强制注销用户所有会话(所有端踢下线)
     * @param userId 用户ID
     */
    @PostMapping("/force/logout/all")
    public Result<Integer> forceLogoutAll(@RequestParam String userId) {
        int count = sessionManagerUtil.forceLogoutAll(userId);
        return Result.success(count);
    }

    /**
     * 6. 测试会话属性(无侵入,与原生HttpSession一致)
     */
    @GetMapping("/test/attr")
    public Result<String> testSessionAttr(@RequestParam String key,
                                          @RequestParam String value,
                                          HttpSession session) {
        // 存储属性(自动同步到Redis)
        session.setAttribute(key, value);
        // 获取属性(从Redis读取)
        String val = (String) session.getAttribute(key);
        return Result.success("Session属性设置成功:" + key + "=" + val);
    }
}

七、生产安全加固(必做)

7.1 防止 Session 固定攻击

问题:攻击者获取用户的 SessionID 后,诱导用户登录,从而盗用登录态;解决方案用户登录成功后,重置 SessionID(Spring Security 可自动处理,无 Spring Security 则手动处理)。

手动重置 SessionID 代码(在登录接口中添加):

// 登录成功后重置SessionID(核心:防止Session固定攻击)
public void resetSessionId(HttpSession session) {
    // 复制原有Session属性
    Map<String, Object> attrMap = new HashMap<>();
    Enumeration<String> attrNames = session.getAttributeNames();
    while (attrNames.hasMoreElements()) {
        String name = attrNames.nextElement();
        attrMap.put(name, session.getAttribute(name));
    }
    // 销毁原有Session
    session.invalidate();
    // 创建新Session,恢复属性
    HttpServletRequest request = sessionManagerUtil.getCurrentRequest();
    HttpSession newSession = request.getSession(true);
    attrMap.forEach(newSession::setAttribute);
    log.info("SessionID重置成功,旧sessionId={},新sessionId={}", session.getId(), newSession.getId());
}

7.2 其他安全配置

  1. 开启 HTTPS:生产必须开启 HTTPS,配合 Cookie 的secure: true,防止 Cookie 被明文窃取;
  2. 限制 SessionID 长度:Spring Session 默认生成 32 位 UUID 的 SessionID,足够安全,无需修改;
  3. IP 绑定(可选) :敏感业务(如金融)可在登录时绑定用户 IP,后续请求验证 IP,防止 SessionID 被盗用;
  4. 添加接口权限控制:强制注销 / 多端查询接口需添加管理员 / 用户自身权限控制,避免越权操作;
  5. 限流:对登录 / 登出接口添加 Redis 分布式限流,防止暴力攻击(可复用之前的限流模板)。

八、生产环境监控埋点(可选)

添加Micrometer+Prometheus/Grafana监控,统计核心指标,方便告警和问题排查:

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
 * 会话监控指标工具类
 */
@Component
@RequiredArgsConstructor
public class SessionMonitorUtil {
    private final MeterRegistry meterRegistry;
    private Counter sessionCreateCounter; // 会话创建数
    private Counter sessionDestroyCounter; // 会话销毁数
    private Counter forceLogoutCounter; // 强制注销数
    private Counter redisErrorCounter; // Redis操作异常数

    @PostConstruct
    public void initCounter() {
        sessionCreateCounter = Counter.builder("spring.session.create.count")
                .description("分布式会话创建总数")
                .register(meterRegistry);
        sessionDestroyCounter = Counter.builder("spring.session.destroy.count")
                .description("分布式会话销毁/过期总数")
                .register(meterRegistry);
        forceLogoutCounter = Counter.builder("spring.session.force.logout.count")
                .description("强制注销会话总数")
                .register(meterRegistry);
        redisErrorCounter = Counter.builder("spring.session.redis.error.count")
                .description("会话Redis操作异常总数")
                .register(meterRegistry);
    }

    // 记录指标方法
    public void recordSessionCreate() {sessionCreateCounter.increment();}
    public void recordSessionDestroy() {sessionDestroyCounter.increment();}
    public void recordForceLogout() {forceLogoutCounter.increment();}
    public void recordRedisError() {redisErrorCounter.increment();}
}

使用方式:在SessionEventListenerSessionManagerUtil的对应方法中调用监控方法。

九、生产环境落地关键注意事项

  1. Redis 高可用部署必须使用 Redis Cluster / 主从 + 哨兵,避免单点故障;开启RDB+AOF 混合持久化,保证 Redis 宕机重启后会话数据不丢失;
  2. Redis 键管理:通过spring.session.redis.namespace设置键前缀,区分不同环境 / 服务,避免键冲突;所有自定义 Redis 键均设置过期时间,防止内存泄漏;
  3. 本地兜底局限性:本地 Caffeine 缓存为服务实例独享,分布式环境下各实例数据不互通,因此仅作为 Redis 宕机的临时兜底,Redis 恢复后需立即切回;
  4. 会话超时设置:通过 Nacos 动态配置会话超时时间,根据业务调整(如后台管理系统可设置 2 小时,移动端设置 30 分钟);
  5. 避免热点 key:用户 - 会话映射的 Key 为user:session:{userId},天然分散,无热点问题;若存在超大型用户(如百万级会话),可按用户 ID 哈希分段;
  6. 压测验证:上线前通过 JMeter/Gatling 做压测,验证会话创建 / 销毁 / 查询的性能,确保 Redis 能支撑业务 QPS;
  7. 异常处理:所有 Redis 操作均包裹try-catch,避免 Redis 异常导致服务不可用;
  8. 日志规范:在会话创建 / 销毁 / 强制注销 / Redis 异常时添加分级日志(debug/info/error),方便问题排查;
  9. 版本兼容:Spring Boot 3.x 需注意jakarta.servlet代替javax.servlet,避免包冲突;
  10. 框架整合:若项目使用Spring Security/Sa-Token/Shiro,可直接整合本模板,仅需保证用户 ID 与会话的绑定键一致(即loginUserId)。

十、核心能力总结

本方案一站式实现 Redis 分布式 Session 的生产级能力,核心亮点:

  1. 无侵入:完全兼容原生HttpSession,业务代码无需修改;
  2. 序列化优化:替换 JDK 序列化为 JSON,解决体积大、兼容差问题;
  3. 多端管理:精细化记录用户多端登录信息,支持前端展示;
  4. 强制注销:支持单个 / 所有会话强制注销,实现后台踢人功能;
  5. 容灾兜底:Redis 宕机时自动切换到 Caffeine 本地缓存,保证服务可用;
  6. 动态配置:通过 Nacos 实现会话超时、兜底规则的热更新,无需重启服务;
  7. 高安全:Cookie 防 XSS/CSRF、防止 Session 固定攻击、HTTPS 传输;
  8. 可监控:提供监控埋点,支持 Prometheus/Grafana 可视化和告警。

使用时仅需根据业务调整Redis 配置、Nacos 规则、用户 ID 绑定键,即可快速落地到生产环境。