1. 程式人生 > >一張優惠券引發的血案

一張優惠券引發的血案

一個月前——

整個優惠券中心分為前端和後端,小灰所負責的是後端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倒是可以實現高效的去重,但並未在根本上解決重複更新的問題。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!