若是連鐵將軍都再也不可靠--記一次排查使用分佈式輪候鎖+SESSION防訂單重複仍然加鎖失效問題經歷

0.問題背景

這次問題源於一次挺嚴重的生產事故:客戶的訂單被重複生成了,而出問題的代碼其實很簡單:html

// ....
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,臨時訂單號防止重複提交,一切看上去是多麼的健壯啊,怎麼會出問題呢!java

項目使用了spring-session框架的RedisSession實現基於Redis的跨應用的Session共享

1.初步分析

一開始,咱們並不能穩定的重現問題,老是在正常訂單中偶爾的出現一些重複單,在經過不斷的嘗試後,終於讓咱們發現了一些規律:redis

  1. 使用QQ瀏覽器會極大的提升重現成功率(不要問我爲何QQ瀏覽器總會發送兩個時間間隔極短的請求!ε=( o`ω′)ノ)
  2. 當程序處理較慢時容易重現

接下來咱們模擬了連續發送重複請求的場景進行了測試,結果發現了一個有趣的狀況,提交兩個連續的請求,會生成兩個同樣的訂單,而提交三個連續請求時也只會生成兩個同樣的訂單,提交4個請求呢,生成了3個訂單!而訂單的生成時間間隔一般都在2s到3s之間,這基本就能夠排除輪候鎖的問題了,那,難道是rid的判重出問題了?
接下來的測試咱們將主要關注rid的變化,如下是其中一組數據示意:spring

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. req一、req2在調用request.getSession().getAttribute(GlobalContants.ORDER_TEMP_ID)後,均可獲取到同一個rid,而req3爲空
  2. req一、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搞的鬼?跟進源碼看看吧...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!
那麼,問題來了,若是不設置這個配置呢?app

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髒讀」事件!svn

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 無效

相關文章
相關標籤/搜索