Redis 分布式 Session 容灾兜底实战:Redis 宕机不丢登录态,实现本地缓存兜底高可用
前言
Redis 分布式 Session 的核心依赖 Redis 服务,而 Redis 作为中间件,存在单点故障、集群宕机、网络分区等风险 —— 一旦 Redis 不可用,所有依赖 Session 的请求都会返回未登录,导致系统整体不可用,这在生产环境中是无法接受的。
本文将实现 Redis 分布式 Session 的生产级容灾兜底方案,核心是Redis 优先,本地缓存兜底:正常情况下 Session 数据存储在 Redis 中,实现分布式共享;当 Redis 宕机 / 超时 / 网络异常时,自动切换到本地内存缓存存储 Session 数据,保证登录态不丢失,系统持续可用,Redis 恢复后可平滑切回。
一、容灾兜底的核心设计思路
Redis 分布式 Session 容灾兜底的核心是双存储策略,结合 Redis 的分布式共享能力和本地缓存的高性能、高可用能力,整体设计遵循以下高可用原则:
- 降级自动触发:无需人工干预,当 Redis 操作抛出异常(宕机、超时、网络异常)时,自动切换到本地缓存,无感知降级;
- 本地临时兜底:本地缓存仅作为Redis 宕机时的临时方案,不追求分布式共享,保证单节点内的登录态有效即可;
- 数据一致性:正常情况下,Redis 和本地缓存双写,保证数据一致;Redis 异常时,仅写本地缓存,恢复后可手动同步(小型项目可忽略);
- 资源隔离:本地缓存设置最大容量和过期时间,避免内存溢出,同时与业务缓存隔离,防止相互影响;
- 平滑切回: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+本地缓存双写
二、生产级落地实现
前置条件
- 已实现 Redis 分布式 Session + 多端登录管理 + 强制注销;
- 已完成序列化优化(Jackson JSON 序列化);
- 项目为 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 操作的异常捕获和本地缓存兜底,核心改造点包括:
- 初始化 Caffeine 本地缓存,加载动态配置;
- 对会话注册、多端查询、强制注销等所有 Redis 操作,添加
try-catch异常捕获; - Redis 正常时,Redis + 本地缓存双写 / 双查,保证数据一致;
- Redis 异常时,自动切换到本地缓存,仅操作本地缓存,保证登录态有效;
- 本地缓存的 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 宕机时登录态正常
- 正常情况:启动 Redis,用户登录后,Redis 和本地缓存均存储会话信息;
- 模拟 Redis 宕机:关闭 Redis 服务;
- 验证登录态:用户发起请求,系统自动切换到本地缓存,仍能正常获取用户信息,登录态不丢失;
- 验证强制注销:调用强制注销接口,本地缓存中的会话信息被清理,登录态失效。
验证 2:Redis 恢复后平滑切回
- 重启 Redis:Redis 服务恢复;
- 用户操作:用户发起新的登录 / 查询请求,系统自动切回 Redis 存储,本地缓存作为兜底继续保留;
- 数据一致性:新会话数据写入 Redis,旧本地缓存数据在过期后自动清理,无脏数据残留。
五、容灾兜底的边界与解决方案
1. 本地缓存的分布式局限性
问题:本地缓存为服务实例独享,分布式环境下各实例的本地缓存数据不互通,可能导致 “用户在实例 A 登录,实例 B 无法查询到会话信息”;解决方案:
- 本地缓存仅作为临时兜底,Redis 恢复后立即切回,避免长期依赖本地缓存;
- 大型项目可添加Redis 恢复后的数据同步任务:通过定时任务将本地缓存中的会话信息同步到 Redis,保证分布式一致性。
2. 本地缓存内存溢出风险
问题:本地缓存无限制存储会话信息,可能导致服务实例内存溢出;解决方案:
- 通过动态配置设置最大容量(如 10000 个会话),使用 Caffeine 的 LFU 回收策略自动淘汰冷数据;
- 设置与 Redis 一致的过期时间,避免会话信息长期占用内存;
- 监控本地缓存的使用率,超过阈值时触发告警,手动扩容或调整配置。
3. 数据同步问题
问题:Redis 宕机期间,本地缓存存储的会话信息在 Redis 恢复后未同步,导致分布式环境下数据不一致;解决方案:
- 小型项目:Redis 恢复后,无需同步本地缓存数据,等待本地缓存数据过期后自动清理,新会话数据正常写入 Redis;
- 大型项目:编写数据同步工具类,Redis 恢复后,通过定时任务将本地缓存中的会话信息批量写入 Redis,保证数据一致性。
六、生产环境落地关键注意事项
- Redis 高可用优先:容灾兜底是最后一道防线,生产环境仍需优先保证 Redis 的高可用(Cluster / 主从 + 哨兵、持久化开启);
- 本地缓存动态开关:通过 Nacos/Apollo 配置动态开启 / 关闭本地缓存,便于紧急情况下人工干预;
- 监控告警:添加 Redis 状态监控和本地缓存使用率监控,Redis 宕机时触发告警,本地缓存使用率超过阈值时触发告警;
- 日志分级:Redis 异常时输出 warn 级日志,本地缓存兜底时输出 info 级日志,便于问题排查;
- 避免滥用本地缓存:本地缓存仅用于 Session 数据,不用于业务数据,防止与业务缓存相互影响。
七、总结
Redis 分布式 Session 的容灾兜底方案,核心是Redis 优先,本地缓存兜底的双存储策略,通过 Caffeine 本地缓存实现 Redis 宕机时的登录态不丢失,保证系统持续可用。
本文提供的方案自动降级、无感知切换、可动态配置,兼容原生 Spring Session 和HttpSessionAPI,无需修改现有业务代码,仅通过核心工具类和自定义 Session 包装类即可实现生产级容灾兜底。