Redis專題-秒殺

Redis專題-併發/秒殺

開局一張圖,內容全靠“編”。

昨天晚上在羣友裏看到有人在討論庫存併發的問題,看到這裏我就決定寫一篇關於redis秒殺的文章。

img

img

1、理論部分

我們看看一般我們庫存是怎麼出問題的

img

其實redis提供了兩種解決方案:加鎖和原子操作

1.1、加鎖

加鎖:其實非常常見,讀取數據前,客戶端先獲取鎖,再操作。
當客戶端獲得鎖後,一直持有直到客戶端完成操作,再釋放。

怎麼操作呢,客戶端使用分佈式鎖來獲取鎖,(使用redis或者zookeeper來實現一個分佈式鎖)以商品的維度來加鎖,在獲取到鎖的線程中,按順序執行商品的庫存查詢和扣減,同時實現了順序性和原子性。

img

但是,但是,有問題:
1、如果使用redis來實現分佈式鎖,那麼鎖的時效性是個問題。太短了,業務還沒跑完鎖就釋放了。太長了,如果異常,其他業務就一直阻塞等着自動釋放。

2、如果使用zookeeper,確實不用擔心鎖釋放問題(臨時節點),而且一致性好,但是性能不高。ZK中創建和刪除節點只能通過Leader服務器來執行,然後Leader服務器還需要將數據同不到所有的Follower機器上,這樣頻繁的網絡通信,性能的短板是非常突出的。(挖坑😂後續寫一個redis和zookeeper實現分佈式鎖的文章)

所以。。繼續往下看。。

1.2、原子操作

原子操作:執行過程中保持原子性操作,而原子性操作是不需要加鎖的,也就是無鎖操作。所以既保證了併發也不會減少系統併發性能。

redis的原子操作其實也有兩種方式:
1、單命令操作:多個操作在redis中一個操作完成
2、lua:多個操作寫成lua腳本,以原子性方式執行單個lua腳本

1.2.1、INCR/DECR

Redis 是使用單線程來串行處理客戶端的請求操作命令的,所以,當 Redis 執行某個命令操作時,其他命令是無法執行的,這相當於命令操作是互斥執行的。

Redis 的單個命令操作可以原子性地執行,但是在實際應用中,數據修改時可能包含多個操作,至少包括讀數據、數據增減、寫回數據三個操作,這顯然就不是單個命令操作了,那該怎麼辦呢?

Redis提供INCR/DECR,將讀數據、數據增減、寫回數據三個操作合併爲了一個,可以對數據進行增值 / 減值操作,而且它們本身就是單個命令操作,所以本身具有互斥性。可以直接幫助我們進行併發控制。

// 將商量id的庫存減1
DECR id

是的,就是這麼簡單就搞定了扣減庫存。

1.2.2、Lua腳本

Redis 會把整個 Lua 腳本作爲一個整體執行,在執行的過程中不會被其他命令打斷,從而保證了 Lua 腳本中操作的原子性。

將要執行的操作編寫到一個 Lua 腳本中,使用 Redis 的 EVAL 命令來執行腳本。

原生 EVAL 方法的使用語法如下:

EVAL script numkeys key [key ...] arg [arg ...]

script 是我們 Lua 腳本的字符串形式,numkeys 是我們要傳入的參數數量,key 是我們的入參,可以傳入多個,arg 是額外的入參。

但這種方式需要每次都傳入 Lua 腳本字符串,不僅浪費網絡開銷,同時 Redis 需要每次重新編譯 Lua 腳本,對於我們追求性能極限的系統來說,不是很完美。所以這裏就要說到另一個命令 EVALSHA 了,原生語法如下:

EVALSHA sha1 numkeys key [key ...] arg [arg ...]

可以看到其語法與 EVAL 類似,不同的是這裏傳入的不是腳本字符串,而是一個加密串 sha1。這個 sha1 是從哪來的呢?它是通過另一個命令 SCRIPT LOAD 返回的,該命令是預加載腳本用的,語法爲:

SCRIPT LOAD script

將 Lua 腳本先存儲在 Redis 中,並返回一個 sha1,下次要執行對應腳本時,只需要傳入 sha1 即可執行對應的腳本。這完美地解決了 EVAL 命令存在的弊端,所以我們這裏也是基於 EVALSHA 方式來實現的。

-- 調用Redis的get指令,查詢活動庫存,其中KEYS[1]爲傳入的參數1,即庫存key
local c_s = redis.call('get', KEYS[1])
-- 判斷活動庫存是否充足,其中KEYS[2]爲傳入的參數2,即當前搶購數量
if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
   return 0
end
-- 如果活動庫存充足,則進行扣減操作。其中KEYS[2]爲傳入的參數2,即當前搶購數量
redis.call('decrby',KEYS[1], KEYS[2])
return 1

我們可以將腳本先寫在配置中心,代碼執行的時候就去拉取最新的sha1。或者代碼裏面寫死。

當然這個腳本也可以擴展,比如加上IP限制等等。但是太多操作放在Lua裏也會降低redis的併發性能,所以非併發控制就不寫到lua了。

理論看完了,實操一下吧

2、Talk is cheap. Show me the code

2.1、安裝redis

跳過,不會安裝的出門右拐。

我自己用podman。

podman run -p 6379:6379 --name my_redis --privileged=true -v D:\podman\redis\conf\redis.conf:/etc/redis/redis.conf  -v D:\podman\redis\data:/data -d docker.io/library/redis redis-server /etc/redis/redis.conf --appendonly yes

2.2、代碼

在下.neter,就寫C#代碼了

   [ApiController]
    [Route("[controller]")]
    public class HomeController : ControllerBase
    {
        private static string _redisConnection = "localhost:6379";
        private static ConnectionMultiplexer _connMultiplexer;
         
        private string _redisScript = @"local c_s = redis.call('get', KEYS[1])
                                        if not c_s or tonumber(c_s) < tonumber(KEYS[2]) then
                                           return 0
                                        end
                                        redis.call('decrby',KEYS[1], KEYS[2])
                                          return 1";
        private string _sha1 = string.Empty;
        /// <summary>
        /// 鎖
        /// </summary>
        private static readonly object Locker = new object();

        private static int _count = 0;
        private static int _rushToPurchaseCount = 0;

        /// <summary>
        /// 獲取 Redis 連接對象
        /// </summary>
        /// <returns></returns>
        private IConnectionMultiplexer GetConnectionRedisMultiplexer()
        {
            if ((_connMultiplexer == null) || !_connMultiplexer.IsConnected)
            {
                lock (Locker)
                {
                    if ((_connMultiplexer == null) || !_connMultiplexer.IsConnected)
                    {
                        _connMultiplexer = ConnectionMultiplexer.Connect(_redisConnection);
                    } 
                }
            }
            return _connMultiplexer;
        }
        
        [HttpPost("/Init")]
        public IActionResult Init()
        {
            GetConnectionRedisMultiplexer();
            return Ok();
        }
        [HttpPost]
        public async Task<IActionResult> Post()
        {
            System.Diagnostics.Stopwatch stopwatch = new Stopwatch();
            stopwatch.Start(); 
            var db = _connMultiplexer.GetDatabase();
            var cache = db.ScriptEvaluateAsync(_redisScript,
                new RedisKey[] { "key999", "1" });

            var results = (string[]?)await cache;
            
            if (results[0] == "1")
            {
                Interlocked.Increment(ref _rushToPurchaseCount);
                Console.WriteLine($"恭喜您搶到了,{_rushToPurchaseCount}");
            }
            else
            {
                Console.WriteLine("很遺憾,您沒有搶到");
            }
            return Ok();
        }
    }

我們在redis中新增5個庫存

img

配置一下Jmeter,100個線程3秒內跑完

img

img

家人們!準備開槍!3!2!1!上鍊接!😆😆😆

img

讓我們恭喜這5位大冤種😏

Jmeter聚合報告
img

redis庫存爲0
img

好了,到這裏就先結束了。拜拜

github StackExchange
手把手帶你搭建秒殺系統-不差毫釐:秒殺的庫存與限購
Redis 核心技術與實戰-無鎖的原子操作:Redis如何應對併發訪問?
.Net Core使用分佈式緩存Redis:Lua腳本

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章