秒杀设计--mysql的锁机制应用和redis方案
2019獨角獸企業重金招聘Python工程師標準>>>
背景
在工作中接到一個需求:對于訪問頁面的前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變量提供理想的線程安全,必須同時滿足以下兩個條件:
當變量聲明為volatile后,所有線程對該對象的讀取都會直接從主內存中獲取,不會使用緩存的值,而在CPU緩存的一些值都會被標識為過期,從而完成線程對該對象的同步操作。具體介紹可見 Java 理論與實踐: 正確使用 Volatile 變量.
回歸正題,在service層的處理方法giveAward()中,偽代碼如下:
這里對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就可以了。
轉載于:https://my.oschina.net/JoeyXieIsCool/blog/664842
總結
以上是生活随笔為你收集整理的秒杀设计--mysql的锁机制应用和redis方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: django 的ORM
- 下一篇: Table 'mysql.plugin'