集群的session共享問題分析
session共享問題:多臺Tomcat無法共享session存儲空間,當請求切換到不同Tomcat服務時,原來存儲在一臺Tomcat服務中的數據,在其他Tomcat中是看不到的,這就導致了導致數據丟失的問題。
雖然系統為單體式的架構,但是為了將來應對并發要做水平擴展,部署多個形成負載均衡的集群。
當請求進入nignx會在多臺Tomcat之間做一個輪詢,每一個Tomcat都有自己的session空間,假設用戶請求第一次被負載均衡到了Tomcat1,去發送驗證碼或者登錄時所獲取到的用戶信息僅僅是保存到這一臺Tomcat里了,當用戶第二次來執行某些業務被負載均衡到了第二臺Tomcat服務上,當該服務要去獲取驗證碼或者用戶信息時,而他自身的session內存空間空無一物,這時服務就會中斷。
這就是session共享問題。
解決方案:
在初期,為了解決session共享問題,官方提供了session拷貝功能,多臺Tomcat之間只要做好一些配置,它們之間就可以互相實現一個數據拷貝,但數據拷貝也有不小的問題,首先就是多臺Tomcat保存相同的數據,浪費內存空間,其次拷貝數據是需要時間的,這就造成了時間延遲,在這個延遲之間如果有服務來訪問,依舊會造成數據不一致的問題。問題太多,該方案就被pass了。
因此就必須尋找到可以替代session的方案,且必須滿足:
-
數據共享
-
內存存儲(因為session是基于內存的,讀寫效率較高,像登錄校驗這種業務訪問頻率較高,需要滿足高并發的需求)
-
鍵值對結構(session存儲較為簡單)
這就是Redis,首先redis是在Tomcat以外的一個存儲方案,,任意一臺Tomcat都能訪問到Redis,所以就可以實現數據共享了,就不會出現數據丟失的情況 其次Redis就是存儲在內存中的,而且性能非常強,并且redis就是鍵值對類型的數據庫,因此我們是可以用redis來代替session。
基于Redis實現共享session登錄
業務流程
如果要使用Redis來代替session,那么前面的短信登錄業務也有相應的變化。
比如在發送驗證碼的業務流程中,需要將驗證碼存入redis,并且還需要考慮value是什么類型的數據結構,存入驗證碼時就可以直接用String類型即可。
而key類型就不能和原先一致,因為session在發送請求的時候都有一個獨立的session,在Tomcat服務中維護了很多session,那么不同瀏覽器攜帶的手機號請求服務時都有著自己獨立的session,這些服務都是使用code作為key,但是互相之間互不干擾。
在使用session時,不需要考慮取數據的問題,因為Tomcat會自動的幫助我們去維護session:瀏覽器發去請求時,Tomcat就為瀏覽器新建一個session,如果session存在,直接使用即可,在創建session時,就會自動創建sessionID寫入到對應瀏覽器的cookie中,以后瀏覽器的每次請求就會帶著cookie,帶著sessionID,這樣就能精確找到對應的session,也不用去考慮取的問題。
但redis是一個共享的內存空間,不管是哪個服務發請求,都是往redis的內存空間存儲的,如果每個服務使用的key都是code,就會相互覆蓋,就會造成數據丟失。而必須要確保每次不同服務訪問的key都不同。
那既然如此,那就直接將手機號碼作為每個服務的key,這樣就能保證每個服務都有自己不同的key,這樣的操作也有助于將來我們去獲取驗證碼進行驗證。
現在要使用redis,沒有維護,現在以手機為key存入進去,那瀏覽器做登錄時還需要帶著這個key的值來取,才可以驗證。
而在等短信驗證碼登錄注冊時,需要將手機號碼與驗證碼存入,正好可以根據手機號碼去拿到value。
再去根據手機號查詢用戶,如果用戶存在,則將用戶存入redis中。
此時需要考慮兩個問題,一是value的數據類型選擇問題,二是考慮key的命名,
在短信登錄業務存入的是Java對象,那么redis的value雖然可以使用string類型,用json字符串保存,比較直觀,但是無法針對單個字段作出修改,只能修改整個字段。
這時可以使用hash類型,hash結構可以將對象中的每個字段獨立存儲,可以針對單個字段做修改,并且內存占用更少(Hash結構只需要保存數據本身即可,但是String類型還需要保存json字符串的格式)。
如果從優化角度來看,比較推薦Hash結構。
而key的要求也有兩點:一是唯一性,二是較為便攜。
這里推薦使用隨機的token(隨機字符串,可以使用UUID來生成)作為key存儲用戶數據。
而在登錄校驗這一業務中,以前使用session時的登錄憑證就是sessionID,被存在瀏覽器的cookie中被一直攜帶,且一直被Tomcat維護。
而現在使用的redis來代替session,則我們使用的隨機token則是登錄憑證,也就意味著以后瀏覽器來訪問我們需要攜帶token將其作為憑證。而Tomcat不會將其自動的寫入瀏覽器中,我們需要手動的將其返回前端,那么此處流程就產生了變化。
那當服務器拿到token之后,我們就可以基于token來從redis獲取用戶信息,剩下校驗登錄狀態流程就不變
而登錄憑證是通過前端的邏輯代碼進行接收并保留的,在前端使用axios的攔截器,利用攔截器將用戶token放在“authorization”頭,這樣每一條用戶請求就會攜帶token。如果我們使用手機號碼作為key去保存,將來返回到前端直接保存在瀏覽器會有泄漏的風險。這就是我們key不能再次使用手機號碼的原因。
代碼修改
發送驗證碼
?@Overridepublic Result sendCode(String phone, HttpSession session) {//1.校驗手機號if (RegexUtils.isPhoneInvalid(phone)) {//2.如果不符合,返回錯誤信息return Result.fail("手機號格式錯誤");}//3.如果符合,生成驗證碼String code = RandomUtil.randomNumbers(6);//4.保存驗證碼到redis 還需要給驗證碼設置有效時間 set key value ex 120//一般都會定義一個工具類來保存常量,避免重復及手誤stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);//5.發送驗證碼(模擬發送驗證碼,該業務并未實現)log.debug("發送短信驗證碼成功,驗證碼:{}",code);// 6.返回結果return Result.ok();}
短信驗證碼登錄注冊
?@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {//1.校驗手機號String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {//2.如果不符合,返回錯誤信息return Result.fail("手機號格式錯誤");}//2.從Redis中獲取驗證碼并校驗String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);String code = loginForm.getCode();// 一般校驗時,從反向校驗,這種校驗不需要if嵌套,否則會嵌套if,避免if嵌套過深if (cacheCode == null || !cacheCode.equals(code)){//3.不一致,返回錯誤信息return Result.fail("驗證碼錯誤");}//4.一致,根據手機號查詢用戶 select ? * from user where phone = ?User user = query().eq("phone", phone).one();//5.判斷用戶是否存在if (user == null){//6.不存在,創建新用戶并保存// 方法定義在函數中 創建用戶//在創建完用戶到數據庫后還需要保存在session中,所以直接賦值給useruser = createUserWithPhone(phone);}//7.保存用戶信息到redis,//7.1隨機生成token,作為登錄令牌String token = UUID.randomUUID().toString(true);//7.2將user對象轉換為hash存儲UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);//7.3存儲到redis中 利用工具類將userDTO轉為mapstringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,BeanUtil.beanToMap(userDTO));// 7.4設置token有效期stringRedisTemplate.expire(LOGIN_USER_KEY+token,LOGIN_USER_TTL,TimeUnit.MINUTES);//7.4返回token給客戶端return Result.ok(token);}
問題提出:
在之前使用session時,session的有效期是30分鐘,但session的有效期是指只要一直在訪問session,那么session的有效期就一直是30分鐘,只有超過30分鐘不訪問session,session才會失效。
但是redis的有效時間就是從新建到移除的時間,不在乎是否訪問,這樣就有弊端。
應該像session一樣,只要用戶在訪問,就應該更新有效時間(即Redis中的token有效期),但是Redis無法得知用戶有沒有訪問服務端,也無法得知用戶何時訪問服務端。
而在登錄攔截校驗中,我們所有的請求訪問時都要經過攔截器的攔截與校驗,只要經過了這個校驗,就能證明該瀏覽器是一個正在活躍著的用戶,這是我們就可以更新redis的有效期。
這樣就可以做到和session一樣的效果,只要有瀏覽器訪問服務端,那么Redis就會去更新token的有效期。所以在接下來修改登錄攔截校驗代碼時還需要添加更新token有效期的邏輯。
代碼如下:
?public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}// 前置攔截器@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.獲取請求頭中的tokenString token = request.getHeader("authorization");// 判斷是否存在if (StrUtil.isBlank(token)) {response.setStatus(401);return false;}// 2.以token為key獲取redis的用戶Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);// 3.判斷用戶是否存在if (userMap.isEmpty()) {// 4.不存在 攔截器攔截 返回401狀態碼 未授權response.setStatus(401);return false;}//5.將查詢到的Hash數據轉換為UserDTO對象UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 6.存在,保存用戶信息到ThreadLocal// 在工具類中定義了一個UserHolder 是一個線程安全的ThreadLocal變量,用于保存當前線程的用戶信息。// 其中有三個方法:saveUser( 保存),getUser(拿到),removeUser(移除)。UserHolder.saveUser(userDTO);//7.刷新token有效期stringRedisTemplate.expire(LOGIN_USER_KEY+token, LOGIN_USER_TTL, TimeUnit.MINUTES);//8.放行return true;}?// 攔截器 后處理@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用戶 避免用戶泄漏UserHolder.removeUser();}}
修改成功,
測試結果:
報錯,類強制轉換異常ClassCastException,redis serializer報錯 long類型不能轉換成String類型,在UserServiceImpl中向redis存入用戶信息時報錯,userMap來自userDTO,userDTO中的id為long類型,無法存儲到redis中去,因為我們使用的RedisTemplate為StringRedisTemplate,他要求key與value都必須是string類型,但userMap中的id為long類型,因此報錯。
解決方案:
-
自己在重寫toMap函數,在將userDTO轉換成userMap時將值的類型轉換成string字符串
-
提供的工具類有自定義的功能,如下所示
?Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));
再次測試:
解決狀態登錄刷新的問題
問題引入:
目前的短信登錄攔截器無法做到只要用戶一直登陸就不會過期。
因為攔截器攔截的不是一切路徑,而是那些需要登錄校驗的路徑,比如user/me,或者將來用戶的下單,支付這樣一些對用戶信息有需求的路徑,或者說被攔截器攔截的路徑,但攔截器并不是攔截一切。
如果用戶一直訪問的是不需要登錄的頁面,比如首頁或者商戶詳情頁,這些不需要登錄校驗就可以看,這些就不會去刷新有效期,過了指定的有效期后,即使用戶還在訪問,但token就會被移除,問題因此出現。
解決方案:
在原有攔截器的基礎上再加上一個攔截器,這樣用戶請求就要先經過第一個攔截器,在經過第二個,第一個攔截器攔截全部路徑,所有請求都會被攔截,就可以在這個攔截器中做刷新token有效期的業務(獲取token,查詢Redis用戶,保存到ThreadLocal中,刷新token有效期,放行),第一個攔截器不做攔截,這樣就可以確保一切請求都可以觸發刷新的動作,第二個攔截器只需要做攔截業務(查詢ThreadLocal的用戶,不存在則攔截,存在,則繼續)即可
代碼展示:
LoginInterceptor.java
?public class LoginInterceptor implements HandlerInterceptor {// 前置攔截器@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.判斷是否需要攔截( ThreadLocal中是否有用戶)if (UserHolder.getUser() == null) {// 2.沒有,攔截,返回401response.setStatus(401);return false;} else {// 有用戶,放行return true;}}
RefreshTokenInterceptor.java
?public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;?public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}?// 前置攔截器@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1.獲取請求頭中的tokenString token = request.getHeader("authorization");// 判斷是否存在if (StrUtil.isBlank(token)) {return true;}// 2.以token為key獲取redis的用戶Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);// 3.判斷用戶是否存在if (userMap.isEmpty()) {// 4.不存在 攔截器攔截 返回401狀態碼 未授權return true;}//5.將查詢到的Hash數據轉換為UserDTO對象UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 6.存在,保存用戶信息到ThreadLocal// 在工具類中定義了一個UserHolder 是一個線程安全的ThreadLocal變量,用于保存當前線程的用戶信息。// 其中有三個方法:saveUser( 保存),getUser(拿到),removeUser(移除)。UserHolder.saveUser(userDTO);//7.刷新token有效期stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);//8.放行return true;}?// 攔截器 后處理@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 移除用戶 避免用戶泄漏UserHolder.removeUser();}}
裝配攔截器:
?
@Configurationpublic class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 登錄攔截器registry.addInterceptor(new LoginInterceptor())// 除了這些路徑,其他路徑都進行攔截.excludePathPatterns("/user/code","/user/login","/blog/hot","/voucher/**","/shop/**","/shop-type/**","/upload/**","/blog/query/hot","/druid/**").order(1);// token刷新攔截器registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}}
如何控制攔截器的前后順序:在源碼中order的值越大,優先級越低,值越小,優先級越高。
測試成功,無論訪問哪個頁面,都會刷新token的有效期。
至此,基于Redis實現共享session登錄的業務完成。
總結:基于Redis改造短信登錄,改造的點如下:
-
發送短信驗證碼時將驗證碼存入redis中,key使用的是手機號碼,value的類型為String
-
短信登錄時保存用戶到Redis,key要保證唯一以及便攜,因此將key設為了UUID,放在了前端的請求頭中,返回給了用戶,保存到瀏覽器中,這樣一來瀏覽器可以攜帶token來訪問服務端,從而實現登陸的效果。
注意事項:
-
在使用redis存儲數據的時候,key的規范非常重要。還有數據類型的選擇,code選擇String類型,而用戶選擇了Hash類型。
-
我們在存儲數據的過程中,要記得設置存儲有效期。
-
要選擇合適的存儲粒度,我們并沒有存儲完整的用戶信息,而是將一些敏感信息給去掉了,只保存一些不太敏感、頁面需要的數據,這樣還可以節省內存空間