Redis分散式鎖的原理和實現

學習中的苦與樂發表於2021-08-23

前言

  我們之前聊過redis的,對基礎不瞭解的可以移步檢視一下:

幾分鐘搞定redis儲存session共享——設計實現:https://www.cnblogs.com/xiongze520/p/10333233.html

【原創】詳細案例解剖——淺談Redis快取的常用5種方式(String,Hash,List,set,SetSorted ):https://www.cnblogs.com/xiongze520/p/10267804.html

  對同一個資源進行操作,單一的快取讀取沒問題了,但是存在併發的時候怎麼辦呢,為了避免資料不一致,我們需要在操作共享資源之前進行加鎖操作。

我們在開發很多業務場景會使用到鎖,例如庫存控制,抽獎,秒殺等。一般我們會使用記憶體鎖的方式來保證線性的執行。

但現在大多站點都會使用分散式部署,那多臺伺服器間的就必須使用同一個目標來判斷鎖。分散式與單機情況下最大的不同在於其不是多執行緒而是多程式。

 

 

         圖1:分散式站點使用記憶體鎖

 

 

 

          圖2:分散式站點使用分散式鎖

 

當然我們暫時用不了這麼複雜的場景,我們就簡單訪問redis就行。


 

設計(悲觀鎖/樂觀鎖)

悲觀鎖方式(認為操作的時候,會出現問題,所以都加鎖)

悲觀鎖(Pessimistic Lock), 顧名思義,就是很悲觀,每次去拿資料的時候都認為別人會修改,
所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會block直到它拿到鎖。
傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

 

樂觀鎖方式(認為什麼時候不會出問題,所以不上鎖,更新的時候去查詢判斷一下,再此期間是否有人修改過這個資料。)

樂觀鎖(Optimistic Lock), 顧名思義,就是很樂觀,每次去拿資料的時候都認為別人不會修改,
所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號等機制。
樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量,像資料庫如果提供類似於write_condition機制的其實都是提供的樂觀鎖。

 

  兩種鎖各有優缺點,不可認為一種好於另一種,像樂觀鎖適用於寫比較少的情況下,即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,
加大了系統的整個吞吐量。但如果經常產生衝突,上層應用會不斷的進行retry,這樣反倒是降低了效能,所以這種情況下用悲觀鎖就比較合適。


 

Redis三個命令

1、SETNX

SETNX key value:當且僅當key不存在時,set一個key為val的字串,返回1;若key存在,則什麼都不做,返回0。

2、expire

expire key timeout:為key設定一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。

3、delete

delete key:刪除key

在使用Redis實現分散式鎖的時候,主要就會使用到這三個命令。

 

命題:某商品進行庫存秒殺。

假設要給某個商品舉行秒殺活動,我們事先把庫存資料100已經存入到了redis中,我們現在需要來進行庫存扣減。

 

 

             圖3:加鎖請求示意圖

 


程式碼實現

我們基於 ServiceStack.Redis 操作

 

我們建立一個控制檯應用(.NET Framework),命名為 RedisLock ,注意,如果建立的是net core的應用,引入的ServiceStack.Redis就要選擇core的。

然後在NuGet裡面安裝ServiceStack.Redis。

Redis連線池

       //Redis連線池(配置連線地址,讀寫連線地址等)
        public static PooledRedisClientManager RedisClientPool = CreateManager();
        private static PooledRedisClientManager CreateManager()
        {
            //寫節點(主節點)
            List<string> writes = new List<string>();
            writes.Add("10.17.3.97:6379");  
            //讀節點
            List<string> reads = new List<string>();
            reads.Add("10.17.3.97:6379");

            //配置連線池和讀寫分類
            return new PooledRedisClientManager(writes, reads, new RedisClientManagerConfig()
            {
                MaxReadPoolSize = 50, //讀節點個數
                MaxWritePoolSize = 50,//寫節點個數
                AutoStart = true,
                DefaultDb = 0
            });
        }

使用Redis的SetNX命令實現加鎖

        /// <summary>
        /// 加鎖(使用Redis的SetNX命令實現加鎖)
        /// </summary>
        /// <param name="key">鎖key</param>
        /// <param name="selfMark">自己標記</param>
        /// <param name="lockExpirySeconds">鎖自動過期時間[預設10](s)</param>
        /// <param name="waitLockMilliseconds">等待鎖時間(ms)</param>
        /// <returns></returns>
        public static bool Lock(string key, out string selfMark, int lockExpirySeconds = 10, long waitLockMilliseconds = long.MaxValue)
        {
            DateTime begin = DateTime.Now;
            selfMark = Guid.NewGuid().ToString("N");//自己標記,釋放鎖時會用到,自己加的鎖除非過期否則只能自己開啟
            using (RedisClient redisClient = (RedisClient)RedisClientPool.GetClient())
            {
                
                string lockKey = "Lock:" + key;
                while (true)
                {
                    string script = string.Format("if redis.call('SETNX', KEYS[1], ARGV[1]) == 1 then redis.call('PEXPIRE',KEYS[1],{0}) return 1 else return 0 end", lockExpirySeconds * 1000);
                    //迴圈獲取取鎖
                    if (redisClient.ExecLuaAsInt(script, new[] { lockKey }, new[] { selfMark }) == 1)
                    {
                        return true;
                    }

                    //不等待鎖則返回
                    if (waitLockMilliseconds == 0)
                    {
                        break;
                    }

                    //超過等待時間,則不再等待
                    if ((DateTime.Now - begin).TotalMilliseconds >= waitLockMilliseconds)
                    {
                        break;
                    }
                    Thread.Sleep(100);
                }

                return false;
            }
        }

因為ServiceStack.Redis提供的SetNX方法,並沒有提供設定過期時間的方法,對於加鎖業務又不能分開執行(如果加鎖成功設定過期時間失敗導致的永久死鎖問題),所以就使用指令碼實現,解決了異常情況死鎖問題.

如果設定為0,為樂觀鎖機制,獲取不到鎖,直接返回未獲取到鎖.
預設值為long最大值,為悲觀鎖機制,約等於很多很多天,可以理解為一直等待.

 

釋放鎖

     /// <summary>
        /// 釋放鎖
        /// </summary>
        /// <param name="key">鎖key</param>
        /// <param name="selfMark">自己標記</param>
        public static void UnLock(string key, string selfMark)
        {
            using (RedisClient redisClient = (RedisClient)RedisClientPool.GetClient())
            {
                string lockKey = "Lock:" + key;
                var script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                redisClient.ExecLuaAsString(script, new[] { lockKey }, new[] { selfMark });
            }
        }

 

業務呼叫(我們使用多執行緒模擬多使用者秒殺的場景)

     //業務:悲觀鎖方式
        public static void PessimisticLock()
        {
            int num = 10;  //總數量
            string lockkey = "xianseng";

            //悲觀鎖開啟20個人同時拿寶貝
            for (int i = 0; i < 20; i++)
            {
                Task.Run(() =>
                {
                    string selfmark = ""; 
                    try
                    {
                        if (Lock(lockkey, out selfmark))
                        {
                            if (num > 0)
                            {
                                num--;
                                Console.WriteLine($"我拿到了寶貝:寶貝剩餘{num}個\t\t{selfmark}");
                            }
                            else
                            {
                                Console.WriteLine("寶貝已經沒有了");
                            }
                            Thread.Sleep(100);
                        }
                    }
                    finally
                    {
                        UnLock(lockkey, selfmark);
                    }
                });
            }

            Console.ReadLine();
        }

        //業務:樂觀鎖方式
        public static void OptimisticLock()
        {
            int num = 10; //總數量
            string lockkey = "xianseng";

            //樂觀鎖開啟10個執行緒,每個執行緒拿5次
            for (int i = 0; i < 10; i++)
            {
                var lineOn = "執行緒" + (i + 1);
                Task.Run(() =>
                {
                    for (int j = 0; j < 5; j++)
                    {
                        string selfmark = "";
                        try
                        {
                            if (Lock(lockkey, out selfmark, 10, 0))
                            {
                                if (num > 0)
                                {
                                    num--;
                                    Console.WriteLine($"{lineOn} 第{(j+1)}次 我拿到了寶貝:寶貝剩餘{num}個\t\t{selfmark}");
                                }
                                else
                                {
                                    Console.WriteLine($"{lineOn} 第{(j + 1)}次 寶貝已經沒有了");
                                }

                                Thread.Sleep(1000);
                            }
                            else
                            {
                                Console.WriteLine($"{lineOn} 第{(j+1)}次 沒有拿到,不想等了");
                            }
                        }
                        finally
                        {
                            UnLock(lockkey, selfmark);
                        }
                    }
                });
            }

            Console.ReadLine();
        }

然後在main函式裡面呼叫檢視展示效果

       static void Main(string[] args)
        {
            ////呼叫:悲觀鎖方式(認為我操作的時候,會出現問題,所以都加鎖)
            PessimisticLock();

            ///呼叫:樂觀鎖方式(認為什麼時候不會出問題,所以不上鎖,更新的時候去查詢判斷一下,再此期間是否有人修改過這個資料。)
            //OptimisticLock();
        }

 

這就簡單實現了Redis分佈鎖的功能,快去試試吧。

 

參考文獻

 

 

 
歡迎關注訂閱微信公眾號【熊澤有話說】,更多好玩易學知識等你來取
作者:熊澤-學習中的苦與樂
公眾號:熊澤有話說
出處: https://www.cnblogs.com/xiongze520/p/15176559.html
創作不易,任何人或團體、機構全部轉載或者部分轉載、摘錄,請在文章明顯位置註明作者和原文連結。  

 

 

 

 

相關文章