背景
在工作中接到一個需求:對於訪問頁面的前x名用戶分發A獎品,x+1名及以後的用戶分發另外一種獎品。在J2EE的開發中,我們知道servlet是單實例多線程的,Spring的Controller類也一樣,所以這裏需要考慮多線程併發時如何判斷該用戶是否爲前x名。一種辦法是在代碼中用內存控制,例如添加一個成員變量,創建一個方法,並在內部使用synchronized
塊對該變量加鎖,每次調用這個方法時,來一個用戶就先判斷變量是否大於x,小於的話就對該變量+1,直到該變量超過x爲止。但是因爲我們的代碼是部署在多臺服務器上的,而在多臺服務器上同步內存比較麻煩,所以這種方法只適用於一臺服務器的情況。另一種方法就是在數據庫級別加鎖,因爲我們的數據庫只有一個節點,所以只要在這一個節點上加了鎖就可以控制來訪的用戶了。
mysql鎖機制簡介
mysql提供了locking read機制,可以參考官方文檔,一共有兩種方式:SELECT ... FOR UPDATE
和SELECT ... 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操作,然後在select
和update
之後,再調用connection.commit()
提交事務。如果想要在Navicat或mysql workbench中測試locking read功能,則需要先執行set autocommit=0
語句關閉自動提交,然後再進行操作。
優化
上面的方法對於每一次用戶請求,都需要通過數據庫級別的SELECT ... FOR UPDATE
語句來加鎖,可是往往前x名用戶在總用戶中所佔的比例都是比較小的,畢竟大獎總是掌握在少數人手中嘛!如果每次都訪問數據庫,這樣IO次數多了(同樣也會導致網絡請求次數增多,因爲數據庫只有一個節點)就會影響性能,所以我們在內存中再添加一個控制。在某個類中創建一個變量,用於判斷前x名的獎品是否已經分發完畢:
public static volatile boolean isQueryNecessary = true;
順便複習一下,要使得volatile
變量提供理想的線程安全,必須同時滿足以下兩個條件:
- 對變量的寫操作不依賴於當前值
- 該變量沒有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作爲獎池,每個秒殺請求進來時使用LPOP
或RPOP
命令從list中抽取一個獎品,如果返回值爲空,則說明獎池已經空了,否則表示秒殺成功。因爲redis命令執行的時候都是單線程的原子操作,所以該方案的好處是實現簡單且不需要用分佈式鎖,感覺分佈式鎖可能會更耗時間,因爲即要加鎖又要更新獎品數量,而這個方案只要讀一次redis就可以了。