Tomcat集群Session共享的几个方案

问题由来

当我们的业务使用单个Tomcat不足以支持访问请求的时候,需要引入Tomcat集群。而每个Tomcat的Session是不互通的,如果用户的请求落入到不同的Tomcat中,用户需要频繁的登录,给用户造成困扰。所以,在一个应用服务器产生Session之后,应该让其他应用服务器也能够获取到,也就是Session共享。

解决方法

以下几种方案有的是查资料找到的,并没有花费时间去实现,我们可以根据自己的实际情况选择使用哪一种。个人比较倾向于第1和第5种方案,因为这2种比较亲切๑乛◡乛๑。

1. 基于Cookie+Redis+Filter解决方案

用户登录之后,将Session Id和用户信息存储到Redis中,并添加一个Cookie,将该Session Id带到客户端。当发起其他请求之后,携带该Cookie,应用服务器获取到Session Id之后去Redis中查询是否存在,如果存在则继续进行相关业务,否则提示用户未登录。那种在Cookie中存放用户信息的方式直接Pass掉了。

实现过程
  • 登录过程

    public ServerResponse<User> login(String username, String password, HttpSession session, HttpServletResponse response){
        //获取数据库中的用户信息
        ServerResponse<User> serverResponse = iUserService.login(username,password);
        if (serverResponse.isSuccess()){
            String sessionId = session.getId();
            //添加Cookie
            CookieUtil.writeCookie(response,sessionId);
            //将sessionId和Json化的用户信息保存到分片的Redis中
            RedisShardedUtil.setEx(sessionId, JsonUtil.obj2String(serverResponse.getData()), Const.RedisCache.SESSION_EXTIME);
        }
        return serverResponse;
    }
    
  • 刷新“Session”有效时间

    假设“Session”的默认时间设置为半个小时,当我们登录之后,每次请求都应该将Session的有效期设置为半个小时。否则的话,一到半个小时“Session”失效了用户还得重新登录。为了解决这个问题,我们设置一个过滤器:

    public class SessionExpireFilter implements Filter {
        @Override
        public void init(FilterConfig filterConfig) throws ServletException {
    
        }
    
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
            //将ServletRequest转换为HttpServletRequest
            HttpServletRequest request = (HttpServletRequest)servletRequest;
            String token = CookieUtil.readCookieValue(request);
            //如果token不为空的话,符合条件,则获取user信息,user不为空,则将redis缓存中的session时间重置为指定时时长
            if(StringUtils.isNotBlank(token)){
                String userJsonStr = RedisShardedUtil.get(token);
                User user = JsonUtil.string2Obj(userJsonStr,User.class);
                if(user != null){
                    //如果user不为空,则重置session的时间,即调用expire命令
                    RedisShardedUtil.expire(token, Const.RedisCache.SESSION_EXTIME);
                }
            }
            filterChain.doFilter(servletRequest,servletResponse);
        }
    
        @Override
        public void destroy() {
    
        }
    }
    

    在web.xml中配置该过滤器:

    <!--注册延长"session"有效时间的filter,注意:需要配置在其他业务过滤器之前-->
    <filter>
        <filter-name>sessionExpireFilter</filter-name>
        <filter-class>com.lcmall.common.filter.SessionExpireFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>sessionExpireFilter</filter-name>
        <url-pattern>*.do</url-pattern>
    </filter-mapping>
    

    其中CookieUtil封装的代码如下,注释很详细,就不再解释了:

    /**
     * cookie操作工具类
     * @author wlc
     */
    @Slf4j
    public class CookieUtil {
    
        /**设置为一级域名,子域名就可以共享该一级域名下的cookie*/
        private static final String COOKIE_DOMAIN = ".lcmall.com";
        /**设置cookie的目录为根目录"/",子级目录可以共享*/
        private static final String COOKIE_PATH = "/";
        /**设置cookie的name,读写均使用它*/
        private static final String COOKIE_NAME = "login_token";
    
        /**
         * 读取cookie中的value
         * @param request
         * @return
         */
        public static String readCookieValue(HttpServletRequest request){
            Map<String,Cookie> cookieMap = getCookieMap(request);
            if (cookieMap.containsKey(COOKIE_NAME)){
                Cookie cookie = cookieMap.get(COOKIE_NAME);
                log.info("return cookieName:{},cookieValue:{}",cookie.getName(),cookie.getValue());
                return cookie.getValue();
            }
            return null;
        }
    
        /**
         * 写入cookie,下面解释一下domain与path
         *     //X:domain=".lcmall.com"
         *     //a:A.lcmall.com            cookie:domain=A.lcmall.com;path="/"
         *     //b:B.lcmall.com            cookie:domain=B.lcmall.com;path="/"
         *     //c:A.lcmall.com/test/cc    cookie:domain=A.lcmall.com;path="/test/cc"
         *     //d:A.lcmall.com/test/dd    cookie:domain=A.lcmall.com;path="/test/dd"
         *     //e:A.lcmall.com/test       cookie:domain=A.lcmall.com;path="/test"
         *
         *     //由于domain和path的设置以上的结果如下:
         *     //a,b,c,d,e都能拿到X这个domain下的cookie
         *     //a与b相互之间是拿不到之间的cookie的
         *     //c与d均能够共享a与e产生的cookie
         *     //a与b相互之间是拿不到之间的cookie的,c、d均拿不到b的
         * @param response
         * @param token
         * @return
         */
        public static void writeCookie(HttpServletResponse response, String token){
            Cookie cookie = new Cookie(COOKIE_NAME,token);
            cookie.setDomain(COOKIE_DOMAIN);
            //设置cookie的访问仅通过http方式,可一定程度防止脚本攻击
            cookie.setHttpOnly(true);
            //如果不设置该值,则cookie不会保存到硬盘中,只存在于内存中,只在当前页面有效。
            //单位为s,这里设置为一年,如果设置为-1,则代表永久
            cookie.setMaxAge(60*60*24*365);
            cookie.setPath(COOKIE_PATH);
            log.info("wirte cookie name:{},value:{}",cookie.getName(),cookie.getValue());
            response.addCookie(cookie);
        }
    
        /**
         * 删除cookie
         * @param response
         * @return
         */
        public static void delCookie(HttpServletRequest request,HttpServletResponse response){
            Map<String,Cookie> cookieMap = getCookieMap(request);
            if (cookieMap.containsKey(COOKIE_NAME)){
                Cookie cookie = cookieMap.get(COOKIE_NAME);
                cookie.setDomain(COOKIE_DOMAIN);
                cookie.setPath(COOKIE_PATH);
                //设置成0,代表删除此cookie
                cookie.setMaxAge(0);
                log.info("del cookieName:{},cookieValue:{}",cookie.getName(),cookie.getValue());
                response.addCookie(cookie);
            }
        }
    
        /**
         * 将request中的cookie包装成一个map,实现代码复用
         * @param request
         * @return
         */
        private static Map<String,Cookie> getCookieMap(HttpServletRequest request){
            Cookie[] cookies = request.getCookies();
            Map<String,Cookie> cookieMap = new HashMap<>();
            if (cookies != null){
                for (Cookie cookie: cookies) {
                    cookieMap.put(cookie.getName(),cookie);
                }
            }
            return cookieMap;
        }
    }
    

    RedisShardedUtil的封装过程可以参见博客:分布式redis连接池工具类的封装

    JsonUtil可以自己写,也可以使用现成的Json工具类,如FastJson等,这里是对Jackson进行的二次封装。

    上面可以看出,这个方案其实并不是使用真正的Session。而且不论是明文还是加密之后的用户信息并没有放到Cookie中,所以一般情况下也不存在用户数据泄露的问题。测试方法可参见:Tomcat集群的Debug方法

优缺点
  • 优点

    代码灵活,基于分布式Redis,可以实现对高并发请求的支持。

  • 缺点

    需要修改的代码较多,涉及到Session的地方都需要更改。不太适合对老系统的改造,比较适合于新开发的系统。但是如果我们提前将用户接口抽离成了一个单独的服务,那么改造起来还是比较好处理的。

踩坑
  • An invalid domain [.lcmall.com] was specified for this cookie

原因是Tomcat8.5以后,Cookie的校验规则更改了,只允许以数字和字母开头。解决方法如下:

如果项目使用的外置Tomcat,需要更改Tomcat的配置文件,步骤:

1. Edit the Tomcat/conf/context.xml 
2. Add the statement in betweeen the <context> and </context> tags: 
	<CookieProcessor className="org.apache.tomcat.util.http.LegacyCookieProcessor" /> 
3. Restart Tomcat.

当项目为SpringBoot的时候,由于使用的内嵌Tomcat,需要更改代码:

@Configuration
public class CookieConfig {
 
    /**
     * 解决问题:
     * An invalid domain [.localhost.com] was specified for this cookie
     */
    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> cookieProcessorCustomizer() {
        return (factory) -> factory.addContextCustomizers(
                (context) -> context.setCookieProcessor(new LegacyCookieProcessor()));
    }
 
}
2. Tomcat内置的Session复制方案
实现过程
  • 第1步

    修改tomcat的server.xml文件,在 节点下,添加以下内容:

    <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster" channelSendOptions="8">
        <Manager className="org.apache.catalina.ha.session.DeltaManager" expireSessionsOnShutdown="false" notifyListenersOnReplication="true" />
        <Channel className="org.apache.catalina.tribes.group.GroupChannel">
            <Membership className="org.apache.catalina.tribes.membership.McastService" address="228.0.0.4" 
                        port="45564" frequency="500" dropTime="3000" />
            <!-- 这里如果启动出现异常,则可以尝试把address中的"auto"改为"localhost" -->
            <Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver" address="auto" port="4000" 
                      autoBind="100" selectorTimeout="5000" maxThreads="6" />
            <Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
                <Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender" />
            </Sender>
            <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector" />
            <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor" />
        </Channel>
        <Valve className="org.apache.catalina.ha.tcp.ReplicationValve" filter="" />
        <Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve" />
        <Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer" tempDir="/tmp/war-temp/" 
                  deployDir="/tmp/war-deploy/" watchDir="/tmp/war-listen/" watchEnabled="false" />
        <ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener" />
    </Cluster>
    
  • 第2步

    在项目的web.xml中添加如下节点:

    <distributable/>
    
优缺点
  • 优点

    Java代码上不需要做任何修改

  • 缺点

    依赖应用服务器容器,这里是Tomcat,其他的容器是使用不了的;

    适合小集群,不适合大集群,因为Session的复制是 all to all的,每个Tomcat都会存储其他的Session,会造成很大的资源浪费;

    在高并发的情况下延迟较为严重且占用网络资源。

3. 使用Session粘滞方案
实现形式

可以通过某种形式,将用户的每次请求都固定到某一台机器上。例如通过Nginx的ip_hash策略进行负载均衡,只要用户的IP固定不变,总能访问到同一台服务器上。举例如下:

upstream backend{
    ip_hash;
    server 192.168.128.1:8080 ;
    server 192.168.128.2:8080 ;
}
server {
    listen 8081;
    server_name test.csdn.net;
    root /home/system/test.csdn.net/test;
    location ^~ /Upload/upload {
    	proxy_pass http://backend;
    }
}
优缺点
  • 优点

    不需要修改项目代码,没有额外开销。

  • 缺点

    如果某台服务器挂掉了,Session丢失,所有被分发到这台服务器的请求都会发生故障,然后转向其他服务器,从而重新登录进行操作;

    如果是某一个局域网大量用户同时登录,这样负载均衡就没什么作用了。

4. 基于Session持久化方案

将Session存储到Mysql数据库中,这个很少有人这么做,毕竟请求量一旦上去,Mysql支持不住。

5. 基于Spring-Session无侵入性方案

Spring Session提供了多种方式来存储Session信息,包括redis、mongo、gemfire、hazelcast、jdbc等。其原理其实就是通过包装HttpSession,将每次请求都使用它的包装类,同时结合Cookie和Redis存储Session,从而实现Session共享。跟方案1差不多,只不过这里不需要自己实现而已。

实现过程
  • pom文件引入jar包

    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
        <version>1.2.0.RELEASE</version>
    </dependency>
    
  • web.xml增加filter

    <filter>
        <filter-name>springSessionRepositoryFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>springSessionRepositoryFilter</filter-name>
        <url-pattern>*.do</url-pattern>
    </filter-mapping>
    
  • 配置spring中的bean

    <bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
        <property name="maxInactiveIntervalInSeconds" value="1800" />
    </bean>
    
    <bean id="defaultCookieSerializer" class="org.springframework.session.web.http.DefaultCookieSerializer">
        <property name="domainName" value=".lcmall.com" />
        <property name="useHttpOnlyCookie" value="true" />
        <property name="cookiePath" value="/" />
        <property name="cookieMaxAge" value="31536000" />
    </bean>
    
    <bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
        <property name="maxTotal" value="20"/>
    </bean>
    
    <bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
        <property name="hostName" value="127.0.0.1" />
        <property name="port" value="6379" />
        <property name="poolConfig" ref="jedisPoolConfig" />
    </bean>
    
优缺点
  • 优点

    对代码基本上属于零侵入,适合对老系统想要实现Session共享的改造。比如在方案1中的登录方法,原来的逻辑是:

    public ServerResponse<User> login(String username, String password, HttpSession session){
        ServerResponse<User> response = iUserService.login(username,password);
        if(response.isSuccess()){
            session.setAttribute(Const.CURRENT_USER,response.getData());
        }
        return response;
    }
    

    如果使用方案1的方式,需要改动不少逻辑。但使用Spring Session的情况下,则这个代码逻辑完全不用改动。

  • 缺点

    jedisConnectionFactory中暂时不支持Redis的分片

踩坑
  • 注意spring Session 与 Spring版本的兼容性问题
    spring-session-data-redis我这里使用的1.2.0,对应的spring版本为4.0.6。如果想要将spring-session-data-redis的版本升级,如1.3.1,那现在的spring版本是不支持的,应用根本起不来。至于匹配的版本是多少,看官方文档或者自己测试吧。
结语

Tomcat集群虽然也带来了一些问题,如Session共享,与用户属性相关的本地缓存可能失效等等,但带来的好处是也是显而易见的。流量的增加,应用的架构肯定也需要不断的变化。上面的几个应用方案提供的思路和实现,没有最好,只有最合适的。

  • 0
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值