0.问题背景

此次问题源于一次挺严重的生产事故:客户的订单被重复生成了,而出问题的代码其实很简单:

// ....
redisLockUtil.lock(memberVo.getMember().getId());

String orderTmpId = orderSubmitVo.getRid();

/** 防止表单重复提交,orderTmpId只能一次有效 */
String rid = (String) request.getSession().getAttribute(GlobalContants.ORDER_TEMP_ID);
if (!Lang.isEmpty(rid) && rid.equals(orderTmpId)) {
    request.getSession().removeAttribute(GlobalContants.ORDER_TEMP_ID);
} else {
    attr.addAttribute("error", errorCode);
    attr.addAttribute("message", "订单提交数据有误,请不要重复提交");
    return "redirect:/order/orderSubmitResult";
}
//...

代码的逻辑很简单,首先,通过redisLockUtil.lock实现了一个轮候锁,每个用户的多次请求是以轮候排队形式进行处理;其次,通过预分配并存入Session的RID,临时订单号防止重复提交,一切看上去是多么的健壮啊,怎么会出问题呢!

项目使用了spring-session框架的RedisSession实现基于Redis的跨应用的Session共享

1.初步分析

一开始,我们并不能稳定的重现问题,总是在正常订单中偶尔的出现一些重复单,在通过不断的尝试后,终于让我们发现了一些规律:

  1. 使用QQ浏览器会极大的提高重现成功率(不要问我为什么QQ浏览器总会发送两个时间间隔极短的请求!ε=( o`ω′)ノ)
  2. 当程序处理较慢时容易重现

接下来我们模拟了连续发送重复请求的场景进行了测试,结果发现了一个有趣的情况,提交两个连续的请求,会生成两个一样的订单,而提交三个连续请求时也只会生成两个一样的订单,提交4个请求呢,生成了3个订单!而订单的生成时间间隔通常都在2s到3s之间,这基本就可以排除轮候锁的问题了,那,难道是rid的判重出问题了?
接下来的测试我们将主要关注rid的变化,以下是其中一组数据示意:

req1: {SESSION[TEMP_ORDER_ID]: 2018052204911}
req2: {SESSION[TEMP_ORDER_ID]: 2018052204911}
req3: {SESSION[TEMP_ORDER_ID]: null}

等等!session_rid重复了2次,怎么可能!根据代码,在req1处理之后,session中的TEMP_ORDER_ID应该立即被remove掉才对!
于是,我们继续关注这个rid,发现存在这样的诡异情况:

  1. req1、req2在调用request.getSession().getAttribute(GlobalContants.ORDER_TEMP_ID)后,都可获取到同一个rid,而req3为空
  2. req1、req2在调用完 request.getSession().removeAttribute(GlobalContants.ORDER_TEMP_ID) 后打印Session中的ORDER_TEMP_ID,值为空

req2中可以获取到req1中本应被删除的rid,而直到处理req3时,SESSION中的TEMO_ORDER_ID才被正确移除!但是,每次removeAttribute后,request.getSession().getAttribute(GlobalContants.ORDER_TEMP_ID)的取值又的确为空!这怎么可能?!
因为项目使用了RedisSession实现Session共享,冷静下来的我又去看了看Redis中的数据,结果发现,当req1调用完removeAttribute后,Redis上Value里的ORDER_TEMP_ID属性根本没置空,同样的,也是直到req2处理完毕req3开始处理时才变为空!现在基本可以确定就是removeAttribute没有如我们所想的那样去正确删除Redis里的值导致了下一请求处理时仍然能获取到本应被删除的属性。

难道是spring-session搞的鬼?跟进源码看看吧...

2.抽丝剥茧

先看看RedisSession里是怎么实现removeAttribute的:
clipboard.png
先在cached中移除待删除的属性,然后将detla中的对应属性至空
嗯....好像也没什么问题...再看看flushImmediateIfNecessary方法,这个方法应该就是吧detla中保存的属性写入Redis了吧,至少也是前置的某些步骤吧:
clipboard.png
嗯,果然调用了saveDelta,看名字相当直白,就是保存detla,看看具体实现吧
clipboard.png
可见,delta就是Session里的内容,通过BoundHashOperations写入Redis,嗯,很Spring,很正路,应该也没有太多问题...
等等,好像哪里不对
flushImmediateIfNecessary? IfNecessary?!
clipboard.png
回顾一下之前看到的代码,调用saveDelta前可是有个判断的,只有配置了redisFlushMode为RedisFlushMode.IMMEDIATE时才会立即将session写入Redis!
那么,问题来了,如果不设置这个配置呢?

3.真相大白

来看看RedisSession提供了什么FlushMode:
clipboard.png
可以看到,RedisFlushMode提供了ON_SAVE跟IMMEDIATE两种方式,根据这里的注释,这两个配置的作用分别是这样的:

ON_SAVE: 只有当SessionRepository.save方法被调用的时候才将缓存的Session属性写入Redis,而在一般的Web项目中,上述方法会在Http Response被提交的时候才会被调用。
IMMEDIATE: 尽可能地将数据写入Redis,例如创建Session、设置Session的Attribute都会将数据立即的写入Redis

再来看看API文档怎么描述的
图片描述
看看这可爱的默认值!我们终于知道了当我们不做任何设置时,spring-session默认采用的是ON_SAVE方式!显而易见,使用ON_SAVE方式能最大限度的减少与Redis的IO交互,而在大多数场景下都是没有问题的。然而我们的代码就恰恰是在第一个请求还没提交,第二个请求已经进入到Action方法并获取Session,此时缓存中的TEMP_ORDER_ID并没有在Redis中被设置成空,因此导致了这个几乎不可能发生的“Session脏读”事件!

4. 解决方案

目前我们采取将RedisFlushMode改为IMMEDIATE,修改方法为在@EnableRedisHttpSession注解中指定flushMode:

Configuration
@EnableRedisHttpSession(redisFlushMode = RedisFlushMode.IMMEDIATE)
public class WebSessionConfig {
    //...
}

如此修改后,在每次调用removeAttribure后,都能正确的观察到Redis中相应的属性被置为空,问题也就基本得到了解决。

更多的思考

到此,其实问题已经解决了,但是还有一个疑问:我的轮候锁是假的么?说好的锁中贵族铁将军呢?!怎么还能有重复的请求进来呢?!
让我们再次的回顾一下整体的代码,将业务代码去掉,我们的代码是这样的:

@RequestMapping(value = {"/orderSubmit", "/orderSubmit.action", "/orderSubmit.html"}, method = RequestMethod.POST)
public String orderSubmit(OrderSubmitVo orderSubmitVo, Map model, HttpServletRequest request, RedirectAttributes attr) {
    MemberVo memberVo = loginService.findMemberVo(request);
    try {
        //同一用户排队下单
        redisLockUtil.lock(memberVo.getMember().getId());
        String orderTmpId = orderSubmitVo.getRid();
        /** 防止表单重复提交,orderTmpId只能一次有效 */
        String rid = (String) request.getSession().getAttribute(GlobalContants.ORDER_TEMP_ID);
        if (!Lang.isEmpty(rid) && rid.equals(orderTmpId)) {
            request.getSession().removeAttribute(GlobalContants.ORDER_TEMP_ID);
        } else {
            attr.addAttribute("error", errorCode);
            attr.addAttribute("message", "订单提交数据有误,请不要重复提交");
            return "redirect:/order/orderSubmitRe
        }
        // ...balabalabala 这里有很多代码..
        return "redirect:/order/orderSubmitResult";
    } catch (Exception e) {
        logger.error("提交订单异常", e);
        attr.addAttribute("error", GlobalContants.CREATE_ORDER_ERROR_NEW_ORDER_FAIL);
    } finally {
        // 释放锁
        redisLockUtil.unlock(memberVo.getMember().getId());
    }
    model.put("error", GlobalContants.CREATE_ORDER_ERROR_NEW_ORDER_FAIL);
    return "redirect:/order/orderSubmitResult";
}

简而言之,就是这么一个流程:

获取锁 -> 获取session的rid -> 校验rid是否重复提交 -> 删除session的rid -> 业务逻辑 -> 释放锁

看似很严谨啊,那问题出在哪里呢?回忆一下上文提到的,spring-session在默认情况下,是在response被commit后,将数据写入Redis。相信到此大家都明白了吧,释放锁的操作在respone被commit之前!当在较短的间隔内有A、B两个请求进入这个Action,A获得锁进行处理,而B在等待A释放锁,此时A处理完了业务逻辑但还没有提交response锁就被释放了!B获得了锁并且读取了A还没提交的Session!就好比小明上厕所,屁股还没擦水还没冲就把门打开了,后面进来的人就当然能看到马桶里aslfkjsdalvijasdvjlsaslvjasdiovjvjsdalvjasdlvjsdvjasdklv哎!我写文章呢lkjaslfjladsjfldfjafl你干嘛!aslfjasldkvjlasdnvlsavjnsljuiewosvnvowijjvsovn

咳咳,大家不要误会,我的脸绝对没有被摁在键盘上摩擦,OK,这篇分享就先到这,我们有缘再会!

keywords: spring-session removeAttribute 无效


Akers
29 声望1 粉丝

屡次转投Python神教失败的Java全栈工程师,目前主要关注微服务、人工智能、Koltin语言、用还没学会的区块链技术割韭菜(哎呀说漏嘴了)