spring session date redis趟坑经验

本文探讨了Spring Session在多实例或多服务环境下如何实现session共享,并详细分析了session存储于Redis的具体实现方式及其潜在问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

概述

目前大多数应用都是多实例或者多服务,这样带来了session共享的问题。
session共享问题解决途径有很多:

  1. 前端负载均衡根据一定的策略,将来自相同客户端的请求,固定路由到某个节点。
    该方法比较简洁,不过会降低负载均衡的效果。
  2. session放在公共存储中,比如spring-session
  3. 使用jwt

spring session的原理

在这里插入图片描述
spring session是靠SessionRepositoryFilter拦截请求,将HttpServletRequest包装成SessionRepositoryRequestWrapper;
可以看一下SessionRepositoryRequestWrapper的源码,它仅仅只是覆写操作session相关的函数,其他的全部委托给HttpServletRequest,这样能够透明的对session进行增强。

spring session中的SessionRepositoryFilter是Filter类型,是如何注入到servlet容器的

spring设计了RegistrationBean机制,能够在内嵌的servlet容器启动后,注册servlet,Filter,listener,该机制可以参考我之前的文章《ServletContainerInitializer、WebApplicationInitializer、ServletContextInitializer》
故猜想应该在spring boot的自动配置中,使用FilterRegistrationBean包装了SessionRepositoryFilter。因此到spring-boot-autoconfigure.jar中去寻找踪迹。可以看到是在SessionRepositoryFilterConfiguration 中进行配置的,另外为了对应用透明,SessionRepositoryFilter默认优先级应该是最高的,因为框架无法预知业务层会写什么样的Filter,而且Filter中是否会操作session。

class SessionRepositoryFilterConfiguration {
    # 此处FilterRegistrationBean没有配置UrlPatterns,默认是"/*",即拦截所有请求
    @Bean
    FilterRegistrationBean<SessionRepositoryFilter<?>> sessionRepositoryFilterRegistration(SessionProperties sessionProperties, SessionRepositoryFilter<?> filter) {
        FilterRegistrationBean<SessionRepositoryFilter<?>> registration = new FilterRegistrationBean(filter, new ServletRegistrationBean[0]);
        registration.setDispatcherTypes(this.getDispatcherTypes(sessionProperties));
        registration.setOrder(sessionProperties.getServlet().getFilterOrder());
        return registration;
    }

从默认配置来看SessionRepositoryFilter Session的优先级并不是最高,应该是框架留了一点余地,防止将来可能需要配置更高优先级的Filter

public static class Servlet {
	private int filterOrder = SessionRepositoryFilter.DEFAULT_ORDER;
}

public class SessionRepositoryFilter<S extends Session> extends OncePerRequestFilter {
    public static final int DEFAULT_ORDER = Integer.MIN_VALUE + 50;
}

闭坑指导

需要覆盖SessionRepositoryFilterConfiguration对SessionRepositoryFilter的默认配置

从源码可以看到SessionRepositoryFilterConfiguration配置SessionRepositoryFilter时,默认拦截所有请求。

  1. 一般系统中会存在一些机机接口,并不需要创建session,因此最好覆盖掉默认配置,只对人机接口进行拦截,能够节省一点消耗算一点

redis存储session时,key是有命名空间的,要保证所有服务的命名空间一致

见org.springframework.boot.autoconfigure.session.RedisSessionConfiguration源码

	@Configuration(proxyBeanMethods = false)
	public static class SpringBootRedisHttpSessionConfiguration extends RedisHttpSessionConfiguration {

		@Autowired
		public void customize(SessionProperties sessionProperties, RedisSessionProperties redisSessionProperties,
				ServerProperties serverProperties) {
			Duration timeout = sessionProperties
					.determineTimeout(() -> serverProperties.getServlet().getSession().getTimeout());
			if (timeout != null) {
				setMaxInactiveIntervalInSeconds((int) timeout.getSeconds());
			}
			# 配置session在redis中存储时key的前缀,具体用法参考RedisIndexedSessionRepository
			setRedisNamespace(redisSessionProperties.getNamespace());
			setFlushMode(redisSessionProperties.getFlushMode());
			setSaveMode(redisSessionProperties.getSaveMode());
			setCleanupCron(redisSessionProperties.getCleanupCron());
		}
	}

RedisIndexedSessionRepository去redis查询session的时候,是拿着前端的sessionid,拼接上命名空间作为key值的。如果各个服务命名空间不一致,会导致session不互认,随着请求不断地交叉请求不同服务,session会反复创建,不断侵占redis的存储空间。我们有一次就是系统升级框架,但只升级了部分服务进行试点,结果高低版本框架对命名空间的配置不一致,导致上线后,redis内存不断的增长,最后达到100%。
我们登录认证的流程如下:

  1. 判断session是否存在,如果存在则说明是登录用户,结束登录认证
  2. 使用单点登录的cookie找sso服务端进行认证,如果认证通过,则创建应用自己的session,这样每次请求不用都去找sso服务端认证,减小sso服务端的压力。
  3. 如果第2步认证不过,则说明用户未登录,则返回401,页面跳转到公司统一的登录界面。

从上面逻辑可以看出虽然服务间的session因为命名空间不同不共享,但有sso的认证作为兜底逻辑。虽然对业务没什么造成影响,但是在定位出根因之前,还是心理很慌的。
在这里插入图片描述

记一次线上问题定位

现象

现网redis CPU占用超过60%,而且晚上业务低峰期,cpu利用率也几乎不怎么降低

分析

1.业务低峰期使用人数非常少,可以忽略不计,因此让运维在业务低峰期时dump redis命令,看一下命令的分布,在做进一步分析。

可以看到大部分(99%)命令都是下面几条
“SREM” “spring:session:expirations:xxxx” “\xac\xed\x00\x05t\x00,expires:xxxx”
“SADD” “spring:session:expirations:xxxx” “\xac\xed\x00\x05t\x00,expires:xxxx”
“PEXPIRE” “spring:session:expirations:xxxx” “xxxx”
“APPEND” “spring:session:sessions:expires:xxxx” “”
“PEXPIRE” “spring:session:sessions:expires:xxxx” “xxxx”
“HMSET” “spring:session:sessions:xxxx” “lastAccessedTime” “\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\x88Z&O”" “maxInactiveInterval” “\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b” “creationTime” “\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\x88Z&O”"

从命令来看,是spring session的操作,可见redis的操作还是由接口访问导致的,而不是后台异步任务。但是半夜又几乎没有人员使用,为此决定看一下访问日志,看半夜到时候是什么接口在被调用。

2.分析半夜接口调用日志

从系统监控来看,半夜大量调用集中在一个接口上了(99%),而且从频率来看,应该是某个系统定时调用的,因为频率非常稳定。而且上述信息也可以做一下推论:

  • 半夜redis的cpu利用率主要是由该接口产生的,因为半夜用户非常少,从调用日志来看,99%的调用记录都是该接口,而且redis的操作也基本是spring session相关的。
  • 该接口半天晚上调用频率一样,所以半天的cpu利用率也大部分是该接口产生的,因为白天晚上cpu 利用率差异不大。

验证:阻断该接口的调用,观察redis的cpu利用率是否下降。

结果,接口阻断后,大概半个小时redis的cpu下降到很低的水平。

疑点

该接口为什么会产生大量session相关的操作?

该接口为人机接口,会在登录认证的filter中判断session是否存在(调用request的getSession方法),以及并校验session中存储的值。
由于接口由另外一个系统服务端调用,所以每次getSession都无法获取到,导致每一次请求都会创建session。

	private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {
        # getSession()调用的是getSession(true)
		@Override
		public HttpSessionWrapper getSession(boolean create) {
            # 有省略,因为接口是另外一个系统的服务端调过来的,没有进行登录,自然也就不存在session,因此每次都会走到这里创建session
            # 虽然这里会创建session,但是session存的东西是空的,所以登录认证还是过不了
			S session = SessionRepositoryFilter.this.sessionRepository.createSession();
			session.setLastAccessedTime(Instant.now());
			currentSession = new HttpSessionWrapper(session, getServletContext());
			setCurrentSession(currentSession);
			return currentSession;
		}

org.springframework.session.data.redis.RedisIndexedSessionRepository#createSession看看创建session需要对redis做哪些操作

	@Override
	public RedisSession createSession() {
		MapSession cached = new MapSession();
		if (this.defaultMaxInactiveInterval != null) {
			cached.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval));
		}
		RedisSession session = new RedisSession(cached, true);
		# redis的操作在这里
		session.flushImmediateIfNecessary();
		return session;
	}
    # redis的操作就在这里了,代码中删减了一些实际不会走的分支
	private void saveDelta() {
			String sessionId = getId();
			# "HMSET" "spring:session:sessions:sessionid" xxx
			getSessionBoundHashOperations(sessionId).putAll(this.delta);

			this.delta = new HashMap<>(this.delta.size());

			Long originalExpiration = (this.originalLastAccessTime != null)
					? this.originalLastAccessTime.plus(getMaxInactiveInterval()).toEpochMilli() : null;
			# 这部分在下面详述
			RedisIndexedSessionRepository.this.expirationPolicy.onExpirationUpdated(originalExpiration, this);
		}

org.springframework.session.data.redis.RedisSessionExpirationPolicy#onExpirationUpdated

	void onExpirationUpdated(Long originalExpirationTimeInMilli, Session session) {
	    # 删减不会走的分支代码
		String keyToExpire = SESSION_EXPIRES_PREFIX + session.getId();
		long toExpire = roundUpToNextMinute(expiresInMillis(session));

		if (originalExpirationTimeInMilli != null) {
			long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
			if (toExpire != originalRoundedUp) {
				String expireKey = getExpirationKey(originalRoundedUp);
				this.redis.boundSetOps(expireKey).remove(keyToExpire);
			}
		}

		long sessionExpireInSeconds = session.getMaxInactiveInterval().getSeconds();
		String sessionKey = getSessionKey(keyToExpire);

		if (sessionExpireInSeconds < 0) {
			this.redis.boundValueOps(sessionKey).append("");
			this.redis.boundValueOps(sessionKey).persist();
			this.redis.boundHashOps(getSessionKey(session.getId())).persist();
			return;
		}

		String expireKey = getExpirationKey(toExpire);
		BoundSetOperations<Object, Object> expireOperations = this.redis.boundSetOps(expireKey);
		# "SADD" "spring:session:expirations:${expiretime}" "\xac\xed\x00\x05t\x00,expires:sessionid"
		expireOperations.add(keyToExpire);

		long fiveMinutesAfterExpires = sessionExpireInSeconds + TimeUnit.MINUTES.toSeconds(5);

        # "PEXPIRE" "spring:session:expirations:${expiretime}" "2100000"
		expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
		if (sessionExpireInSeconds == 0) {
			this.redis.delete(sessionKey);
		}
		else {
		    # "APPEND" "spring:session:sessions:expires:sessionid" ""
			this.redis.boundValueOps(sessionKey).append("");
			"PEXPIRE" "spring:session:sessions:expires:sessionid" "1800000"
			this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds, TimeUnit.SECONDS);
		}
		"PEXPIRE" "spring:session:sessions:sessionid" "2100000"
		this.redis.boundHashOps(getSessionKey(session.getId())).expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
	}

可见创建一次session要执行不少session

接口被阻断后,为啥半个小时候cpu才下降到很低的水平?

从上面可以看到接口每次调用都会创建一个session,该session后续不会被访问,半个小时候相关的key就会过期。
从spring boot的自动配置中可以看到RedisIndexedSessionRepository对键过期做了处理(RedisIndexedSessionRepository是redis的MessageListener)。
半个小时候,相关的key全部过期后,redis相关的通知操作才会基本消失。因此redis的cpu 利用率在
半个小时后在下降到最低点,并趋于平稳。

	public RedisIndexedSessionRepository(RedisOperations<Object, Object> sessionRedisOperations) {
		Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null");
		this.sessionRedisOperations = sessionRedisOperations;
		this.expirationPolicy = new RedisSessionExpirationPolicy(sessionRedisOperations, this::getExpirationsKey,
				this::getSessionKey);
		# 配置了相关的监听的channel
		configureSessionChannels();
	}
	private void configureSessionChannels() {
		this.sessionCreatedChannelPrefix = this.namespace + "event:" + this.database + ":created:";
		this.sessionCreatedChannelPrefixBytes = this.sessionCreatedChannelPrefix.getBytes();
		this.sessionDeletedChannel = "__keyevent@" + this.database + "__:del";
		this.sessionDeletedChannelBytes = this.sessionDeletedChannel.getBytes();
		this.sessionExpiredChannel = "__keyevent@" + this.database + "__:expired";
		this.sessionExpiredChannelBytes = this.sessionExpiredChannel.getBytes();
		this.expiredKeyPrefix = this.namespace + "sessions:expires:";
		this.expiredKeyPrefixBytes = this.expiredKeyPrefix.getBytes();
	}

RedisHttpSessionConfiguration中创建了RedisMessageListenerContainer

	@Bean
	public RedisMessageListenerContainer springSessionRedisMessageListenerContainer(
			RedisIndexedSessionRepository sessionRepository) {
		RedisMessageListenerContainer container = new RedisMessageListenerContainer();
		container.setConnectionFactory(this.redisConnectionFactory);
		if (this.redisTaskExecutor != null) {
			container.setTaskExecutor(this.redisTaskExecutor);
		}
		if (this.redisSubscriptionExecutor != null) {
			container.setSubscriptionExecutor(this.redisSubscriptionExecutor);
		}
		container.addMessageListener(sessionRepository,
				Arrays.asList(new ChannelTopic(sessionRepository.getSessionDeletedChannel()),
						new ChannelTopic(sessionRepository.getSessionExpiredChannel())));
		container.addMessageListener(sessionRepository,
				Collections.singletonList(new PatternTopic(sessionRepository.getSessionCreatedChannelPrefix() + "*")));
		return container;
	}
### Spring Security 集成 Redis 实现会话管理与认证授权 #### 1. 添加依赖项 为了使项目能够使用 Spring Security 和 Redis,需要在 `pom.xml` 文件中添加必要的 Maven 依赖。 ```xml <dependencies> <!-- Spring Boot Starter Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- Spring Data Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- Lettuce 连接池 (默认配置) --> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> </dependency> <!-- JSON Web Token 支持 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.2</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.2</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.2</version> <scope>runtime</scope> </dependency> </dependencies> ``` #### 2. 配置 Redis 连接工厂 通过自定义 Bean 来覆盖默认的连接设置,确保应用程序能正确访问到 Redis 数据库实例。 ```java @Configuration public class RedisConfig { @Bean public LettuceConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(); } } ``` #### 3. 设置 Session 存储位置为 Redis 修改 application.properties 或者 yml 文件来指定 session 的存储方式: ```properties server.servlet.session.timeout=86400 # 单位秒,一天有效期 spring.redis.host=localhost # Redis服务器地址 spring.redis.port=6379 # Redis端口号 spring.session.store-type=redis # 使用Redis作为session仓库 ``` #### 4. 自定义 UserDetailsService 接口实现类 创建一个服务组件用于加载用户特定数据,并返回 UserDetails 对象给框架处理验证逻辑。 ```java @Service public class CustomUserDetailService implements UserDetailsService { private final UserRepository userRepository; @Autowired public CustomUserDetailService(UserRepository userRepository){ this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Optional<UserEntity> userOpt = userRepository.findByUsername(username); if (!userOpt.isPresent()) throw new UsernameNotFoundException("Invalid username or password."); UserEntity user = userOpt.get(); Set<GrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities ); } } ``` #### 5. 创建 JWT 工具类 编写工具方法来进行 token 的生成和解析操作。 ```java @Component public class JwtUtil { private static final String SECRET_KEY = "secret"; public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); return createToken(claims, userDetails.getUsername()); } private String createToken(Map<String, Object> claims, String subject) { return Jwts.builder() .setClaims(claims) .setSubject(subject) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 有效时间十分钟 .signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes()) .compact(); } public Boolean validateToken(String token, UserDetails userDetails) { final String username = extractUsername(token); return ( username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } private Boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } private Claims extractAllClaims(String token) { try{ return Jwts.parserBuilder() .setSigningKey(SECRET_KEY.getBytes()) .build() .parseClaimsJws(token) .getBody(); }catch(Exception e){ System.out.println(e.getMessage()); return null; } } } ``` #### 6. 构建过滤器链并注册至安全上下文中 最后一步是在全局范围内应用这些更改,即向 HTTP 请求管道注入新的身份验证机制。 ```java @EnableWebSecurity public class SecurityConfigurer extends WebSecurityConfigurerAdapter { private final JwtRequestFilter jwtRequestFilter; @Autowired public SecurityConfigurer(JwtRequestFilter jwtRequestFilter) {this.jwtRequestFilter = jwtRequestFilter;} @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/authenticate").permitAll() .anyRequest().authenticated() .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS); http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailService()).passwordEncoder(passwordEncoder()); } @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean public CustomUserDetailService customUserDetailService(){ return new CustomUserDetailService(null); // 此处应传入实际的服务层对象 } @Bean public JwtRequestFilter authenticationJwtTokenFilter(){ return new Jwt
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值