-
2022-02-25 14:59:35
写在前面
Session简介
是什么?
Session在网络中表示“会话控制”,用于存储特定用户所需的属性和其他的配置信息;Session表示一个特定的时间间隔,可以指用户从登陆系统到注销退出系统之家的时间。
为什么出现?
因为http 是一种无状态协议,如果没有Session的话,服务器无法识别请求是否来自同一个用户!在一些业务场景中需要知道前面的操作和后台的操作是不是同一个用户的行为,即业务之间是有关联性的。
怎么用?
使用Session结合浏览器Cookie,将服务器Session保存到浏览器cookie中,这样可以保持http会话状态。
Session服务器创建,如Tomcat,浏览器发起请求到Tomcat服务器,然后Tomcat服务器生成SessionId保存在内存中,并将SessionId返回给浏览器,浏览器通过Cookie保存SessionId信息,用户每次通过浏览器访问服务器都会带上SessionId信息,这样就可以判断每次的请求是不是同一个用户,解决http协议无状态问题。
前言
公司现在业务服务器都采用分布式集群的方式进行部署,一个Web应用,可能部署在几台不同的服务器上,通过Nginx等进行负载均衡(一般使用Nginx+Tomcat实现负载均衡)。此时来自同一用户的Http请求将有可能被分发到不同的Web站点中去(如:第一次分配到A站点,第二次可能分配到B站点)。分布式使用Nginxt实现负载均衡,最常用的均衡算法有IP_Hash、轮训、根据权重、随机等。不管对于哪一种负载均衡算法,由于Nginx对不同的请求分发到某一个Tomcat,Tomcat在运行的时候分别是不同的容器里,因此会出现Session不同步或者丢失的问题。那么问题就来了,如何保证不同的Web站点能够共享同一份Session数据呢?
实际上,实现Session共享的方案很多,最简单的想法就是把session数据保存到内存以外的一个统一的地方,例如MongoDB/Redis等数据库中,那么问题又来了,如何替换掉Servlet容器创建和管理HttpSession的实现呢?
这里我们就可以引入一个新的框架来接管会话的session数据,那么,Spring-Session就应运而生了。
实现原理简单介绍
Spring-Session的实现就是设计一个过滤器Filter,当Web服务器接收到http请求后,当请求进入对应的Filter进行过滤,利用HttpServletRequestWrapper,实现自己的 getSession()方法,接管创建和管理Session数据的工作。将原本需要由web服务器创建会话的过程转交给Spring-Session进行创建,本来创建的会话保存在Web服务器内存中,通过Spring-Session创建的会话信息可以保存第三方的服务中,如:redis,mysql等。Web服务器之间通过连接第三方服务来共享数据,实现Session共享!
当然,我们也可以通过其他方法实现接管创建和管理Session数据的工作,可以利用Servlet容器提供的插件功能,自定义HttpSession的创建和管理策略,并通过配置的方式替换掉默认的策略。不过这种方式有个缺点,就是需要耦合Tomcat/Jetty等Servlet容器的代码。这方面其实早就有开源项目了,例如 memcached-session-manager,以及 tomcat-redis-session-manager。暂时都只支持Tomcat6/Tomcat7。这里我们将学习应用最为广泛的Spring-Session。
Spring-Session官网地址:Spring Session
Spring-Session官方文档:Spring Session
Spring Session特性
下面是来自官网的特性介绍:Spring Session提供了一套创建和管理Servlet HttpSession的方案。Spring Session提供了集群Session(Clustered Sessions)功能,默认采用外置的Redis来存储Session数据,以此来解决Session共享的问题。
Spring Session提供以下特性:
- API和用于管理用户会话的实现;
- 允许以应用程序容器(即Tomcat)中性的方式替换HttpSession;
- Spring Session 让支持集群会话变得不那么繁琐,并且不和应用程序容器金习性绑定到。
- Spring 会话支持在单个浏览器实例中管理多个用户的会话。
- Spring Session 允许在headers 中提供会话ID以使用RESTful API。
集成Spring Session的正确姿势
springboot整合redis集成spring-session非常的方便,这也是springboot的宗旨,简化配置。
(1)第一步,添加Maven依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
(2)第二步,配置redis
在application.properties中配置redis
# Redis服务器地址 spring.redis.host=localhost # Redis服务器连接端口 spring.redis.port=6379 # Redis服务器连接密码(默认为空) spring.redis.password=
(3)第三步,编写一个配置类
用来启用RedisHttpSession功能,并向Spring容器中注册一个RedisConnectionFactory,同时将RedisHttpSessionConfig加入到容器中
@Configuration @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800) public class RedisHttpSessionConfig { }
还可以把该注解@@EnableRedisHttpSession注解加在启动类的上面。
一、Spring Session原理
前面集成spring-sesion的最后一步中,编写了一个配置类RedisHttpSessionConfig,它包含注解@EnableRedisHttpSession,@EnableRedisHttpSession注解通过Import引入了RedisHttpSessionConfiguration配置类。该配置类通过@Bean注解,向Spring容器中注册了一个SessionRepositoryFilter(SessionRepositoryFilter的依赖关系:SessionRepositoryFilter --> SessionRepository --> RedisTemplate --> RedisConnectionFactory)。
SessionRepositoryFilter这个过滤器的主要作用是拦所有的请求,接管创建和管理Session数据。具体怎样接管session数据我们后边讲,我们现在只需要了解SessionRepositoryFilter整个过滤器作用就行。
RedisHttpSessionConfiguration继承了SpringHttpSessionConfiguration,SpringHttpSessionConfiguration中进行了SessionRepositoryFilter的注册,代码如下:
@Configuration public class SpringHttpSessionConfiguration implements ApplicationContextAware { //..... @Bean public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter( SessionRepository<S> sessionRepository) { SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<>(sessionRepository); sessionRepositoryFilter.setServletContext(this.servletContext); sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver); return sessionRepositoryFilter; } }
这里我们可以看到,注册这个filter时需要一个SessionRepository参数,那么,这个参数又是从哪里来的呢?
在SpringHttpSessionConfiguration的继承类RedisHttpSessionConfiguration中,我们找到了SessionRepository被注入的代码
@Configuration @EnableScheduling public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware, SchedulingConfigurer { @Bean public RedisOperationsSessionRepository sessionRepository() { RedisTemplate<Object, Object> redisTemplate = createRedisTemplate(); RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository( redisTemplate); sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher); if (this.defaultRedisSerializer != null) { sessionRepository.setDefaultSerializer(this.defaultRedisSerializer); } sessionRepository .setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds); if (StringUtils.hasText(this.redisNamespace)) { sessionRepository.setRedisKeyNamespace(this.redisNamespace); } sessionRepository.setRedisFlushMode(this.redisFlushMode); int database = resolveDatabase(); sessionRepository.setDatabase(database); return sessionRepository; } }
这样一来就不需要开发人员主动配置一个RedisOperationsSessionRepository,但是这个配置需要一个RedisOperations,而这个RedisOperations也是定义在这个类中的。而这个RedisTemplate依赖一个RedisConnectionFactory是需要开发人员配置的。如果我们使用spring-boot,只需要指定application.properties的spring.redis.cluster.nodes即可配置一个redis集群JedisConnectionFactory。具体请参考org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.RedisConnectionConfiguration
好了,下面我们可以来介绍一下SessionRepositoryFilter如何接管创建和管理Session数据了
SessionRepositoryFilter
SessionRepositoryFilter是一个优先级最高的 javax.servlet. Filter,它使用了一个SessionRepositoryRequestWrapper类接管了Http Session的创建和管理工作。
每当有请求进入时,过滤器会首先将ServletRequest 和ServletResponse 这两个对象转换成Spring内部的包装类SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper对象。
@Order(SessionRepositoryFilter.DEFAULT_ORDER) public class SessionRepositoryFilter<S extends Session> extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository); //使用HttpServletRequest 、HttpServletResponse和servletContext创建一个SessionRepositoryRequestWrapper SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper( request, response, this.servletContext); SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper( wrappedRequest, response); try { filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { //保存session信息 wrappedRequest.commitSession(); } } }
SessionRepositoryRequestWrapper类
重写了原生的getSession方法。
@Override public HttpSessionWrapper getSession(boolean create) { //获取当前Request作用域中代表Session的属性,缓存作用避免每次都从sessionRepository获取 HttpSessionWrapper currentSession = getCurrentSession(); if (currentSession != null) { return currentSession; } //查找客户端中一个叫SESSION的cookie,拿到sessionId,通过sessionRepository对象根据sessionId去Redis中查找 S requestedSession = getRequestedSession(); //如果从redis中查询到了值 if (requestedSession != null) { //客户端存在sessionId 并且未过期 if (getAttribute(INVALID_SESSION_ID_ATTR) == null) { requestedSession.setLastAccessedTime(Instant.now()); this.requestedSessionIdValid = true; currentSession = new HttpSessionWrapper(requestedSession, getServletContext()); currentSession.setNew(false); //将Session设置到request属性中 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"); } //不创建Session就直接返回null 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 // 通过sessionRepository创建RedisSession这个对象 S session = SessionRepositoryFilter.this.sessionRepository.createSession(); session.setLastAccessedTime(Instant.now()); currentSession = new HttpSessionWrapper(session, getServletContext()); setCurrentSession(currentSession); return currentSession; } // 通过sessionRepository创建RedisSession这个对象 @Override public RedisSession createSession() { Duration maxInactiveInterval = Duration .ofSeconds((this.defaultMaxInactiveInterval != null) ? this.defaultMaxInactiveInterval : MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS); RedisSession session = new RedisSession(maxInactiveInterval); session.flushImmediateIfNecessary(); return session; }
上面有一点需要注意就是将Sesison对象包装成了HttpSessionWrapper,目的是当Session失效时可以从sessionRepository删除。
这里重写了getSession方法,也就是为什么每当执行HttpServletRequest执行.getSession()方法后就会刷新session的过期时间。
private final class HttpSessionWrapper extends HttpSessionAdapter<S> { HttpSessionWrapper(S session, ServletContext servletContext) { super(session, servletContext); } @Override public void invalidate() { super.invalidate(); SessionRepositoryRequestWrapper.this.requestedSessionInvalidated = true; setCurrentSession(null); clearRequestedSessionCache(); SessionRepositoryFilter.this.sessionRepository.deleteById(getId()); } }
到这里,我们了解了SessionRepositoryRequestWrapper类接管Http Session并重写了getSession来实现了session的管理,那么,session数据又是怎么存放到redis中的呢?
SessionRepository保存session数据
上边,我们看到SessionRepositoryFilter的doFilterInternal方法最后有一个finally中执行了wrappedRequest.commitSession();方法,这里就是保存session数据到redis。
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository); SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper( request, response, this.servletContext); SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper( wrappedRequest, response); try { filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { //将Session同步到Redis,同时这个方法还会将当前的SESSIONID写到cookie中去,同时还会发布SESSION创建事件到队列里面去 wrappedRequest.commitSession(); } } //使用httpessionidresolver将会话id写入响应并持久化会话。 private void commitSession() { HttpSessionWrapper wrappedSession = getCurrentSession(); if (wrappedSession == null) { //session已过期,更新过期时间 if (isInvalidateClientSession()) { SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this, this.response); } } else { S session = wrappedSession.getSession(); clearRequestedSessionCache(); //持久化session数据到redis SessionRepositoryFilter.this.sessionRepository.save(session); String sessionId = session.getId(); if (!isRequestedSessionIdValid() || !sessionId.equals(getRequestedSessionId())) { SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this, this.response, sessionId); } } }
commitSession这个方法的作用就是当前Session存在则使用sessionRepository保存(可能是新Session)或更新(老Session则更新一下避免过期)Session。如果Session不存在并且isInvalidateClientSession()为true说明Session已过期调用httpSessionStrategy .onInvalidateSession(this, this.response);更新Cookie。
commitSession()方法还会在过滤器结束后调用,用来更新Session。
二、Spring Session机制实现
1、Spring Session 数据结构解读
使用 Spring Session 管理服务器的session信息,在Redis 中看到如下的session信息数据结构:
A) "spring:session:sessions:39feb101-87d4-42c7-ab53-ac6fe0d91925" B) "spring:session:expirations:1523934840000" C) "spring:session:sessions:expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"
为了统一叙述,在此将他们进行编号,后续简称为 A 类型键,B 类型键,C 类型键。先简单分析下他们的特点
- 他们公用的前缀是 spring:session
- A 类型键的组成是前缀 +”sessions”+sessionId,对应的值是一个 hash 数据结构。
{ "lastAccessedTime": 1523933008926,/*2018/4/17 10:43:28*/ "creationTime": 1523933008926, /*2018/4/17 10:43:28*/ "maxInactiveInterval": 1800, "sessionAttr:name": "xu" }
其中 creationTime(创建时间),lastAccessedTime(最后访问时间),maxInactiveInterval(session 失效的间隔时长) 等字段是系统字段,sessionAttr:xx 是HttpServletRequest.setAttribute("xxx","xxx")存入的,它可能会存在多个键值对,用户存放在 session 中的数据如数存放于此。A 类型键对应的默认 TTL 是 35 分钟。
- B 类型键的组成是前缀 +”expirations”+ 时间戳。其对应的值是一个 set 数据结构,这个 set 数据结构中存储着一系列的 C 类型键。B 类型键对应的默认 TTL 是 30 分钟
[ "expires:39feb101-87d4-42c7-ab53-ac6fe0d91925" "expires:836d11aa-11e2-44e0-a0b2-92b54dec2324" ]
- C 类型键的组成是前缀 +”sessions:expires”+sessionId,对应一个空值,它仅仅是 sessionId 在 redis 中的一个引用,具体作用后边介绍。C 类型键对应的默认 TTL 是 30 分钟。
2、设计 A类型键记录session信息
使用 redis 存储 session 数据,session 需要有过期机制,redis 的键可以自动过期,肯定很方便,但是,从 Spring Session 的数据结构我们可以看到, Spring Session 管理session数据使用了三种数据进行存储,为什么要如此设计呢?每个类型的数据都有什么作用呢?我们接下来就会逐一解释这三种数据的作用及用法。
我们可以想到,对 A 类型的键设置 ttl A 30 分钟,这样 30 分钟之后 session 过期,0-30 分钟期间如果用户持续操作,那就根据 sessionId 找到 A 类型的 key,刷新 lastAccessedTime 的值,并重新设置 ttl,这样就完成了「续签」的特性。
显然 Spring Session 没有采用如此简练的设计,为什么呢?我们通过阅读 Spring Session 的文档,得知,redis 的键过期机制不“保险”,这和 redis 的过期删除策略和内存淘汰策略有关,大致意思可以理解为:
- redis 在键实际过期之后不一定会被删除,可能会继续存留,但具体存留的时间我没有做过研究,可能是 1~2 分钟,可能会更久。
- 具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。划重点:这启发我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键。
- 如果没有指令持续关注 key,并且 redis 中存在许多与 TTL 关联的 key,则 key 真正被删除的时间将会有显著的延迟!显著的延迟!显著的延迟!
这里插一个题外话,简单介绍一下Redis的过期删除策略和内存淘汰策略
Redis的过期删除策略和内存淘汰策略
1、Redis关于过期时间的判定依据
在Redis内部,每当我们设置一个键的过期时间时,Redis就会将该键带上过期时间存放到一个过期字典中。当我们查询一个键时,Redis便首先检查该键是否存在过期字典中,如果存在,那就获取其过期时间。然后将过期时间和当前系统时间进行比对,比系统时间大,那就没有过期;反之判定该键过期。
2、过期删除策略
通常删除某个key,我们有如下三种方式进行处理。
①、定时删除
在设置某个key 的过期时间同时,我们创建一个定时器,让定时器在该过期时间到来时,立即执行对其进行删除的操作。
优点:定时删除对内存是最友好的,能够保存内存的key一旦过期就能立即从内存中删除。
缺点:对CPU最不友好,在过期键比较多的时候,删除过期键会占用一部分 CPU 时间,对服务器的响应时间和吞吐量造成影响。
②、惰性删除
设置该key 过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。
优点:对 CPU友好,我们只会在使用该键时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。
缺点:对内存不友好,如果一个键已经过期,但是一直没有使用,那么该键就会一直存在内存中,如果数据库中有很多这种使用不到的过期键,这些键便永远不会被删除,内存永远不会释放。从而造成内存泄漏。
③、定期删除
每隔一段时间,我们就对一些key进行检查,删除里面过期的key。
优点:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。
缺点:难以确定删除操作执行的时长和频率。
如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好。
如果执行的太少,那又和惰性删除一样了,过期键占用的内存不会及时得到释放。
另外最重要的是,在获取某个键时,如果某个键的过期时间已经到了,但是还没执行定期删除,那么就会返回这个键的值,这是业务不能忍受的错误。
Redis采用的过期删除策略
前面讨论了删除过期键的三种策略,发现单一使用某一策略都不能满足实际需求,聪明的你可能想到了,既然单一策略不能满足,那就组合来使用吧。
没错,Redis的过期删除策略就是:惰性删除和定期删除两种策略配合使用。
惰性删除:Redis的惰性删除策略由 db.c/expireIfNeeded 函数实现,所有键读写命令执行之前都会调用 expireIfNeeded 函数对其进行检查,如果过期,则删除该键,然后执行键不存在的操作;未过期则不作操作,继续执行原有的命令。
定期删除:由redis.c/activeExpireCycle 函数实现,函数以一定的频率运行,每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
注意:并不是一次运行就检查所有的库,所有的键,而是随机检查一定数量的键。
定期删除函数的运行频率,在Redis2.6版本中,规定每秒运行10次,大概100ms运行一次。在Redis2.8版本后,可以通过修改配置文件redis.conf 的 hz 选项来调整这个次数。
3、内存淘汰策略
①、设置Redis最大内存
在配置文件redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大内存。不设定该参数默认是无限制的,但是通常会设定其为物理内存的四分之三
②、设置内存淘汰方式
当现有内存大于 maxmemory 时,便会触发redis主动淘汰内存方式,通过设置 maxmemory-policy, 在redis.conf 配置文件中,可以设置淘汰方式,默认方式为:noeviction 不移除任何key,只是返回一个写错误
小结:Redis过期删除策略是采用惰性删除和定期删除这两种方式组合进行的,惰性删除能够保证过期的数据我们在获取时一定获取不到,而定期删除设置合适的频率,则可以保证无效的数据及时得到释放,而不会一直占用内存数据。但是我们说Redis是部署在物理机上的,内存不可能无限扩充的,当内存达到我们设定的界限后,便自动触发Redis内存淘汰策略,而具体的策略方式要根据实际业务情况进行选取。
我们可以看出,单纯依赖于 redis 的过期时间是不可靠的,所以Spring Session又进行了第二步的设计。
3、引入 B 类型键确保session的过期机制
如果Redis的过期删除策略不能确保过期的key立刻就被删除,那么为什么不再设计一个后台定时任务,定时去删除那些过期的键,配合上 redis 的自动过期,这样可以双重保险?但是,第一个问题来了,我们将这些过期键存在哪儿呢?不找个合适的地方存起来,定时任务到哪儿去删除这些应该过期的键呢?总不能扫描全库吧!所以,Spring Session引入了 B 类型键。
spring:session:expirations:1523934840000
这里的1523934840000 这明显是个 Unix 时间戳,它的含义是存放着这一分钟内应该过期的键,所以它是一个 set 数据结构。还记得 lastAccessedTime=1523933008926,maxInactiveInterval=1800 吧,lastAccessedTime 转换成北京时间是: 2018/4/17 10:43:28,向上取整是 2018/4/17 10:44:00,再次转换为 Unix 时间戳得到 1523932980000,单位是 ms,1800 是过期时间的间隔,单位是 s,二者相加 1523932980000+1800*1000=1523934840000。这样 B 类型键便作为了一个「桶」,存放着这一分钟应当过期的 session 的 key。
后台定时任务
org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanupExpiredSessions
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}") public void cleanupExpiredSessions() { this.expirationPolicy.cleanExpiredSessions(); }
后台提供了定时任务去“删除”过期的 key,来补偿 redis 到期未删除的 key。即:取得当前时间的时间戳作为 key,去 redis 中定位到 spring:session:expirations:{当前时间戳} ,这个 set 里面存放的便是所有过期的 key 了。
续签的影响
每次 session 的续签,需要将旧桶中的数据移除,放到新桶中。验证这一点很容易。
在第一分钟访问一次 http://localhost:8080/helloworld 端点,得到的 B 类型键为:spring:session:expirations:1523934840000;第二分钟再访问一次 http://localhost:8080/helloworld 端点,A 类型键的 lastAccessedTime 得到更新,并且 spring:session:expirations:1523934840000 这个桶被删除了,新增了 spring:session:expirations:1523934900000 这个桶。当众多用户活跃时,桶的增删和以及 set 中数据的增删都是很频繁的。对了,没提到的一点,对应 key 的 ttl 时间也会被更新。
B 类型键的并发问题
目前为止使用了 A 类型键和 B 类型键解决了 session 存储和 redis 键到期不删除的两个问题,但还是存在问题的。引入 B 类型键看似解决了问题,却也引入了一个新的问题:并发问题。
想象这样一个场景:用户在浏览器连续点击多次,形成多个线程,线程1和线程2,
- 线程 1 在第 2 分钟请求,产生了续签,session:1 应当从 1420656360000 这个桶移动到 142065642000 这个桶
- 线程 2 在第 3 分钟请求,也产生了续签,session:1 本应当从 1420656360000 这个桶移动到 142065648000 这个桶
- 如果上两步按照次序执行,自然不会有问题。但第 3 分钟的请求可能已经执行完毕了,第 2 分钟才刚开始执行。
后台定时任务会在第 32 分钟扫描到 spring:session:expirations:1420656420000 桶中存在的 session,这意味着,本应该在第 33 分钟才会过期的 key,在第 32 分钟就会被删除!一种简单的方法是用户的每次 session 续期加上分布式锁,这显然不能被接受。来看看 Spring Session 是怎么巧妙地应对这个并发问题的。
public void cleanExpiredSessions() { long now = System.currentTimeMillis(); long prevMin = roundDownMinute(now); if (logger.isDebugEnabled()) { logger.debug("Cleaning up sessions expiring at" + new Date(prevMin)); } // 获取到 B 类型键 String expirationKey = getExpirationKey(prevMin); // 取出当前这一分钟应当过期的 session Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members(); // 注意:这里删除的是 B 类型键,不是删除 session 本身! this.redis.delete(expirationKey); for (Object session : sessionsToExpire) { String sessionKey = getSessionKey((String) session); // 遍历一下 C 类型的键 touch(sessionKey); } } /** * 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) { // 并不是删除 key,而只是访问 key this.redis.hasKey(key); }
这里面逻辑主要是拿到过期键的集合(实际上是 C 类型的 key,但这里可以理解为 sessionId,C 类型我下面会介绍),此时这个集合里面存在三种类型的 sessionId。
- 已经被 redis 删除的过期键。万事大吉,redis 很靠谱的及时清理了过期的键。
- 已经过期,但是还没来得及被 redis 清除的 key。还记得前面 redis 文档里面提到的一个技巧吗?我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键,所以 redis.hasKey(key); 该操作就是为了触发 redis 的自己删除。
- 并发问题导致的多余数据,实际上并未过期。如上所述,第 32 分钟的桶里面存在的 session:1 实际上并不应该被删除,使用 touch 的好处便是我只负责检测,删不删交给 redis 判断。session:1 在第 32 分钟被 touch 了一次,并未被删除,在第 33 分钟时应当被 redis 删除,但可能存在延时,这个时候 touch 一次,确保删除。所以,源码里面特别强调了一下:要用 touch 去触发 key 的删除,而不能直接 del key。
参考 https://github.com/spring-projects/spring-session/issues/93
4、增加 C 类型键完善过期通知事件
虽然引入了 B 类型键,并且在后台加了定时器去确保 session 的过期,但似乎还是不够完善。注意一个细节,spring-session 中 A 类型键的过期时间是 35 分钟,比实际的 30 分钟多了 5 分钟,这意味着即便 session 已经过期,我们还是可以在 redis 中有 5 分钟间隔来操作过期的 session。于此同时,spring-session 引入了 C 类型键来作为 session 的引用。
为什么引入 C 类型键?redis只会告诉我们哪个键过期了,不会告诉我们内容是什么。关键就在于如果 session 过期后监听器可能想要访问 session 的具体内容,然而自身都过期了,还怎么获取内容 。所以,C 类型键存在的意义便是解耦 session 的存储和 session 的过期,并且使得 server 获取到过期通知后可以访问到 session 真实的值。对于用户来说,C 类型键过期后,意味着登录失效,而对于服务端而言,真正的过期其实是 A 类型键过期,这中间会有 5 分钟的误差。
更多相关内容 -
PHP实现负载均衡session共享redis缓存操作示例
2020-10-18 04:18:19主要介绍了PHP实现负载均衡session共享redis缓存操作,涉及php用户登陆、session存储、判断等相关操作技巧,需要的朋友可以参考下 -
多个SpringBoot项目采用redis实现Session共享功能
2020-09-08 15:19:58主要介绍了多个SpringBoot项目采用redis实现Session共享,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下 -
nginx负载均衡session共享
2021-08-18 16:04:15session共享:缓存session至内存数据库中,使用redis,memcached实现。(可以设置过期时间,过期自动清理) session持久化:将session存储至数据库中,像操作数据一样操作session。(会导致数据库大量脏数据,几乎...一、可道云项目结合redis缓存部署
redis缓存可道云项目数据及会话,加快网站访问速度
环境准备:
主机名 应用环境 外网IP(WAN) 内网IP(LAN) web01 nginx+php 10.0.0.7 172.16.1.7 redis01 redis 172.16.1.41 db01 mysql (mariadb) 172.16.1.51 1.下载可道云开源代码,部署在web01服务器上。
下载源码,解压到指定目录,授权。
[root@web01 ~]# wget wget https://static.kodcloud.com/update/download/kodbox.1.22.zip [root@web01 ~]# mkdir /code/kedaoyun -p [root@web01 ~]# unzip kodbox.1.22.zip -d /code/kedaoyun [root@web01 ~]# chmod -R www.www /code/kedaoyun
2.编写配置文件
[root@web01 conf.d]# cat kedaoyun.conf server { listen 80; server_name kdy.bertwu.net; root /code/kedaoyun; location / { index index.php; } location ~ .*\.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }
3.mysql数据库授权远程登录,确保db01开启了远程连接用户。
MariaDB [(none)]> grant all privileges on *.* to 'app'@'172.16.1.%' identified by '123kod456';
4.db01上创建可道云数据库
MariaDB [(none)]> create database kodbox; Query OK, 1 row affected (0.00 sec)
5.配置hosts文件,浏览器访问 kdy.bertwu.net
6. redis服务器上安装redis并启用[root@redis01 ~]# yum install redis -y
7.配置 redis 监听在本地的内网网卡上
[root@redis01 ~]# sed -i '/^bind/c bind 127.0.0.1 172.16.1.41' /etc/redis.conf
8.启动redis
[root@redis01 ~]# systemctl start redis [root@redis01 ~]# systemctl enable redis [root@redis01 ~]# netstat -ntlp Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 172.16.1.41:6379 0.0.0.0:* LISTEN 1262/redis-server 1 tcp 0 0 127.0.0.1:6379 0.0.0.0:* LISTEN 1262/redis-server 1
9.接入redis
10.查看redis已经有用户的数据了127.0.0.1:6379> keys * 1) "ac3e2_UserModel_getInfoFull_ID_1" 2) "43abe268181757bedee0e410ac6c5e4b" 3) "ac3e2_SystemOption_System.storageList" 4) "ac3e2_SystemOption_System.sourceAuthList" 5) "ac3e2_UserModel_getInfoSimple_ID_1" 6) "ac3e2_SystemOption_System.noticeList" 7) "ac3e2_UserTagSourceModel_listData_ID_0"
二、Nginx负载均衡会话共享
1.1 什么是会话保持
会话(Session)跟踪是Web程序中常用的技术,用来跟踪用户的整个会话。常用的会话跟踪技术是Cookie与Session。Cookie通过在客户端记录信息确定用户身份,Session通过在服务器端记录信息确定用户身份。当用户登陆一个网站服务器,网站服务器会将用户的登陆信息存储下来(存储下来的内容叫 Session )以保证我们能够一直处于 ”登陆在线“ 状态。
1.2 为什么需要会话保持
由于我们使用的是负载均衡轮询机制,会导致用户请求分散在不同的节点,从而造成会话无法保持。假设用户A,通过负载均衡登陆了网站,此时会话信息存储在A节点,那么当它一刷新,负载均衡会将请求分发给B节点,那么B节点没有用户A的登陆信息,就会提示用户A登陆,当A用户点击登陆时又会将请求分发给C节点,从而造成用户A无法实现会话保持。
1.3 Cookie机制、Session机制、token机制
请参考:
Cookie与Session
彻底理解cookie,session,token的区别
cookie保存在客户端,但是不安全。session存在服务器端,由服务器保存,但是会增加服务器压力,而且负载均衡机制会调度在不同节点。token是结合了两者的优势。1.4 如何实现会话保持
- 粘性session:指Ngnix每次都将同一用户的所有请求转发至同一台服务器上,及Nginx的 IP_hash。
- session复制(几乎不用):每次session发生变化,就广播给集群中的服务器,使所有的服务器上的session相同。
- session共享:缓存session至内存数据库中,使用redis,memcached实现。(可以设置过期时间,过期自动清理)
- session持久化:将session存储至数据库中,像操作数据一样操作session。(会导致数据库大量脏数据,几乎不用)
1.5 会话保持场景实践
环境准备:
主机名 应用环境 外网IP(WAN) 内网IP(LAN) web01 nginx+php 10.0.0.7 172.16.1.7 web02 nginx+php 10.0.0.8 172.16.1.8 db01 mysql(mariadb) 172.16.1.41 redis01 redis 172.16.1.41 lb01 nginx负载均衡 10.0.0.5 172.16.1.51 1.下载 下载phpmyadmin项目
官网
2.解压到指定目录制作软连接,授权[root@web01 ~]# unzip phpMyAdmin-5.1.1-all-languages.zip -d /code # 解压 [root@web01 ~]# cd /code/ [root@web01 code]# ln -s phpMyAdmin-5.1.1-all-languages/ phpmyadmin # 制作软连接 [root@web01 code]# ll lrwxrwxrwx 1 root root 31 Aug 18 19:57 phpmyadmin -> phpMyAdmin-5.1.1-all-languages/ [root@web01 code]# chown -R www.www phpmyadmin/ # 授权
3.修改 phpmyadmin 连接远程的数据库
[root@web01 code]# cd phpmyadmin/ [root@web01 phpmyadmin]# cp config.sample.inc.php config.inc.php # 修改样本名,然后修改内容 [root@web01 phpmyadmin]# vim config.inc.php $cfg['Servers'][$i]['host'] = '172.16.1.51';
4编写nginx配置文件
[root@web01 conf.d]# cat phpmyadmin.conf server { listen 80; server_name phpmyadmin.bertwu.net; root /code/phpmyadmin; location / { index index.php index.htmp; } location ~ .*\.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } [root@web01 conf.d]# nginx -t [root@web01 conf.d]# systemctl reload nginx
5.db01端授权远程用户登录
MariaDB [(none)]> grant all privileges on *.* to 'app'@'172.16.1.%' identified by '123guest456'; Query OK, 0 rows affected (0.00 sec)
7.浏览器测试是否连接成功
8.web02节点同样的部署
9.编写负载均衡nginx配置文件
编写一份 proxy 负载均衡的配置文件,将请求调度到后端 web 节点[root@lb01 conf.d]# cat lb_phpmyadmin.conf upstream lb_phpmyadmin { server 172.16.1.7:80; server 172.16.1.8:80; } server { listen 80; server_name phpmyadmin.bertwu.net; location / { proxy_pass http://lb_phpmyadmin; include proxy_params; } } [root@lb01 conf.d]# nginx -t [root@lb01 conf.d]# nginx -t
其中proxy_params配置如下
为什么要接入redis,是因为需要共享session 。否则负载均衡时候,客户第一次请求后端,服务器下发的session与第二次请求携带给后端的cookie总是不能在同一主机,所以导致永远都登录不上。解决方案有多种,可以用ip_hash、 url_hash、(缺点是只能调度到某一个节点,请求数据倾斜)。如果想调度到任意节点都不影想会话不掉线,建议使用 redis的session共享方案。[root@lb01 nginx]# cat proxy_params # ip proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # http version proxy_http_version 1.1; proxy_set_header Connection ""; # timeout proxy_connect_timeout 120s; proxy_read_timeout 120s; proxy_send_timeout 120s; # buffer proxy_buffering on; proxy_buffer_size 8k; proxy_buffers 8 8k;
10.redis01服务器上安装redis并启用
[root@redis01 ~]# yum install redis -y
11.配置 redis 监听在本地的内网网卡上
[root@redis01 ~]# sed -i '/^bind/c bind 127.0.0.1 172.16.1.41' /etc/redis.conf
12.启动redis
[root@redis01 ~]# systemctl start redis [root@redis01 ~]# systemctl enable redis [root@redis01 ~]# netstat -ntlp Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 172.16.1.41:6379 0.0.0.0:* LISTEN 1262/redis-server 1 tcp 0 0 127.0.0.1:6379 0.0.0.0:* LISTEN 1262/redis-server 1
13.配置php连接Redis
1.修改 /etc/php.ini 文件。[所有节点都需要操作][root@web01 conf.d]# vim /etc/php.ini session.save_handler = redis session.save_path = "tcp://172.16.1.41:6379" ;session.save_path = "tcp://172.16.1.41:6379?auth=123456" #如果redis存在密码,则使用该方式
2.注释 php-fpm.d/www.conf 里面的两条内容,否则 session 内容会一直写入 /var/lib/php/session 目录中,从而造成会话共享失败。(所有节点都需要操作)
[root@web01 conf.d]# vim /etc/php-fpm.d/www.conf ;php_value[session.save_handler] = files ;php_value[session.save_path] = /var/lib/php/session
3.重启 php-fpm 服务。(所有节点都需要操作)
[root@web01 conf.d]# php-fpm -t [root@web01 conf.d]# systemctl restart nginx
14.测试集群会话共享
1.浏览器查看请求响应的cookie信息
2检查 redis 中是否存在 cookie 对应的 session 信息[root@redis01 ~]# redis-cli 127.0.0.1:6379> 127.0.0.1:6379> keys * 1) "PHPREDIS_SESSION:b7681e67c147e7ce1e296a5b650b9b4d" 127.0.0.1:6379>
3.此时用户的 cookie 始终都不会发生任何变化,无论请求被负载调度到那一台后端web 节点服务器都不会出现没有登陆情况。
三、 后端节点异常容错机制
使用nginx负载均衡时,如何将后端请求超时的服务器流量平滑的切换到另一台上?如果后台服务连接超时,Nginx是本身是有机制的,如果出现一个节点down掉的时候,Nginx会更据你具体负载均衡的设置,将请求转移到其他的节点上,但是,如果后台服务连接没有down掉,而是返回了错误异常码 如:504、502、500,该怎么办?
3.1 配置语法
# 当其中一台返回错误码 404,500 等错误时,可以分配到下一台服务器程序继续处理,提高平台访问成功率。 proxy_next_upstream error | timeout | http_500 | http_502 | http_503 | http_504 |http_404;
3.2 场景示例
blog项目为例,如果有一个节点php-fpm服务异常停止,这样前端用户请求页面就会时而502时而正常,用户体验度极差。
解决方案:[root@lb01 conf.d]# cat proxy_blog.conf upstream blog { server 172.16.1.7:80; server 172.16.1.8:80; } server { listen 80; server_name blog.bertwu.net; location / { proxy_pass http://blog; proxy_next_upstream error timeout http_500 http_502 http_503 http_504; proxy_next_upstream_tries 2; # 尝试的次数 proxy_next_upstream_timeout 3s; # 尝试的超时时间 include /etc/nginx/proxy_params; } }
四、Nginx负载均衡调度场景
4.1 根据uri进行调度(路由)
由于资源有限,每个集群其实是一台主机,基于开放多端口模拟集群中的多台主机。主机名 应用环境 外网IP(WAN) 内网IP(LAN) 开放端口 web01 nginx 10.0.0.7 172.16.1.7 8081, 8082 web02 nginx 10.0.0.8 172.16.1.8 8081, 8082 lb01 nginx 10.0.0.5 172.16.1.5 80 web01配置如下:
[root@web01 conf.d]# cat uri.conf server { listen 8081; server_name uri.bertwu.net; root /code/user1; location / { index index.html; } } server { listen 8082; server_name uri.bertwu.net; root /code/user2; location / { index index.html; } } [root@web01 conf.d]# mkdir /code/user1 [root@web01 conf.d]# mkdir /code/user2 [root@web01 conf.d]# echo 'user-8081' > /code/user1/index.html [root@web01 conf.d]# echo 'user-8082' > /code/user2/index.html [root@web01 conf.d]# nginx -t [root@web01 conf.d]# systemctl reload nginx
web02配置如下:
[root@web02 conf.d]# cat uri.conf server { listen 8081; server_name uri.bertwu.net; root /code/pass1; location / { index index.html; } } server { listen 8082; server_name uri.bertwu.net; root /code/pass2; location / { index index.html; } } [root@web02 conf.d]# mkdir /code/pass1 [root@web02 conf.d]# mkdir /code/pass2 [root@web02 conf.d]# echo 'pass-8081' > /code/pass1/index.html [root@web02 conf.d]# echo 'pass-8082' > /code/pass2/index.html [root@web02 conf.d]# nginx -t [root@web02 conf.d]# systemctl reload nginx
lb01负载均衡配置如下:
[root@lb01 conf.d]# cat lb_uri.conf upstream user { server 172.16.1.7:8081; server 172.16.1.7:8082; } upstream pass { server 172.16.1.8:8081; server 172.16.1.8:8082; } server { listen 80; server_name uri.bertwu.net; location /user { proxy_pass http://user; include proxy_params; } location /pass { proxy_pass http://pass; include proxy_params; } } [root@lb01 conf.d]# nginx -t [root@lb01 conf.d]# systemctl reload nginx
前端修改完hosts文件后访问http://uri.bertwu.net/user 或http://uri.bertwu.net/pass,都会报404错误。打开任意一个节点的错误日志发现:
2021/08/19 00:22:01 [error] 2804#2804: *198 open() "/code/user1/user" failed (2: No such file or directory), client: 172.16.1.5, server: uri.bertwu.net, request: "GET /user HTTP/1.1", host: "uri.bertwu.net"
TMD 竟然路径成了 /code/user1/user ,这是为什么?
4.2 Proxy添加/与不添加/
在使用proxy_pass反向代理时,最后结尾添加/和不添加/有什么区别?
proxy_pass http://localhost:8080; proxy_pass http://localhost:8080/;
不添加时 #用户请求URL: /user/index.html #请求到达Nginx负载均衡: /user/index.html #Nginx负载均衡到后端节点: /user/index.html 添加/时 #用户请求URL: /user/index.html #请求到达Nginx负载均衡: /user/index.html #Nginx负载均衡到后端节点: /index.html
1.带 / 意味着Nginx代理会修改用户请求的URL,将location匹配的URL进行删除。
2.不带 / 意味着Nginx代理不会修改用户请求的URL,而是直接代理到后端应用服务器上述问题只需要在prox_pass后面加/,代码如下:
[root@lb01 conf.d]# cat lb_uri.conf upstream user { server 172.16.1.7:8081; server 172.16.1.7:8082; } upstream pass { server 172.16.1.8:8081; server 172.16.1.8:8082; } server { listen 80; server_name uri.bertwu.net; location /user { proxy_pass http://user/; include proxy_params; } location /pass { proxy_pass http://pass/; include proxy_params; } }
方案二,创建/code/user1/user 目录,将/code/user1/index.htm 移动到code/user1/user/index.html ,其他节点同理。
4.3 根据请求设备进行调度
[root@lb01 conf.d]# cat agent.conf upstream phone { server 172.16.1.7:80; } upstream pc { server 172.16.1.8:80; } server { listen 80; charset utf-8; include proxy_params; default_type text/html; server_name agent.bertwu.net; location / { if ($http_user_agent ~* 'android|iphone|ipad') { proxy_pass http://phone; } if ($http_user_agent ~* 'MSIE|Trident') { return 200 "浏览器真棒!"; } proxy_pass http://pc; } }
[root@web02 conf.d]# cat pc.conf server { listen 80; server_name agent.bertwu.net; root /code/pc; location / { index index.html; } }
server { listen 80; server_name agent.bertwu.net; root /code/phone; location / { index index.html; } }
五、 Nginx多级代理获取真实IP
5.1 多级代理获取地址概述
用户发起请求,沿途可能会经过多级代理,最终抵达应用节点,那应用节点如何获取客户端真实IP地址
1:通过X-Forwarded-For透传客户端的真实IP实现方式
2:使用Nginx RealIP模块实现客户端地址透传
5.2 多级代理获取地址实践
5.1 多级代理获取地址概述
用户发起请求,经过多级代理之后, web服务器无法直接获取到客户端真实的IP地址。
$remote_addr获取的是上级反反向代理的IP地址。 反向代理服务器在转发请求的http头信息中,
增加http_x_forwarded_for信息,用来记录客户端IP地址和客户端请求的服务器地址的详细过程。5.2.1.环境准备
一级代理 10.0.0.5 proxy_node1 二级代理 10.0.0.7 proxy_node2 三级代理 10.0.0.9 proxy_node3 真实节点 10.0.0.8 webserver 路径: 10.0.0.1--->10.0.0.5----->10.0.0.9------>10.0.0.8
5.2.2 配置一级代理
[root@lproxy_node1 conf.d]# cat proxy_node1.conf server { listen 80; server_name ip.bertwu.net; location / { proxy_pass http://10.0.0.7:80; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
5.2.3 配置二级代理
[root@proxy_node2 conf.d]# cat proxy_node2.conf server { listen 80; server_name ip.bertwu.net; location / { proxy_pass http://10.0.0.9:80; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
5.2.4 配置三级代理
[root@proxy_node3 conf.d]# cat proxy_node3.conf server { listen 80; server_name ip.bertwu.net; location / { proxy_pass http://10.0.0.8:80; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
5.2.5 配置webserver
web02.conf web.conf [root@weberver conf.d]# cat web.conf server { listen 80; server_name ip.bertwu.net; root /code/ip; location / { index index.html; } }
5.2.5 浏览器访问ip.bertwu.net,观察并分析各节点日志
以下是各级代理的访问日志信息,请注意观察第一段
$remote_addr
和最后一段$http_x_forwarded_for
的信息。# 一级代理日志 10.0.0.1 - - [20/Aug/2021:00:24:12 +0800] "GET / HTTP/1.1" 200 11 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-" # 二级代理日志 10.0.0.5 - - [20/Aug/2021:00:24:12 +0800] "GET / HTTP/1.1" 200 11 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1" # 三级代理日志 172.16.1.7 - - [20/Aug/2021:00:24:12 +0800] "GET / HTTP/1.1" 200 11 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1, 10.0.0.5" # webserver日志 10.0.0.9 - - [20/Aug/2021:00:24:12 +0800] "GET / HTTP/1.1" 200 11 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1, 10.0.0.5, 10.0.0.7"
5.3 RealiP模块获取真实IP
使用
nginx Realip_module
获取多级代理下的客户端真实IP地址,在后端Web节点上添加如下配置即可(Realip需要知道所有沿途经过的代理节点的IP地址或IP段);[root@web02 conf.d]# cat web.conf server { listen 80; server_name ip.bertwu.net; set_real_ip_from 10.0.0.5; set_real_ip_from 10.0.0.7; set_real_ip_from 10.0.0.9; real_ip_header X-Forwarded-For; real_ip_recursive on; root /code/ip; location / { index index.html; } } #set_real_ip_from:真实服务器上一级代理的IP地址或者IP段,可以写多行 #real_ip_header:从哪个header头检索出需要的IP地址 #real_ip_recursive:递归排除set_real_ip_from里面出现的IP,其余没有出现的认为是用户真实IP
再次查看日志:
# 一级代理日志 10.0.0.1 - - [20/Aug/2021:01:06:39 +0800] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "-" # 二级代理日志 10.0.0.5 - - [20/Aug/2021:01:06:39 +0800] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1" # 三级代理日志 10.0.0.7 - - [20/Aug/2021:01:06:39 +0800] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1, 10.0.0.5" # webserver日志 10.0.0.1 - - [20/Aug/2021:01:06:39 +0800] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1, 10.0.0.5, 10.0.0.7"
可以看到,最终结果是
10.0.0.1 - - [20/Aug/2021:01:06:39 +0800] "GET / HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36" "10.0.0.1, 10.0.0.5, 10.0.0.7"
而
10.0.0.5、10.0.0.7、10.0.0.9
都出现在出现在set_real_ip_from中,仅仅10.0.0.1没出现,那么他就被认为是用户真实的ip地址,同时会被赋值到$remote_addr
变量中,这样我们的程序无需做任何修改,直接使用$remote_addr
变量即可获取真实IP地址。 -
使用redis实现session共享
2022-07-13 22:44:30使用redis实现session共享继上一遍文章,使用nginx实现负载均衡以后,新的问题又产生了,当使用负载均衡以后,我们如何实现session共享,因为session是保存在服务端的,对于单体应用来说这不是问题,因为只有一个服务器,但是在多个服务器的情况下,我们在A服务器登陆成功,session存在A服务器,下一次请求被转发到了B,但是B服务器中没有对应的用户信息,重新让用户登录肯定不合适,这个时候我们需要将该session信息放在一个公共的地方,不管后续请求被转发到了哪个服务器,我们都去从公共的位置去取,这样就实现了session共享(类似问题的解决方案有很多,比如存入数据库、配置tomcat实现session同步,使用jwt等等),今天我们使用的是spring session+redis来实现.
此处的代码基于上一篇的nginx实现负载均衡
改造而来。
1.pom文件引入相关依赖(这是我全部的依赖,贴出来方便复制粘贴)<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.1</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.weige</groupId> <artifactId>test-db-nginx</artifactId> <version>0.0.1-SNAPSHOT</version> <name>test-db-nginx</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.1</version> </dependency> <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.6.4</version> </dependency> <!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>2.6.3</version> </dependency> </dependencies> <build> <finalName>bookTest86</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
2.配置redis相关信息
spring.redis.host=127.0.0.1 spring.redis.port=6380 spring.redis.password=123456
3.添加一个配置类
package com.weige.config; import org.springframework.context.annotation.Configuration; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; /** * Created with IntelliJ IDEA. * * @Author: weige * @Date: 2022/07/13/19:41 * @Description: */ @Configuration @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 70)//session过期时间,单位为秒 public class RedisSessionConfig { }
4.添加一个模拟用户登录/注销的接口
package com.weige.controller; import com.weige.entity.User; import com.weige.vo.RespBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; /** * Created with IntelliJ IDEA. * * @Author: weige * @Date: 2022/07/13/7:59 * @Description: */ @RestController public class UserController { @Value("${server.port}") private String port; //登录 @PostMapping("/login") public RespBean login( HttpServletRequest request){ HttpSession session=request.getSession(); //获取当前的sessionId String sessionId = session.getId(); //懒得传入,在这里写死的,能说明问题就行 User user=new User(); user.setUsername("悟空"); user.setPassword("123456"); user.setAddress("北京市天安门"); System.out.println("登陆成功,sessionId:"+sessionId+";user===>"+user); session.setAttribute(sessionId,user); return RespBean.ok("登陆成功:"+port,user); } //获取当前用户 @GetMapping("/getuser") public RespBean getuser(HttpServletRequest request){ HttpSession session=request.getSession(); String sessionId = session.getId(); System.out.println("获取用户,sessionId==>"+sessionId); return RespBean.ok("登陆成功:"+port,session.getAttribute(sessionId)); } //注销 @GetMapping("/logout") public RespBean loginout(String username, HttpServletRequest request){ HttpSession session=request.getSession(); //这里不做查询,直接清除session String sessionId = session.getId(); System.out.println("注销登录,sessionId==>"+sessionId+"服务端口:"+port); session.removeAttribute(sessionId); return RespBean.ok("注销成功:"+port); } }
5.改造获取数据的接口,添加了对用户是否登录的判断
package com.weige.controller; import com.weige.entity.Book; import com.weige.entity.User; import com.weige.service.BookService; import com.weige.vo.RespBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import java.util.List; /** * Created with IntelliJ IDEA. * * @Author: weige * @Date: 2022/07/11/20:57 * @Description: */ @RestController @RequestMapping("/book") public class BookController { @Autowired BookService bookService; @Value("${server.port}") private String port; @GetMapping("/") public RespBean getAllBooks(HttpSession session){ //HttpSession session=request.getSession(); String sessionId = session.getId(); System.out.println("查询book,sessionId==>"+sessionId+"服务端口:"+port); User user= ((User) session.getAttribute(sessionId)); if(user==null){ return RespBean.error("用户还没登录,请先登录!服务端口:"+port); } System.out.println("我从session中拿到了User--->"+user ); List<Book> books=bookService.getAllBooks(); return RespBean.ok(port,books); } @PostMapping("/") public RespBean addBook(@RequestBody Book book){ int ret=bookService.addBook(book); return RespBean.ok(port,ret); } @PutMapping("/") public RespBean updateBook(@RequestBody Book book){ int ret=bookService.updateBook(book); return RespBean.ok(port,ret); } @DeleteMapping("/{id}") public RespBean delBook(@PathVariable Integer id){ int ret=bookService.delBook(id); return RespBean.ok(port,ret); } }
关键的信息就是这些,在获取全部book信息的时候,会根据sessionId,从当前session中获取用户信息,如果用户不存在,就提示没有登录,登录后可以正常获取,我们将项目打包,放在服务器上,以8886和8887端口启动,结合之前配置的nginx负载均衡,我们希望无论在那一台服务上登录过,都可以正常的获取book信息,这样我们session共享的需求就实现了
(1)启动8886
(2)启动8887
(3)请求获取book接口,提示未登录,此时提供服务的是8886
(4)请求登录接口,注意此时提供服务的是8887,多次请求,8886和8887会交替出现,这是因为我们在nginx中配置的权重
(5)再次请求获取book接口(此时提供服务的是8886)
以上说明,同一个session,我们从任意一台服务器登陆成功后,在其它服务器上都可以获取到session中的用户信息,实现了session共享。 -
基于SpringBoot+Redis的Session共享与单点登录详解
2020-08-25 19:36:00主要介绍了基于SpringBoot+Redis的Session共享与单点登录,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 -
第三章 Session共享&单点登陆笔记
2019-11-29 22:24:43一、传统Session机制及身份认证方案 1、Cookie与服务器的交互 如上图,http是无状态的协议,客户每次读取web页面时,服务器都打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一...一、传统Session机制及身份认证方案
1、Cookie与服务器的交互
如上图,http是无状态的协议,客户每次读取web页面时,服务器都打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内的购物车功能,要怎么才能知道哪些购物车请求对应的是来自同一个客户的请求呢?session就是一种保存上下文信息的机制,它是针对每一个用户的,变量的值保存在服务器端,通过SessionID来区分不同的客户。session是以cookie或URL重写为基础的,默认使用cookie来实现,系统会创造一个名为JSESSIONID的值输出到cookie。
注意JSESSIONID是存储于浏览器内存中的,并不是写到硬盘上的,如果我们把浏览器的cookie禁止,则web服务器会采用URL重写的方式传递Sessionid,我们就可以在地址栏看到 sessionid=KWJHUG6JJM65之类的字符串。 通常JSESSIONID是不能跨窗口使用的,当你新开了一个浏览器窗口进入相同页面时,系统会赋予你一个新的sessionid,这样我们信息共享的目的就达不到了。
2、服务器端的session的机制
session机制是一种服务器端的机制,服务器使用一种类似于散列表的结构(也可能就是使用散列表)来保存信息。但程序需要为某个客户端的请求创建一个session的时候,服务器首先检查这个客户端的请求里是否包含了一个JSESSIONID标识的sessionid,如果已经包含一个session id则说明以前已经为此客户创建过session,服务器就根据sessionid把这个session检索出来使用(如果检索不到,可能会新建一个,这种情况可能出现在服务端已经删除了该用户对应的session对象,但用户人为地在请求的URL后面附加上一个JSESSION的参数)。
如果客户请求不包含 session id,则为此客户创建一个session并且生成一个与此session相关联的session id,这个session id将在本次响应中返回给客户端保存。对每次http请求,都经历以下步骤处理:服务端首先查找对应的cookie的值(sessionid)。 根据sessionid从服务器端session存储中获取对应id的 session数据,进行返回。 如果找不到sessionid,服务器端就创建session,生成sessionid对应的cookie,写入到响应头中。
3、基于session的身份认证
看下图:
因为http请求是无状态请求,所以在Web领域,几乎所有的身份认证过程,都是这种模式。
二、集群下Session困境及解决方案
如上图,随着分布式架构的流行,单个服务器已经不能满足系统的需要了,通常都会把系统部署在多台服务器上,通过负载均衡把请求分发到其中的一台服务器上,这样很可能同一个用户的请求被分发到不同的服务器上,因为session是保存在服务器上的,那么很有可能第一次请求访问的A服务器,创建了session,但是第二次访问到了B服务器,这时就会出现取不到session的情况。我们知道,Session一般是用来存会话全局的用户信息(不仅仅是登陆方面的问题),用来简化/加速后续的业务请求。要在集群环境下使用,最好的的解决办法就是使用session共享:
1、Session共享方案
传统的session由服务器端生成并存储,当应用进行分布式集群部署的时候,如何保证不同服务器上session信息能够共享呢?两种实现思路:session集中存储(redis,memcached,hbase等)。不同服务器上session数据进行复制,此方案延迟问题比较严重。 我们一般推荐第一种方案,基于session集中存储的实现方案,见下图:
具体过程如下:
新增Filter拦截请求,包装HttpServletRequest(使用HttpServletRequestWrapper) 改写getSession方法,从第三方存储中获取session数据(若没有则创建一个),返回自定义的HttpSession实例在http返回response时,提交session信息到第三方存储中。
2、需要考虑的问题
2.1、需要考虑以下问题:
session数据如何在Redis中存储?
session属性变更何时触发存储?
2.2、实现:
考虑到session中数据类似map的结构,采用redis中hash存储session数据比较合适,如果使用单个value存储session数据,不加锁的情况下,就会存在session覆盖的问题,因此使用hash存储session,每次只保存本次变更session属性的数据,避免了锁处理,性能更好。如果每改一个session的属性就触发存储,在变更较多session属性时会触发多次redis写操作, 对性能也会有影响,我们是在每次请求处理完后,做一次session的写入,并且写入变更过的属性。如果本次没有做session的更改,是不会做redis写入的,仅当没有变更的session超过一个时间阀值(不变更session刷新过期时间的阀值),就会触发session保存,以便session能够延长有效期。
3、代码实战
3.1、新建项目springboot-session-bussiness
主要实现正常的登录拦截功能,为后面项目改造提供参考。
代码参见:springboot-platform------>springboot-session-bussiness模块
代码git地址:https://gitee.com/hankin_chj/springboot-platform.git
3.2、新建springboot-session-bussiness模块
该模块为公共模块,主要用于为其他服务提供统一的session拦截、分装等功能,主要对系统的request和session对象进行重写,其他服务需要用到request的时候就使用封装好的MyRequestWrapper来获取session相关内容。
1)重写HttpSession
public class MySession implements Serializable,HttpSession {
private static final long serialVersionUID = -3923541488767125713L;
private String id;
private Map<String,Object> attrs;
.....2)request封装代码实现如下:
public class MyRequestWrapper extends HttpServletRequestWrapper {
private volatile boolean committed = false;
private String uuid = UUID.randomUUID().toString();
private MySession session;
private RedisTemplate redisTemplate;
public MyRequestWrapper(HttpServletRequest request,RedisTemplate redisTemplate) {
super(request);
this.redisTemplate = redisTemplate;
}
// 提交session内值到redis
public void commitSession() {
if (committed) {
return;
}
committed = true;
MySession session = this.getSession();
if (session != null && null != session.getAttrs()) {
redisTemplate.opsForHash().putAll(session.getId(),session.getAttrs());
}
}
//创建新session
public MySession createSession() {
String sessionId = CookieBasedSession.getRequestedSessionId(this);//从页面传来的
Map<String,Object> attr ;
if (null != sessionId){
attr = redisTemplate.opsForHash().entries(sessionId);
} else {
System.out.println("create session by rId:"+uuid);
sessionId = UUID.randomUUID().toString();
attr = new HashMap<>();
}
//session成员变量持有
session = new MySession();
session.setId(sessionId);
session.setAttrs(attr);
return session;
}
//或取session
public MySession getSession() {
return this.getSession(true);
}
// 取session
public MySession getSession(boolean create) {
if (null != session){
return session;
}
return this.createSession();
}
//是否已登陆
public boolean isLogin(){
Object user = getSession().getAttribute(SessionFilter.USER_INFO);
return null != user;
}
}3)包装request对象,提交session到redis,SessionFilter代码实现:
public class SessionFilter implements Filter {
public static final String USER_INFO = "user";
private RedisTemplate redisTemplate;
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
//包装request对象
MyRequestWrapper myRequestWrapper = new MyRequestWrapper(request,redisTemplate);
//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&& !requestUrl.startsWith("/login")//不是去登陆
&& !myRequestWrapper.isLogin()) {//不是登陆状态
request.getRequestDispatcher("/toLogin").forward(myRequestWrapper,response);
return ;
}
try {
filterChain.doFilter(myRequestWrapper,servletResponse);
} finally {
//提交session到redis
myRequestWrapper.commitSession();
}
}
@Override
public void destroy() { }
}4)工具类CookieBasedSession通过cookie获取sessionID:
public class CookieBasedSession{
public static final String COOKIE_NAME_SESSION = "psession";
public static String getRequestedSessionId(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (!COOKIE_NAME_SESSION.equalsIgnoreCase(cookie.getName())) {
continue;
}
return cookie.getValue();
}
return null;
}
public static void onNewSession(HttpServletRequest request,HttpServletResponse response) {
HttpSession session = request.getSession();
String sessionId = session.getId();
Cookie cookie = new Cookie(COOKIE_NAME_SESSION, sessionId);
cookie.setHttpOnly(true);
cookie.setPath(request.getContextPath() + "/");
// 设置一级域名
cookie.setDomain("dev.com");
cookie.setMaxAge(Integer.MAX_VALUE);
response.addCookie(cookie);
}
}3.3、新建springboot-sessionA与springboot-sessionB项目
SessionA和sessionB项目代码基本一致,知识配置不一样,模拟两个业务服务登录场景
1)pom文件配置
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.chj</groupId>
<artifactId>springboot-session-support</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--使用swagger2-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.6.1</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.6.1</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.1.3.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>2)application.yml配置
spring:
redis:
host: 127.0.0.1
port: 6379
devtools:
restart:
enabled: true #开启
additional-paths: src/main/java #监听目录
swagger:
host: local.dev.com3)controller层代码实现,注意接口参数MyRequestWrapper代替了HttpServletRequest
@Controller
public class IndexController {
@GetMapping("/toLogin")
public String toLogin(Model model,MyRequestWrapper request) {//仅限本次会话
UserForm user = new UserForm();
user.setUsername("hankin");
user.setPassword("hankin");
user.setBackurl(request.getParameter("url"));
model.addAttribute("user", user);
return "login";
}
@PostMapping("/login")
public void login(@ModelAttribute UserForm user,MyRequestWrapper request,HttpServletResponse response) throws IOException, ServletException {
request.getSession().setAttribute(SessionFilter.USER_INFO,user);
//TODO 种cookie
CookieBasedSession.onNewSession(request,response);
//重定向
response.sendRedirect("/index");
}
@GetMapping("/index")
public ModelAndView index(MyRequestWrapper request) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", request.getSession().getAttribute(SessionFilter.USER_INFO));
request.getSession().setAttribute("test","123");
return modelAndView;
}
}4)session配置将SessionFilter注入到servlet容器
/**
* 把SessionFilter 注册掉servlet容器里面
*/
@Configuration
public class SessionConfig {
//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration(SessionFilter sessionFilter) {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(sessionFilter);
registration.addUrlPatterns("/*");
registration.addInitParameter("paramName", "paramValue");
registration.setName("sessionFilter");
registration.setOrder(1);
return registration;
}
//定义过滤器组件
@Bean
public SessionFilter sessionFilter(RedisTemplate redisTemplate){
SessionFilter sessionFilter = new SessionFilter();
sessionFilter.setRedisTemplate(redisTemplate);
return sessionFilter;
}
}注意:
SessionA和sessionB两个服务的登录方法里面都会讲讲session信息放到一级域名cookie里面,所以一旦一个系统登录,另外一个系统也会从这个一级域名下面获取到登录信息。
@PostMapping("/login")
public void login(@ModelAttribute UserForm user, MyRequestWrapper request, HttpServletResponse response) throws IOException, ServletException {
request.getSession().setAttribute(SessionFilter.USER_INFO,user);
//种cookie
CookieBasedSession.onNewSession(request,response);
//重定向
response.sendRedirect("/index");
}3.4、启动服务访问测试:
A、B服务刚开始访问都是未登录状态如下:
当a登录以后刷新b服务,发现已经是登录状态:
三、多服务下的登陆困境及SSO方案
1、SSO的产生背景
较大的企业内部,一般都有很多的业务支持系统为其提供相应的管理和IT服务。通常来说,每个单独的系统都会有自己的安全体系和身份认证系统。进入每个系统都需要进行登录,这样的局面不仅给管理上带来了很大的困难,对客户来说也极不友好。那么如何让客户只需登陆一次,就可以进入多个系统,而不需要重新登录呢?
“单点登录”就是专为解决此类问题的。其大致思想流程如下:通过一个ticket进行串接各系统间的用户信息
2、SSO的底层原理CAS
2.1、起点
1)对于完全不同域名的系统,cookie是无法跨域名共享的,因此sessionId在页面端也无法共享。
2)cas方案,直接启用一个专业的用来登陆的域名(比如:cas.com)来供所有的系统的sessionId。
3)当业务系统(如 b.com)被打开时,借助cast系统来登陆,具体流程参见2.2
4)cas.com的登陆页面被打开时,如果此时cas.com本来就是登陆状态的,则自动返回生成ticket给业务系统。整个单点登陆的关键部位,是利用cas.com的cookie保持cas.com是登陆状态,此后任何第三个系统跳入,都将自动完成登陆过程。
5)本示例中,使用了redis来做cas的服务接口,根据工作情况,自行替换为合适的服务接口(主要是根据sessionid来判断用户是否已登陆)。
6)为提高安全性,ticket应该使用过即作废(本例中,会用有效期机制)。
2.2、cas登陆的全过程:
1)b.com打开时,发现自己未登陆,于是跳转到cas.com去登陆。
2)cas.com登陆页面被打开,用户输入帐户/密码登陆成功。
3)cas.com登陆成功,种cookie到cas.com域名下,把sessionid放入后台redis《ticket,sessionId》然后页面跳回b.com。
4)b.com重新被打开,发现仍然是未登陆,但是有了一个ticket值。
5)b.com用ticket值,到redis里查到sessionid,并做session同步,种cookie到自己域名下面,页面原地重跳。
6)b.com打开自己页面,此时有了cookie,后台校验登陆状态,成功。
3、代码实战:
新建项目:springboot-cas-login,springboot-cas-website,springboot-cas-website
3.1、springboot-cas-login项目核心代码配置如下:
1)pom配置
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>springboot-cas-login</artifactId>
<name>springboot-cas-login</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>2)application.yml配置,注意本地host配置域名cas.com
#启动: http://cas.com:8080/index
server:
port: 8080
compression:
enabled: true
connection-timeout: 3000
spring:
redis:
host: 127.0.0.1
port: 63793)登录拦截过滤处理类代码
public class LoginFilter implements Filter {
public static final String USER_INFO = "user";
@Override
public void init(FilterConfig filterConfig) throws ServletException { }
@Override
public void doFilter(ServletRequest servletRequest,ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
Object userInfo = request.getSession().getAttribute(USER_INFO);;
//TODO 如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&& !requestUrl.startsWith("/login")//不是去登陆
&& null == userInfo) {//不是登陆状态
request.getRequestDispatcher("/toLogin").forward(request,response);
return ;
}
filterChain.doFilter(request,servletResponse);
}
@Override
public void destroy() {
}
}4)配置filter生效
@Configuration
public class LoginConfig {
//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new LoginFilter());
registration.addUrlPatterns("/*");
registration.addInitParameter("paramName", "paramValue");
registration.setName("sessionFilter");
registration.setOrder(1);
return registration;
}
}5)接口访问类代码
@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/toLogin")
public String toLogin(Model model,HttpServletRequest request) {
Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
//不为空,则是已登陆状态
if (null != userInfo){
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
return "redirect:"+request.getParameter("url")+"?ticket="+ticket;
}
UserForm user = new UserForm();
user.setUsername("hankin");
user.setPassword("hankin");
user.setBackurl(request.getParameter("url"));
model.addAttribute("user", user);
return "login";
}
@PostMapping("/login")
public void login(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response) throws IOException, ServletException {
System.out.println("backurl:"+user.getBackurl());
request.getSession().setAttribute(LoginFilter.USER_INFO,user);
//TODO 登陆成功,创建用户信息票据
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
//TODO 重定向,回原url---a.com
if (null == user.getBackurl() || user.getBackurl().length()==0){
response.sendRedirect("/index");
} else {
response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
}
}
@GetMapping("/index")
public ModelAndView index(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("index");
modelAndView.addObject("user", request.getSession().getAttribute(LoginFilter.USER_INFO));
request.getSession().setAttribute("test","123");
return modelAndView;
}
}3.2、springboot-cas-website项目代码配置
其他配置基本同cas-login项目一样,唯一不同的就是拦截过滤代码部分:
1)如果未登陆,则拒绝请求,转向登陆页面;
2)有票据,则使用票据去尝试拿取用户信息;
3)无法得到用户信息,则去登陆页面;
4)将用户信息,加载进session中。
public class SSOFilter implements Filter {
private RedisTemplate redisTemplate;
public static final String USER_INFO = "user";
public SSOFilter(RedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
Object userInfo = request.getSession().getAttribute(USER_INFO);;
//TODO 如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&& !requestUrl.startsWith("/login")//不是去登陆
&& null == userInfo) {//不是登陆状态
String ticket = request.getParameter("ticket");
//TODO 有票据,则使用票据去尝试拿取用户信息
if (null != ticket){
userInfo = redisTemplate.opsForValue().get(ticket);
}
//TODO 无法得到用户信息,则去登陆页面
if (null == userInfo){
response.sendRedirect("http://cas.com:8080/toLogin?url="+request.getRequestURL().toString());
return ;
}
// TODO 将用户信息,加载进session中
request.getSession().setAttribute(SSOFilter.USER_INFO,userInfo);
redisTemplate.delete(ticket);
}
filterChain.doFilter(request,servletResponse);
}
@Override
public void destroy() { }
}3.3、springboot-cas-website2项目代码
springboot-cas-website2与springboot-cas-website代码一样,除了application.yml配置文件。
3.4、启动项目测试
访问地址:
http://cas.com:8080/toLogin?url=http://a.com:8090/index
http://a.com:8090/index?ticket=1ef7a91b-ea3b-4990-a46d-eaeeaaf37a3b
http://b.com:8091/index?ticket=38089aee-6dc9-498c-8284-ef4b95fb2368
开始没登录的时候三个地址访问进去都是跳转到登录页面,需要登录当有一个地方登录以后,再次访问其他两个地址,发现都已经登录成功,结果如下所示:
-
SpringBoot Session共享实现图解
2020-08-25 04:40:42主要介绍了SpringBoot Session共享实现图解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 -
同一服务器不同域名session共享
2021-02-26 15:24:54Tomcat下,不同的二级域名之间或根域与子域之间,Session默认是不共享的,因为Cookie名称为JSESSIONID的Cookie根域是默认是没设置 的,访问不同的二级域名,其Cookie就重新生成,而session就是根据这个Cookie来生成... -
Redis实现Session共享
2021-08-04 14:19:01这几天在做session共享这么一个小模块,也查了好多资料,给我的感觉,就是太乱了,一直找不到我想要的东西,几乎全部实现方法都与我的想法不一样,在这里,我总结一下自己是如何用redis实现session共享的,方便自己... -
基于redis解决session共享问题
2021-08-09 20:51:02总共有3个模块基于微服务,解决session共享,遇到的问题,结果在文末。 1.eureka(注册中心) 1.1启动类 @EnableEurekaServer @SpringBootApplication public class EurekaServerApplication { public static void ... -
实现session共享的三种方式
2021-01-19 08:57:01实现session共享的三种方式发布时间:2020-06-05 16:16:43来源:亿速云阅读:341作者:Leah这篇文章为大家带来有关实现session共享的三种方式的详细介绍。大部分知识点都是大家经常用到的,为此分享给大家做个参考。... -
cas session共享
2019-09-09 10:19:26cas的session存储及实现共享的原理: cas在HashMapBackedSessionMappingStorage存session所以后面可以根据自己的tic,结合cookie反向生成session放到新的web服务器中 集群思想:cas通过redis覆写可以实现多节点cas... -
利用Spring Session和redis对Session进行共享详解
2020-08-29 12:40:59主要给大家介绍了关于利用Spring、Session和redis对Session进行共享的相关资料,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧。 -
shiro 多项目登录访问共享session
2018-10-23 22:43:57需求: 两个项目,主项目A,子项目B,...解决办法:多项目通过redis共享session 1.项目都添加redis 依赖 <dependency> <groupId>org.springframework.boot</groupId&... -
Session共享问题
2020-09-01 15:20:19目录session原理Session共享问题集群环境下的`session`共享问题分布式环境下的`session`共享问题Session解决方案方案一:session复制方案二:客户端存储方案三:hash一致性方案四:统一存储不同服务,子域session... -
【集群分布式问题】session共享问题及解决方案
2021-10-06 10:40:31文章目录一、session共享问题还原二、解决方案1.Nginx的 IP_Hash 策略(可以使⽤)2. Session复制(不推荐)3. Session共享,Session集中存储(推荐) 一、session共享问题还原 以登录为例,比如输入用户名和密码,... -
redis实现session共享
2018-10-10 15:41:57session共享 什么是session? 由于 HTTP 协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识具体的用户。Session 是另一种记录客户状态的机制,不同的是 Cookie 保存在客户端浏览器中,而... -
tomcat集群session共享
2019-09-12 11:34:08在部署cas系统的时候,遇到个问题,在tomcat集群部署cas系统,不能在一边...在nginx的upstream中加入ip_hash(这种不是session共享,个人理解是种取巧的方式,而且在一侧服务器宕机后,就会丢失登陆状态。) tomca... -
域名间的session共享
2021-02-26 15:24:45最近在做一个jsp的网站遇到了session共享的问题,下面以一个简单的实例讲解一下其中的细节及解决方法:网站有两个域名:主域名www.test.com 二级域名xxx.test.com1、用主域名打开网站,比如访问...2、接着进行登陆操作,... -
分布式Session共享和单点登录实现
2020-11-09 23:11:41文章目录 基于Session的身份认证 简介 实现 分布式Session共享 简介 实现 单点登陆 简介 实现过程 总结 基于Session的身份认证 简介 对于一个Web应用,客户端每次请求时,服务器都会打开一个新的会话,而且服务器... -
如何实现session共享的几种解决方案?
2021-08-16 00:08:09如何进行代码的实现: 使用Redis实现session共享的优缺点: 解决方案四:结合cookie 其实还可以把session放到cookie中去,因为每次用户请求的时候,都会把自己的cookie放到请求中,所以这样就能保证每次用户请求的... -
PHP使用Redis实现session共享
2019-03-16 10:28:57但是,问题也随着产生,比如今天要讲的内容——“session共享”。如果在多台服务器共同处理请求时,无法保证用户的登陆状态,那对用户来说绝对绝对的差体验! 对于session共享,有一个老方法就是让... -
分布式环境下,如何实现 Session共享
2021-10-14 11:56:19先了解一下为什么会出现这种session共享的解决方案? 随着互联网公司的项目在微服务和分布式的环境下进行的搭建,导致一个项目可能分别部署在几个甚至很多的服务器集群下,此时就会出现一个问题: 当用户进行一个... -
解决session共享问题
2019-08-09 10:25:50业务需求:在此基础上搭建负载均衡,解决session共享问题。 尝试1: nginx配置ip_hash,ip_hash技术能够在一定时间里将某个ip的请求定向到同一台后端服务中,这样一来这个ip下的客户端和某个后端就能建立起稳固的... -
分布式常见场景解决方案——session共享及单点登录
2020-11-29 20:55:301. 传统Session机制及身份认证方案 1.1 Cookie与服务器的交互 http 是无状态的协议,客户每次读取 web 页面时,服务器都打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内... -
web项目集群时共享session方案实践
2014-05-28 17:17:54web项目集群时共享session方案实践 -
session共享的原因和方案
2021-01-14 12:18:07先了解一下为什么会出现这种session共享的解决方案? 随着互联网公司的项目在微服务和分布式的环境下进行的搭建,导致一个项目可能分别部署在几个甚至很多的服务器集群下,此时就会出现一个问题 当用户进行一个...