Spring Session實現Session共享下的坑與建議

weixin_34126215發表於2013-12-19

相信用過spring-session做session共享的朋友都很喜歡它的精巧易用-不依賴具體web容器、不需要修改已成專案的程式碼。筆者在使用spring-session的過程中也對spring-session的絕佳包容性、穩定性讚歎不已,spring-session 和 redis 的結合堪稱神器,但是兩者結合下來真的可以完全代替原本的session管理嗎?

一、url rewrite保持Session

相信很多做過檔案上傳的朋友遇到過這樣的需求-在瀏覽器中顯示上傳進度條並且要求多瀏覽器相容性,特殊國情~相容IE低版本,OK,只能用上筆者認為已經過時的技術-Flash,做前端比較多的肯定知道SWFUpload、Uploadify這類通過呼叫Flash上傳實現瀏覽器本身不具備的顯示進度條的功能。但是在某些瀏覽器、某些flash客戶端版本下,上傳的HTTP請求是不帶cookie的,so,session問題如何解決?普遍的做法是通過url rewrite保持Session,即獲取cookie中的jsessionid來放到請求url的引數中。那麼spring-session支援嗎?回答NO,至少spring-session原始碼中是沒有支援的,如何支援呢? 
我們閱讀程式碼可以看到spring-session中實現從cookie到session的策略類是CookieHttpSessionStrategy,並且允許自定義策略類,只需要在spring-session中定義bean就行了,所以我們來擴充套件這個CookieHttpSessionStrategy。 
1. 想要直接繼承CookieHttpSessionStrategy?那是不可能的,它是final的,為啥?暫時不清楚。 
2. 看來只能硬來了,首先把CookieHttpSessionStrategy的原始碼複製出來,放到自己的專案裡一份,去掉final關鍵字,姑且新類名就叫SessionForCookieStrategy吧。 
3. 為了整潔,不建議在這個類下直接修改了,我們還是應該堅持java人的操守不是?新建一個SessionUnionStrategy類,提供了從request域中獲取jsessionid的引數。 
4. 建立SessionForURLFilter,即處理從url中獲取jsessionid然後把值丟給request Attribute中。 
5. 配置檔案配置Strategy和Filter 
上程式碼: 
SessionUnionStrategy類:

public class SessionUnionStrategy extends SessionForCookieStrategy{

    @Override
    public Map<String, String> getSessionIds(HttpServletRequest request) {
        Map<String, String> result = super.getSessionIds(request);
        if(result.isEmpty()){
            String jsessionId = (String)request.getAttribute(SessionForURLFilter.OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME);
            if ((jsessionId != null) && (!"".equals(jsessionId.trim())))
            {
                result.put(DEFAULT_ALIAS, jsessionId);
            }
        }
        return result;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

SessionForURLFilter類:

public class SessionForURLFilter extends OncePerRequestFilter{

    public static final String OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME = "OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if(request.isRequestedSessionIdFromURL()){
            String jsessionId = request.getRequestedSessionId();
            if ((jsessionId != null) && (!"".equals(jsessionId.trim()))){
                request.setAttribute(OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME, jsessionId);
            }
        }
        filterChain.doFilter(request, response);
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

spring容器配置檔案中:

<bean name="sessionForURLFilter" class="cn.emay.bootstrap.util.SessionForURLFilter"/>
<bean class="cn.emay.bootstrap.util.SessionUnionStrategy">
        <property name="cookieSerializer">
            <bean class="org.springframework.session.web.http.DefaultCookieSerializer">
                <property name="cookieName" value="JSESSIONID"/>
            </bean>
        </property>
    </bean>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

web.xml檔案中:

<filter>
        <filter-name>sessionForURLFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>sessionForURLFilter</filter-name>
        <url-pattern>/*</url-pattern>
        <dispatcher>REQUEST</dispatcher>
        <dispatcher>ERROR</dispatcher>
    </filter-mapping>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

二、除了JDK序列化還能用JSON序列化方式嗎?

用過spring-session的朋友都知道,它的基本工作原理是把原本session中的物件從單機的記憶體中剝離出來放到的公共儲存中,這就需要序列化了,預設使用JDK序列化方式,並且是支援自定義序列化方式的。很多人知道既然一般一個JAVA物件的JSON的儲存量肯定比JDK序列化方式的儲存量小的多,那為啥不用JSON來儲存?一來可以減輕IO的壓力,二來可以直接在redis中直接閱讀session資料。 
首先在spring-session的文件中找到這麼一段:

Custom RedisSerializer 
You can customize the serialization by creating a Bean named springSessionDefaultRedisSerializer that implements RedisSerializer<Object>.

筆者也忍不住也就試了一番,spring容器配置:

    <bean id="springSessionDefaultRedisSerializer" class="org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer"/>
  • 1

可以跑起來,不過遇見下列程式碼就頭疼了:

    @RequestMapping("/setS")
    public String setSession(HttpServletRequest req) {
        Long value = 1l;
        req.getSession().setAttribute("key", value);
        return null;
    }

    @RequestMapping("/getS")
    public String getSession(HttpServletRequest req) {
        Long value = (Long)req.getSession().getAttribute("key");
        System.out.println(value);
        return null;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

觸發異常:

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.Long
  • 1

去redis中找具體儲存資料:

 7) "sessionAttr:key"
 8) "1"
  • 1
  • 2

瞭然,JSON的虛化列方式明瞭是明瞭,但是連個java型別都沒有限定說明,雖然我們可以去獲取物件前判斷型別再轉化,但是也就喪失了spring-session使用的關鍵優點-不需要修改已有程式碼。

三、JSP下的session設定坑

這是一個比較難發現的問題,有些朋友在spring-session上手之後可能一帆風順就沒有去關注spring-session的基本工作流程,但是在spring-session何時將放入session中的物件序列化儲存到redis中如果沒有一個清晰的認識可能會進入這個坑。 
如果你在你的程式碼中有這樣存入session物件: 
controller中:

@RequestMapping("/setS")
    public String setSession(HttpServletRequest req) {
        Map<Object,Object> value = new HashMap<Object,Object>();
        req.getSession().setAttribute("valid", value);
        return "test";
    }

    @RequestMapping("/getS")
    public String getSession(HttpServletRequest req) {
        Map<Object,Object> value=(Map<Object,Object>)req.getSession().getAttribute("valid");
        System.out.println(value.keySet().size());
        return null;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

test.jsp中:

<c:forEach var="v" begin="1" end="100" step="1">
    <%-- 任意長文字--%>
    <c:set target="${valid}" property="${v}" value="1"/>y
</c:forEach>
  • 1
  • 2
  • 3
  • 4

最終getS列印的size未必是100,本地測試在jetty下正常,在tomcat下就不是100了,可能只有一半,只存入了一半資料?除錯得出問題所在,看圖: 
這裡寫圖片描述 
結論是當JSP輸出到buffer的時候如果buffer滿了的話將flushBuffer,同時將由spring-session提交session,即寫入redis。spring-session原始碼中: 
RedisOperationsSessionRepository中部分方法:

public void setAttribute(String attributeName, Object attributeValue)
{
      this.cached.setAttribute(attributeName, attributeValue); 
      this.delta.put(RedisOperationsSessionRepository.getSessionAttrNameKey(attributeName), attributeValue);
      flushImmediateIfNecessary();
}

private void saveDelta()
    {
    ...序列化存入redis
    this.delta = new HashMap(this.delta.size());
    ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

由此可見當flushBuffer的時候會將delta重置,此時已經將物件序列化入redis中了,不會管之後這裡邊的物件會不會改變,除非再次delta.put(...) 
最終解決辦法及建議:在完成物件修改之後最後將需要設定進session中的物件setAttribute...

四、redis鍵空間通知與物件序列化serialVersionUID改變之後

筆者對spring-session的redis鍵空間通知方面的接觸始於一個開發問題,如果在一個web叢集下單個web容器中修改了將放入session中的物件的class結構(或者說是serialVersionUID改變),那麼在其它web容器在有session失效中,該容器將觸發異常-無法反序列化session物件,最終通過抓包發現,當其它伺服器有session的重新登入的時候該web容器向redis發出了hgetall (舊sessionid)命令。也就是說web叢集中所有的session失效時,其它所有伺服器將接受到通知並反序列化這個session中的所有物件。結合spring-session文件可以找到:

Firing SessionDeletedEvent or SessionExpiredEvent is made available through the SessionMessageListener which listens to Redis Keyspace events. In order for this to work, Redis Keyspace events for Generic commands and Expired events needs to be enabled. For example: 
redis-cli config set notify-keyspace-events Egx

很明顯spring-session實現Session刪除事件和Session過期事件需要依賴redis的鍵空間通知功能,spring-session的原始碼中直接預設執行這句redis命令(是的,直接執行config set,筆者對這種直接侵入的做法實不敢苟同)。當然會有朋友想到實現這種全域性通知對redis的效能影響得多大,在高併發訪問情況下尤其影響吧。對此筆者翻閱了spring-session的線上文件,沒有一個清晰的解釋。只有提到如果使用者的redis是一個安全較高的公共redis(比如阿里雲的),可以這樣配置:

<util:constant
    static-field="org.springframework.session.data.redis.config.ConfigureRedisAction.NO_OP"/>
  • 1
  • 2

筆者也同樣搜尋了很久,大多博文對這個的解釋模稜兩可。通過測試得出這句配置只是說讓spring-session不去直接執行config set,並沒有說可以不用redis的鍵空間通知,而且如果你的程式已經執行過了,即已經對redis設定過這個鍵空間通知了,不去手動在redis種清除這個config那麼將依然收到鍵空間通知。如果需要徹底不接受redis鍵空間通知,可首先加入這句配置,然後去redis中將鍵空間通知config置空(筆者只是實現了不通知,是否有其它程式上的問題沒有全面的測試,為了穩定暫時只能按照spring-session預設的來)。對於能否取消redis鍵空間通知以提高web叢集的效能筆者沒有再深入spring-session原始碼,有經驗的讀者可以給予下意見。

五、題外:spring升級後的一個問題

spring-session要求spring基礎庫版本在3.2.14以上,如果你的web應用的spring框架版本是3.0.x,那麼在升級至該版本時,請升級關鍵配置: 
將過時的配置:

<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" >
  • 1

修改為:

<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter" >
  • 1

否則在檔案上傳至返回json的請求處理器時,web容器將上傳成功但返回http錯誤碼,更多的關於這個過時配置的bug讀者可自行Google。

六、spring-session測試效能簡說

筆者在實際LR壓力測試監控過程中,spring-session呼叫redis方面效能還是挺穩定的,粗略得出的資料有在最高5000人併發訪問web叢集時redis佔用記憶體6G,redis連線數600(當然這只是個參考,具體web應用的session儲存內容不同),redis和web容器在同一個內網的環境下前端開啟速度與沒有共享session情況下未發生明顯的延遲,建議保證redis伺服器與web應用間的資料聯通速率。對測試資料感興趣的開發者推薦使用Apache ab工具進行壓測。

相關文章