SpringCloud gateway偶发creationTime key must not be null

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" 错误,并预防类似问题的再次发生。建议你根据实际情况选择合适的解决方案,并在实施前进行充分的测试。

相关推荐
我是天龙_绍2 小时前
java 中的 Lombok
后端
初见0012 小时前
Spring事务失效的十大陷阱与终极解决方案
后端·架构
子夜master2 小时前
玩转EasyExcel,看这一篇就够了!!(合并导入 自定义导出 动态表头 合并单元格)
后端
武子康3 小时前
大数据-131 Flink CEP 实战 24 小时≥5 次交易 & 10 分钟未支付检测 案例附代码
大数据·后端·flink
Postkarte不想说话3 小时前
Cisco配置PIM-DM
后端
程序猿有风3 小时前
Java GC 全系列一小时速通教程
后端·面试
BingoGo3 小时前
PHP 8.5 新特性 闭包可以作为常量表达式了
后端·php
SimonKing3 小时前
Komari:一款专为开发者打造的轻量级服务“看守神器”
后端
间彧3 小时前
Spring Security如何解析JWT,并自行构造SecurityContex
后端