秒殺設計--mysql的鎖機制應用和redis方案

背景

  在工作中接到一個需求:對於訪問頁面的前x名用戶分發A獎品,x+1名及以後的用戶分發另外一種獎品。在J2EE的開發中,我們知道servlet是單實例多線程的,Spring的Controller類也一樣,所以這裏需要考慮多線程併發時如何判斷該用戶是否爲前x名。一種辦法是在代碼中用內存控制,例如添加一個成員變量,創建一個方法,並在內部使用synchronized塊對該變量加鎖,每次調用這個方法時,來一個用戶就先判斷變量是否大於x,小於的話就對該變量+1,直到該變量超過x爲止。但是因爲我們的代碼是部署在多臺服務器上的,而在多臺服務器上同步內存比較麻煩,所以這種方法只適用於一臺服務器的情況。另一種方法就是在數據庫級別加鎖,因爲我們的數據庫只有一個節點,所以只要在這一個節點上加了鎖就可以控制來訪的用戶了。

mysql鎖機制簡介

  mysql提供了locking read機制,可以參考官方文檔,一共有兩種方式:SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE。介紹它們之前,這裏首先說一下X鎖和S鎖:

  • 若事務 T 對數據對象 A 加了 X 鎖,則 T 就可以對 A 進行讀取以及更新。在 T 釋放 A 上的 X 鎖以前,其它事務不能對 A 加任何類型的鎖,但可以使用普通select語句獲取值,而這個值不能保證是最新的,因爲事務 T 可能修改了 A 的值,而它還沒有提交;

  • 若事務 T 對數據對象 A 加了 S 鎖,則 T 就可以對 A 進行讀取,但不能進行更新。在 T 釋放 A 上的 S 鎖以前,其他事務可以再對 A 加 S 鎖,但不能加 X 鎖,從而可以讀取 A ,但不能更新 A;

    SELECT ... FOR UPDATE是:

  爲選擇的行添加排它鎖(X鎖),保證查詢到的數據是最新的數據,允許其它事務對該數據加上共享鎖(S鎖),但不能修改,只有當前事務可以修改,其它事務需要等當前事務commit或rollback之後纔可以修改加鎖的行;

SELECT ... LOCK IN SHARE MODE

  爲選擇的行添加共享鎖(S鎖),其它事務也可以對該行數據添加S鎖,它保證了讀取到的是最新的數據,並且不允許別人修改,但是自己也** 不一定 **能夠修改,因爲可能別的事務也對這個數據加了S鎖;

實現

  從上面對mysql鎖的介紹可以看到,我的業務需要不僅讀的時候要阻止別人讀最新值,而且還可能要修改讀取後的結果,因此這裏使用SELECT ... FOR UPDATE語句來控制用戶訪問的排名最合適。
  這裏要注意一下,在mysql中用SELECT ... FOR UPDATE加鎖,後面的WHERE條件是主鍵和非主鍵時有不同的加鎖情況的,當WHERE後面是主鍵時,僅對行加鎖,其它事務中可以對錶的其他行進行增刪改查,允許插入新的行;當WHERE後面的條件不是主鍵時,會鎖全表,則其它事務不能對錶的任意行進增刪改的操作,插入新的行也不可以,只能查詢。
  首先在數據庫創建一個簡單的表,結構如下:

列名 類型 備註
LOCK_KEY int 主鍵,每個鎖是一行
LOCK_NUM int 當前排名,即代碼中需要判斷的變量x,初始值爲0
LOCK_DESC varchar 鎖的描述

  這個表中的每一行代表一個鎖,也就是說下一次搞其它的活動,如果也需要對前x名進行控制,則插入一行記錄用於代表一個鎖。在java代碼中,創建一個跟表映射的實體類LockBean,然後在DAO中添加兩個方法,分別對應於查詢和修改:

@Select(" select LOCK_KEY, LOCK_NUM, LOCK_DESC FROM LOCK_TABLE WHERE LOCK_KEY=#{lockKey} FOR UPDATE")
public LockBean findCurrentLock(int lockKey);

@Update(" update LOCK_TABLE set LOCK_NUM = #{lockNum} where LOCK_KEY = #{lockKey}  ")
public void updateCurrentLock(LockBean lockBean);

  最後,在service層中添加事務控制,保證這兩個DAO的方法在一個事務裏面執行。需要注意的是,SELECT ... FOR UPDATE語句必須要關閉自動提交,例如使用普通的JDBC來調用,則需要先調用 connection.setAutocommit(flase)關閉自動commit操作,然後在selectupdate之後,再調用connection.commit()提交事務。如果想要在Navicat或mysql workbench中測試locking read功能,則需要先執行set autocommit=0語句關閉自動提交,然後再進行操作。

優化

  上面的方法對於每一次用戶請求,都需要通過數據庫級別的SELECT ... FOR UPDATE語句來加鎖,可是往往前x名用戶在總用戶中所佔的比例都是比較小的,畢竟大獎總是掌握在少數人手中嘛!如果每次都訪問數據庫,這樣IO次數多了(同樣也會導致網絡請求次數增多,因爲數據庫只有一個節點)就會影響性能,所以我們在內存中再添加一個控制。在某個類中創建一個變量,用於判斷前x名的獎品是否已經分發完畢:

public static volatile boolean isQueryNecessary = true;

  順便複習一下,要使得volatile變量提供理想的線程安全,必須同時滿足以下兩個條件:

  1. 對變量的寫操作不依賴於當前值
  2. 該變量沒有bao含在具有其他變量的不變式中

  當變量聲明爲volatile後,所有線程對該對象的讀取都會直接從主內存中獲取,不會使用緩存的值,而在CPU緩存的一些值都會被標識爲過期,從而完成線程對該對象的同步操作。具體介紹可見 Java 理論與實踐: 正確使用 Volatile 變量.
  迴歸正題,在service層的處理方法giveAward()中,僞代碼如下:

if(true == isQueryNecessary) {
    // 如果isQueryNecessary爲真,則查詢數據庫,注意這裏可能需要等待有X鎖的線程釋放鎖
    LockBean bean = dao.findCurrentLock(lockKey);
    /** 判斷bean中的lockNum是否>=x
     *  true :此時可能剛好等於x,也可能是在查詢數據庫時被別的線程搶先並更新了鎖,
     *         即獎品別別人先搶完了,總之需要更新isQueryNecessary的值爲false
     *         isQueryNecessary = false;
     *  false:lockNum++,
     *         dao.updateCurrentLock(bean); 
     */
}

if(false == isQueryNecessary) {
    // 再次判斷是因爲之前在查詢數據庫的時候有可能結果是lockNum >= x,
    // 導致isQueryNecessary的值被更新爲false了
    // 總之這裏處理x+1名以後的用戶的邏輯
    logicForUserAfterX();
}

  這裏對isQueryNecessary判斷了兩次,主要是因爲在多線程搶資源的情況下,變量的值可能會在等待過程中改變,所以採用單例模式中DCL的思想,雙重判斷,從而確保對每個用戶請求正確分流。
  通過這種優化後,對於單臺服務器,頂多在第x個用戶之後的部分請求(因爲這些請求可能在搶第x個席位的過程中等待)會發生多於的數據庫查詢操作;而對於多臺服務器,也只有部分的請求會執行多於的數據庫查詢,只要有一個請求在查詢數據庫之後發現已經不滿足條件了就會把isQueryNecessary設爲false,這臺服務器後續的請求就不會再去查詢數據庫了,當全部的服務器上的isQueryNecessary都設爲false之後,集羣中後續的所有請求就都不再會查詢數據庫了,這樣可以節省很多IO和網絡操作。

redis實現方案

1. setnx方案

  redis的 setnx 命令可以用來實現分佈式鎖的功能,因此可以把獎品數量放到redis中,例如系統加載時從DB獲取到獎品總數爲80,則SET AWARDNUM 80,接下來每個請求線程中用setnx命令加分佈式鎖(具體實現可以參考網上的方案,思路是給一個常量設置值,即setnx constant value,value爲隨機值,設置可以的過期時間,這樣只有當前線程能釋放該分佈式鎖,若沒有及時釋放也可以等待鎖過期後重新嘗試獲取),獲取到分佈式鎖後,先判斷獎品庫存是否<=0,如是則同步更新內存變量,避免下次再查詢redis;如果>0則表示秒殺成功,然後對該獎品數量減一,並釋放分佈式鎖即可。

2. MQ方案

  該方案參考了這篇博文。redis有多種數據結構,例如鏈表,它可以作爲一個MQ來使用,例如每個秒殺請求都放到隊列中,再啓動其它的線程去處理隊列中前n個請求作爲秒殺成功的處理。但是還有更簡單的實現方案,例如系統初始化時從DB獲取獎品數量爲80,則初始化一個長度爲80的list作爲獎池,每個秒殺請求進來時使用LPOPRPOP命令從list中抽取一個獎品,如果返回值爲空,則說明獎池已經空了,否則表示秒殺成功。因爲redis命令執行的時候都是單線程的原子操作,所以該方案的好處是實現簡單且不需要用分佈式鎖,感覺分佈式鎖可能會更耗時間,因爲即要加鎖又要更新獎品數量,而這個方案只要讀一次redis就可以了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章