spring session date redis趟坑经验

概述

目前大多数应用都是多实例或者多服务,这样带来了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;
	}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值