技术干货实战(4)- 分布式集群部署模式下Nginx如何实现用户登录Session共享(含详细配置与代码实战)

作者: 修罗debug
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。


最近有小伙伴催更,让debug多写点技术干货,以便多学习、巩固一些技能;没办法,debug也只好应承下来,再忙也要挤出时间撸一撸,以对得起时常关注debug的那些看官老爷们! 本文将重点介绍:Nginx如何进行配置从而实现用户登录成功后Session共享的功能,其中我们将以“企业权限管理平台”为例,加入Redis最终真正实现Session共享的效果(真正的代码落地哈!)

说在前面的话:debug近几个是真的忙,各种项目、产品开发进度跟进、上线,都快累成了狗,但累归累,生活还是要继续,梦想还是要追寻!于是乎debug用了差不多3个月的时间,早起晚睡又肝了一本新书:《Spring Boot企业级项目-入门到精通》,双12期间应该可以出版!为了能让大家一睹为快,先贴一下封面吧,后续debug会专门出篇文章专门介绍这本书(同时提供优惠购书渠道)!   


言归正传,咱们继续聊聊一个Java开发的系统在分布式集群部署模式下如何实现用户登录成功后Session的共享功能!在这里debug以之前撸过的课程“企业权限管理平台实战”中的 系统源码为例,实打实地介绍如何实现集群部署模式下Session的共享!

其中,该课程的观看地址:https://www.fightjava.com/web/index/course/detail/8 ,也可以找debug咨询学习该课程;但为了降低各位小伙伴学习的门槛,debug特意将该系统抽丝剖茧,得出个可运行的简化版,其源码数据库等资料的下载地址在文末有提供,各位看官老爷们只需要下载解压即可照着本文介绍的步骤往下撸!(切忌眼高手低哈,不仅仅要看懂,更希望诸位能撸懂,真正地去动一动手!)


本文实操所在的环境与软件信息如下:准备1Linux服务器 操作系统为Centos7(因为debug穷,没有买多台服务器,因此下面的集群部署模式主要为单机多实例集群部署),Nginx版本为1.16.1Redis版本为6.x,用于演示的系统为:企业权限管理管理【简化版】(下载地址在文末有提供

OK,我们继续往下讲!在动手实操之前,我们将撸一撸相关的理论知识要点!

一、理论的东西(不多,很快哈!)

1Nginx的负载均衡:所谓的负载,可以理解为服务器承担的压力,而在一个常规的Web应用系统中,压力主要来源于前端以及其他应用服务的请求;如果应用系统只是部署单例且在一台机器上,那么几乎就是由这台机器承担下了所有的压力(“终究是一个人扛下了所有”)

这种方式的弊端显而易见:当Web应用系统访问量达到一定的程度后,单机负载很可能会承受不了,万一要是宕了机,应用系统也就访问不了了,带来的损失难以估量;

因此也就有了“均衡负载”,专业术语叫“负载均衡”,见名知意:部署多台服务器以便平均分担来自四面八方的压力,当前端发来请求时,Nginx会自动根据某种策略检查哪台服务器空闲,则将该请求转发给该服务器处理;某种程度上讲,“负载均衡”可以用于保证应用系统架构的高可用。

2)系统部署到Linux后,Nginx配置起到的作用:主要有几个,一个是用于充当前端http请求处理服务器(即网页等静态资源的处理);一个是请求代理转发(反向代理),如下所示:   

location / {
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header remote_addr $remote_addr;
proxy_pass http://ip地址:应用服务的端口号/;
}

最后一个作用则是“负载均衡”;   


二、动手实操走起:

1)将“权限管理平台【简化版】”的源码下载解压,并导入IDEA,在本地运行没问题后,点击右侧的Maven,执行cleaninstall操作,稍等片刻即可打包出一个Jar包,名为ym-1.0.1.jar;然后在Linux环境下创建目录:/srv/dubbo/jiqun,在该目录下创建3个文件夹,分别是180811808218083,代表着单台机多个服务实例对应的端口。

2)将打包出来的ym-1.0.1.jar通过winscp等工具上传至/srv/dubbo/jiqun/18081/srv/dubbo/jiqun/18082/srv/dubbo/jiqun/18083  3个目录,紧接着,cd切换到上面3个目录,然后执行以下的命令:

cd /srv/dubbo/jiqun/18081 进入该目录后执行:   

nohup java -jar ym-1.0.1.jar --server.port=18081 &
tail -f nohup.out

cd /srv/dubbo/jiqun/18082 进入该目录后执行:   

nohup java -jar ym-1.0.1.jar --server.port=18082 &
tail -f nohup.out

cd /srv/dubbo/jiqun/18083 进入该目录后执行:

nohup java -jar ym-1.0.1.jar --server.port=18083 &
tail -f nohup.out

观察上面多个服务实例打印出来的日志,如果正常运行则进入下一步,如果不正常,则按照报错信息自行检查,然后重新打包部署上传上去即可(解决问题期间有任何问题都可以联系debug,与debug交流)

3)完了之后,将180811808218083 这三个端口加入到防火墙白名单(iptables或者firewall),同时也需要在云服务器提供商ECS的网络安全组配置下安全规则(将端口加入进去即可);完了之后就是Nginx的配置了:

首先是配置个服务器组(单机多实例,即多端口的组名;如果是多机实例,则只需要将这行配置加入到每台机的Nginx配置文件nginx.conf即可)   :

upstream  debug-server {
#ip_hash;
server localhost:18081;
server localhost:18082;
server localhost:18083;
}

其中,ip_hash被我们注释起来了,则此时的集群负载均衡策略为默认的“轮询”,所谓的“轮询”,顾名思义就是轮番检查哪台机/哪个服务实例目前处于空闲状态,如果有资源(CPU/内存)闲置,则交给那台机/那个服务器实例处理即可!

如果将 ip_hash前面的注释去掉,则变为:源地址哈希hash,即根据请求客户端(浏览器)的IP地址通过某种hash算法计算出对应的服务实例(比如localhost:18081),那么往后在不做调整的情况下,该客户端几乎所有的请求将交给 localhost:18081 进行处理(可以理解一一绑定);

如果是以下的配置,则该负载均衡的策略为“加权轮询”,见名知意,它是建立在轮询的基础之上的,只不过加了个参数:权重weight(数值类型),它将表示Nginx在轮番查询哪台机/哪个服务实例可用时,weight系数越大,被命中的几率将越大!

upstream  debug-server {
server localhost:18081 weight=2;
server localhost:18082 weight=8;
server localhost:18083 weight=6;
}

我们暂且选择默认的“轮询策略”;完了之后,则是应用服务本身的反向代理(http请求代理服务配置),如下所示:   

server{
listen 80;
server_name history.huicairj.com;

charset utf8;
location / {
proxy_pass http://debug-server;
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

在上述配置中,我们配置了个域名:history.huicairj.com (当然,如果没有域名的话也没关系,可以用本机公网IP代替即可), 配置好之后,进入到Nginx的安装目录,重启Nginx即可:/sbin/nginx -s reload

1)在浏览器打开地址:http://history.huicairj.com/ ,此时客户端会发出“获取验证码”的请求,会发现该请求打在了 18082 那个服务实例上;

2)登录成功后,会发现首页也会发出“获取当前用户登录信息以及获取左边菜单栏”的请求,该请求则打在了 18081 那个服务实例;

3)点击“用户管理”,会发现发出的“查询用户列表”的请求又打回了 18082服务实例上;

4)再点击“部门管理”,会发现发出的“查询用户列表”的请求打在了 18083服务实例上………

如下图所示:


在整个测试期间,会发现用户的Session都是时刻有效的,可能有些小伙伴会有疑问,这是咋做到的呢?我们都知道Session是存储在服务端的,更确切的讲,它是跟服务器的某个应用服务挂钩的,而现在我们做了集群部署,有3个服务实例,而且根据上述的演示过程,我们已经得知每次的请求并不是固定的落在某个服务实例上的,也就意味着Session应该是存储在某个服务实例上的吧,如下所示为整个系统部署的简化架构模型如下所示:


在上述该架构模型中,如果Nginx负载均衡策略采用的是IP_Hash,即源地址哈希Hash法的话,那么Session将没啥问题;

但如果是轮询策略的话,按照上述单一Shiro的开发模式,那问题就很大了,即很可能会出现“登录成功后进入主页时,点击某个功能模块会弹框提示:用户没登录”的现象;归根结底还是因为SessionId在登录成功后只存在了某个服务实例上,但是又由于采用的是轮询策略,因此很可能后续的请求会打在其他服务实例上,而其他服务实例又没有存储该SessionId,于是乎就认为用户没登录!

因此,如果Nginx配置的负载均衡策略是“轮询”,那么需要在项目上Shiro层面的开发做下改进,思路为“构建一虚拟的Session共享服务器”,于是乎我们就搬上了Redis,其调整后的整个系统部署的简化架构模型如下所示:


如下所示为调整后的Shiro + Redis的配置:   

/**
* 显示自定义注入配置shiro+redis的相关组件
* @Author:debug (SteadyJack)
* @Date: 2019/9/11 18:01
**/
@Configuration
public class ShiroRedisConfig implements EnvironmentAware {
private Environment env;

@Override
public void setEnvironment(Environment environment) {
this.env=environment;
}

//securityManager-管理subject
@Bean
public SecurityManager securityManager(UserRealm userRealm){
DefaultWebSecurityManager securityManager=new DefaultWebSecurityManager();
securityManager.setRealm(userRealm);
securityManager.setRememberMeManager(null);

//自定义缓存管理器-redis
securityManager.setCacheManager(cacheManager());

//自定义一个存储session的管理器
securityManager.setSessionManager(sessionManager());
return securityManager;
}

//自定义session缓存管理器
@Bean
public RedisCacheManager cacheManager(){
RedisCacheManager cacheManager=new RedisCacheManager();
cacheManager.setRedisManager(redisManager());
return cacheManager;
}

@Bean
public RedisManager redisManager(){
RedisManager redisManager=new RedisManager();
redisManager.setHost(env.getProperty("spring.redis.host"));
redisManager.setPort(env.getProperty("spring.redis.port",Integer.class));
//链接超时
redisManager.setTimeout(env.getProperty("spring.redis.timeout",Integer.class));
//缓存key时效
redisManager.setExpire(env.getProperty("spring.redis.expire",Integer.class));
return redisManager;
}

//自定义session管理器
public DefaultWebSessionManager sessionManager(){
DefaultWebSessionManager sessionManager=new DefaultWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
return sessionManager;
}

//shiro sessionDao层的实现,通过redis - 使用的是shiro-redis开源插件
@Bean
public RedisSessionDAO redisSessionDAO(){
RedisSessionDAO redisSessionDAO=new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());

//设置存储在缓存中session的Key的前缀
redisSessionDAO.setKeyPrefix("shiro_redis_session:");
return redisSessionDAO;
}

//过滤链配置
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilter=new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);

//设定用户没有登录认证时的跳转链接、没有授权时的跳转链接
shiroFilter.setLoginUrl("/login.html");
shiroFilter.setUnauthorizedUrl("/");

//过滤器链配置
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/swagger/**", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/webjars/**", "anon");
filterMap.put("/swagger-resources/**", "anon");

filterMap.put("/statics/**", "anon");
filterMap.put("/fonts/**", "anon");
filterMap.put("/image/**", "anon");
filterMap.put("/login.html", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/favicon.ico", "anon");
filterMap.put("/captcha.jpg", "anon");

filterMap.put("/**","authc");

shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}

//shiro bean生命周期的管理
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}

@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor advisor=new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

其中,要记得在pom.xmlserver模块)中加入shiro redis配置相关的依赖Jar:   

<!-- shiro+redis -->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>2.4.2.1-RELEASE</version>
<exclusions>
<exclusion>
<artifactId>shiro-core</artifactId>
<groupId>org.apache.shiro</groupId>
</exclusion>
</exclusions>
</dependency>

从该配置中可以得知,其实就是将后端服务实例产生的SessionID存储到具有独立的、中间角色性质的缓存中,即缓存中间件Redis里,而不依赖于任何一台服务器、任何一个服务实例;




OK,打完收工!咱们下期再见!!!

总结

1代码下载:关注微信公众号: 程序员实战基地 (扫描下图微信公众号即可),回复数字: 101 ,即可获取本文实操演示用的源码数据库,即“企业权限管理平台【简化版】”

2)本文的内容来源于debug最新撸的课程:Java工程师核心技术-典型案例与面试实战系列二(基于Spring Boot2.0  感兴趣的小伙伴可以前往 fightjava.com 的课程中心进行学习,地址为:https://www.fightjava.com/web/index/course/detail/16


 我是debug,一个相信技术改变生活、技术成就梦想 的攻城狮;如果本文对你有帮助,请关注公众号,并动动手指收藏、点赞、以及转发哦!!!