原生HttpSession解决集群Session共享问题 实现SSO单点登录
在介绍本节内容之前,在这里谈谈我接触到的一些后端架构出现的问题
就在前两天辅导员早上9点突然发布一条选课通知,到中午12点之前完成大三下学期的选课,好的,我打开了链接想着4个小时的选课时间怎么选不上?然而还真没选上
问题出现
-
请求超时
仔细看了一下之后大概得出了结论,这个web选课应用后端使用php编写,部署到了Apache服务器上,查阅了一下php部署在Apache的集群方式更多人叫它拓展用用服务器组,个人感觉没有配置应用服务器组,不然全院四个年级加起来也不够5000的流量怎么会做不到
我查阅了一下,因为自己没有使用过Apache服务器,大概谈一下我对这个问题的认识,Apache服务器有自己的几种工作模式,并且给我感觉有一套自己的进程管理体系,类似于线程池,为了减少建立进程去处理请求的额外开销,启动Apache服务器的时候,就会建立默认配置的空闲进程等待请求的到来去处理,(Apache是以进程为基础的结构,进程要比线程消耗更多的系统开支,不太适合于多处理器环境,因此,在一个Apache Web站点扩容时,通常是增加服务器或扩充群集节点而不是增加处理器),而在启动Tomcat的时候能够发现进程其实只有Tomcat进程,但是它其中的线程却存在许多。这是两者不太一样的地方
-
Apache服务器与Tomcat服务器的区别
- Apache多被称为web服务器,并且对Linux支持的相当完美,Apache是以进程为基础的结构,进程要比线程消耗更多的系统开支,不太适合于多处理器环境,因此,在一个Apache Web站点扩容时,通常是增加服务器或扩充群集节点而不是增加处理器
- Apache给我的感觉和Nginx效果和功能一样,都是web服务器,但是Apache支持php拓展模块,也就使得php的后端应用程序能够使用它作为载体,也就和Tomcat这种基于J2EE规范的应用服务器可以和Nginx配置集群使Nginx作为负载均衡服务器来使用
-
举个帖子中的例子
- Apache是一辆卡车,上面可以装一些东西如html等。但是不能装水,要装水必须要有容器(桶),Tomcat就是一个桶(装像Java这样的水),而这个桶也可以不放在卡车上。
- Apache只支持静态网页,但像jsp等动态网页就需要Tomcat这种应用服务器来处理。
- Apache和Tomcat整合使用:如果客户端请求的是静态页面,则只需要Apache服务器响应请求;如果客户端请求动态页面,则是Tomcat这种应用服务器服务器响应请求;
- 因为jsp是服务器端解释代码的,这样整合就可以减少Tomcat的服务开销 。
终于请求到了登录页却执行不了登录操作
-
验证码错误
经过无数次的刷新尝试之后总算有一条刚刚忙碌完的进程顾及到了我,这个时候,我开始执行了登录操作,却提示我验证码失败,我校验了很多次却不能够成功登录,这个时候我又分析了一下,因为自己也实现过验证码登录的逻辑,所以说这个流程还是掌握的比较清楚的
请求登录页的时候,请求后端获取验证码的接口,这个时候后端如果不使用Redis缓存的技术去解决验证码的校验,最简单的方式就是放置在session中,key可为一个常亮,我们就叫LOGIN_CODE_SESSION_KEY那么值的话很好理解就是验证码的值了,再次请求登录接口的时候,可以实现一个过滤器去过滤登录借口,校验请求中的验证码是否与session中的验证码值匹配
那么为什么会提示验证码错误导致验证码错误进一步致使登录失败呢
- 可以想想这样一种情况,Apache服务器的进程数已经到达了接近极限的地步,这种情况下换做是什么服务器我想效率的话肯定低得不能再低甚至可能发生宕机问题,我在登录的时候有点击过验证码的动作,但是却得不到任何响应,可以再这样想一下,因为后端服务器的负担太重,生成验证码的逻辑已经执行,但是在页面上因为效率太慢,响应没有及时到达,web页面没有刷新最新的验证码,导致我们验证时携带过期验证码进行登录,提示登录失败验证码错误
-
登录压根没响应
下面会介绍怎么成了一个没有响应的web应用
服务器未响应
-
服务器宕机
服务器没有响应这个东西我曾经折腾实验室服务器的时候就出现过这种尴尬的情况,那会儿造成的错误还不是一台软件级别的服务器宕机,而是整个一台物理级别的服务器宕机...难怪怎么用ssh想要上去都没用,很快很多线上应用就开始找我了,然而我还很懵逼,和毕业的学长分析了一下,没错是关机了..
-
服务器为什么会宕机
简单说一下服务器这个概念,在物理级别的服务器这个概念,简单一点来说,它是一台机器,机房里面很多个大机箱基本就是这个了,软件级别的服务器是什么,类似Nginx Apache Tomcat 这类的web服务器和应用服务器
至于web服务器和应用服务器我就不在这里赘述,下面来分析一下服务器为什么会宕机
先说说我搞崩的实验室云服务器,上面部署了很多应用服务器node的tomcat好像还有php的之类应用服务器,上面的应用也就更不用说,实验室官网可以去参观一下 www.xiyoumobile.com 学长学姐们的心血,真的很赞,尤其是在我搞崩之后觉得有点对不起他们,但是学长还是给我鼓励,说正题,我造成的线上事故是因为暑假写的SpringBoot项目需要部署,并且因为一些接口只能通过学校的内网才能够访问爬到数据,这个时候果断想到了折腾一下实验室服务器,但是没有经验的我按照原始方式简单的打了.war包移除内置Tomcat之后放在上面,当时还没事,直到第二天早上我知道的时候应该已经关机了几个小时了
原因
SpringBoot在我看来是Spring官方为了简化基于Spring框架组件的一套为了简化自身开发的框架,说句实话用起来很方便,但是也正是因为他的方便,其中很多依赖关系以及Bean的依赖,组装变得规模很庞大,使用一些提供的支持的时候也只是去操作高度封装的Api接口,看过一些源码,确实觉得写的很好,这个时候会造成什么问题呢,Jvm方法区正是因为有了这么多的Bean以及一些动态代理类的信息,硬生生地让整个SpringBoot后端服务占到了可能高于2G的内存,实验室服务器因为申请的早,后来才知道是动态4G内存,再加上之前上面那么多东西,后来想想自己真的是有点弱智...也是因为当时对Jvm没有什么了解,以至于没有意识到Jvm的简单调优,导致实验室服务器内存耗尽宕机最终关机
服务器宕机的原因
就像我上文一样,物理级别的服务器宕机的原因,要么是创建的进程过多,占用内存过多,导致操作系统调度变慢,以至于到最后不能合理地去管理进程回收一些空闲进程,导致内存一直持续过高占用,这个时候如果有新的进程需要执行任务,可能就会出现死机的情况,进而就关机了,像这种情况,可以分析Jvm的GC情况,可能是自己的编码导致一直存在某些引用持有一些本该被GC的引用,导致GC的时候并没有将其回收导致的问题,可能最后还会出现OOM的问题,分析起来还是挺麻烦的,因为我对这里还不是特别清楚,所以也就先不说了,最终这个选课系统的后台服务器还是被重启了,这个时候再次尝试的时候...一个字爽,畅快的感觉,总的来说我觉得一个后端项目如果不能保证并发量的出现能够正常运行,给我感觉是个失败的项目
应用服务器宕机
应用服务器为什么会宕机?例如Tomcat来说,其中的Connector组件维护一个线程池,一条新的请求到达服务器的时候,简单地来说就是一条线程去处理一条请求,这个在SpringBoot项目或者SSM项目中基于J2EE规范的后端服务中log打的全的话可以观察到,处理请求和响应其实是同一个线程,如果服务器采用同步方式去处理请求,这个时候大家都知道I/O的效率是很低的,如果说一条请求需要处理一条很费时的I/O操作,也就是说这次请求需要占用这个这个线程直到它执行完I/O操作,使用过Tomcat应用服务器的人应该都知道,线程池也是有默认最高上限的,调得过高可能会影响线程池的工作,低了可能并发量比较低,我一直用的默认的没有去管过
这个是Tomcat conf下server.conf文件的配置,需要了解的可以去试试,这个时候同步策略处理请求,一旦占用时间过长,例如部署了一个并发量较高的服务,请求峰值一旦来临,线程池将会被耗尽,并且可能造成整个应用服务器的宕机,当然处理这种逻辑,我们可以在代码中使用异步处理请求来实现
同步服务为每个请求创建单一线程,由此线程完成整个请求的处理:接收消息,处理消息,返回数据;这种情况下服务器资源对所有请求开放,服务器资源被所有入栈请求竞争使用,如果请求过多就会导致服务器资源耗尽宕机,或者导致竞争加剧,资源调度频繁,服务器资源利用效率降低。
来降低web服务器的负担,并且还能够响应并发量较大的情况,综上所述,为了能够配置一个高并发量的后端架构,最好是项目后端架构转向集群
-
要是让我做这个选课系统我会如何架构
首先考虑到并发量,因为其实实现一个服务来说很简单,主要就是并发量较大的情况下,服务器能不能承受住这种压力正常地运转,限时选课系统如果不作处理很难保证在后端运行的时候不会出现响应过慢甚至宕机的情况,我还是选择Nginx作为负载均衡服务器,因为官方给定的Nginx访问的并发量最高能到5W,可是我看过实际测试也就只能到3W,但是对于我们这个系统..完全够了,其次就是Tomcat的集群,项目使用SpringBoot搭建,验证码以及SSO处理逻辑会使用到Redis这种NoSql数据库,如果一旦使用到数据库,最好还是做数据库的集群,主从库的建立,Redis的集群以及主从库设置可以看我上一篇博客,MySql的集群搭建,主从库的建立,MySql这里我没有尝试过搭建集群,所以也不再赘述,如果使用Nginx负载均衡去配合应用服务器的集群的话,即使是应用服务器集群中的某一台宕机,也不会影响到别的服务器运行也不会影响业务
项目架构演进示意图
集群产生的问题
Cookie Session策略实现登录逻辑
试想一下这个场景,后端采用Tomcat集群,有5台Tomcat,配置Nginx作为负载均衡服务器,采用权重策略进行反向代理,假如Nginx将一个用户的请求首先转发到了Tomcat1上,用户进行了登录,响应中可以拿到cookie或者set-cookie字段,并且value若是基于Tomcat应用服务器的话,value的值基本都是JSESSION=xxxxxxxx类似的情况,Tomcat底层维护着一个Map,通过这个JSESSIONID寻找属于用户与服务器之间的会话,并get到session对象,就可以实现访问放置在session中的一些用户信息或一些其余别的放置在session中的敏感信息
问题出现
cookie session策略用于解决Http无状态的问题,但是如果集群Tomcat之后,用户如果登录请求被Nginx转发到了Tomcat1上,并且做了登录,那么这个cookie默认情况下会被保存至浏览器的缓存中,直至一次浏览器的生命周期结束cookie将被销毁,但是这个cookie所对应的session会话也只是针对于对客户端/Web与Tomcat1之间,用户登录了,那么之后呢?
如果用户接下来访问个人信息页,这个时候假如配置Nginx的负载均衡策略为权重策略,并且5台Tomcat的权重相同(转发到每一台的几率都相同,还有ip hash等等一些策略去实现负载均衡,这里也不赘述),如果访问个人信息这个请求被Nginx转发到了除Tomcat1之外的任意一台服务器,都会出现一个问题,这个问题是什么大家都可以想一想
继续要求登录
因为请求个人信息这个请求携带的cookie并不能标示Tomcat2上的一次会话,想来也很清楚,这个用户根本没在Tomcat2上做过登录,那这样的话集群带来的代价有点高,这样的话如果集群的规模比较大,也就是说有可能后来访问任何需要验证登录的接口都会判断为未登录,这种情况只要不解决session共享问题,那么都会出现问题
如何解决session共享 实现SSO
github: https://github.com/challengerzsz/Mall 项目可以参考一下
-
贴上一个简单的用户登录Controller,在登录逻辑中,若用户登录成功,则使用封装的Cookie工具操作,实例化一个Cookie对象,并且设置时长以及domain参数(为了让这个cookie在请求二级域名的时候可以获取到),还有一些设置都可以自行百度,在代码中设置的超时时间为1年,可以根据自己的逻辑来使用,最后向响应中加入这个Cookie,Cookie中的key为一个常量,value为登录这次请求的会话sessionId
/** * 用户登录 * * @param username * @param password * @return */ @PostMapping("/login") public ServerResponse
login(String username, String password, HttpSession session, HttpServletResponse response) { ServerResponse serverResponse = userService.login(username, password); if (serverResponse.isSuccess()) { CookieUtil.writeLoginToken(response, session.getId()); redisUtil.setRedisValueEx(session.getId(), JsonUtil.objToString(serverResponse.getData()), Const.RedisCacheExTime.REDIS_SESSION_EXTIME); } return serverResponse; } public static void writeLoginToken(HttpServletResponse response, String token) { Cookie cookie = new Cookie(COOKIE_NAME, token); cookie.setDomain(COOKIE_DOMAIN); //设置cookie的path为/ 这样二级域名可以共享到最大域名下的cookie实现共享 cookie.setPath("/"); //通过脚本将无法读取到Cookie信息,避免脚本攻击 cookie.setHttpOnly(true); //若不设置cookie的有效期 生命周期为浏览器的生命周期 在内存不会持久化到硬盘 cookie.setMaxAge(60 * 60 * 24 * 365); logger.info("write cookieName :{}, cookieValue :{}", cookie.getName(), cookie.getValue()); response.addCookie(cookie); }
其实大家能够看出来,这种解决session共享的问题是通过我们强行向浏览器写入一个新Cookie,规定这个Cookie中的key为一个声明的常量标示这个Cookie,value为首次登录请求的那一次会话中,应用服务器返回给浏览器的sessionId,当然这个Cookie只会在登录成功的逻辑下才会被回写回响应
-
调用Cookie工具校验是否登录
大家应该已经猜到封装的Cookie工具要实现什么了,所有访问需要身份验证的接口都应该调用这个工具类,首先从请求中取出Cookie,这里要强调一下,取出的Cookie如果做过登录操作,那么应该有两个Cookie,一个是Tomcat1自己返回给浏览器的Cookie,另一个是我们手动写入的一个Cookie,通过校验是否存在有我们手写的这个Cookie,进而判断用户是否已经完成过登录 ,这样就完了吗?大家可以想一想,这个时候如果知道了服务端手写的Cookie的key就可以伪造一个Cookie去进行请求,那么如果校验逻辑真的就这样的话,我们如何确保这个用户是我们的用户,并且是登录后访问的我们的服务?
-
HttpOnly
大家应该可以看到上面代码段有设置cookie属性的语句
cookie.setHttpOnly(true);
这句话是什么意思呢?
如果cookie中设置了HttpOnly属性,那么通过js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击,窃取cookie内容,这样就增加了cookie的安全性,即便是这样,也不要将重要信息存入cookie。
XSS全称Cross SiteScript,跨站脚本攻击,是Web程序中常见的漏洞,XSS属于被动式且用于客户端的攻击方式,所以容易被忽略其危害性。其原理是攻击者向有XSS漏洞的网站中输入(传入)恶意的HTML代码,当其它用户浏览该网站时,这段HTML代码会自动执行,从而达到攻击的目的。如,盗取用户Cookie、破坏页面结构、重定向到其它网站等。
也就是说cookie通过设置这一参数为true则可以实现防止脚本伪造cookie进行攻击,但是这样后端就不需要校验了吗?我在有的网站也看到了HttpOnly这种安全措施有的时候也不安全的说法,那么我们如何去做呢
Redis的参与
细心的人应该已经看到上面UserController登录中有一句代码
redisUtil.setRedisValueEx(session.getId(), JsonUtil.objToString(serverResponse.getData()),
Const.RedisCacheExTime.REDIS_SESSION_EXTIME);
这句话是什么意思呢,我封装了一个对RedisTemplate操作的工具类,通过使用RedisTemplate操作Redis,并且设置键值携带过期属性,Redis中的key为登录时会话session的Id,值为将此用户的实例通过封装好的JsonUtil进行序列化后的Json字符串,最终以字符串的形式作为key保存在Redis中
工具类读取Cookie校验的时候,如果有我们手写的Cookie并且有value的情况下,通过调用redis中的get方法去校验这个sessionId是否是登录是我们set进Redis中的值,如果能够从Redis中通过这个sessionId能够get到用户的Json数据,也就说明确实登录过也就防止了伪造,如需使用用户信息的时候,将这个Json字符串反序列化成为实例对象即可
封装CookieUtil读取Cookie的方法
/**
* 获取属于mall服务器下的cookie 并且返回cookie的值即登录时的sessionId
* @param request
* @return
*/
public static String readLoginToken(HttpServletRequest request
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
logger.info("read cookieName :{} cookieValue :{}", cookie.getName(), cookie.getValue());
if (StringUtils.equals(cookie.getName(), COOKIE_NAME)) {
logger.info("return cookieName :{} cookieValue :{}", cookie.getName(), cookie.getValue());
return cookie.getValue();
}
}
}
return null;
}
调用需要校验身份信息的借口时可以这样来操作
@GetMapping("/getInfo")
public ServerResponse getInfo(HttpServletRequest request) {
String loginToken = CookieUtil.readLoginToken(request);
logger.error("error {}", loginToken);
if (StringUtils.isEmpty(loginToken)) {
return ServerResponse.createByErrorMsg("用户未登录");
}
String userJson = redisUtil.getRedisValue(loginToken);
User currentUser = JsonUtil.stringToObj(userJson, User.class);
if (currentUser == null) {
return ServerResponse.createByErrorCodeMsg(ResponseCode.NEED_LOGIN.getCode(), "未登录,需要强制登录");
return userService.getInfo(currentUser.getId());
}
如果博客中有问题,请私信我一同解决