Tomcat集群存在的问题与单点登录的实现

背景

最近打算对自己之前写的个人项目进行一个迭代优化,之前是采用单机单应用的架构进行部署,如果用户量一旦大起来,单机肯定是抗不住的,于是对项目进行这方面的优化

架构分析

目前项目的架构如下图,用nginx做一个反向代理,一个tomcat,Tomcat和ftp server都部署在同一台机器上面

Tomcat集群存在的问题与单点登录的实现_第1张图片

项目优化

网站的性能优化主要由下面三个方面进行优化
1. web前端性能优化
2. 服务端性能优化
3. 存储性能优化

下面简单说一下前后端性能优化具体有哪些方法

前端性能优化

1.浏览器访问优化
- 减少http请求
- 使用浏览器缓存
- 启用压缩
- 放在页面的最上面,js放在页面的最下面
- 减少cookie的传输
2. CDN加速
3. 反向代理

服务端性能优化
  1. 分布式缓存
  2. 异步操作
  3. 使用集群
  4. 代码优化

我所选择的优化方案

想要提高并发量,最方便的方法就是直接加机器,也就是构建一个Tomcat集群,具体架构如下
Tomcat集群存在的问题与单点登录的实现_第2张图片

该架构下能很方便的提高并发量,只要简单的配置nginx的负载均衡即可,但是这可能会引发一个问题,我们考虑以下场景

如果当一个用户登陆,请求打到了tomcat server1上的时候,我们把用户信息存储在了Tomcat server1的session中,那么用户访问我们网站的另一个页面时(该页面需要校验登陆),这个请求打到了Tomcat server2上面,会发生什么情况呢?

由于Tomcat 2中并没有存储该用户的登陆信息,我们的网站就会提示用户重新登陆,这样的用户体验会变得非常不好,这个时候我们可以采取一些办法去处理这个问题,办法如下

配置nginx的负载均衡策略

在不改动代码的前提下,我们只要改变nginx的负载均衡策略,使得该用户每次的请求都打到同一台Tomcat上,这里可以用ip hash的负载均衡策略,nginx会对请求过来的ip做一次hash计算,同一个ip每次都会打到同一台Tomcat上面。

但是,这个方法还是有缺点的,如果用户的ip是动态ip,那么之前场景存在的问题还是没有办法解决,所以我们要引出下一个解决方法——使用redis缓存

使用redis缓存存储

redis简介:redis是一款基于k-v形式存储的NoSQL

我们可以通过用redis替换服务端session,把之前需要存储在session中的内容存到redis中,读取的时候,无论有多少台Tomcat集群,都从redis读取,这样就可以很好的解决session失效的问题了,具体架构图如下:

Tomcat集群存在的问题与单点登录的实现_第3张图片

代码单点登录实现

配置pom.xml

   
    <dependency>
      <groupId>redis.clientsgroupId>
      <artifactId>jedisartifactId>
      <version>2.9.0version>
    dependency>

封装redis Pool



/**
 * Created by xiao
 * User: xiao
 * Date: 2018/8/24
 * Time: 16:37
 */


import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class RedisPool {

    //jedis连接池
    private static JedisPool pool;
    //最大连接数
    private static Integer maxTotal = Integer.parseInt("20"));
    //最大idle(空闲)状态的jedis实例的个数
    private static Integer maxIdle = Integer.parseInt("10");
    //最小idle(空闲)状态的jedis实例的个数
    private static Integer minIdle = Integer.parseInt("2");
    //在borrow一个jedis实例的时候,如果该值为true的时候,实例肯定是OK的
    private static Boolean testOnBorrow = true;
    //在return一个jedis实例的时候,如果该值为true的时候,放回jedis的连接池的实例肯定是OK的
    private static Boolean testOnReturn = true;
    //redis ip
    private static String redisIp = "127.0.0.1";
    //redis port
    private static Integer redisPort = 6379;

    private static void initPool() {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(maxTotal);
        config.setMaxIdle(maxIdle);
        config.setMinIdle(minIdle);
        config.setTestOnBorrow(testOnBorrow);
        config.setTestOnReturn(testOnReturn);
        //连接耗尽时,是否阻塞,false会抛出异常,true会阻塞直到超时,默认是true
        config.setBlockWhenExhausted(true);
        pool = new JedisPool(config,redisIp,redisPort,1000*2);
    }

    static{
        initPool();
    }

    public static Jedis getJedis(){
        //返回jedis实例
        return pool.getResource();
    }

    public  static void returnJedis(Jedis jedis){
        if(jedis != null){
            jedis.close();
        }
    }

    public  static void returnBrokenJedis(Jedis jedis){
        if(jedis != null){
            jedis.close();
        }
    }

}

封装redis常用api


import com.mmall.common.RedisPool;
import com.mmall.common.RedisShardedPool;
import lombok.extern.slf4j.Slf4j;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.ShardedJedis;

/**
 * Created by xiao
 * User: xiao
 * Date: 2018/8/27
 * Time: 14:00
 */

/**
 * 封装jedis常用api
 */
@Slf4j
public class RedisPoolUtil {


    /***
     * 设置KV
     * @param key
     * @return
     */
    public static String get(String key){
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.get(key);
        } catch (Exception e) {
            log.error("set key:{} error ",key,e);
            RedisPool.returnBrokenJedis(jedis);
            return result;
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    /***
     * 删除key
     * @param key
     * @return
     */
    public static Long del(String key){
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.del(key);
        } catch (Exception e) {
            log.error("del key:{} error ",key,e);
            RedisPool.returnBrokenJedis(jedis);
            return result;
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    /**
     * 根据key获取value
     * @param key
     * @param value
     * @return
     */
    public static String set(String key,String value){
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.set(key,value);
        } catch (Exception e) {
            log.error("set key:{} value:{} error ",key,value,e);
            RedisPool.returnBrokenJedis(jedis);
            return result;
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    /***
     *设置session服务器有效时间
     * @param key
     * @param value
     * @param exTime 单位是秒
     * @return
     */
    public static String setEx(String key,String value,int exTime ){
        Jedis jedis = null;
        String result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.setex(key,exTime,value);
        } catch (Exception e) {
            log.error("set key:{} exTime {} value:{} error ",key,exTime,value,e);
            RedisPool.returnBrokenJedis(jedis);
            return result;
        }
        RedisPool.returnJedis(jedis);
        return result;
    }

    /**
     * 设置key的有效期
     * @param key
     * @param exTime
     * @return
     */
    public static Long expire(String key,int exTime ){
        Jedis jedis = null;
        Long result = null;
        try {
            jedis = RedisPool.getJedis();
            result = jedis.expire(key,exTime);
        } catch (Exception e) {
            log.error("set key:{} exTime {}  error ",key,exTime,e);
            RedisPool.returnBrokenJedis(jedis);
            return result;
        }
        RedisPool.returnJedis(jedis);
        return result;
    }
}

封装cookie常用API


import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Created by xiao
 * User: xiao
 * Date: 2018/8/27
 * Time: 18:31
 */
@Slf4j
public class CookieUtil {

    private final static String COOKIE_DOMAIN = ".test.com";
    private final static String COOKIE_NAME = "login_token";

    public static String readLoginToken(HttpServletRequest request){
        Cookie[] cks = request.getCookies();
        for (Cookie ck : cks){
            log.info("read cookieName:{},cookieValue{}",ck.getName(),ck.getValue());
            if(StringUtils.equals(ck.getName(),COOKIE_NAME)){
                return ck.getValue();
            }
        }
        return null;
     }


    /**
     *设置浏览器cookie
     * @param response
     * @param token
     */
    public static void writeLoginToken(HttpServletResponse response,String token){
        Cookie ck = new  Cookie(COOKIE_NAME,token);
        //设置cookie的域
        ck.setDomain(COOKIE_DOMAIN);
        //代表设在根目录
        ck.setPath("/");
        //防止脚本读取
        ck.setHttpOnly(true);
        //单位是秒,设置成-1代表永久,如果cookie不设置maxage的话,cookie就不会写入硬盘,写在内存中,只在当前页面有效
        ck.setMaxAge(60*60*24*7);
        response.addCookie(ck);
    }

    /**
     * 删除浏览器cookie
     * @param request
     * @param response
     */
    public static void delLoginToken(HttpServletRequest request,HttpServletResponse response){
        Cookie[] cks = request.getCookies();
        for (Cookie ck : cks){

            if(StringUtils.equals(ck.getName(),COOKIE_NAME)){
                ck.setDomain(COOKIE_DOMAIN);
                ck.setPath("/");
                ck.setMaxAge(0);
                log.info("del cookieName:{},cookieValue{}",ck.getName(),ck.getValue());
                response.addCookie(ck);
                return;
            }
        }
    }
}

json序列化反序列化帮助类封装

我们在项目中,如果要把对象存到redis,就要把对象序列化成字符串,然后存到redis中去,如果需要使用对象,就从redis中取出字符串,反序列化成对象


/**
 * Created by xiao
 * User: xiao
 * Date: 2018/8/27
 * Time: 14:39
 */

import com.mmall.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.codehaus.jackson.map.DeserializationConfig;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.SerializationConfig;
import org.codehaus.jackson.map.annotate.JsonSerialize;
import org.codehaus.jackson.type.JavaType;
import org.codehaus.jackson.type.TypeReference;

import java.text.SimpleDateFormat;

@Slf4j
public class JsonUtil {

    private static ObjectMapper objectMapper = new ObjectMapper();
    static {
        //设置序列化属性

        //对象全部字段都列入
        objectMapper.setSerializationInclusion(JsonSerialize.Inclusion.ALWAYS);
        //取消默认把日期转换成timestamp形式
        objectMapper.configure(SerializationConfig.Feature.WRITE_DATE_KEYS_AS_TIMESTAMPS,false);
        //忽略空bean转json的错误
        objectMapper.configure(SerializationConfig.Feature.FAIL_ON_EMPTY_BEANS,false);
        //统一日期格式
        objectMapper.setDateFormat(new SimpleDateFormat(DateTimeUtil.STANDARD_FORMAT));

        //反序列化属性
        //忽略在json字符串中存在,但是java对象中不存在对应属性的情况,防止错误
        objectMapper.configure(DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES,false);
    }

    public static  String obj2String(T obj){
        if(obj == null){
            return null;
        }
        try {
            return obj instanceof String ? (String)obj : objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            log.warn("Parse object to string error ",e);
            return null;
        }
    }

    /**
     * 返回漂亮的序列化的字符串
     * @param obj
     * @param 
     * @return
     */
    public static  String obj2StringPretty(T obj){
        if(obj == null){
            return null;
        }
        try {
            return obj instanceof String ? (String)obj : objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
        } catch (Exception e) {
            log.warn("Parse object to string error ",e);
            return null;
        }
    }

    public static  T string2Obj(String str,Class clazz){
        if(StringUtils.isEmpty(str)||clazz == null){
            return null;
        }
        try {
            return clazz.equals(String.class) ? (T)str : objectMapper.readValue(str,clazz);
        } catch (Exception e) {
            log.warn("Parse string to object error ",e);
            return null;
        }
    }


    public static  T string2Obj(String str, TypeReference typeReference){
        if(StringUtils.isEmpty(str)||typeReference == null){
            return null;
        }
        try {
            return (T)(typeReference.getType().equals(String.class)? str : objectMapper.readValue(str,typeReference));        } catch (Exception e) {
            log.warn("Parse string to object error ",e);
            return null;
        }
    }

    public static  T string2Obj(String str,Class collectionClass,Class... elementClasses){
        JavaType javaType = objectMapper.getTypeFactory().constructParametricType(collectionClass,elementClasses);
        try {
            return objectMapper.readValue(str,javaType);
        }catch (Exception e){
            log.warn("Parse string to object error ",e);
            return null;
        }
    }
    public static void main(String[] args) {
        User u1 = new User();
        u1.setId(1);
        u1.setEmail("[email protected]");
        String userJson = JsonUtil.obj2String(u1);
        String userJsonPretty = JsonUtil.obj2StringPretty(u1);
        log.info("userJson:{}",userJson);
        log.info("userJsonPretty:{}",userJsonPretty);
        User user = JsonUtil.string2Obj(userJson,User.class);
        User user2 = JsonUtil.string2Obj(userJsonPretty,User.class);


    }
}

登录改造例子

    /**
     * 用户登录
     * @param username
     * @param userpwd
     * @param session
     * @return
     */
    @RequestMapping(value = "login.do",method = RequestMethod.POST)
    @ResponseBody
    public ServerResponse login(String username, String userpwd, HttpSession session, HttpServletResponse httpServletResponse)
    {

        ServerResponse response = iUserService.login(username,userpwd);
        if(response.isSuccess()){
            //session.setAttribute(Const.CURRENT_USER,response.getData());
            //把session存储改成cookie记录session id,然后通过在指定域种下cookie,下次访问要验证登陆的话,直接读取session id 去redis取数据,以此判断登陆
            CookieUtil.writeLoginToken(httpServletResponse,session.getId());
            RedisPoolUtil.setEx(session.getId(), JsonUtil.obj2String(response.getData()),Const.RedisCacheExtime.REDIS_SESSION_EXTIME);
        }
        return response;
    }

登陆redis验证示例

    @RequestMapping(value = "get_information.do",method = RequestMethod.POST)
    @ResponseBody
    public ServerResponse get_information(HttpSession session,HttpServletRequest httpServletRequest){
        //从cookie中获取session id(token)
        String loginToken = CookieUtil.readLoginToken(httpServletRequest);
        if(StringUtils.isEmpty(loginToken)){
            return ServerResponse.createByErrorCodeMessage( ResponseCode.NEED_LOGIN.getCode(),"未登录,需要强制登录status=10");
        }

        String jsonStr = RedisPoolUtil.get(loginToken);
        //json反序列化成对象的方法
        User user = JsonUtil.string2Obj(jsonStr,User.class);

        if(user == null || user.getId()==null){
            return ServerResponse.createByErrorCodeMessage( ResponseCode.NEED_LOGIN.getCode(),"未登录,需要强制登录status=10");
        }
        return iUserService.getInformation(user.getId());
    }

总结

这次优化解决了集群session失效的问题,也进一步的优化了项目代码,学习到了如何使用redis实现单点登录,不过这个架构还是有可以优化的地方,比如mysql优化,redis集群化,一步一步去迭代项目,提升自己的编码与思考能力。

你可能感兴趣的:(springmvc,java,redis)