最近在專案中遇到了類似“秒殺”的業務場景,在本篇部落格中,我將用一個非常簡單的demo,闡述實現所謂“秒殺”的基本思路。
業務場景
所謂秒殺,從業務角度看,是短時間內多個使用者“爭搶”資源,這裡的資源在大部分秒殺場景裡是商品;將業務抽象,技術角度看,秒殺就是多個執行緒對資源進行操作,所以實現秒殺,就必須控制執行緒對資源的爭搶,既要保證高效併發,也要保證操作的正確。
一些可能的實現
剛才提到過,實現秒殺的關鍵點是控制執行緒對資源的爭搶,根據基本的執行緒知識,可以不加思索的想到下面的一些方法:
1、秒殺在技術層面的抽象應該就是一個方法,在這個方法裡可能的操作是將商品庫存-1,將商品加入使用者的購物車等等,在不考慮快取的情況下應該是要運算元據庫的。那麼最簡單直接的實現就是在這個方法上加上synchronized關鍵字,通俗的講就是鎖住整個方法;
2、鎖住整個方法這個策略簡單方便,但是似乎有點粗暴。可以稍微最佳化一下,只鎖住秒殺的程式碼塊,比如寫資料庫的部分;
3、既然有併發問題,那我就讓他“不併發”,將所有的執行緒用一個佇列管理起來,使之變成序列操作,自然不會有併發問題。
上面所述的方法都是有效的,但是都不好。為什麼?第一和第二種方法本質上是“加鎖”,但是鎖粒度依然比較高。什麼意思?試想一下,如果兩個執行緒同時執行秒殺方法,這兩個執行緒操作的是不同的商品,從業務上講應該是可以同時進行的,但是如果採用第一二種方法,這兩個執行緒也會去爭搶同一個鎖,這其實是不必要的。第三種方法也沒有解決上面說的問題。
那麼如何將鎖控制在更細的粒度上呢?可以考慮為每個商品設定一個互斥鎖,以和商品ID相關的字串為唯一標識,這樣就可以做到只有爭搶同一件商品的執行緒互斥,不會導致所有的執行緒互斥。分散式鎖恰好可以幫助我們解決這個問題。
何為分散式鎖
分散式鎖是控制分散式系統之間同步訪問共享資源的一種方式。在分散式系統中,常常需要協調他們的動作。如果不同的系統或是同一個系統的不同主機之間共享了一個或一組資源,那麼訪問這些資源的時候,往往需要互斥來防止彼此干擾來保證一致性,在這種情況下,便需要使用到分散式鎖。
我們來假設一個最簡單的秒殺場景:資料庫裡有一張表,column分別是商品ID,和商品ID對應的庫存量,秒殺成功就將此商品庫存量-1。現在假設有1000個執行緒來秒殺兩件商品,500個執行緒秒殺第一個商品,500個執行緒秒殺第二個商品。我們來根據這個簡單的業務場景來解釋一下分散式鎖。
通常具有秒殺場景的業務系統都比較複雜,承載的業務量非常巨大,併發量也很高。這樣的系統往往採用分散式的架構來均衡負載。那麼這1000個併發就會是從不同的地方過來,商品庫存就是共享的資源,也是這1000個併發爭搶的資源,這個時候我們需要將併發互斥管理起來。這就是分散式鎖的應用。
而key-value儲存系統,如redis,因為其一些特性,是實現分散式鎖的重要工具。
具體的實現
先來看看一些redis的基本命令:
SETNX key value
如果key不存在,就設定key對應字串value。在這種情況下,該命令和SET一樣。當key已經存在時,就不做任何操作。SETNX是”SET if Not eXists”。
expire KEY seconds
設定key的過期時間。如果key已過期,將會被自動刪除。
del KEY
刪除key
由於筆者的實現只用到這三個命令,就只介紹這三個命令,更多的命令以及redis的特性和使用,可以參考redis官網。
需要考慮的問題
1、用什麼操作redis?幸虧redis已經提供了jedis客戶端用於java應用程式,直接呼叫jedis API即可。
2、怎麼實現加鎖?“鎖”其實是一個抽象的概念,將這個抽象概念變為具體的東西,就是一個儲存在redis裡的key-value對,key是於商品ID相關的字串來唯一標識,value其實並不重要,因為只要這個唯一的key-value存在,就表示這個商品已經上鎖。
3、如何釋放鎖?既然key-value對存在就表示上鎖,那麼釋放鎖就自然是在redis裡刪除key-value對。
4、阻塞還是非阻塞?筆者採用了阻塞式的實現,若執行緒發現已經上鎖,會在特定時間內輪詢鎖。
5、如何處理異常情況?比如一個執行緒把一個商品上了鎖,但是由於各種原因,沒有完成操作(在上面的業務場景裡就是沒有將庫存-1寫入資料庫),自然沒有釋放鎖,這個情況筆者加入了鎖超時機制,利用redis的expire命令為key設定超時時長,過了超時時間redis就會將這個key自動刪除,即強制釋放鎖(可以認為超時釋放鎖是一個非同步操作,由redis完成,應用程式只需要根據系統特點設定超時時間即可)。
talk is cheap,show me the code
在程式碼實現層面,註解有併發的方法和引數,透過動態代理獲取註解的方法和引數,在代理中加鎖,執行完被代理的方法後釋放鎖。
幾個註解定義:
cachelock是方法級的註解,用於註解會產生併發問題的方法:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface CacheLock { String lockedPrefix() default "";//redis 鎖key的字首 long timeOut() default 2000;//輪詢鎖的時間 int expireTime() default 1000;//key在redis裡存在的時間,1000S}
lockedObject是引數級的註解,用於註解商品ID等基本型別的引數:
@Target(ElementType.PARAMETER)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface LockedObject { //不需要值}
LockedComplexObject也是引數級的註解,用於註解自定義型別的引數:
@Target(ElementType.PARAMETER)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface LockedComplexObject { String field() default "";//含有成員變數的複雜物件中需要加鎖的成員變數,如一個商品物件的商品ID}
CacheLockInterceptor實現InvocationHandler介面,在invoke方法中獲取註解的方法和引數,在執行註解的方法前加鎖,執行被註解的方法後釋放鎖:
public class CacheLockInterceptor implements InvocationHandler{ public static int ERROR_COUNT = 0; private Object proxied; public CacheLockInterceptor(Object proxied) { this.proxied = proxied; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { CacheLock cacheLock = method.getAnnotation(CacheLock.class); //沒有cacheLock註解,pass if(null == cacheLock){ System.out.println("no cacheLock annotation"); return method.invoke(proxied, args); } //獲得方法中引數的註解 Annotation[][] annotations = method.getParameterAnnotations(); //根據獲取到的引數註解和引數列表獲得加鎖的引數 Object lockedObject = getLockedObject(annotations,args); String objectValue = lockedObject.toString(); //新建一個鎖 RedisLock lock = new RedisLock(cacheLock.lockedPrefix(), objectValue); //加鎖 boolean result = lock.lock(cacheLock.timeOut(), cacheLock.expireTime()); if(!result){//取鎖失敗 ERROR_COUNT += 1; throw new CacheLockException("get lock fail"); } try{ //加鎖成功,執行方法 return method.invoke(proxied, args); }finally{ lock.unlock();//釋放鎖 } } /** * * @param annotations * @param args * @return * @throws CacheLockException */ private Object getLockedObject(Annotation[][] annotations,Object[] args) throws CacheLockException{ if(null == args || args.length == 0){ throw new CacheLockException("方法引數為空,沒有被鎖定的物件"); } if(null == annotations || annotations.length == 0){ throw new CacheLockException("沒有被註解的引數"); } //不支援多個引數加鎖,只支援第一個註解為lockedObject或者lockedComplexObject的引數 int index = -1;//標記引數的位置指標 for(int i = 0;i < annotations.length;i++){ for(int j = 0;j < annotations[i].length;j++){ if(annotations[i][j] instanceof LockedComplexObject){//註解為LockedComplexObject index = i; try { return args[i].getClass().getField(((LockedComplexObject)annotations[i][j]).field()); } catch (NoSuchFieldException | SecurityException e) { throw new CacheLockException("註解物件中沒有該屬性" + ((LockedComplexObject)annotations[i][j]).field()); } } if(annotations[i][j] instanceof LockedObject){ index = i; break; } } //找到第一個後直接break,不支援多引數加鎖 if(index != -1){ break; } } if(index == -1){ throw new CacheLockException("請指定被鎖定引數"); } return args[index]; } }
最關鍵的RedisLock類中的lock方法和unlock方法:
/** * 加鎖 * 使用方式為: * lock(); * try{ * executeMethod(); * }finally{ * unlock(); * } * @param timeout timeout的時間範圍內輪詢鎖 * @param expire 設定鎖超時時間 * @return 成功 or 失敗 */ public boolean lock(long timeout,int expire){ long nanoTime = System.nanoTime(); timeout *= MILLI_NANO_TIME; try { //在timeout的時間範圍內不斷輪詢鎖 while (System.nanoTime() - nanoTime < timeout) { //鎖不存在的話,設定鎖並設定鎖過期時間,即加鎖 if (this.redisClient.setnx(this.key, LOCKED) == 1) { this.redisClient.expire(key, expire);//設定鎖過期時間是為了在沒有釋放 //鎖的情況下鎖過期後消失,不會造成永久阻塞 this.lock = true; return this.lock; } System.out.println("出現鎖等待"); //短暫休眠,避免可能的活鎖 Thread.sleep(3, RANDOM.nextInt(30)); } } catch (Exception e) { throw new RuntimeException("locking error",e); } return false; } public void unlock() { try { if(this.lock){ redisClient.delKey(key);//直接刪除 } } catch (Throwable e) { } }
上述的程式碼是框架性的程式碼,現在來講解如何使用上面的簡單框架來寫一個秒殺函式。
先定義一個介面,介面裡定義了一個秒殺方法:
public interface SeckillInterface {/** *現在暫時只支援在介面方法上註解 */ //cacheLock註解可能產生併發的方法 @CacheLock(lockedPrefix="TEST_PREFIX") public void secKill(String userID,@LockedObject Long commidityID);//最簡單的秒殺方法,引數是使用者ID和商品ID。可能有多個執行緒爭搶一個商品,所以商品ID加上LockedObject註解}
上述SeckillInterface介面的實現類,即秒殺的具體實現:
public class SecKillImpl implements SeckillInterface{ static Map<Long, Long> inventory ; static{ inventory = new HashMap<>(); inventory.put(10000001L, 10000l); inventory.put(10000002L, 10000l); } @Override public void secKill(String arg1, Long arg2) { //最簡單的秒殺,這裡僅作為demo示例 reduceInventory(arg2); } //模擬秒殺操作,姑且認為一個秒殺就是將庫存減一,實際情景要複雜的多 public Long reduceInventory(Long commodityId){ inventory.put(commodityId,inventory.get(commodityId) - 1); return inventory.get(commodityId); } }
模擬秒殺場景,1000個執行緒來爭搶兩個商品:
@Test public void testSecKill(){ int threadCount = 1000; int splitPoint = 500; CountDownLatch endCount = new CountDownLatch(threadCount); CountDownLatch beginCount = new CountDownLatch(1); SecKillImpl testClass = new SecKillImpl(); Thread[] threads = new Thread[threadCount]; //起500個執行緒,秒殺第一個商品 for(int i= 0;i < splitPoint;i++){ threads[i] = new Thread(new Runnable() { public void run() { try { //等待在一個訊號量上,掛起 beginCount.await(); //用動態代理的方式呼叫secKill方法 SeckillInterface proxy = (SeckillInterface) Proxy.newProxyInstance(SeckillInterface.class.getClassLoader(), new Class[]{SeckillInterface.class}, new CacheLockInterceptor(testClass)); proxy.secKill("test", commidityId1); endCount.countDown(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }); threads[i].start(); } //再起500個執行緒,秒殺第二件商品 for(int i= splitPoint;i < threadCount;i++){ threads[i] = new Thread(new Runnable() { public void run() { try { //等待在一個訊號量上,掛起 beginCount.await(); //用動態代理的方式呼叫secKill方法 SeckillInterface proxy = (SeckillInterface) Proxy.newProxyInstance(SeckillInterface.class.getClassLoader(), new Class[]{SeckillInterface.class}, new CacheLockInterceptor(testClass)); proxy.secKill("test", commidityId2); //testClass.testFunc("test", 10000001L); endCount.countDown(); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } }); threads[i].start(); } long startTime = System.currentTimeMillis(); //主執行緒釋放開始訊號量,並等待結束訊號量,這樣做保證1000個執行緒做到完全同時執行,保證測試的正確性 beginCount.countDown(); try { //主執行緒等待結束訊號量 endCount.await(); //觀察秒殺結果是否正確 System.out.println(SecKillImpl.inventory.get(commidityId1)); System.out.println(SecKillImpl.inventory.get(commidityId2)); System.out.println("error count" + CacheLockInterceptor.ERROR_COUNT); System.out.println("total cost " + (System.currentTimeMillis() - startTime)); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } }
在正確的預想下,應該每個商品的庫存都減少了500,在多次試驗後,實際情況符合預想。如果不採用鎖機制,會出現庫存減少499,498的情況。
這裡採用了動態代理的方法,利用註解和反射機制得到分散式鎖ID,進行加鎖和釋放鎖操作。當然也可以直接在方法進行這些操作,採用動態代理也是為了能夠將鎖操作程式碼集中在代理中,便於維護。
通常秒殺場景發生在web專案中,可以考慮利用spring的AOP特性將鎖操作程式碼置於切面中,當然AOP本質上也是動態代理。
小結
這篇文章從業務場景出發,從抽象到實現闡述瞭如何利用redis實現分散式鎖,完成簡單的秒殺功能,也記錄了筆者思考的過程,希望能給閱讀到本篇文章的人一些啟發。如讀者有其他見解歡迎留言。
大家對技術感興趣的朋友也可以來 關注我的微信公眾號 : Java填坑之路 架構技術資料會不定期更新。