概述
目前大多数应用都是多实例或者多服务,这样带来了session共享的问题。
session共享问题解决途径有很多:
- 前端负载均衡根据一定的策略,将来自相同客户端的请求,固定路由到某个节点。
该方法比较简洁,不过会降低负载均衡的效果。 - session放在公共存储中,比如spring-session
- 使用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时,默认拦截所有请求。
- 一般系统中会存在一些机机接口,并不需要创建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%。
我们登录认证的流程如下:
- 判断session是否存在,如果存在则说明是登录用户,结束登录认证
- 使用单点登录的cookie找sso服务端进行认证,如果认证通过,则创建应用自己的session,这样每次请求不用都去找sso服务端认证,减小sso服务端的压力。
- 如果第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;
}