深入理解PHP+Redis實現分散式鎖的相關問題

小松聊PHP进阶發表於2024-03-23

概念

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

  • 通俗理解單機鎖問題:自家的鎖鎖自家的門,只能保證自家的事,管不了別人家不鎖門引發的問題,於是有了分散式鎖。
  • 分散式鎖概念:是針對多個節點的鎖。避免出現資料不一致或者併發衝突的問題,讓每個節點確保在任意時刻只有一個節點能夠對公共資源進行操作,單機的鎖只能夠單節點使用,多節點防不住。
  • 核心原理:分散式鎖的核心原理,就是在每個節點執行時,先去一個公共的地方判斷是否持有鎖,如果有鎖就說明資源被佔用,沒鎖就可以持有該資源。
  • 通俗舉例:多個部門,開部門會議,需要佔用會議室的位置,發現會議室門關著,不知道里面有沒有人,此時門外面有個牌子說明是會議中,還是會議結束,離老遠就知道會議室是不是被佔用了,避免會議競爭引起的錯亂。

應用場景

  • 分散式排它:保證只有一個節點被訪問,常用於秒殺,等併發問題的處理。
  • 分散式任務排程:在分散式任務排程系統中,多個節點可能會競爭執行同一個任務,使用分散式鎖可以確保只有一個節點能夠執行該任務,避免重複執行和衝突。
  • 併發下資料庫事務幻讀問題:併發下的MySQL事務當中,插入資料前先判斷有沒有,沒有再插入,從而避免重複,但是其它事務未提交,就檢測不到(RR的隔離級別導致的),但是插入相同資料,又會導致唯一約束起作用從而報錯,新增分散式鎖,從而避免報錯。(這場景適用於唯一約束衝突報錯很多的場景功能,否則使用了會影響效能)。

分散式鎖的特點

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

可用的儲存元件選擇

Redis、MySQL(樂觀鎖、或悲觀鎖)、ZooKeeper、Etcd、Memcache等儲存元件都可以實現分散式鎖。
ZooKeeper、Etcd是Java生態,PHP幾乎不用。
Memcache很少用了,一般都會用redis。
MySQL效能比不了Redis,高併發過來容易被夯住,資料不會自動過期刪除,需要邏輯判斷。所以也不用。

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

用Redis手動實現分散式鎖(示例)

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

class RedLock {
    //宣告redis
    private $redis;

    /**
     * @function 構造方法初始化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|false 成功返回陣列,失敗返回false
     * @other    void
     */
    public function addLock($lock_name, $ttl = 10000) {
        $val = base64_encode(openssl_random_pseudo_bytes(32));
        $set = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
        if($set === false) {
            return false;
        }

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



    /**
     * @function 阻塞式分散式鎖
     * @param    $key string 鎖名稱
     * @param    $ttl int    key自動過期時間,單位毫秒
     * @param    $ttl int    超時時間,單位毫秒
     * @return   array|false 成功返回陣列,失敗返回false
     * @other    void
     */
    public function addLockSpin($lock_name, $ttl = 10000, $timeout = 3000) {
        $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 false;
                }

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

                usleep(50000);
            }
        }

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


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

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

        if(! $this->redis->eval($script, [$lock['key'], $lock['val']], 1)) {
            return false;
        }

        return true;
    }
}

$redLock = new RedLock();
if($lock = $redLock->addLockSpin('test_key')) {
    echo '搶到鎖了,處理一些業務邏輯';
    $redLock->unLock($lock); //記得及時釋放鎖資源
} else {
    echo '鎖沒有搶到';
}

現有的解決方案

java實現分散式鎖有redisson,PHP也有自己的包。
看過一些博主的用PHP實現分散式鎖,好多沒有使用Lua,這沒辦法保證多條Redis語句原子性的執行。
專案中能用到這種東西的,對於高可用、原子性、穩定性有很強的依賴,所以推薦使用成熟的擴充套件包。

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

非自旋寫法
$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 '';
        } 
    }
}

如果需要:拿到鎖後,釋放鎖前,業務邏輯程式碼塊再對拿到鎖的分散式鎖續期。
因為redis的key與val值都不變,只變動過期時間,所以使用PEXPIRE指令,也可使用PSETEX指令。
又需要防止這個鎖自動過期,已經被其它節點佔用,已經改成了其它節點的資料,所以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使用分散式鎖的侷限性問題

  • 重入性無法實現:PHP這門語言有侷限性,不適合和redis結合做分散式鎖,分散式鎖的重入性無法實現,因為指令碼能執行完記憶體就被回收了,無法像C/C++那樣輕鬆操控程序和執行緒。
  • 超時問題沒有監控機制:沒有像redisson一樣的watch dog看門狗的機制,去監控業務執行過長導致redis分散式鎖自動釋放,被其它鎖佔用的問題。

PHP使用分散式鎖,有種東施效顰的感覺。

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

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

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

redis單執行緒通常不會發生死鎖問題。
Redis在客戶端掛掉的情況的情況,會導致分散式鎖鎖資源無法及時釋放,這可能會導致其它節點無法加鎖從而阻塞,類似死鎖的效果。
新增過期時間做兜底即可。

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

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

就算是redis事務(multi)也是弱事務,仍舊會出現併發安全問題,最好使用Lua+Redis的方式去實現原子性的分散式鎖,這會把一些指令集當做一個任務佇列去處理,保證原子性。

如何設定拿到鎖資源後的超時時間

對於Java,redisson有watch dog的自動監控機制,但是PHP沒有。
PHP也很難實現,原因有2:

  • 不知道自動續期的時機:業務流程沒走完,分散式鎖臨近過期才續期,業務流程走完了還續什麼期?這個時機,高併發場景下難以獲取,淨增加複雜度。
  • PHP語言本身缺少鎖機制:就算知道了要續期,加鎖與續期監控,缺少鎖機制的強關聯,加鎖一個程序,監控又一個程序,程序間通訊是一個問題,PHP程序間通訊與Redis操作無法原子執行又是一個問題,也就是說就算被通知要續期了,再續期時,鎖資源超時自動釋放後,可能都被別的節點佔用了。

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

鎖資源的錯誤釋放問題

時序圖:

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

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

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

分散式鎖的可重入問題

PHP解決不了。
假設同一個節點,遞迴或迴圈新增分散式鎖,是否讓同一節點重複加同一把鎖,大部分場景不需要,但是也得看業務場景。
這種機制是為了避免第一層迴圈新增成功,之後失敗的問題。

對於非PHP而言,重入問題,還需要再維持一個redis hash,key為鎖名,field為節點的唯一標識,value為重入次數,重入1次次數加1。因為重入相當於重新獲取鎖,但是不會新增鎖資源,如果這個時間被刪掉,那麼重入時會加鎖成功,但鎖資源被強制釋放,此時重入後的業務邏輯還不一定執行完畢。所以刪除時需要判斷value值是否為0,如果不為0,說明有重入,這兩步操作,也是需要再一個Lua指令碼中。

分散式鎖的自旋機制

自旋可以理解為內部死迴圈,內部不斷重試,直到滿足條件,直觀感受就是被阻塞。
如果沒有自旋,10個節點,只有1個能加鎖成功,其餘9個失敗,如果這9個全部失敗掉,看起來差點意思。

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

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

此處也可以新增一個次數限制,防止永久死迴圈的兜底策略
$retry_count = 0;
while(true) {
    $retry_count ++;
    if('加鎖成功') {
        return '加鎖成功';
    }

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

也可以根據時間去做限制,防止永久死迴圈的兜底策略
$start_time = microtime(true);
while(true) {
	if('加鎖成功') {
		return '加鎖成功';
	}

	if($start_time + 5 <= microtime(true)) {
		return '超時';	
	}

	usleep(30000);
}

Redis主從架構對分散式鎖的高可用問題

節點1再master上獲取到了分散式鎖,叫lock1,此時master還沒有同步到slave,結果master掛掉了。
此時故障轉移,slave做頂樑柱,節點2也獲取到了slave的分散式鎖,也叫lock1。
這種情況違背了分散式鎖的排它性。機率很小,但是有可能發生。
setnx無法解決分散式場景下的鎖排它性問題。
這個是運維層面要考慮的東西。

手動實現分散式鎖容易被忽略的問題

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

  • 若忘記加超時時間:上鎖的節點掛掉沒有釋放鎖資源,其它節點會一直拿不到鎖,嚴重影響業務。
  • 若忘記加value值判斷去釋放鎖:A節點在執行業務邏輯超時,自動釋放鎖資源被B節點搶去,等A節點執行完業務程式碼後釋放鎖,會把B節點的鎖刪除。
  • 若忘記用Lua指令碼:這導致redis在執行任務期間,同一客戶端的多個指令碼不會在一個Redis內建的任務佇列處理,保證不了原子性,超賣的併發安全問題就是這樣產生的。
  • 覆蓋問題:redis分散式鎖設定值時,用的setnx思想(有值則不設定,避免覆蓋),若用set,整不好把原先的覆蓋掉了。
  • 對於像Java(PHP不行)語言:手動實現可能缺少key的監控過期,以及重入性問題。

相關文章