• 【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 中创建。

    具体源码

    1. /**
    2. * HttpServletRequest getSession()实现
    3. */
    4. @Override
    5. public HttpSessionWrapper getSession() {
    6. return getSession(true);
    7. }
    8. @Override
    9. public HttpSessionWrapper getSession(boolean create) {
    10. HttpSessionWrapper currentSession = getCurrentSession();
    11. if (currentSession != null) {
    12. return currentSession;
    13. }
    14. //从当前请求获取sessionId
    15. String requestedSessionId = getRequestedSessionId();
    16. if (requestedSessionId != null
    17. && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
    18. S session = getSession(requestedSessionId);
    19. if (session != null) {
    20. this.requestedSessionIdValid = true;
    21. currentSession = new HttpSessionWrapper(session, getServletContext());
    22. currentSession.setNew(false);
    23. setCurrentSession(currentSession);
    24. return currentSession;
    25. }
    26. else {
    27. // This is an invalid session id. No need to ask again if
    28. // request.getSession is invoked for the duration of this request
    29. if (SESSION_LOGGER.isDebugEnabled()) {
    30. SESSION_LOGGER.debug(
    31. "No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
    32. }
    33. setAttribute(INVALID_SESSION_ID_ATTR, "true");
    34. }
    35. }
    36. if (!create) {
    37. return null;
    38. }
    39. if (SESSION_LOGGER.isDebugEnabled()) {
    40. SESSION_LOGGER.debug(
    41. "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 "
    42. + SESSION_LOGGER_NAME,
    43. new RuntimeException(
    44. "For debugging purposes only (not an error)"));
    45. }
    46. //为当前请求创建session
    47. S session = SessionRepositoryFilter.this.sessionRepository.createSession();
    48. //更新时间
    49. session.setLastAccessedTime(System.currentTimeMillis());
    50. //对Spring session 进行包装(包装成HttpSession)
    51. currentSession = new HttpSessionWrapper(session, getServletContext());
    52. setCurrentSession(currentSession);
    53. return currentSession;
    54. }
    55. /**
    56. * 根据sessionId获取session
    57. */
    58. private S getSession(String sessionId) {
    59. S session = SessionRepositoryFilter.this.sessionRepository
    60. .getSession(sessionId);
    61. if (session == null) {
    62. return null;
    63. }
    64. session.setLastAccessedTime(System.currentTimeMillis());
    65. return session;
    66. }
    67. /**
    68. * 从当前请求获取sessionId
    69. */
    70. @Override
    71. public String getRequestedSessionId() {
    72. return SessionRepositoryFilter.this.httpSessionStrategy
    73. .getRequestedSessionId(this);
    74. }
    75. private void setCurrentSession(HttpSessionWrapper currentSession) {
    76. if (currentSession == null) {
    77. removeAttribute(CURRENT_SESSION_ATTR);
    78. }
    79. else {
    80. setAttribute(CURRENT_SESSION_ATTR, currentSession);
    81. }
    82. }
    83. /**
    84. * 获取当前请求session
    85. */
    86. @SuppressWarnings("unchecked")
    87. private HttpSessionWrapper getCurrentSession() {
    88. return (HttpSessionWrapper) getAttribute(CURRENT_SESSION_ATTR);
    89. }
    90. 复制代码

    查询我们搞懂了,很简单,其实就是透明的包装,我们拿还是直接用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

    1. hgetall spring:session:sessions:1b8b2340-da25-4ca6-864c-4af28f033327
    2. 1) "creationTime"
    3. 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"
    4. 3) "maxInactiveInterval"
    5. 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"
    6. 5) "lastAccessedTime"
    7. 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"
    8. 复制代码
    • 第三个用来表示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过期时的过期事件,所以增加了:

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

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

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

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

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

    具体源码

    定时任务代码

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

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

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

    将时间戳滚动至整分

    1. static long roundDownMinute(long timeInMs) {
    2. Calendar date = Calendar.getInstance();
    3. date.setTimeInMillis(timeInMs);
    4. // 清理时间错的秒位和毫秒位
    5. date.clear(Calendar.SECOND);
    6. date.clear(Calendar.MILLISECOND);
    7. return date.getTimeInMillis();
    8. }
    9. 复制代码

    获取过期Session的集合

    1. String getExpirationKey(long expires) {
    2. return this.redisSession.getExpirationsKey(expires);
    3. }
    4. // 如:spring:session:expirations:1439245080000
    5. String getExpirationsKey(long expiration) {
    6. return this.keyPrefix + "expirations:" + expiration;
    7. }
    8. 复制代码

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

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

    🎯token取代session

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

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

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

  • 相关阅读:
    JavaScript 中创建函数的多种方式
    dubbo-admin 的安装
    制定项目管理计划
    设计模式-04-原型模式
    ceph存储系统
    C. Rotation Matching
    配电房能源监测系统
    Python每日一练——第5天:闰年问题升级版
    手写模拟SpringBoot核心流程
    用HarmonyOS ArkUI调用三方库PhotoView实现图片的联播、缩放
  • 原文地址:https://blog.csdn.net/m0_73311735/article/details/127245671