资讯专栏INFORMATION COLUMN

如果连铁将军都不再可靠--记一次排查使用分布式轮候锁+SESSION防订单重复仍然加锁失效问题经历

econi / 2158人阅读

摘要:尽可能地将数据写入,例如创建设置的都会将数据立即的写入再来看看文档怎么描述的看看这可爱的默认值我们终于知道了当我们不做任何设置时,默认采用的是方式显而易见,使用方式能最大限度的减少与的交互,而在大多数场景下都是没有问题的。

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.初步分析

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

使用QQ浏览器会极大的提高重现成功率(不要问我为什么QQ浏览器总会发送两个时间间隔极短的请求!ε=( o`ω′)ノ)

当程序处理较慢时容易重现

接下来我们模拟了连续发送重复请求的场景进行了测试,结果发现了一个有趣的情况,提交两个连续的请求,会生成两个一样的订单,而提交三个连续请求时也只会生成两个一样的订单,提交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,发现存在这样的诡异情况:

req1、req2在调用request.getSession().getAttribute(GlobalContants.ORDER_TEMP_ID)后,都可获取到同一个rid,而req3为空

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的:

先在cached中移除待删除的属性,然后将detla中的对应属性至空
嗯....好像也没什么问题...再看看flushImmediateIfNecessary方法,这个方法应该就是吧detla中保存的属性写入Redis了吧,至少也是前置的某些步骤吧:

嗯,果然调用了saveDelta,看名字相当直白,就是保存detla,看看具体实现吧

可见,delta就是Session里的内容,通过BoundHashOperations写入Redis,嗯,很Spring,很正路,应该也没有太多问题...
等等,好像哪里不对
flushImmediateIfNecessary? IfNecessary?!

回顾一下之前看到的代码,调用saveDelta前可是有个判断的,只有配置了redisFlushMode为RedisFlushMode.IMMEDIATE时才会立即将session写入Redis!
那么,问题来了,如果不设置这个配置呢?

3.真相大白

来看看RedisSession提供了什么FlushMode:

可以看到,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 无效

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/69472.html

相关文章

  • 布式幂等问题解决方案三部曲

    摘要:解决幂等问题的三部曲,也是作者的思考框架。这是解决幂等问题的第二部曲列出并减少副作用的分析维度。所以在并发执行的维度,将并发重复执行变成串行重复执行是最好的幂等解决方案。 纲要 文章目的:本文旨在提炼一套分布式幂等问题的思考框架,而非解决某个具体的分布式幂等问题。在这个框架体系内,会有一些方案举例说明。文章目标:希望读者能通过这套思考框架设计出符合自己业务的完备的幂等解决方案。文章内容...

    mumumu 评论0 收藏0
  • 后端好书阅读与推荐(续三)

    摘要:后端好书阅读与推荐系列文章后端好书阅读与推荐后端好书阅读与推荐续后端好书阅读与推荐续二后端好书阅读与推荐续三这里依然记录一下每本书的亮点与自己读书心得和体会,分享并求拍砖。然后又请求封锁,当释放了上的封锁之后,系统又批准了的请求一直等待。 后端好书阅读与推荐系列文章:后端好书阅读与推荐后端好书阅读与推荐(续)后端好书阅读与推荐(续二)后端好书阅读与推荐(续三) 这里依然记录一下每本书的...

    ckllj 评论0 收藏0
  • 后端好书阅读与推荐(续三)

    摘要:后端好书阅读与推荐系列文章后端好书阅读与推荐后端好书阅读与推荐续后端好书阅读与推荐续二后端好书阅读与推荐续三这里依然记录一下每本书的亮点与自己读书心得和体会,分享并求拍砖。然后又请求封锁,当释放了上的封锁之后,系统又批准了的请求一直等待。 后端好书阅读与推荐系列文章:后端好书阅读与推荐后端好书阅读与推荐(续)后端好书阅读与推荐(续二)后端好书阅读与推荐(续三) 这里依然记录一下每本书的...

    jcc 评论0 收藏0
  • 后端好书阅读与推荐(续三)

    摘要:后端好书阅读与推荐系列文章后端好书阅读与推荐后端好书阅读与推荐续后端好书阅读与推荐续二后端好书阅读与推荐续三这里依然记录一下每本书的亮点与自己读书心得和体会,分享并求拍砖。然后又请求封锁,当释放了上的封锁之后,系统又批准了的请求一直等待。 后端好书阅读与推荐系列文章:后端好书阅读与推荐后端好书阅读与推荐(续)后端好书阅读与推荐(续二)后端好书阅读与推荐(续三) 这里依然记录一下每本书的...

    lauren_liuling 评论0 收藏0

发表评论

0条评论

econi

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<