分布式系统中session一致性问题

业务场景

在单机系统中,用户登录以后,服务端会保存用户的会话信息,只要用户不退出从新登录,在一段时间内用户能够一直访问该网站,无需重复登录。用户的信息存在服务端的 session 中,session中能够存放服务端须要的一些用户信息,例如用户ID,所属公司companyId,所属部门deptId等等。java

可是随着业务的发展,技术架构须要调整,原来的单机系统逐渐被更换,架构由单机扩展到分布式,甚至当下流行的微服务。虽然在用户端看来系统仍然是一个总体,但在技术端来讲业务则被拆分红多个模块,各个模块之间相互独立,甚至不在同一台物理机器上,模块之间经过 RPC 进行通讯。web

那么原来单机只需一份的 session, 如何知足在多系统的运行下保证会话一致性呢?单独保存在任何一个系统中都不合适,并且每一个单独模块系统也多是分布式形式的,是由集群组成。那么session的分配就更复杂了。redis

Redis 实现

针对以上问题,咱们可能会从如下几个方面想到解决的方法,每一个服务端存储一份,经过同步的方式保证一致性,可是这种方式有个很明显的缺点:session的同步须要数据传输,占内网带宽,有时延,网络不稳定的时候会形成部分系统同步延迟,那么就不能保证 session 一致性。并且全部服务端都包含全部session数据,数据量受内存限制,没法水平扩展。spring

那么咱们是否能够单独将 session 信息存储在某一个独立的介质中,介质能够是DB也能够是缓存。数据库

考虑到以下业务:登录的时候咱们常常会给用户一个过时时间(通常移动端常设置为7天或者一个月甚至更久),到期后用户须要输入登录信息从新登录,即会话过时。这种到期的设置咱们天然想到了Redis的 key expire功能,因此最终咱们能够将Redis引入进来实现咱们的这种需求。系统以下图所示:缓存

咱们只需在用户首次登录的时候将用户信息放到 Token并缓存到 Redis 中,同时设置一个过时时间,伪代码以下:markdown

@Override
    public Map login(UserDto dto) {
        Map<String, Object> restMap = new HashMap<>();
        
        // 校验登录信息
        User user = checkLoginInfo(dto);

         //删除旧的token
        String token = (String) redisUtils.get(CacheConstants.USER_TOKEN_KEY_COPY + user.getUserName());
        
        if (!ObjectUtils.isEmpty(token)) {
            redisUtils.delete(CacheConstants.USER_TOKEN_KEY_WEB + token);
        }
        // 惟一签名信息
        String signStr = user.getCompanyId() + user.getUserName() + dto.getPassword() + DateUtils.now().getTime();
        token = MD5Utils.md5(signStr);
        // 设置用户 token
        redisUtils.setExpiredAt(CacheConstants.USER_TOKEN_KEY_WEB + token, user.getId(), LOGIN_EXPIRED_TIME);
        //缓存新的token
        redisUtils.setExpiredAt(CacheConstants.USER_TOKEN_KEY_COPY + user.getUserName(), token, LOGIN_EXPIRED_TIME);
        dto.setCompanyId(user.getCompanyId());
        dto.setId(user.getId());
        restMap.put("token", token);
        restMap.put("userName", user.getUserName());
        return restMap;
    }
复制代码

那么在系统中如何使用呢,咱们能够定义一个拦截器 SessionInterceptor,当访问 web 接口的时候检验用户的 token 信息,判断用户是否登录,未登陆的状况下一些业务接口是没法访问的,以及在登录的状况下拿到咱们须要的用户信息,如 userId。网络

public class SessionInterceptor {

    @Autowired
    private RedisUtils redisUtils;
    
    @Autowired
    private UserService userService;

    @Pointcut("execution(* com.jajian.demo.web.*.controller.*.*(..)) && @annotation(org.springframework.web.bind.annotation.RequestMapping)")
    public void controllerMethodPointcut() {

    }

    @Around("controllerMethodPointcut()")
    public Object Interceptor(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        
        Signature signature = proceedingJoinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method targetMethod = methodSignature.getMethod();
        if (targetMethod.getDeclaringClass().isAnnotationPresent(NoLogin.class) || targetMethod.isAnnotationPresent(NoLogin.class)) {
            return proceedingJoinPoint.proceed();
        }
        // 从获取RequestAttributes中获取HttpServletRequest的信息
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);

        String token = request.getHeader("token");

        if(StringUtils.isEmpty(token)){
            Log.debug("验证token", "token验证失败,{}", "token不存在");
            throw new FieldException(Constants.LOGIN_ERROR_CODE, "login.session.timeout");
        }
        Integer userId= (Integer)redisUtils.get(CacheConstants.USER_TOKEN_KEY_WEB + token);
       
        if (null == userId) {
            Log.debug("验证token", "token验证失败,{}", "token超时");
            throw new FieldException(Constants.LOGIN_ERROR_CODE, "login.session.timeout");
        }
        User user = userService.getById(userId.longValue());
        if (ObjectUtils.isEmpty(user)){
            Log.debug("验证token", "token验证失败,{}", "用户信息不存在");
            throw new FieldException(Constants.LOGIN_ERROR_CODE, "login.session.timeout");
        }
        if (user.getStatus() == UserStatusEnum.NO.getCode() || user.getDeleteFlag() == DeleteFlagEnum.YES.getCode()){
            Log.debug("验证token", "token验证失败,用户信息异常 userName : {}, status : {},deleteFlag : {}", user.getUserName(),user.getStatus(), user.getDeleteFlag());
            throw new FieldException(Constants.LOGIN_ERROR_CODE, "login.session.timeout");
        }
        return proceedingJoinPoint.proceed();
    }
    
}
复制代码

以上实现方式简单易用,并且Redis 在分布式系统中的使用率也很高,因此无需额外的技术引入。能够支持水平扩展,数据库或缓存水平切分便可,服务端重启或者扩容都不会有session丢失的状况发生。session


我的公众号:JaJian架构

欢迎长按下图关注公众号:JaJian!

按期为你奉上分布式,微服务等一线互联网公司相关技术的讲解和分析。


1557975294786730.png