1. 程式人生 > >分散式系統中session一致性問題

分散式系統中session一致性問題

業務場景

在單機系統中,使用者登陸之後,服務端會儲存使用者的會話資訊,只要使用者不退出重新登陸,在一段時間內使用者可以一直訪問該網站,無需重複登陸。使用者的資訊存在服務端的 session 中,session中可以存放服務端需要的一些使用者資訊,例如使用者ID,所屬公司companyId,所屬部門deptId等等。

但是隨著業務的發展,技術架構需要調整,原來的單機系統逐漸被更換,架構由單機擴充套件到分散式,甚至當下流行的微服務。雖然在使用者端看來系統仍然是一個整體,但在技術端來說業務則被拆分成多個模組,各個模組之間相互獨立,甚至不在同一臺物理機器上,模組之間通過 RPC 進行通訊。

那麼原來單機只需一份的 session, 如何滿足在多系統的執行下保證會話一致性呢?單獨儲存在任何一個系統中都不合適,而且每個單獨模組系統也可能是分散式形式的,是由叢集組成。那麼session的分配就更復雜了。

Redis 實現

針對以上問題,我們可能會從以下幾個方面想到解決的方法,每個服務端儲存一份,通過同步的方式保證一致性,但是這種方式有個很明顯的缺點:session的同步需要資料傳輸,佔內網頻寬,有時延,網路不穩定的時候會造成部分系統同步延遲,那麼就不能保證 session 一致性。而且所有服務端都包含所有session資料,資料量受記憶體限制,無法水平擴充套件。

那麼我們是否可以單獨將 session 資訊儲存在某一個獨立的介質中,介質可以是DB也可以是快取。

考慮到如下業務:登陸的時候我們經常會給使用者一個過期時間(一般移動端常設定為7天或者一個月甚至更久),到期後用戶需要輸入登陸資訊重新登陸,即會話過期。這種到期的設定我們自然想到了Redis的 key expire功能,所以最終我們可以將Redis引入進來實現我們的這種需求。系統如下圖所示:

我們只需在使用者首次登陸的時候將使用者資訊放到 Token並快取到 Redis 中,同時設定一個過期時間,虛擬碼如下:

@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丟失的情況發生。