基于redis分布式鎖實(shí)現(xiàn)“秒殺”

最近在項(xiàng)目中遇到了類似“秒殺”的業(yè)務(wù)場景,在本篇博客中,我將用一個非常簡單的demo臣淤,闡述實(shí)現(xiàn)所謂“秒殺”的基本思路。

業(yè)務(wù)場景

所謂秒殺晌姚,從業(yè)務(wù)角度看,是短時間內(nèi)多個用戶“爭搶”資源歇竟,這里的資源在大部分秒殺場景里是商品挥唠;將業(yè)務(wù)抽象,技術(shù)角度看途蒋,秒殺就是多個線程對資源進(jìn)行操作猛遍,所以實(shí)現(xiàn)秒殺,就必須控制線程對資源的爭搶号坡,既要保證高效并發(fā),也要保證操作的正確梯醒。

一些可能的實(shí)現(xiàn)

剛才提到過宽堆,實(shí)現(xiàn)秒殺的關(guān)鍵點(diǎn)是控制線程對資源的爭搶,根據(jù)基本的線程知識茸习,可以不加思索的想到下面的一些方法:
1畜隶、秒殺在技術(shù)層面的抽象應(yīng)該就是一個方法,在這個方法里可能的操作是將商品庫存-1号胚,將商品加入用戶的購物車等等籽慢,在不考慮緩存的情況下應(yīng)該是要操作數(shù)據(jù)庫的。那么最簡單直接的實(shí)現(xiàn)就是在這個方法上加上synchronized關(guān)鍵字猫胁,通俗的講就是鎖住整個方法箱亿;
2、鎖住整個方法這個策略簡單方便弃秆,但是似乎有點(diǎn)粗暴届惋∷杳保可以稍微優(yōu)化一下,只鎖住秒殺的代碼塊脑豹,比如寫數(shù)據(jù)庫的部分郑藏;
3、既然有并發(fā)問題瘩欺,那我就讓他“不并發(fā)”必盖,將所有的線程用一個隊(duì)列管理起來,使之變成串行操作俱饿,自然不會有并發(fā)問題筑悴。

上面所述的方法都是有效的,但是都不好稍途。為什么阁吝?第一和第二種方法本質(zhì)上是“加鎖”,但是鎖粒度依然比較高械拍。什么意思突勇?試想一下,如果兩個線程同時執(zhí)行秒殺方法坷虑,這兩個線程操作的是不同的商品,從業(yè)務(wù)上講應(yīng)該是可以同時進(jìn)行的甲馋,但是如果采用第一二種方法,這兩個線程也會去爭搶同一個鎖迄损,這其實(shí)是不必要的定躏。第三種方法也沒有解決上面說的問題。

那么如何將鎖控制在更細(xì)的粒度上呢芹敌?可以考慮為每個商品設(shè)置一個互斥鎖痊远,以和商品ID相關(guān)的字符串為唯一標(biāo)識,這樣就可以做到只有爭搶同一件商品的線程互斥氏捞,不會導(dǎo)致所有的線程互斥碧聪。分布式鎖恰好可以幫助我們解決這個問題。

何為分布式鎖

分布式鎖是控制分布式系統(tǒng)之間同步訪問共享資源的一種方式液茎。在分布式系統(tǒng)中逞姿,常常需要協(xié)調(diào)他們的動作。如果不同的系統(tǒng)或是同一個系統(tǒng)的不同主機(jī)之間共享了一個或一組資源捆等,那么訪問這些資源的時候滞造,往往需要互斥來防止彼此干擾來保證一致性,在這種情況下栋烤,便需要使用到分布式鎖谒养。

我們來假設(shè)一個最簡單的秒殺場景:數(shù)據(jù)庫里有一張表,column分別是商品ID班缎,和商品ID對應(yīng)的庫存量蝴光,秒殺成功就將此商品庫存量-1∷剩現(xiàn)在假設(shè)有1000個線程來秒殺兩件商品,500個線程秒殺第一個商品蔑祟,500個線程秒殺第二個商品趁耗。我們來根據(jù)這個簡單的業(yè)務(wù)場景來解釋一下分布式鎖。
通常具有秒殺場景的業(yè)務(wù)系統(tǒng)都比較復(fù)雜疆虚,承載的業(yè)務(wù)量非常巨大苛败,并發(fā)量也很高。這樣的系統(tǒng)往往采用分布式的架構(gòu)來均衡負(fù)載径簿。那么這1000個并發(fā)就會是從不同的地方過來罢屈,商品庫存就是共享的資源,也是這1000個并發(fā)爭搶的資源篇亭,這個時候我們需要將并發(fā)互斥管理起來缠捌。這就是分布式鎖的應(yīng)用。
而key-value存儲系統(tǒng)译蒂,如redis曼月,因?yàn)槠湟恍┨匦裕菍?shí)現(xiàn)分布式鎖的重要工具柔昼。

具體的實(shí)現(xiàn)

先來看看一些redis的基本命令:
SETNX key value
如果key不存在哑芹,就設(shè)置key對應(yīng)字符串value。在這種情況下捕透,該命令和SET一樣聪姿。當(dāng)key已經(jīng)存在時,就不做任何操作乙嘀。SETNX是"SET if Not eXists"末购。
expire KEY seconds
設(shè)置key的過期時間。如果key已過期乒躺,將會被自動刪除招盲。
del KEY
刪除key
由于筆者的實(shí)現(xiàn)只用到這三個命令,就只介紹這三個命令嘉冒,更多的命令以及redis的特性和使用,可以參考redis官網(wǎng)咆繁。

需要考慮的問題

1讳推、用什么操作redis?幸虧redis已經(jīng)提供了jedis客戶端用于java應(yīng)用程序玩般,直接調(diào)用jedis API即可银觅。
2、怎么實(shí)現(xiàn)加鎖坏为?“鎖”其實(shí)是一個抽象的概念究驴,將這個抽象概念變?yōu)榫唧w的東西镊绪,就是一個存儲在redis里的key-value對,key是于商品ID相關(guān)的字符串來唯一標(biāo)識洒忧,value其實(shí)并不重要蝴韭,因?yàn)橹灰@個唯一的key-value存在,就表示這個商品已經(jīng)上鎖熙侍。
3榄鉴、如何釋放鎖?既然key-value對存在就表示上鎖蛉抓,那么釋放鎖就自然是在redis里刪除key-value對庆尘。
4、阻塞還是非阻塞巷送?筆者采用了阻塞式的實(shí)現(xiàn)驶忌,若線程發(fā)現(xiàn)已經(jīng)上鎖,會在特定時間內(nèi)輪詢鎖笑跛。
5付魔、如何處理異常情況?比如一個線程把一個商品上了鎖堡牡,但是由于各種原因抒抬,沒有完成操作(在上面的業(yè)務(wù)場景里就是沒有將庫存-1寫入數(shù)據(jù)庫),自然沒有釋放鎖晤柄,這個情況筆者加入了鎖超時機(jī)制擦剑,利用redis的expire命令為key設(shè)置超時時長,過了超時時間redis就會將這個key自動刪除芥颈,即強(qiáng)制釋放鎖(可以認(rèn)為超時釋放鎖是一個異步操作惠勒,由redis完成,應(yīng)用程序只需要根據(jù)系統(tǒng)特點(diǎn)設(shè)置超時時間即可)爬坑。

talk is cheap,show me the code

在代碼實(shí)現(xiàn)層面纠屋,注解有并發(fā)的方法和參數(shù),通過動態(tài)代理獲取注解的方法和參數(shù)盾计,在代理中加鎖售担,執(zhí)行完被代理的方法后釋放鎖。

幾個注解定義:
cachelock是方法級的注解署辉,用于注解會產(chǎn)生并發(fā)問題的方法:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CacheLock {
    String lockedPrefix() default "";//redis 鎖key的前綴
    long timeOut() default 2000;//輪詢鎖的時間
    int expireTime() default 1000;//key在redis里存在的時間族铆,1000S
}

lockedObject是參數(shù)級的注解,用于注解商品ID等基本類型的參數(shù):

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LockedObject {
    //不需要值
}

LockedComplexObject也是參數(shù)級的注解哭尝,用于注解自定義類型的參數(shù):

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LockedComplexObject {
    String field() default "";//含有成員變量的復(fù)雜對象中需要加鎖的成員變量哥攘,如一個商品對象的商品ID

}

CacheLockInterceptor實(shí)現(xiàn)InvocationHandler接口,在invoke方法中獲取注解的方法和參數(shù),在執(zhí)行注解的方法前加鎖逝淹,執(zhí)行被注解的方法后釋放鎖:

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);
        }
        //獲得方法中參數(shù)的注解
        Annotation[][] annotations = method.getParameterAnnotations();
        //根據(jù)獲取到的參數(shù)注解和參數(shù)列表獲得加鎖的參數(shù)
        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{
            //加鎖成功,執(zhí)行方法
            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("方法參數(shù)為空栅葡,沒有被鎖定的對象");
        }
        
        if(null == annotations || annotations.length == 0){
            throw new CacheLockException("沒有被注解的參數(shù)");
        }
        //不支持多個參數(shù)加鎖茉兰,只支持第一個注解為lockedObject或者lockedComplexObject的參數(shù)
        int index = -1;//標(biāo)記參數(shù)的位置指針
        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,不支持多參數(shù)加鎖
            if(index != -1){
                break;
            }
        }
        
        if(index == -1){
            throw new CacheLockException("請指定被鎖定參數(shù)");
        }
        
        return args[index];
    }
}

最關(guān)鍵的RedisLock類中的lock方法和unlock方法:

/**
     * 加鎖
     * 使用方式為:
     * lock();
     * try{
     *    executeMethod();
     * }finally{
     *   unlock();
     * }
     * @param timeout timeout的時間范圍內(nèi)輪詢鎖
     * @param expire 設(shè)置鎖超時時間
     * @return 成功 or 失敗
     */
    public boolean lock(long timeout,int expire){
        long nanoTime = System.nanoTime();
        timeout *= MILLI_NANO_TIME;
        try {
            //在timeout的時間范圍內(nèi)不斷輪詢鎖
            while (System.nanoTime() - nanoTime < timeout) {
                //鎖不存在的話妥畏,設(shè)置鎖并設(shè)置鎖過期時間邦邦,即加鎖
                if (this.redisClient.setnx(this.key, LOCKED) == 1) {
                    this.redisClient.expire(key, expire);//設(shè)置鎖過期時間是為了在沒有釋放
                    //鎖的情況下鎖過期后消失,不會造成永久阻塞
                    this.lock = true;
                    return this.lock;
                }
                System.out.println("出現(xiàn)鎖等待");
                //短暫休眠醉蚁,避免可能的活鎖
                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) {
            
        }
    }

上述的代碼是框架性的代碼燃辖,現(xiàn)在來講解如何使用上面的簡單框架來寫一個秒殺函數(shù)。
先定義一個接口网棍,接口里定義了一個秒殺方法:

public interface SeckillInterface {
/**
*現(xiàn)在暫時只支持在接口方法上注解
*/
    //cacheLock注解可能產(chǎn)生并發(fā)的方法
    @CacheLock(lockedPrefix="TEST_PREFIX")
    public void secKill(String userID,@LockedObject Long commidityID);//最簡單的秒殺方法黔龟,參數(shù)是用戶ID和商品ID±溺瑁可能有多個線程爭搶一個商品氏身,所以商品ID加上LockedObject注解
}

上述SeckillInterface接口的實(shí)現(xiàn)類,即秒殺的具體實(shí)現(xiàn):

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);
    }
    //模擬秒殺操作蛋欣,姑且認(rèn)為一個秒殺就是將庫存減一,實(shí)際情景要復(fù)雜的多
    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();
                        //用動態(tài)代理的方式調(diào)用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();
                        //用動態(tài)代理的方式調(diào)用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();
        //主線程釋放開始信號量,并等待結(jié)束信號量楣富,這樣做保證1000個線程做到完全同時執(zhí)行凿掂,保證測試的正確性
        beginCount.countDown();
        
        try {
            //主線程等待結(jié)束信號量
            endCount.await();
            //觀察秒殺結(jié)果是否正確
            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();
        }
    }

在正確的預(yù)想下,應(yīng)該每個商品的庫存都減少了500纹蝴,在多次試驗(yàn)后庄萎,實(shí)際情況符合預(yù)想。如果不采用鎖機(jī)制塘安,會出現(xiàn)庫存減少499惨恭,498的情況。
這里采用了動態(tài)代理的方法耙旦,利用注解和反射機(jī)制得到分布式鎖ID,進(jìn)行加鎖和釋放鎖操作。當(dāng)然也可以直接在方法進(jìn)行這些操作免都,采用動態(tài)代理也是為了能夠?qū)㈡i操作代碼集中在代理中锉罐,便于維護(hù)。
通常秒殺場景發(fā)生在web項(xiàng)目中绕娘,可以考慮利用spring的AOP特性將鎖操作代碼置于切面中脓规,當(dāng)然AOP本質(zhì)上也是動態(tài)代理。

小結(jié)

這篇文章從業(yè)務(wù)場景出發(fā)险领,從抽象到實(shí)現(xiàn)闡述了如何利用redis實(shí)現(xiàn)分布式鎖侨舆,完成簡單的秒殺功能,也記錄了筆者思考的過程绢陌,希望能給閱讀到本篇文章的人一些啟發(fā)挨下。

源碼倉庫:https://github.com/lsfire/redisframework

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市脐湾,隨后出現(xiàn)的幾起案子臭笆,更是在濱河造成了極大的恐慌,老刑警劉巖秤掌,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件愁铺,死亡現(xiàn)場離奇詭異,居然都是意外死亡闻鉴,警方通過查閱死者的電腦和手機(jī)茵乱,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來孟岛,“玉大人瓶竭,你說我怎么就攤上這事∈纯粒” “怎么了在验?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長堵未。 經(jīng)常有香客問我腋舌,道長,這世上最難降的妖魔是什么渗蟹? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任块饺,我火速辦了婚禮,結(jié)果婚禮上雌芽,老公的妹妹穿的比我還像新娘授艰。我一直安慰自己,他們只是感情好世落,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布淮腾。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪谷朝。 梳的紋絲不亂的頭發(fā)上洲押,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天,我揣著相機(jī)與錄音圆凰,去河邊找鬼杈帐。 笑死,一個胖子當(dāng)著我的面吹牛专钉,可吹牛的內(nèi)容都是我干的挑童。 我是一名探鬼主播,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼跃须,長吁一口氣:“原來是場噩夢啊……” “哼站叼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起回怜,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤大年,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后玉雾,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體翔试,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年复旬,在試婚紗的時候發(fā)現(xiàn)自己被綠了垦缅。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡驹碍,死狀恐怖壁涎,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情志秃,我是刑警寧澤怔球,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站浮还,受9級特大地震影響竟坛,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜钧舌,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一担汤、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧洼冻,春花似錦崭歧、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽叔营。三九已至,卻和暖如春播掷,著一層夾襖步出監(jiān)牢的瞬間审编,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工歧匈, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人砰嘁。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓件炉,卻偏偏與公主長得像,于是被迫代替她去往敵國和親矮湘。 傳聞我的和親對象是個殘疾皇子斟冕,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容