深入理解PHP+Redis實現(xiàn)分布式鎖的相關(guān)問題

概念

PHP使用分布式鎖,受語言本身的限制,有一些局限性。

  • 通俗理解單機鎖問題:自家的鎖鎖自家的門,只能保證自家的事,管不了別人家不鎖門引發(fā)的問題,于是有了分布式鎖。
  • 分布式鎖概念:是針對多個節(jié)點的鎖。避免出現(xiàn)數(shù)據(jù)不一致或者并發(fā)沖突的問題,讓每個節(jié)點確保在任意時刻只有一個節(jié)點能夠?qū)操Y源進行操作,單機的鎖只能夠單節(jié)點使用,多節(jié)點防不住。
  • 核心原理:分布式鎖的核心原理,就是在每個節(jié)點執(zhí)行時,先去一個公共的地方判斷是否持有鎖,如果有鎖就說明資源被占用,沒鎖就可以持有該資源。
  • 通俗舉例:多個部門,開部門會議,需要占用會議室的位置,發(fā)現(xiàn)會議室門關(guān)著,不知道里面有沒有人,此時門外面有個牌子說明是會議中,還是會議結(jié)束,離老遠就知道會議室是不是被占用了,避免會議競爭引起的錯亂。

應用場景

  • 分布式排它:保證只有一個節(jié)點被訪問,常用于秒殺,等并發(fā)問題的處理。
  • 分布式任務調(diào)度:在分布式任務調(diào)度系統(tǒng)中,多個節(jié)點可能會競爭執(zhí)行同一個任務,使用分布式鎖可以確保只有一個節(jié)點能夠執(zhí)行該任務,避免重復執(zhí)行和沖突。
  • 并發(fā)下數(shù)據(jù)庫事務幻讀問題:并發(fā)下的MySQL事務當中,插入數(shù)據(jù)前先判斷有沒有,沒有再插入,從而避免重復,但是其它事務未提交,就檢測不到(RR的隔離級別導致的),但是插入相同數(shù)據(jù),又會導致唯一約束起作用從而報錯,添加分布式鎖,從而避免報錯。(這場景適用于唯一約束沖突報錯很多的場景功能,否則使用了會影響性能)。

分布式鎖的特點

  1. 互斥性,相同時間,只能有一個節(jié)點會獲取該鎖,其它節(jié)點要么等待要么直接返回失敗。
  2. 可重入(單個節(jié)點可重復獲取該鎖且不會發(fā)生阻塞)。
  3. 安全(獲取鎖的節(jié)點崩潰或失去連接、鎖資源會釋放)。

可用的存儲組件選擇

Redis、MySQL(樂觀鎖、或悲觀鎖)、ZooKeeper、Etcd、Memcache等存儲組件都可以實現(xiàn)分布式鎖。
ZooKeeper、Etcd是Java生態(tài),PHP幾乎不用。
Memcache很少用了,一般都會用redis。
MySQL性能比不了Redis,高并發(fā)過來容易被夯住,數(shù)據(jù)不會自動過期刪除,需要邏輯判斷。所以也不用。

分布式鎖要求高性能,和自動過期的兜底特性,所以用Redis的set命令剛好。
Redis分布式鎖,又稱為Redis Distributed Lock,也叫RedLock。

用Redis手動實現(xiàn)分布式鎖(示例)

這是花十分鐘寫出來的例子,不建議商用。

class RedLock {
    //聲明redis
    private $redis;

    /**
     * @function 構(gòu)造方法初始化redis
     * @other    void
     */
    public function __construct() {
        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);
        $this->redis = $redis;
    }


    /**
     * @function 非阻塞分布式鎖
     * @param    $key string 鎖名稱
     * @param    $ttl int    key自動過期時間,單位毫秒
     * @return   array       返回操作的結(jié)果
     * @other    void
     */
    public function addLock($lock_name, $ttl = 10000) {
        $lock_name = 'red_lock_' . $lock_name;
        $val = base64_encode(openssl_random_pseudo_bytes(32));
        $set = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
        if($set === false) {
            return ['status' => false, 'msg' => '鎖設置失敗', 'key' => '', 'val' => ''];
        }
        return ['status' => true, 'msg' => '', 'key' => $lock_name, 'val' => $val];
    }



    /**
     * @function 阻塞式分布式鎖
     * @param    $key string 鎖名稱
     * @param    $ttl int    key自動過期時間,單位毫秒
     * @param    $ttl int    超時時間,單位毫秒
     * @return   array       成功返回數(shù)組,失敗返回false
     * @other    void
     */
    public function addLockSpin($lock_name, $ttl = 10000, $timeout = 3000) {
        $lock_name = 'red_lock_' . $lock_name;
        $start = bcmul(microtime(true), 1000, 2);
        $val = base64_encode(openssl_random_pseudo_bytes(32));
        $set = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
        if($set === false) {
            while(true) {
                //超時
                $start_loop = bcmul(microtime(true), 1000, 2);
                if(bcadd($start, $timeout, 2) <= $start_loop) {
                    return ['status' => false, 'msg' => '超時', 'key' => '', 'val' => ''];
                }

                //嘗試獲取鎖
                $set_loop = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
                if($set_loop) {
                    return ['status' => true, 'msg' => '', 'key' => $lock_name, 'val' => $val];
                }

                usleep(50000);
            }
        }

        return ['status' => true, 'msg' => '', 'key' => $lock_name, 'val' => $val];
    }


    /**
     * @function 釋放鎖資源
     * @param    $key array 鎖資源
     * @return   bool
     * @other    void
     */
    public function unLock($lock) {
        if($lock['status'] === false) {
            return false;
        }

        $script = <<<LUA_DEL_LOCK
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            else
                return 0
            end
        LUA_DEL_LOCK;

        return $this->redis->eval($script, [$lock['key'], $lock['val']], 1) ? true : false;
    }
}
//調(diào)用端-------------------------------------------------------------------------------
$redLock = new RedLock();

$lock = $redLock->addLockSpin('test_key', 10000, 3000);
if(! $lock['status']) {
    echo '鎖沒有搶到,原因:' . $lock['msg'];
} else {
    echo '搶到鎖了,處理一些業(yè)務邏輯,然后釋放鎖資源';
    $redLock->unLock($lock);
}

現(xiàn)有的解決方案

java實現(xiàn)分布式鎖有redisson,PHP也有自己的包。
看過一些博主的用PHP實現(xiàn)分布式鎖,好多沒有使用Lua,這沒辦法保證多條Redis語句原子性的執(zhí)行。
項目中能用到這種東西的,對于高可用、原子性、穩(wěn)定性有很強的依賴,所以推薦使用成熟的擴展包。

composer require signe/redlock-php
文檔:https://packagist.org/packages/signe/redlock-php
執(zhí)行之后看使用redis的monitor指令查看,發(fā)現(xiàn)用了Lua,說明這個包,兼顧了原子性的操作。
我這個是示例,記得無論最后執(zhí)行成功還是失敗,都記得及時釋放鎖資源。

非自旋寫法
$server = new \Redis;
$server->connect('127.0.0.1', 6379);
$servers = [$server,];

$redLock = new \RedLock\RedLock($servers);
$lock = $redLock->lock('my_resource_name', 10000);

if($lock) {
    echo '加鎖成功';
    $redLock->unlock($lock);
} else {
    echo '加鎖失敗';
}


自旋寫法
$server = new \Redis;
$server->connect('127.0.0.1', 6379);
$servers = [$server,];

$redLock = new \RedLock\RedLock($servers);
$lock = $redLock->lock('my_resource_name', 10000);

if($lock) {
    echo '加鎖成功';
//    $redLock->unlock($lock);
} else {
    while(true) {
        $lock2 = $redLock->lock('my_resource_name', 10000);
        if($lock2) {
            echo '加鎖成功2';
            //運行某些代碼
            $redLock->unlock($lock2);
            return '';
        } 
    }
}

如果需要:拿到鎖后,釋放鎖前,業(yè)務邏輯代碼塊再對拿到鎖的分布式鎖續(xù)期。
因為redis的key與val值都不變,只變動過期時間,所以使用PEXPIRE指令,也可使用PSETEX指令。
又需要防止這個鎖自動過期,已經(jīng)被其它節(jié)點占用,已經(jīng)改成了其它節(jié)點的數(shù)據(jù),所以value值需要驗證是不是當前鎖的value值。
兩個操作為了保證原子性,就用到了Lua。

//$redLock = new \RedLock\RedLock($servers);
//$lock = $redLock->lock('my_resource_name', 20000);

$script = '
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("PEXPIRE", KEYS[1], KEYS[2])
            else
                return 0
            end
        ';
$server->eval($script, [$lock['resource'], '毫秒過期時間', $lock['token']], 2);

PHP使用分布式鎖的局限性問

  • 超時問題沒有監(jiān)控機制:沒有像redisson一樣的watch dog看門狗的機制,去監(jiān)控業(yè)務執(zhí)行過長導致redis分布式鎖自動釋放,被其它鎖占用的問題。 可能需要Swoole的異步才支持
    PHP使用分布式鎖,有種照貓畫虎的感覺。

為什么加鎖時set指令要加NX

set指令加nx表示,只有在key不存在的情況下才能設置鍵值對。
多個節(jié)點加鎖,獲取分布式鎖資源,實質(zhì)就是在redis中設置一條值。因為分布式鎖的排它性,同一時間內(nèi)只能有一個節(jié)點可以拿到該鎖。
若用set,不加nx,就會產(chǎn)生覆蓋,造成業(yè)務錯亂。

客戶端宕機導致鎖資源無法釋放的死鎖問題

redis單線程通常不會發(fā)生死鎖問題。
Redis在客戶端掛掉的情況的情況,會導致分布式鎖鎖資源無法及時釋放,這可能會導致其它節(jié)點無法加鎖從而阻塞,類似死鎖的效果。
添加過期時間做兜底即可。

對高可用:MySQL可以主從,Redis也可以,從而保證分布式鎖存儲的高可用性。

分布式鎖redis操作的原子性問題

用redis做搶購秒殺仍舊超賣,問題也是出在這里。你寫的多條redis,看起來是針對同一條數(shù)據(jù)的操作,其實在并發(fā)情況下,是有間隙的
就算是redis事務(multi)也是弱事務,仍舊會出現(xiàn)并發(fā)安全問題,最好使用Lua+Redis的方式去實現(xiàn)原子性的分布式鎖,這會把一些指令集當做一個任務隊列去處理,保證原子性。
注意這里說的原子性,不是Redis事務的原子性,而是說操作同一數(shù)據(jù)要么都成功或者都失敗,沒有l(wèi)ua的加持,高并發(fā)情況下,分布式鎖的釋放,無法保證get和del的是同一條數(shù)據(jù)。
若不用Lua,舉個例子:
高并發(fā)情況下:
get庫存為10, decr庫存你以為是9,實際上可能小于9,因為你get之后再decr庫存,中間有間隙,可能已經(jīng)被其它并發(fā)過來的請求decr過了,超賣的實質(zhì)就是這樣產(chǎn)生的。
用Lua寫成一個整體,則可以保證這兩個語句沒有間隙。至于,成功則都成功,失敗則都失敗的原子性,靠的是Lua腳本的判斷邏輯。

難道Redis事務沒辦法保證ACID嗎,非要用Lua

無法保證。
redis的事務是弱事務BASE,ACID無法保證。
multi聲明的事務,我認為叫批量執(zhí)行命令(批處理)更好。官方給他起了個名稱,叫管道(避免頻繁的命令往來,造成的性能問題)。
管道的極簡類比:買10瓶水,去10次超市,1次買1瓶的開銷。和一次性買10瓶水,只去1次的開銷。如果不利用管道,redis需要多次io,用戶態(tài)到內(nèi)核態(tài)的轉(zhuǎn)變,對于進程上下文有影響,管道用來解決這個問題。
把multi當做樂觀鎖來用,那就是弱事務,當做批處理來用,那就是管道。

從底層分析MySQL與Redis事務:

  • 原子性(A):MySQL是由undo log實現(xiàn)的,也叫做回滾日志,支持原子性。Redis事務不支持(multi中遇到語法錯誤會整體回滾,但是遇到執(zhí)行錯誤,例如incr string就不會全部回滾,正確的語句仍執(zhí)行)。
  • 一致性(C):一致性是指從一個合法的狀態(tài)變?yōu)榱硪粋€合法的狀態(tài),執(zhí)行自增10,不會變成11,12,這一點MySQL可以保證,redis可以保證。
  • 隔離性(I):多個事務可以并發(fā)執(zhí)行,各個事務之間的操作互相隔離。MySQL有MVCC機制對4種隔離級別提供的底層支撐,所以有快照讀和當前讀之分。Redis只有Watch實現(xiàn)的樂觀鎖可以保證,沒有隔離級別的概念。
  • 持久性(D):MySQL持久化數(shù)據(jù),數(shù)據(jù)是持久化到了頁上,為了保證高可用出現(xiàn)了redo log(重做日志)機制,而redis雖然有AOF和RDB,但持久化機制不是實時的,實時對持久化是高可用,但會降低性能,而redis就是為了快,所以持久性有,無法保證高可用。

如何設置拿到鎖資源后的超時時間

對于Java,redisson有watch dog的自動監(jiān)控機制,但是PHP沒有。
PHP也很難實現(xiàn),原因有2:

  • 不知道自動續(xù)期的時機:業(yè)務流程沒走完,分布式鎖臨近過期才續(xù)期,業(yè)務流程走完了還續(xù)什么期?這個時機,高并發(fā)場景下難以獲取,凈增加復雜度。
  • PHP語言本身缺少鎖機制:就算知道了要續(xù)期,加鎖與續(xù)期監(jiān)控,缺少鎖機制的強關(guān)聯(lián),加鎖一個進程,監(jiān)控又一個進程,進程間通信是一個問題,PHP進程間通信與Redis操作無法原子執(zhí)行又是一個問題,也就是說就算被通知要續(xù)期了,再續(xù)期時,鎖資源超時自動釋放后,可能都被別的節(jié)點占用了。

PHP能做的只能是設置更多的超時時間,來防止鎖資源自動釋放被其它節(jié)點搶走。
缺點也很明顯,一旦這個節(jié)點掛掉,鎖資源需要很長時間才能釋放,這個時間段的分布式鎖無法被任意一個節(jié)點使用。

鎖資源的錯誤釋放問題

時序圖:

步驟 客戶端1 客戶端2 補充
1 獲取鎖成功 / /
2 執(zhí)行中 獲取鎖失敗 客戶端1的鎖阻塞了客戶端2
3 執(zhí)行中 獲取鎖失敗 客戶端2自旋,不斷嘗試獲取鎖
4 鎖資源到期自動釋放 獲取鎖成功 由于客戶端1的鎖資源過期,才導致客戶端2拿到的分布式鎖
5 釋放鎖 執(zhí)行中 這一步才是客戶端1真正釋放(刪)鎖的時刻,但是由于沒做驗證,這個釋放(刪)的過程,會把會話2創(chuàng)建的鎖給釋放(刪)掉,造成誤刪除

為了避免這個問題,val值可設置為節(jié)點標識。
所以redis在get值的時候,需要判斷,val值是不是當前的節(jié)點標識。
為了保證原子性,查詢和刪除兩個操作需要用Lua腳本。

其次要注意,不管節(jié)點程序執(zhí)行成功或者失敗,只要該走的流程走完了,都需要及時釋放鎖。

分布式鎖的可重入問題

  • 極簡概括:單個進程(或線程),單節(jié)點可重復上鎖,不用等待,避免死鎖。這種機制是為了避免,在循環(huán)或者遞歸獲取鎖時,第一層循環(huán)成功,之后失敗的問題。(大部分場景不需要這個重入性,某些場景才需要)。
  • 動作分析:看我自行封裝的的代碼,假設同一個節(jié)點,遞歸或循環(huán)獲取分布式鎖,就算是同一節(jié)點,獲取分布式鎖后,再次獲取分布式鎖,也得自旋,等自家節(jié)點的分布鎖釋放后,再獲取鎖,這個地方可以改進。
  • 實現(xiàn)思路:重入問題,還需要再維持一個redis hash,key為鎖名,field為節(jié)點的唯一標識,value為重入次數(shù),重入1次次數(shù)加1,釋放重入1次次數(shù)減1,為了避免因業(yè)務邏輯耗時而導致鎖過期,還需要給當前的鎖續(xù)期。期間的多個操作,也需要在Lua腳本中執(zhí)行。

不過對于PHP而言,以當前的認知來看,重入性用不上。 因為重入,相當于在獲取鎖的情況下,多次獲取同一把鎖,那直接在if(拿到鎖){}這里寫邏輯就行了是不是,何必多次拿到一樣的鎖。

分布式鎖的自旋機制

自旋可以理解為內(nèi)部死循環(huán),內(nèi)部不斷重試,直到滿足條件,直觀感受就是被阻塞。
如果沒有自旋,10個節(jié)點,只有1個能加鎖成功,其余9個失敗,如果這9個全部失敗掉,看起來差點意思。

因此可以選擇被阻塞,期間不斷重試,所謂的自旋方案,其實很好理解,重試偽代碼如下:

while(加鎖失敗) {
    usleep(10000);
    重新嘗試加鎖代碼
    if(加鎖成功) {
        return '加鎖成功';
    }
}

此處也可以添加一個次數(shù)限制,防止永久死循環(huán)的兜底策略
$retry_count = 0;
while(true) {
    $retry_count ++;
    if('加鎖成功') {
        return '加鎖成功';
    }

    if($retry_count > 20) {
        echo 1;
        return '重試次數(shù)過多';
    }
    
    usleep(30000);
}

也可以根據(jù)時間去做限制,防止永久死循環(huán)的兜底策略
$start_time = microtime(true);
while(true) {
    if($start_time + 5 <= microtime(true)) {
        return '超時';    
    }
    
    if('加鎖成功') {
        return '加鎖成功';
    }

    usleep(30000);
}

Redis主從架構(gòu)對分布式鎖的高可用問題

節(jié)點1再master上獲取到了分布式鎖,叫l(wèi)ock1,此時master還沒有同步到slave,結(jié)果master掛掉了。
此時故障轉(zhuǎn)移,slave做頂梁柱,節(jié)點2也獲取到了slave的分布式鎖,也叫l(wèi)ock1。
這種情況違背了分布式鎖的排它性。概率很小,但是有可能發(fā)生。
setnx無法解決分布式場景下的鎖排它性問題。
這個是運維層面要考慮的東西。

手動實現(xiàn)分布式鎖容易被忽略的問題

分布式鎖這種工程化的東西,每個零件都有用,雖然RedLock底層用redis set指令實現(xiàn)。

  • 若忘記加超時時間:上鎖的節(jié)點掛掉沒有釋放鎖資源,其它節(jié)點會一直拿不到鎖,嚴重影響業(yè)務。
  • 若忘記加value值判斷去釋放鎖:A節(jié)點在執(zhí)行業(yè)務邏輯超時,自動釋放鎖資源被B節(jié)點搶去,等A節(jié)點執(zhí)行完業(yè)務代碼后釋放鎖,會把B節(jié)點的鎖刪除。
  • 若忘記用Lua腳本:這導致redis在執(zhí)行任務期間,同一客戶端的多個腳本不會在一個Redis內(nèi)置的任務隊列處理,保證不了原子性,超賣的并發(fā)安全問題就是這樣產(chǎn)生的。
  • 覆蓋問題:redis分布式鎖設置值時,用的setnx思想(有值則不設置,避免覆蓋),若用set,整不好把原先的覆蓋掉了。
  • 可能缺少過期鎖自動續(xù)期機制:就對PHP而言,手動實現(xiàn)可能缺少key的監(jiān)控過期,畢竟PHP沒有像Java Redisson中那樣的watch dog機制。
  • 若忘記重入性問題:這會導致多節(jié)點多次添加分布式鎖,有阻塞或者失敗的可能。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容