• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

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

武飞扬头像
juejin
帮助339

🎯session存在的问题

服务端需要存储session,占用内存高

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

分布式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存储了三条信息。

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/edfe3ab35c864a04949bd33ba872cfad~tplv-k3u1fbpfcp-zoom-1.image

  • 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】,就需要考虑共享问题

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanejei
系列文章
更多 icon
同类精品
更多 icon
继续加载