1. 程式人生 > >Redis 分布式鎖的實現

Redis 分布式鎖的實現

xdebug .com 其中 deb process 可能 pro 但是 test

0X00 測試環境

CentOS 6.6 + Redis 3.2.10 + PHP 7.0.7(+ phpredis 4.1.0)

[root@localhost ~]# cat /etc/issue
CentOS release 6.6 (Final)
Kernel \r on an \m

[root@localhost ~]# redis-server -v
Redis server v=3.2.10 sha=00000000:0 malloc=jemalloc-3.6.0 bits=32 build=8903a4502b3c9f88
[root@localhost
~]# php -v PHP 7.0.7 (cli) (built: Feb 11
2017 16:47:30) ( NTS ) Copyright (c) 1997-2016 The PHP Group Zend Engine v3.0.0, Copyright (c) 1998-2016 Zend Technologies with Xdebug v2.5.5, Copyright (c) 2002-2017, by Derick Rethans

0X01 什麽是分布式鎖

redis 官網上對分布式鎖的描述(https://redis.io/topics/distlock)是:

Distributed locks are a very useful primitive in many environments where different processes must operate with shared resources in a mutually exclusive way.

即在很多環境中,分布式鎖是一種非常有用的原語,它使不同的進程必須以 互斥 的方式操作共享資源。

0x02 為什麽要使用分布式鎖

Redis 是 單線程(single-threaded)的內存數據結構存儲,因此 Redis 所有的 基礎命令 都是 原子性 的。但是 多個連貫的命令 高並發 的情況下數據的 一致性 就不能得到保障,數據很可能會被其他的客戶端修改。

舉一個並發情況下沒有使用鎖的例子:

例1. without_lock.php

<?php
$redis  = new Redis();
$redis->connect(‘localhost‘, 6379);

echo date(‘Y-m-d H:i:s‘, time()).‘ start‘.PHP_EOL; for($i = 0; $i < 100000; $i++) { $count = (int)$redis->get(‘key‘); $count += 1; $redis->set(‘key‘, $count); usleep(0.01); } echo date(‘Y-m-d H:i:s‘, time()).‘ end‘.PHP_EOL;

在兩個客戶端中同時執行該程序,如果不考慮並發情況下數據的一致性,那麽兩個客戶端執行完之後,鍵 key 的值應該是 200000,但是實際的結果小於 200000。

客戶端1:

技術分享圖片

客戶端2:

技術分享圖片

結果:

技術分享圖片

原因是:

get、值+1、set 這三個操作不是原子操作,在兩個客戶端同時執行腳本的時候,哪一個客戶端先到就先執行哪個客戶端的命令。例如:

a.當前 key 的值是 10;

b.客戶端1 取出 key 的值是 10,此時客戶端2 的命令也到了,取出 key 的值是 10;

c.客戶端1 把值加 1,存入 key,此時 key 的值是 11;

d.客戶端2 把值加 1,存入 key,此時 key 的值還是 11

所以在並發情況下最終 key 的值會小於希望的值。

0x03 Redis 的事務能不能解決並發下數據一致性問題

redis 中有一系列事務(https://redis.io/topics/transactions)相關的命令,包括 watch、multi、exec 等。能不能使用這些命令保證並發情況下的數據一致性呢。

根據 redis 官網的介紹,redis 可以使用 check-and-set(CAS)實現 樂觀鎖(Optimistic),以下是官網給出的示例:

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

使用 watch 命令監視鍵 mykey,如果在 exec 命令執行之前,其他的客戶端修改了 mykey 的值時,整個事務就會 終止,並且 exec 命令會返回 null 通知事務失敗 —— 根據官網的說明,在接到事務失敗的情況下,只需要重復執行上述操作,並且希望不會有新的競態情況發生,這種形式的鎖被稱為樂觀鎖。如果把事務應用在例1 中,結果很可能會出現大量的事務失敗,而並不能達到希望的結果,即最終 key 的值是 200000。

例2. whith_watch.php

<?php
$redis  = new Redis();
$redis->connect(‘localhost‘, 6379);

echo date(‘Y-m-d H:i:s‘, time()).‘ start‘.PHP_EOL;

$falseCount = 0;
for($i = 0; $i < 100000; $i++)
{
        $redis->watch(‘key1‘);
        $count  = (int)$redis->get(‘key1‘);
        $count  += 1;

        $ret = $redis->multi()
                ->set(‘key1‘, $count)
                ->exec();

        if(false === $ret)
        {
                $falseCount += 1;
        }
}

echo "falseCount:{$falseCount}".PHP_EOL;
echo date(‘Y-m-d H:i:s‘, time()).‘ end‘.PHP_EOL;

客戶端1:

技術分享圖片

事務失敗了 33043 次

客戶端2:

技術分享圖片

事務失敗了 63228 次

結果:

技術分享圖片

結果也是遠遠小於 200000

說明:

以上為什麽不能寫成

for($i=0; $i < 100000; $i++)
{
    $redis->watch(‘key1‘);
    $redis->multi();
    $count = (int)$redis->get(‘key1‘);
    $count += 1;
    $redis->set(‘key1‘, $count);
    $redis->exec();         
}

參考 phpredis 文檔 https://github.com/phpredis/phpredis/#multi:

multi() returns the Redis instance and enters multi-mode. Once in multi-mode, all subsequent method calls return the same object until exec() is called.

即在 multi-mode 下,所有的後續方法都返回同一個對象(Redis Object),直到調用 exec命令。也就是說在 multi-mode 下,任何的命令都不會真正執行,而是會返回 Redis Object,直到調用 exec 命令,才真正執行事務中的每一條命令,因此

$count = (int)$redis->get(‘key1‘);

上述代碼中的 get 命令,並沒有真正執行,該語句實際只會返回一個 Redis 對象。

0x04 分布式鎖的實現流程

基本思路是:

a.一個進程(客戶端)去獲取鎖,如果可以獲取到,則寫入鎖並且設置鎖的有效期,當數據處理完之後,釋放該鎖;

b.當獲取鎖失敗時,判斷鎖是否存在有效期,如果不存在,則設置鎖的有效期,超出有效期後鎖會自動釋放

c.當數據處理完後,釋放鎖時,需要判斷鎖是否是其他進程(客戶端)的鎖,如果不是則釋放,如果是則跳過

分布式鎖需要註意的問題包括:

a. 防止持有鎖的進程(客戶端)意外崩潰,導致鎖得不到釋放,形成死鎖,其他進程(客戶端)一直得不到該鎖;

b.防止持有鎖的進程(客戶端)因為操作時間過長(超過了鎖的有效期)導致鎖自動釋放,最後到了該釋放鎖的時候卻錯誤的釋放了其他進程(客戶端)的鎖;

c.防止一個進程(客戶端)的鎖過期後,其他多個進程(客戶端)同時嘗試獲取鎖,並且都獲取成功了

流程圖:

技術分享圖片

0x05 Redis 實現分布式鎖

要在高並發下消除競爭、保證數據一致性,可以采用 Redis 的分布式鎖來實現。

有兩種實現方式:

第一種是低於 2.6.12 版本的redis,需要使用 setnx、expire、ttl 等命令組合使用;

第二種是 2.6.12 版本起,redis 給 set 命令(https://redis.io/commands/set) 提供了更豐富的參數,來代替以上的幾個命令

SET key value [EX seconds] [NX]

其中可選參數 EX seconds 表示鍵的過期時間為 seconds 秒,NX 表示只有鍵不存在時才對鍵進行設置,這個命令可以替代 setNx 命令加上 expire 命令,而且它是原子性的。

1.Redis Version < 2.6.12

例3.lock.php

<?php
/**
 * redis 分布式鎖
**/

class Lock
{
        private $redis = ‘‘;

        public function __construct($host, $port = 6379)
        {       
                $this->redis = new Redis();
                $this->redis->connect($host, $port);
        }

        // 加鎖
        public function getLock($lockName, $timeout = 2)
        {       
                $identifier     = uniqid();
                $timeout        = ceil($timeout);
                $end            = time() + $timeout;
                
                while(time() < $end)
                {       
                        if($this->redis->setnx($lockName, $identifier))
                        {
                                $this->redis->expire($lockName, $timeout);

                                return $identifier;
                        }
                        elseif($this->redis->ttl($lockName) == -1)
                        {
                                $this->redis->expire($lockName, $timeout);
                        }
                        usleep(0.001);
                }

                return false;
        }

        // 釋放鎖
        public function releaseLock($lockName, $identifier)
        {
                if($this->redis->get($lockName) == $identifier)
                {
                        $this->redis->multi();
                        $this->redis->del($lockName);
                        $this->redis->exec();

                        return true;
                }

                return false;
        }

        // test
        public function test($lockName)
        {
                echo date(‘Y-m-d H:i:s‘, time()).‘ start‘.PHP_EOL;

                for($i = 0; $i < 100000; $i++)
                {
                        $identifier = $this->getLock($lockName);
                        if($identifier)
                        {
                                $count  = $this->redis->get(‘count‘);
                                $count  = intval($count) + 1;
                                $this->redis->set(‘count‘, $count);
                                $this->releaseLock($lockName, $identifier);
                        }
                }

                echo date(‘Y-m-d H:i:s‘, time()).‘ end‘.PHP_EOL;
        }
}

$obj = new Lock(‘localhost‘);
$obj->test(‘lock_name‘);

說明:代碼參考 《Redis構建分布式鎖》

客戶端1:

技術分享圖片

客戶端2:

技術分享圖片

結果:

技術分享圖片

2.Redis Version >= 2.6.12

例4.with_set.php

把上例中的

if($this->redis->setnx($lockName, $identifier))
{
        $this->redis->expire($lockName, $timeout);

        return $identifier;
}
elseif($this->redis->ttl($lockName) == -1)
{
        $this->redis->expire($lockName, $timeout);
}

替換為:

if($this->redis->set($lockName, $identifier, [‘nx‘, ‘ex‘=>intval($timeout)]))
{
        return $identifier;
}

即可。

以上使用 Redis 實現了分布式鎖。

0x06 參考

1.《Redis構建分布式鎖》

3.《在 Redis 上實現的分布式鎖》

Redis 分布式鎖的實現