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

背景

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

mysql鎖機制簡介

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

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

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

    SELECT ... FOR UPDATE是:sql

  爲選擇的行添加排它鎖(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就能夠了。

相關文章
相關標籤/搜索