【SSO单点登录】分布式Session存在问题&& spring-session的设计之妙

👉本篇速览

  • session存在的问题

    • 分布式session如何解决
      • nginx的ip_hash
  • spring-session

    • 查询的原理&源码
    • 过期的原理&源码
      • 扩展redis过期策略
      • 为何spring-session要如此设计数据结构
  • token取代session,实现服务端到客户端的跨变

🎯session存在的问题

  1. 服务端需要存储session,占用内存高
  2. 不同服务器,无法共享session【分布式的场景】,这种情况下通常需要借助redis等数据库来做存储

没有什么是加一层解决不了的hhh

分布式session如何解决

当我们用nginx做负载均衡时,用户在A服务器登录了,A服务器存储了session,客户端也存储了cookie,其中有JSESSIONID。

此时负载均衡,访问B服务器的话,B服务器是没有这个session的,客户端的cookie里边JSESSIONID也就找不到对应的session,相当于没有登录,此时如何解决呢?

nginx的ip_hash

用nginx的ip_hash可以使得某个ip的用户,只固定访问某个特定的服务器,这样就不会跑到其他服务器,也就不需要考虑session共享的问题了

但与此同时,这又违背了Nginx负载均衡的初衷,请求都固定打到某一台服务器,宕机就不好办了,于是我们有了spring-session

🎯spring session

查询的原理

当请求进来的时候,SessionRepositoryFilter 会先拦截到请求,将 request 和 response 对象转换成 SessionRepositoryRequestWrapper 和SessionRepositoryResponseWrapper 。后续当第一次调用 request 的getSession方法时,会调用到 SessionRepositoryRequestWrapper 的getSession方法。

这个方法是被重写过的,逻辑是先从 request 的属性中查找,如果找不到;再查找一个key值是"SESSION"的 Cookie,通过这个 Cookie 拿到 SessionId 去 Redis 中查找,如果查不到,就直接创建一个RedisSession 对象,同步到 Redis 中。

说的简单点就是:拦截请求,将之前在服务器内存中进行 Session 创建销毁的动作,改成在 Redis 中创建。

具体源码


    /**
     * HttpServletRequest getSession()实现
     */
    @Override
    public HttpSessionWrapper getSession() {
        return getSession(true);
    }

    @Override
    public HttpSessionWrapper getSession(boolean create) {
        HttpSessionWrapper currentSession = getCurrentSession();
        if (currentSession != null) {
            return currentSession;
        }
        //从当前请求获取sessionId
        String requestedSessionId = getRequestedSessionId();
        if (requestedSessionId != null
                && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
            S session = getSession(requestedSessionId);
            if (session != null) {
                this.requestedSessionIdValid = true;
                currentSession = new HttpSessionWrapper(session, getServletContext());
                currentSession.setNew(false);
                setCurrentSession(currentSession);
                return currentSession;
            }
            else {
                // This is an invalid session id. No need to ask again if
                // request.getSession is invoked for the duration of this request
                if (SESSION_LOGGER.isDebugEnabled()) {
                    SESSION_LOGGER.debug(
                            "No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
                }
                setAttribute(INVALID_SESSION_ID_ATTR, "true");
            }
        }
        if (!create) {
            return null;
        }
        if (SESSION_LOGGER.isDebugEnabled()) {
            SESSION_LOGGER.debug(
                    "A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
                            + SESSION_LOGGER_NAME,
                    new RuntimeException(
                            "For debugging purposes only (not an error)"));
        }
        //为当前请求创建session
        S session = SessionRepositoryFilter.this.sessionRepository.createSession();
        //更新时间
        session.setLastAccessedTime(System.currentTimeMillis());
        //对Spring session 进行包装(包装成HttpSession)
        currentSession = new HttpSessionWrapper(session, getServletContext());
        setCurrentSession(currentSession);
        return currentSession;
    }

    /**
     * 根据sessionId获取session
     */
    private S getSession(String sessionId) {
        S session = SessionRepositoryFilter.this.sessionRepository
                .getSession(sessionId);
        if (session == null) {
            return null;
        }
        session.setLastAccessedTime(System.currentTimeMillis());
        return session;
    }

    /**
     * 从当前请求获取sessionId
     */
    @Override
    public String getRequestedSessionId() {
        return SessionRepositoryFilter.this.httpSessionStrategy
                .getRequestedSessionId(this);
    }

    private void setCurrentSession(HttpSessionWrapper currentSession) {
        if (currentSession == null) {
            removeAttribute(CURRENT_SESSION_ATTR);
        }
        else {
            setAttribute(CURRENT_SESSION_ATTR, currentSession);
        }
    }
    /**
     * 获取当前请求session
     */
    @SuppressWarnings("unchecked")
    private HttpSessionWrapper getCurrentSession() {
        return (HttpSessionWrapper) getAttribute(CURRENT_SESSION_ATTR);
    }

复制代码

查询我们搞懂了,很简单,其实就是透明的包装,我们拿还是直接用session.getAttributes(),那相应的也带来了问题

  1. 每次拿的都是本地session缓存中的,如何保证redis和本地session缓存尽量同步呢?我们看看spring-session是怎么处理的

redis中存储的数据结构

redis中每个session存储了三条信息。

  • spring:session:expirations 为set结构, 存储1620393360000 时间点过期的 spring:session:sessions:expires 键值

  • 第二个用来存储Session的详细信息,这个key的过期时间为Session的最大过期时间 + 5分钟。如果默认的最大过期时间为30分钟,则这个key的过期时间为35分钟。

spring:session:sessions为hash结构,主要内容:包括Session的过期时间间隔、最近的访问时间、attributes

 hgetall spring:session:sessions:1b8b2340-da25-4ca6-864c-4af28f033327
 1) "creationTime"
 2) "\\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\\x01j\\x9b\\x83\\x9d\\xfd"
 3) "maxInactiveInterval"
 4) "\\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"
 5) "lastAccessedTime"
 6) "\\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\\x01j\\x9b\\x83\\x9d\\xfd"

复制代码
  • 第三个用来表示Session在Redis中的过期,这个key-val不存储任何有用数据【存储一个空值】,只是表示Session过期而设置。这个key在Redis中的过期时间即为Session的过期时间间隔。

处理一个session为什么要存储三条数据,而不是一条呢!对于session的实现,需要监听它的创建、过期等事件,redis可以监听某个key的变化,当key发生变化时,可以快速做出相应的处理。

Redis中过期key的策略有两种:

  • 当访问时发现其过期,此时才删除,触发事件【惰性删除】
  • Redis后台逐步查找过期的键【定时删除】
  1. 当访问时发现其过期,才会产生过期事件,这就意味着,如果一直没有访问的话,过期事件一直不会触发,session也就一直不会销毁。

也就是:无法保证key的过期时间抵达后立即生成过期事件【把session给销毁】。 这也侧面说明了,前端访问的时候,是先拿服务器的Tocamt本地缓存,而不是拿redis,也就导致了,redis的键一直没有被访问,即使expire到了,也还是没被及时访问,没法触发过期事件

🎈扩展 -- redis的过期策略

redis 是一个存储键值数据库系统,那它源码中是如何存储所有键值对的呢?

Redis 本身是一个典型的 key-value 内存存储数据库,因此所有的 key、value 都保存在之前学习过的 Dict 结构中。不过在其 database 结构体中,有两个 Dict:一个用来记录 key-value;另一个用来记录 key-TTL。

内部结构

  • dict 是 hash 结构,用来存放所有的 键值对
  • expires 也是 hash 结构,用来存放所有设置了 过期时间的 键值对,不过它的 value 值是过期时间

这里有两个问题需要我们思考:

  • Redis 是如何知道一个 key 是否过期呢?
  • 利用两个 Dict 分别记录 key-value 对及 key-ttl 对,是不是 TTL 到期就立即删除了呢?

惰性删除

惰性删除:顾明思议并不是在 TTL 到期后就立刻删除,而是在访问一个 key 的时候,检查该 key 的存活时间,如果已经过期才执行删除。

周期删除

周期删除:通过一个定时任务,周期性的抽样部分过期的 key,然后执行删除。执行周期有两种:

  • Redis 服务初始化函数 initServer () 中设置定时任务,按照 server.hz 的频率来执行过期 key 清理,模式为 SLOW
  • Redis 的每个事件循环前会调用 beforeSleep () 函数,执行过期 key 清理,模式为 FAST

SLOW 模式规则:

  • 执行频率受 server.hz 影响,默认为 10,即每秒执行 10 次,每个执行周期 100ms。
  • 执行清理耗时不超过一次执行周期的 25%. 默认 slow 模式耗时不超过 25ms
  • 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期
  • 如果没达到时间上限(25ms)并且过期 key 比例大于 10%,再进行一次抽样,否则结束

FAST 模式规则(过期 key 比例小于 10% 不执行 ):

  • 执行频率受 beforeSleep () 调用频率影响,但两次 FAST 模式间隔不低于 2ms
  • 执行清理耗时不超过 1ms
  • 逐个遍历 db,逐个遍历 db 中的 bucket,抽取 20 个 key 判断是否过期
  • 如果没达到时间上限(1ms)并且过期 key 比例大于 10%,再进行一次抽样,否则结束

spring-session解决过期事件不及时触发的方法

spring-session为了能够及时的产生Session过期时的过期事件,所以增加了:

spring:session:sessions:expires:726de8fc-c045-481a-986d-f7c4c5851a67
spring:session:expirations:1620393360000
复制代码

spring-session中有个定时任务,每个整分钟都会查询相应的spring:session:expirations:【整分钟的时间戳 中的过期SessionId】

🎈然后再访问一次这个SessionId,即spring:session:sessions:expires:SessionId ,【相当于主动访问这个key ,此时会触发redis的过期发生】——即本地缓存的Session过期事件。

可能有同学会问?这不跟redis的第二个过期策略一样吗,都是去扫一遍,有必要这里再扫吗?

  • 关于这个我的理解是:redis中毕竟存储的不仅仅是session,扫描扫到session的周期可能需要很长,所以我们要专门做一个处理session的定时任务,用一个set,只存储session,而且1min就触发一次,保证尽可能同步

具体源码

定时任务代码

@Scheduled(cron = "0 * * * * *")
public void cleanupExpiredSessions() {
	this.expirationPolicy.cleanExpiredSessions();
}
复制代码

定时任务每整分运行,执行cleanExpiredSessions方法。expirationPolicy是RedisSessionExpirationPolicy实例,是RedisSession过期策略。

public void cleanExpiredSessions() {
    // 获取当前时间戳
	long now = System.currentTimeMillis();
	// 时间滚动至整分,去掉秒和毫秒部分
	long prevMin = roundDownMinute(now);
	if (logger.isDebugEnabled()) {
		logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
	}
	// 根据整分时间获取过期键集合,如:spring:session:expirations:1439245080000
	String expirationKey = getExpirationKey(prevMin);
	// 获取所有的所有的过期session
	Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
	// 删除过期Session键集合
	this.redis.delete(expirationKey);
	// touch访问所有已经过期的session,触发Redis键空间通知消息
	for (Object session : sessionsToExpire) {
		String sessionKey = getSessionKey((String) session);
		touch(sessionKey);
	}
}
复制代码

将时间戳滚动至整分

static long roundDownMinute(long timeInMs) {
	Calendar date = Calendar.getInstance();
	date.setTimeInMillis(timeInMs);
	// 清理时间错的秒位和毫秒位
	date.clear(Calendar.SECOND);
	date.clear(Calendar.MILLISECOND);
	return date.getTimeInMillis();
}
复制代码

获取过期Session的集合

String getExpirationKey(long expires) {
	return this.redisSession.getExpirationsKey(expires);
}

// 如:spring:session:expirations:1439245080000
String getExpirationsKey(long expiration) {
	return this.keyPrefix + "expirations:" + expiration;
}
复制代码

调用Redis的Exists命令,访问过期Session键,触发Redis键空间消息

/**
 * By trying to access the session we only trigger a deletion if it the TTL is
 * expired. This is done to handle
 * https://github.com/spring-projects/spring-session/issues/93
 *
 * @param key the key
 */
private void touch(String key) {
	this.redis.hasKey(key);
}
复制代码

🎯token取代session

这个留到下篇,我们再来详讲嘞,简单说就是:

  1. 服务端不存储session了,不需要服务端来维护登录状态
  2. 纯靠客户端来存储token,请求时带上token,后台服务器只需要校验

客户端跟服务端,是1对多的关系,客户端只需要存储一份tokne即可,无需考虑共享问题 而若是服务端存【也就是session】,就需要考虑共享问题

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码

)">
< <上一篇
下一篇>>