js
**Describe the bug**
We are using redis to save sessions in spring cloud gateway, and see some missing key errors in the log.
Versions:
spring-session-data-redis:2.3.3.RELEASE
2025-10-20 01:04:07.219,ERROR,[lettuce-epollEventLoop-5-11] com.xxx.gateway.GatewayErrorHandler ,, - handle error for calling GET status:https://xxx.xxx.com/xxx/languages?lang=en_US downstream:500 INTERNAL_SERVER_ERROR http://172.24.149.220:10240/languages?lang=en_US
java.lang.IllegalStateException: creationTime key must not be null
at org.springframework.session.data.redis.RedisSessionMapper.handleMissingKey(RedisSessionMapper.java:94)
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
|_ checkpoint ⇢ org.springframework.web.cors.reactive.CorsWebFilter [DefaultWebFilterChain]
|_ checkpoint ⇢ org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter [DefaultWebFilterChain]
|_ checkpoint ⇢ org.springframework.boot.actuate.metrics.web.reactive.server.MetricsWebFilter [DefaultWebFilterChain]
|_ checkpoint ⇢ HTTP GET "/xxx/languages?lang=en_US" [ExceptionHandlingWebHandler]
在使用 Spring Boot 2.3.3 版本时遇到了 "ReactiveRedisSessionRepository creationTime key must not be null" 错误。这个问题在 Spring Session 的 Redis 会话存储实现中较为常见,文章将从错误原因、出现时机、影响因素以及解决方案等方面进行详细分析。
一、问题产生的根本原因
1.1 错误的核心逻辑
这个错误的根源在于Spring Session 在从 Redis 反序列化会话数据时,发现 creationTime 字段缺失或为 null。根据 Spring Session 2.3.3 版本的RedisSessionMapper源码分析:
typescript
public MapSession apply(Map<String, Object> map) {
Assert.notEmpty(map, "map must not be empty");
MapSession session = new MapSession(this.sessionId);
Long creationTime = (Long) map.get(CREATION_TIME_KEY);
if (creationTime == null) {
handleMissingKey(CREATION_TIME_KEY);
}
session.setCreationTime(Instant.ofEpochMilli(creationTime));
// 其他字段处理...
}
private static void handleMissingKey(String key) {
throw new IllegalStateException(key + " key must not be null");
}
从这段代码可以看出,RedisSessionMapper 是将 Redis 中的哈希数据转换为 MapSession 对象的关键类。当 Redis 中存储的会话数据缺少creationTime字段时,会立即抛出IllegalStateException异常,错误信息正是你看到的 "creationTime key must not be null"。
1.2 会话数据损坏的常见原因
导致creationTime字段缺失的原因主要有以下几种:
(1)并发操作导致的会话数据不一致
这是最常见的原因。当多个请求同时操作同一个会话时,可能会出现以下竞态条件:
- 会话因过期被 Redis 自动删除
- 同时有另一个请求尝试更新该会话
- 由于 Redis 使用HSET命令设置字段,如果键不存在会自动创建,但此时会话数据可能已经不完整
有用户报告,在使用 Spring Security 配置sessionCreationPolicy(SessionCreationPolicy.NEVER)时,会创建没有creationTime字段的会话记录。
(2)Redis 数据存储格式不兼容
如果你之前使用过不同版本的 Spring Session,或者手动修改过 Redis 中的会话数据格式,可能导致字段缺失。特别是从非 Reactive 版本升级到 Reactive 版本时,数据格式可能不兼容。
(3)序列化 / 反序列化错误
当使用自定义 Redis 序列化器时,如果没有正确配置对 Java 8 时间类型(Instant)的支持,可能导致creationTime在存储或读取时丢失。例如,缺少必要的 Jackson 模块支持会导致:
arduino
org.springframework.data.redis.serializer.SerializationException:
Could not read JSON: Cannot construct instance of java.time.Instant (no Creators, like default construct, exist)
(4)会话数据被意外删除或修改
虽然你提到没有手动操作过 Redis 数据,但仍需考虑以下可能性:
- 其他应用或进程误操作了 Redis 数据
- Redis 的内存淘汰策略导致部分数据丢失
- Redis 持久化配置不当导致数据损坏
二、错误出现的时机分析
2.1 应用启动时出现的情况
在应用启动时出现此错误,通常与以下情况相关:
(1)Bean 初始化失败
如果 Spring 容器在初始化ReactiveRedisSessionRepository时,发现 Redis 中存在损坏的会话数据,可能会在启动过程中就抛出异常。这种情况通常发生在:
- 应用使用了@EnableRedisWebSession注解
- 配置了spring.session.store-type=redis
- Spring Boot 自动配置尝试加载所有会话数据时
(2)配置错误导致的立即失败
在 Spring Boot 2.1.5 版本中存在一个已知的配置错误案例:
- 缺少 Spring Security 依赖
- SessionAutoConfiguration类中使用了SpringSessionRememberMeServices
- 该类依赖 Spring Security 的相关类,导致启动失败
虽然你的版本是 2.3.3,但类似的配置问题仍可能存在。
2.2 运行时出现的情况
更多情况下,这个错误会在应用运行时的特定操作后出现:
(1)会话创建时
当应用尝试创建新会话时,如果底层的MapSession没有正确初始化creationTime,会导致后续存储时出现问题。不过,根据源码分析,MapSession的默认构造器会自动初始化creationTime:
ini
MapSession cached = new MapSession(); // 自动初始化creationTime为当前时间
所以新建会话时出现此问题的概率较低。
(2)会话读取时
这是最常见的情况。当应用尝试读取 Redis 中存储的会话时,如果该会话数据缺少creationTime字段,会立即抛出异常。触发场景包括:
- 用户访问需要会话的资源
- 执行request.getSession()操作
- Spring Security 进行会话管理时
- 会话过期后被清理前的最后一次访问
(3)并发操作时
在高并发场景下,多个请求同时操作同一个会话可能导致数据不一致。有用户报告了一个典型案例:
- WebSessionServerCsrfTokenRepository 和 WebSessionServerSecurityContextRepository 同时尝试保存会话
- 两个操作共享同一个delta映射
- 第一个操作完成后delta被清空,第二个操作因delta为空而失败
(4)会话迁移或升级时
当应用从一个环境迁移到另一个环境,或从旧版本升级到新版本时,可能出现数据格式不兼容的问题。特别是:
- 不同版本的 Spring Session 数据格式差异
- Redis 服务器版本升级
- 应用服务器重启导致的会话恢复
三、自定义实现可能导致的问题
3.1 自定义 Session 实现类的影响
如果你自定义了Session实现类,以下情况可能导致creationTime为null:
(1)未正确实现 getCreationTime() 方法
根据Session接口规范,必须实现getCreationTime()方法:
csharp
public interface Session {
Instant getCreationTime();
// 其他方法...
}
如果你的自定义实现类没有正确实现这个方法,或者返回null,会导致后续处理失败。
(2)构造时未初始化 creationTime
正确的自定义Session实现应该在构造时初始化creationTime:
kotlin
public class CustomSession implements Session {
private Instant creationTime;
private Instant lastAccessedTime;
// 其他字段...
public CustomSession() {
this.creationTime = Instant.now();
this.lastAccessedTime = this.creationTime;
// 初始化其他字段
}
@Override
public Instant getCreationTime() {
return creationTime;
}
// 其他方法实现...
}
(3)与 ReactiveRedisSessionRepository 的兼容性问题
ReactiveRedisSessionRepository内部使用的是RedisSession类:
kotlin
final class RedisSession implements Session {
private final MapSession cached;
// 构造时初始化creationTime
RedisSession(MapSession cached, boolean isNew) {
this.cached = cached;
if (this.isNew) {
this.delta.put("creationTime", cached.getCreationTime().toEpochMilli());
}
}
}
如果你自定义的Session类与这个内部实现不兼容,可能导致序列化时丢失creationTime字段。
3.2 自定义 Redis 序列化方式的影响
(1)Java 8 时间类型支持缺失
这是导致creationTime为null的重要原因之一。默认的Jackson2JsonRedisSerializer不支持 Java 8 时间类型,会导致以下错误:
sql
Java 8 date/time type `java.time.Instant` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
(2)缺少必要的 Jackson 模块
正确支持Instant类型需要添加以下依赖:
xml
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-parameter-names</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jdk8</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
(3)自定义序列化器的实现错误
如果实现了自定义的RedisSerializer,可能因为以下原因导致creationTime丢失:
- 序列化时未包含creationTime字段
- 反序列化时忽略了creationTime字段
- 时间戳转换逻辑错误
- 字段命名与 Spring Session 默认的 "creationTime" 不一致
正确的配置示例:
typescript
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new ParameterNamesModule())
.registerModule(new Jdk8Module())
.registerModule(new JavaTimeModule()); // 关键:注册JavaTimeModule
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(objectMapper);
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
四、Redis 数据操作的影响分析
虽然你明确表示没有手动操作过 Redis 数据,但仍需要考虑以下可能影响 Redis 会话数据完整性的因素:
4.1 Redis 自动过期机制的影响
(1)会话超时配置
Spring Boot 2.3.3 中,Redis 会话的默认超时时间是 30 分钟(1800 秒)。通过以下配置可以修改:
yaml
spring:
session:
timeout: 3600 # 单位:秒
如果会话超时时间设置得过短,可能导致频繁的会话创建和销毁,增加数据不一致的风险。
(2)Redis 键空间通知
Spring Session 依赖 Redis 的键空间通知功能来监听会话过期事件。如果 Redis 配置中禁用了键空间通知,或者通知延迟,可能导致:
- 会话过期后未能及时清理
- 过期会话被意外读取
- 内存中堆积大量过期数据
4.2 Redis 数据存储结构的变化
Spring Session 在 Redis 中的数据存储结构为:
css
spring:session:sessions:{sessionId}
这个键下存储的是一个哈希表,包含:
- creationTime: 会话创建时间(毫秒时间戳)
- maxInactiveInterval: 最大非活动间隔(秒)
- lastAccessedTime: 最后访问时间(毫秒时间戳)
- sessionAttr:{attributeName}: 会话属性
如果这个结构被意外修改,例如:
- 删除了creationTime字段
- 修改了字段的数据类型
- 使用了不同的命名空间
都可能导致反序列化失败。
4.3 多应用共享 Redis 实例的影响
如果多个应用共享同一个 Redis 实例,且使用相同的命名空间(默认是spring:session),可能出现:
- 不同应用的会话数据相互干扰
- 数据格式不统一导致的兼容性问题
- 并发操作的概率增加
建议为每个应用配置独立的命名空间:
arduino
spring.session.redis.namespace: myapp:spring:session
五、解决方案其一,反射+切面
java
package com.xxx.aop;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.ReactiveRedisOperations;
import org.springframework.session.MapSession;
import org.springframework.session.Session;
import org.springframework.session.data.redis.ReactiveRedisSessionRepository;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.lang.reflect.Constructor;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/**
* 修复 ReactiveRedisSessionRepository.findById 方法中 creationTime 缺失导致的异常,
* 并解决 save 时的 MapSession 转 RedisSession 类型转换问题
*/
@Slf4j
@Aspect
@Component
public class ReactiveRedisSessionAspect {
@Value("${spring.session.redis.namespace}")
private String sessionNamespace;
// 反射获取 RedisSession 私有构造器(解决类型转换问题)
private static Constructor<?> redisSessionConstructor;
static {
try {
// 加载 ReactiveRedisSessionRepository 内部的私有静态类 RedisSession
Class<?> redisSessionClass = Class.forName("org.springframework.session.data.redis.ReactiveRedisSessionRepository$RedisSession");
// RedisSession 构造器参数为:MapSession(会话数据)、boolean(是否为新会话)
redisSessionConstructor = redisSessionClass.getDeclaredConstructor(ReactiveRedisSessionRepository.class, MapSession.class, boolean.class);
redisSessionConstructor.setAccessible(true); // 突破私有访问限制
} catch (Exception e) {
log.error("获取 RedisSession 构造器失败", e);
}
}
// 定义切点:拦截 ReactiveRedisSessionRepository 的 findById 方法
@Pointcut("execution(* org.springframework.session.data.redis.ReactiveRedisSessionRepository.findById(..)) && args(sessionId)")
public void findByIdPointcut(String sessionId) {
}
// 环绕通知:增强 findById 方法
@Around("findByIdPointcut(sessionId)")
public Object aroundFindById(ProceedingJoinPoint joinPoint, String sessionId) throws Throwable {
log.info("进入 ReactiveRedisSessionRepository.findById 方法");
// 执行原始方法(获取 Mono<Session>)
Mono<Session> originalMono = (Mono<Session>) joinPoint.proceed();
// 增强逻辑:处理返回结果,修复 creationTime 缺失
return originalMono
.onErrorResume(error -> {
log.warn("ReactiveRedisSessionRepository.findById 异常:{}", error.getMessage());
// 捕获因 creationTime 缺失导致的异常(包括包装异常)
if (isCreationTimeMissingError(error)) {
log.warn("开始修复 CreationTime");
// 从连接点获取目标对象(ReactiveRedisSessionRepository)
ReactiveRedisSessionRepository repository = (ReactiveRedisSessionRepository) joinPoint.getTarget();
// 获取 Redis 中会话的 key(使用配置的 namespace 拼接)
String sessionKey = sessionNamespace + ":sessions:" + sessionId;
// 获取 Redis 操作实例
ReactiveRedisOperations<String, Object> redisOps = repository.getSessionRedisOperations();
// 从 Redis 重新读取原始哈希数据并修复
return redisOps.opsForHash().entries(sessionKey)
.collectMap(
entry -> entry.getKey().toString(),
Map.Entry::getValue
)
.map(redisHash -> fixSessionData(sessionId, redisHash))
// 关键:将修复后的 MapSession 转换为 RedisSession(避免 save 时类型转换失败)
.map(fixedMapSession -> createRedisSession(fixedMapSession, repository))
.switchIfEmpty(Mono.empty()); // 若数据为空,返回空
}
log.warn("其他异常直接返回", error);
// 其他异常原样抛出
return Mono.error(error);
});
}
/**
* 通过反射创建 RedisSession 实例(包装修复后的 MapSession)
*/
private Session createRedisSession(MapSession mapSession, ReactiveRedisSessionRepository reactiveRedisSessionRepository) {
try {
// 调用 RedisSession 的私有构造器:参数为修复后的 MapSession + 非新会话(false)
return (Session) redisSessionConstructor.newInstance(reactiveRedisSessionRepository, mapSession, false);
} catch (Exception e) {
throw new RuntimeException("反射创建 RedisSession 失败", e);
}
}
/**
* 判断是否为 creationTime 缺失导致的异常
*/
private boolean isCreationTimeMissingError(Throwable error) {
String message = error.getMessage();
if (message != null && message.contains("creationTime key must not be null")) {
return true;
}
// 检查嵌套异常
if (error.getCause() != null) {
return isCreationTimeMissingError(error.getCause());
}
return false;
}
/**
* 修复 Redis 中的 Session 数据(补充缺失的核心字段)
*/
private MapSession fixSessionData(String sessionId, Map<String, Object> redisHash) {
log.warn("修复 Session 前数据:{}", JSONUtil.toJsonStr(redisHash));
MapSession session = new MapSession(sessionId);
// 1. 处理 creationTime(核心修复字段)
Long creationTimeMillis = getLongValue(redisHash.get("creationTime"));
if (creationTimeMillis == null) {
// 用 lastAccessedTime 兜底,若仍为空则用当前时间
Long lastAccessedTimeMillis = getLongValue(redisHash.get("lastAccessedTime"));
creationTimeMillis = (lastAccessedTimeMillis != null)
? lastAccessedTimeMillis
: System.currentTimeMillis();
}
session.setCreationTime(Instant.ofEpochMilli(creationTimeMillis));
// 2. 处理 lastAccessedTime
Long lastAccessedTimeMillis = getLongValue(redisHash.get("lastAccessedTime"));
if (lastAccessedTimeMillis != null) {
session.setLastAccessedTime(Instant.ofEpochMilli(lastAccessedTimeMillis));
} else {
// 若缺失,用 creationTime 兜底
session.setLastAccessedTime(session.getCreationTime());
}
// 3. 处理 maxInactiveInterval(会话超时时间)
Integer maxInactiveIntervalSecs = getIntegerValue(redisHash.get("maxInactiveInterval"));
if (maxInactiveIntervalSecs != null) {
session.setMaxInactiveInterval(Duration.ofSeconds(maxInactiveIntervalSecs));
} else {
// 若缺失,默认10小时超时
session.setMaxInactiveInterval(Duration.ofHours(10));
}
// 4. 处理会话属性(保留自定义属性)
redisHash.forEach((key, value) -> {
// 跳过已处理的系统字段,保留自定义属性
if (!key.equals("creationTime") && !key.equals("lastAccessedTime") && !key.equals("maxInactiveInterval")) {
String attributeKey = key.replaceFirst("^(sessionAttr:)+", "");
session.setAttribute(attributeKey, value);
}
});
log.warn("修复 Session 后数据:{}", JSONUtil.toJsonStr(session));
return session;
}
/**
* 安全转换为 Long(处理 Redis 中可能的 Integer 类型)
*/
private Long getLongValue(Object value) {
if (value == null) return null;
if (value instanceof Long) return (Long) value;
if (value instanceof Integer) return ((Integer) value).longValue();
return null;
}
/**
* 安全转换为 Integer
*/
private Integer getIntegerValue(Object value) {
if (value == null) return null;
if (value instanceof Integer) return (Integer) value;
if (value instanceof Long) {
Long longValue = (Long) value;
return longValue.intValue();
}
return null;
}
}
通过以上措施,可以有效解决 "creationTime key must not be null" 错误,并预防类似问题的再次发生。建议你根据实际情况选择合适的解决方案,并在实施前进行充分的测试。