Redis 分布式 Session 容灾兜底实战:Redis 宕机不丢登录态,实现本地缓存兜底高可用

4 阅读13分钟

Redis 分布式 Session 容灾兜底实战:Redis 宕机不丢登录态,实现本地缓存兜底高可用

前言

Redis 分布式 Session 的核心依赖 Redis 服务,而 Redis 作为中间件,存在单点故障、集群宕机、网络分区等风险 —— 一旦 Redis 不可用,所有依赖 Session 的请求都会返回未登录,导致系统整体不可用,这在生产环境中是无法接受的。

本文将实现 Redis 分布式 Session 的生产级容灾兜底方案,核心是Redis 优先,本地缓存兜底:正常情况下 Session 数据存储在 Redis 中,实现分布式共享;当 Redis 宕机 / 超时 / 网络异常时,自动切换到本地内存缓存存储 Session 数据,保证登录态不丢失,系统持续可用,Redis 恢复后可平滑切回。

一、容灾兜底的核心设计思路

Redis 分布式 Session 容灾兜底的核心是双存储策略,结合 Redis 的分布式共享能力和本地缓存的高性能、高可用能力,整体设计遵循以下高可用原则

  1. 降级自动触发:无需人工干预,当 Redis 操作抛出异常(宕机、超时、网络异常)时,自动切换到本地缓存,无感知降级;
  2. 本地临时兜底:本地缓存仅作为Redis 宕机时的临时方案,不追求分布式共享,保证单节点内的登录态有效即可;
  3. 数据一致性:正常情况下,Redis 和本地缓存双写,保证数据一致;Redis 异常时,仅写本地缓存,恢复后可手动同步(小型项目可忽略);
  4. 资源隔离:本地缓存设置最大容量和过期时间,避免内存溢出,同时与业务缓存隔离,防止相互影响;
  5. 平滑切回:Redis 恢复后,无需重启服务,自动切回 Redis 存储,本地缓存作为兜底继续保留,直至过期。

核心技术选型

1. 本地缓存:Caffeine

选择Caffeine作为本地缓存,而非 Guava Cache,原因如下:

  • 性能更优:Caffeine 是基于 Java 8 的高性能缓存库,命中率比 Guava Cache 高 10%-20%;
  • 功能丰富:支持基于容量、时间的过期策略,支持异步加载,适配生产环境;
  • Spring 生态兼容:Spring Boot 已内置 Caffeine 依赖,可直接通过配置开启,无需额外引入。
2. 异常判定:Redis 操作异常捕获

通过捕获 Redis 操作的连接异常、超时异常、执行异常,判定 Redis 是否不可用,触发降级:

  • RedisConnectionException:Redis 连接失败(宕机、网络分区);
  • TimeoutException:Redis 操作超时(Redis 压力过大、网络延迟);
  • RedisSystemException:Redis 执行异常(命令错误、数据结构异常)。

整体架构

用户请求 → 接口层 → Session操作 → 优先调用Redis → Redis正常:Redis+本地缓存双写
                                          ↓
                                     Redis异常:自动触发降级 → 仅写本地缓存(Caffeine)
                                          ↓
                                     Redis恢复:自动切回 → Redis+本地缓存双写

二、生产级落地实现

前置条件

  1. 已实现 Redis 分布式 Session + 多端登录管理 + 强制注销;
  2. 已完成序列化优化(Jackson JSON 序列化);
  3. 项目为 Spring Boot 2.x/3.x,引入 Caffeine 依赖(Spring Boot 2.3 + 已内置)。

步骤 1:引入 Caffeine 依赖(若未内置)

<!-- Caffeine本地缓存(Spring Boot 2.3+已内置,无需重复引入) -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

步骤 2:配置动态兜底规则(支持 Nacos 热更新,可选)

创建SessionDynamicProperties.java,配置本地缓存的最大容量、过期时间、兜底开关,支持通过 Nacos/Apollo 热更新,无需重启服务:

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;

/**
 * Session容灾兜底动态配置(支持Nacos热更新)
 */
@Data
@Component
@RefreshScope // 开启配置热更新
@ConfigurationProperties(prefix = "session.disaster")
public class SessionDynamicProperties {
    /** 是否开启本地缓存兜底,默认开启 */
    private boolean enableLocalCache = true;
    /** 本地缓存最大容量,按服务实例内存调整,默认10000个会话 */
    private int localCacheMaxSize = 10000;
    /** 本地缓存过期时间,与Redis Session超时一致,默认1800秒 */
    private Duration localCacheExpire = Duration.ofSeconds(1800);
}

application.yml中配置兜底规则(可通过 Nacos 覆盖,实现热更新):

# Session容灾兜底配置
session:
  disaster:
    enable-local-cache: true
    local-cache-max-size: 10000
    local-cache-expire: 1800s

步骤 3:改造 SessionManagerUtil,实现 Redis + 本地缓存双存储

在原有SessionManagerUtil.java中,集成 Caffeine 本地缓存,实现所有 Redis 操作的异常捕获和本地缓存兜底,核心改造点包括:

  1. 初始化 Caffeine 本地缓存,加载动态配置;
  2. 会话注册、多端查询、强制注销等所有 Redis 操作,添加try-catch异常捕获;
  3. Redis 正常时,Redis + 本地缓存双写 / 双查,保证数据一致;
  4. Redis 异常时,自动切换到本地缓存,仅操作本地缓存,保证登录态有效;
  5. 本地缓存的 Key 设计与 Redis 保持一致,保证逻辑统一。

改造后的完整代码如下,核心新增Caffeine 本地缓存初始化、Redis 异常捕获降级、本地缓存兜底操作

import cn.hutool.core.collection.CollUtil;
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.RedisConnectionFailureException;
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.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 分布式Session管理工具类:多端管理 + 强制注销 + Redis宕机本地缓存兜底(核心改造)
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionManagerUtil {
    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:";
    // 本地缓存Key前缀:与Redis保持一致,便于维护
    private static final String LOCAL_SESSION_KEY_PREFIX = "local:" + USER_SESSION_KEY_PREFIX;
    // Redis默认会话超时时间(与Spring Session配置一致)
    private static final int SESSION_DEFAULT_TIMEOUT = 1800;

    // Redis Hash操作器
    private final HashOperations<String, String, LoginDeviceInfo> hashOps;
    // Caffeine本地缓存(Redis宕机时兜底)
    private Cache<String, Object> localSessionCache;

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

    /**
     * 初始化Caffeine本地缓存(从动态配置中读取参数)
     */
    private void initLocalCache() {
        SessionDynamicProperties properties = sessionDynamicProperties;
        if (!properties.isEnableLocalCache()) {
            log.info("本地会话兜底缓存已禁用");
            return;
        }
        this.localSessionCache = Caffeine.newBuilder()
                .maximumSize(properties.getLocalCacheMaxSize()) // 最大容量
                .expireAfterWrite(properties.getLocalCacheExpire().toSeconds(), TimeUnit.SECONDS) // 过期时间
                .build();
        log.info("本地会话兜底缓存初始化完成,最大容量:{},过期时间:{}s",
                properties.getLocalCacheMaxSize(), properties.getLocalCacheExpire().toSeconds());
    }

    // ---------------------- 核心改造1:会话注册(Redis+本地双写,异常时本地兜底) ----------------------
    public void registerUserSession(String userId, String deviceType, String deviceInfo) {
        if (StrUtil.isBlank(userId) || StrUtil.isBlank(deviceType)) {
            throw new IllegalArgumentException("用户ID和设备类型不能为空");
        }
        HttpServletRequest request = getCurrentRequest();
        String sessionId = request.getSession().getId();
        String loginIp = getCurrentLoginIp();
        LoginDeviceInfo loginDeviceInfo = new LoginDeviceInfo(
                sessionId, deviceType, loginIp,
                StrUtil.isBlank(deviceInfo) ? "unknown" : deviceInfo,
                java.time.LocalDateTime.now()
        );
        String redisKey = USER_SESSION_KEY_PREFIX + userId;
        String localKey = LOCAL_SESSION_KEY_PREFIX + userId;

        try {
            // 1. Redis正常:双写Redis和本地缓存
            hashOps.put(redisKey, sessionId, loginDeviceInfo);
            redisTemplate.expire(redisKey, SESSION_DEFAULT_TIMEOUT, TimeUnit.SECONDS);
            localSessionCache.put(localKey + ":" + sessionId, loginDeviceInfo);
            log.info("用户会话注册成功(Redis+本地双写),userId={}, sessionId={}", userId, sessionId);
        } catch (Exception e) {
            // 2. Redis异常:仅写本地缓存(兜底)
            if (!sessionDynamicProperties.isEnableLocalCache()) {
                log.error("Redis异常且本地缓存未启用,用户会话注册失败,userId={}", userId, e);
                throw new RuntimeException("会话注册失败,请重试", e);
            }
            localSessionCache.put(localKey + ":" + sessionId, loginDeviceInfo);
            log.warn("Redis异常,用户会话已写入本地兜底缓存,userId={}", userId, e);
        }
        refreshSessionTimeout(sessionId);
    }

    // ---------------------- 核心改造2:多端会话查询(Redis优先,本地兜底) ----------------------
    public List<LoginDeviceInfo> listUserSessions(String userId) {
        if (StrUtil.isBlank(userId)) {
            return new ArrayList<>();
        }
        String redisKey = USER_SESSION_KEY_PREFIX + userId;
        String localKeyPrefix = LOCAL_SESSION_KEY_PREFIX + userId + ":";

        try {
            // 1. Redis正常:优先从Redis查询
            Map<String, LoginDeviceInfo> sessionMap = hashOps.entries(redisKey);
            if (CollUtil.isNotEmpty(sessionMap)) {
                return new ArrayList<>(sessionMap.values());
            }
        } catch (Exception e) {
            log.warn("Redis查询用户会话异常,切换到本地缓存查询,userId={}", userId, e);
        }

        // 2. Redis异常/无数据:从本地缓存查询
        if (!sessionDynamicProperties.isEnableLocalCache()) {
            return new ArrayList<>();
        }
        List<LoginDeviceInfo> localSessions = new ArrayList<>();
        localSessionCache.asMap().forEach((key, value) -> {
            if (key.startsWith(localKeyPrefix) && value instanceof LoginDeviceInfo) {
                localSessions.add((LoginDeviceInfo) value);
            }
        });
        return localSessions;
    }

    // ---------------------- 核心改造3:单个端强制注销(Redis优先,本地兜底) ----------------------
    public boolean forceLogoutSingle(String userId, String sessionId) {
        if (StrUtil.isBlank(userId) || StrUtil.isBlank(sessionId)) {
            log.warn("强制注销单个端失败:参数为空,userId={}, sessionId={}", userId, sessionId);
            return false;
        }
        String redisKey = USER_SESSION_KEY_PREFIX + userId;
        String localKey = LOCAL_SESSION_KEY_PREFIX + userId + ":" + sessionId;
        boolean success = false;

        try {
            // 1. Redis正常:销毁Session+清理Redis+清理本地缓存
            sessionRepository.deleteById(sessionId);
            hashOps.delete(redisKey, sessionId);
            success = true;
            log.info("Redis强制注销单个端成功,userId={}, sessionId={}", userId, sessionId);
        } catch (Exception e) {
            log.error("Redis强制注销单个端异常,切换到本地缓存清理,userId={}, sessionId={}", userId, sessionId, e);
        }

        // 2. 清理本地缓存(无论Redis是否正常,保证本地无残留)
        if (sessionDynamicProperties.isEnableLocalCache()) {
            localSessionCache.invalidate(localKey);
        }
        return success;
    }

    // ---------------------- 核心改造4:所有端强制注销(Redis优先,本地兜底) ----------------------
    public int forceLogoutAll(String userId) {
        if (StrUtil.isBlank(userId)) {
            return 0;
        }
        String redisKey = USER_SESSION_KEY_PREFIX + userId;
        String localKeyPrefix = LOCAL_SESSION_KEY_PREFIX + userId + ":";
        int successCount = 0;

        try {
            // 1. Redis正常:销毁所有Session+删除Redis Hash+清理本地缓存
            Map<String, LoginDeviceInfo> sessionMap = hashOps.entries(redisKey);
            if (CollUtil.isNotEmpty(sessionMap)) {
                for (String sessionId : sessionMap.keySet()) {
                    sessionRepository.deleteById(sessionId);
                    successCount++;
                }
                redisTemplate.delete(redisKey);
                log.info("Redis强制注销所有端成功,userId={}, 注销数量={}", userId, successCount);
            }
        } catch (Exception e) {
            log.error("Redis强制注销所有端异常,切换到本地缓存清理,userId={}", userId, e);
        }

        // 2. 清理本地缓存中该用户的所有会话
        if (sessionDynamicProperties.isEnableLocalCache()) {
            localSessionCache.asMap().keySet().removeIf(key -> key.startsWith(localKeyPrefix));
        }
        return successCount;
    }

    // ---------------------- 核心改造5:当前用户登出(Redis+本地双清理) ----------------------
    public boolean logoutCurrent() {
        try {
            HttpServletRequest request = getCurrentRequest();
            HttpSession session = request.getSession(false);
            if (session == null) {
                log.warn("当前无有效会话,无需登出");
                return false;
            }
            String sessionId = session.getId();
            String userId = (String) session.getAttribute("loginUserId");
            if (StrUtil.isBlank(userId)) {
                log.warn("当前会话未绑定用户ID,无法登出,sessionId={}", sessionId);
                return false;
            }

            // 1. 调用单个端强制注销(Redis+本地双清理)
            boolean result = forceLogoutSingle(userId, sessionId);
            // 2. 销毁当前HttpSession(双重兜底)
            session.invalidate();
            return result;
        } catch (Exception e) {
            log.error("当前用户登出失败", e);
            return false;
        }
    }

    // ---------------------- 核心改造6:会话超时刷新(Redis优先,本地兜底) ----------------------
    public void refreshSessionTimeout(String sessionId) {
        if (StrUtil.isBlank(sessionId)) {
            return;
        }
        try {
            // 1. Redis正常:刷新Redis中Session的过期时间
            Session session = sessionRepository.findById(sessionId);
            if (session != null) {
                session.setLastAccessedTime(new java.util.Date());
                sessionRepository.save(session);
                // 刷新用户-会话映射的过期时间
                String userId = getUserIdBySessionId(session);
                if (StrUtil.isNotBlank(userId)) {
                    redisTemplate.expire(USER_SESSION_KEY_PREFIX + userId, SESSION_DEFAULT_TIMEOUT, TimeUnit.SECONDS);
                }
            }
        } catch (Exception e) {
            log.warn("Redis刷新会话超时异常,userId={}, sessionId={}", getUserIdBySessionId(sessionRepository.findById(sessionId)), sessionId, e);
        }
        // 2. 刷新本地缓存过期时间
        if (sessionDynamicProperties.isEnableLocalCache()) {
            String userId = getUserIdBySessionId(sessionRepository.findById(sessionId));
            if (StrUtil.isNotBlank(userId)) {
                String localKey = LOCAL_SESSION_KEY_PREFIX + userId + ":" + sessionId;
                LoginDeviceInfo info = (LoginDeviceInfo) localSessionCache.getIfPresent(localKey);
                if (info != null) {
                    localSessionCache.put(localKey, info); // 重新put刷新过期时间
                }
            }
        }
    }

    // ---------------------- 辅助方法:获取当前登录IP、用户ID等 ----------------------
    private String getCurrentLoginIp() {
        HttpServletRequest request = getCurrentRequest();
        return request == null ? "unknown" : cn.hutool.core.net.IpUtil.getClientIp(request);
    }

    private HttpServletRequest getCurrentRequest() {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attributes == null ? null : attributes.getRequest();
    }

    private String getUserIdBySessionId(Session session) {
        return session == null ? null : (String) session.getAttribute("loginUserId");
    }

    // 其他原有方法(如forceLogoutSingle、forceLogoutAll等)已整合上述改造,此处省略
}

步骤 4:自定义 HttpSessionWrapper,实现 Session 属性全量兜底(可选)

若需要对HttpSession.getAttribute/setAttribute做全量兜底(Redis 宕机时本地 Session 也能正常使用属性),可自定义LocalCacheSessionWrapper,替换原生 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包装类:实现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_PREFIX = "local:session:attr:";

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

    @Override
    public void setAttribute(String name, Object value) {
        try {
            // 1. Redis正常:双写原生Session和本地缓存
            originalSession.setAttribute(name, value);
            localSessionCache.put(SESSION_ATTR_KEY_PREFIX + sessionId + ":" + name, value);
        } catch (Exception e) {
            // 2. Redis异常:仅写本地缓存
            localSessionCache.put(SESSION_ATTR_KEY_PREFIX + sessionId + ":" + name, value);
        }
    }

    @Override
    public void removeAttribute(String name) {
        try {
            originalSession.removeAttribute(name);
            localSessionCache.invalidate(SESSION_ATTR_KEY_PREFIX + sessionId + ":" + name);
        } catch (Exception e) {
            localSessionCache.invalidate(SESSION_ATTR_KEY_PREFIX + 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_PREFIX + sessionId + ":")) {
                    attrMap.put(k.substring((SESSION_ATTR_KEY_PREFIX + sessionId + ":").length()), v);
                }
            });
            return new Vector<>(attrMap.keySet()).elements();
        }
    }
}

步骤 5:配置 Filter 替换原生 Session(启用全量兜底时)

创建LocalCacheSessionFilter.java,拦截所有请求,用自定义LocalCacheSessionWrapper替换原生 Session:

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import com.github.benmanes.caffeine.cache.Cache;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class LocalCacheSessionFilter implements Filter {
    private final Cache<String, Object> localSessionCache;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        // 替换原生Session为自定义包装类
        HttpServletRequest wrappedRequest = new HttpServletRequestWrapper(httpRequest) {
            @Override
            public HttpSession getSession(boolean create) {
                HttpSession originalSession = super.getSession(create);
                return originalSession == null ? null : new LocalCacheSessionWrapper(originalSession, localSessionCache, originalSession.getId());
            }

            @Override
            public HttpSession getSession() {
                return getSession(true);
            }
        };
        chain.doFilter(wrappedRequest, response);
    }
}

四、容灾兜底有效性验证

验证 1:Redis 宕机时登录态正常

  1. 正常情况:启动 Redis,用户登录后,Redis 和本地缓存均存储会话信息;
  2. 模拟 Redis 宕机:关闭 Redis 服务;
  3. 验证登录态:用户发起请求,系统自动切换到本地缓存,仍能正常获取用户信息,登录态不丢失;
  4. 验证强制注销:调用强制注销接口,本地缓存中的会话信息被清理,登录态失效。

验证 2:Redis 恢复后平滑切回

  1. 重启 Redis:Redis 服务恢复;
  2. 用户操作:用户发起新的登录 / 查询请求,系统自动切回 Redis 存储,本地缓存作为兜底继续保留;
  3. 数据一致性:新会话数据写入 Redis,旧本地缓存数据在过期后自动清理,无脏数据残留。

五、容灾兜底的边界与解决方案

1. 本地缓存的分布式局限性

问题:本地缓存为服务实例独享,分布式环境下各实例的本地缓存数据不互通,可能导致 “用户在实例 A 登录,实例 B 无法查询到会话信息”;解决方案

  • 本地缓存仅作为临时兜底,Redis 恢复后立即切回,避免长期依赖本地缓存;
  • 大型项目可添加Redis 恢复后的数据同步任务:通过定时任务将本地缓存中的会话信息同步到 Redis,保证分布式一致性。

2. 本地缓存内存溢出风险

问题:本地缓存无限制存储会话信息,可能导致服务实例内存溢出;解决方案

  • 通过动态配置设置最大容量(如 10000 个会话),使用 Caffeine 的 LFU 回收策略自动淘汰冷数据;
  • 设置与 Redis 一致的过期时间,避免会话信息长期占用内存;
  • 监控本地缓存的使用率,超过阈值时触发告警,手动扩容或调整配置。

3. 数据同步问题

问题:Redis 宕机期间,本地缓存存储的会话信息在 Redis 恢复后未同步,导致分布式环境下数据不一致;解决方案

  • 小型项目:Redis 恢复后,无需同步本地缓存数据,等待本地缓存数据过期后自动清理,新会话数据正常写入 Redis;
  • 大型项目:编写数据同步工具类,Redis 恢复后,通过定时任务将本地缓存中的会话信息批量写入 Redis,保证数据一致性。

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

  1. Redis 高可用优先:容灾兜底是最后一道防线,生产环境仍需优先保证 Redis 的高可用(Cluster / 主从 + 哨兵、持久化开启);
  2. 本地缓存动态开关:通过 Nacos/Apollo 配置动态开启 / 关闭本地缓存,便于紧急情况下人工干预;
  3. 监控告警:添加 Redis 状态监控和本地缓存使用率监控,Redis 宕机时触发告警,本地缓存使用率超过阈值时触发告警;
  4. 日志分级:Redis 异常时输出 warn 级日志,本地缓存兜底时输出 info 级日志,便于问题排查;
  5. 避免滥用本地缓存:本地缓存仅用于 Session 数据,不用于业务数据,防止与业务缓存相互影响。

七、总结

Redis 分布式 Session 的容灾兜底方案,核心是Redis 优先,本地缓存兜底的双存储策略,通过 Caffeine 本地缓存实现 Redis 宕机时的登录态不丢失,保证系统持续可用。

本文提供的方案自动降级、无感知切换、可动态配置,兼容原生 Spring Session 和HttpSessionAPI,无需修改现有业务代码,仅通过核心工具类和自定义 Session 包装类即可实现生产级容灾兜底。