原生 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 的之类应用服务器, 上面的应用也就更不用说, 实验室官网可以去参观一下 http://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 应用服务器的人应该都知道, 线程池也是有默认最高上限的, 调得过高可能会影响线程池的工作, 低了可能并发量比较低, 我一直用的默认的没有去管过
- <Connector port="8080"
- maxThreads="150" minSpareThreads="25" maxSpareThreads="75"
- enableLookups="false" redirectPort="8443" acceptCount="100"
- debug="0" connectionTimeout="20000"
- disableUploadTimeout="true" />
这个是 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<User> login(String username, String password, HttpSession session, HttpServletResponse response) {
- ServerResponse<User> 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<User> 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());
- }
如果博客中有问题, 请私信我一同解决
来源: http://www.jianshu.com/p/d945862e5dfe