一張優惠券引起的血案









一個月前——
前端







整個優惠券中心分爲前端和後端,小灰所負責的是後端RPC接口的開發。接口中包含「查券」和「領券」兩個方法,項目大致結構以下圖:程序員





兩週後——數據庫




小灰:看,這是優惠券查詢功能的效果! 後端



小灰:看,這是優惠券領取功能的效果! 緩存





三天後——安全















小灰本來的優惠券查詢接口是這樣實現的:
服務器



優惠券列表在Redis中以List的形式存儲,查詢時的邏輯很簡單:併發


1.查詢緩存,若是緩存存在,返回結果分佈式


2.緩存不存在,查詢數據庫線程


3.把查詢數據庫的結果循環放入緩存


然而,當某個時間點緩存不存在,請求量又很大的時候,會出現緩存併發的問題。也就是多個線程會重複去查詢DB,又重複去更新緩存。(注意,這並非緩存擊穿,不少人在這兩個概念上混淆。)


這其中重複查詢DB是次要問題,而重複更新緩存則是主要問題。假若有兩個線程同時進入上述的第三個階段,各自進行rpush操做,那麼最終會在優惠券列表的緩存中插入兩組一樣的數據。


怎麼解決呢?用Java的鎖機制?顯然不行,由於線上環境一般都是多個服務器組成的集羣。因而小灰想到了利用分佈式鎖



所謂分佈式鎖有不少種,能夠利用ZooKeeper、MemCache、Redis來實現。其中Redis的方式比較簡單,無非是利用一個服務器之間共享的Key,以及Setnx指令。


當第一個線程執行Setnx,會存儲對應的鍵值,至關於成功得到鎖。當後續再有線程對同於的Key執行Setnx指令,則會返回空,至關於搶鎖失敗。同時,爲了防止一個線程因意外狀況而長久把持着鎖,程序對Key設置了1秒的過時時間。


概括一下修改後的邏輯:


1.查詢緩存,若是緩存存在,返回結果


2.緩存不存在,查詢數據庫


3.爭奪分佈式鎖


4.成功得到鎖,把查詢數據庫的結果循環放入緩存


5.釋放分佈式鎖







三天後——













詭異的bug又重現了,由於小灰上次的改動仍然存在一個致命的漏洞。在這裏咱們假定緩存不存在,恰好有兩個線程A和B一後一先進入到代碼塊。


第一階段,線程A剛開始查詢優惠券緩存,線程B正嘗試獲取分佈式鎖:


第二階段,因爲緩存不存在,線程A開始查詢數據庫,線程B成功得到鎖,開始更新緩存:



第三階段,線程A嘗試得到分佈式鎖,而線程B已經釋放分佈式鎖:



第四階段,線程A得到了鎖,又一次更新緩存,而線程B已經成功返回:



就這樣,緩存被重複更新了兩次,因此再次出現數據重複的bug。


這種局面如何破解呢?其實不難,只需在線程成功獲得鎖之後,再次判斷優惠券緩存的存在:



概括一下修改後的邏輯:


1.查詢緩存,若是緩存存在,返回結果


2.緩存不存在,查詢數據庫


3.爭奪分佈式鎖


4.成功得到鎖,再次判斷緩存的存在


5.若是緩存仍舊不存在,把查詢數據庫的結果循環放入緩存


6.釋放分佈式鎖


這種二次判斷存在性的機制有一個專門的名字,叫作雙重檢測。該方法在線程安全的單例模式中也經常被用到。







小灰的回憶告一段落——









幾點補充:


1.文中所使用的分佈式鎖,其實並非「正宗」的分佈式鎖,當線程爭奪鎖失敗的時候,會直接返回查詢DB的結果,而不會依靠自旋機制來等鎖。


2.爲何優惠券列表的信息要使用List類型來存入緩存,而不是把整個列表存爲一個很長的Json字符串?這是因爲業務須要,使用List在某些狀況下更方便對單個優惠券信息進行修改(LSET指令)。


3.爲何優惠券列表的信息不使用Redis的Set或者Hash數據類型來存儲,實現自動去重呢?對於Set類型,去重前須要對比整個字符串是否徹底相同,而每一張優惠券是一個較長的Json字符串,對比的效率會比較低。使用Hash卻是能夠實現高效的去重,但並未在根本上解決重複更新的問題。




—————END—————




喜歡本文的朋友們,歡迎長按下圖關注訂閱號程序員小灰,收看更多精彩內容

相關文章
相關標籤/搜索