Spring Session实现Session共享下的坑与建议

相信用过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工具进行压测。

posted on 2013-12-19 14:03  duanxz  阅读(15378)  评论(1编辑  收藏  举报