概念
PHP使用分散式鎖,受語言本身的限制,有一些侷限性。
- 通俗理解單機鎖問題:自家的鎖鎖自家的門,只能保證自家的事,管不了別人家不鎖門引發的問題,於是有了分散式鎖。
- 分散式鎖概念:是針對多個節點的鎖。避免出現資料不一致或者併發衝突的問題,讓每個節點確保在任意時刻只有一個節點能夠對公共資源進行操作,單機的鎖只能夠單節點使用,多節點防不住。
- 核心原理:分散式鎖的核心原理,就是在每個節點執行時,先去一個公共的地方判斷是否持有鎖,如果有鎖就說明資源被佔用,沒鎖就可以持有該資源。
- 通俗舉例:多個部門,開部門會議,需要佔用會議室的位置,發現會議室門關著,不知道里面有沒有人,此時門外面有個牌子說明是會議中,還是會議結束,離老遠就知道會議室是不是被佔用了,避免會議競爭引起的錯亂。
應用場景
- 分散式排它:保證只有一個節點被訪問,常用於秒殺,等併發問題的處理。
- 分散式任務排程:在分散式任務排程系統中,多個節點可能會競爭執行同一個任務,使用分散式鎖可以確保只有一個節點能夠執行該任務,避免重複執行和衝突。
- 併發下資料庫事務幻讀問題:併發下的MySQL事務當中,插入資料前先判斷有沒有,沒有再插入,從而避免重複,但是其它事務未提交,就檢測不到(RR的隔離級別導致的),但是插入相同資料,又會導致唯一約束起作用從而報錯,新增分散式鎖,從而避免報錯。(這場景適用於唯一約束衝突報錯很多的場景功能,否則使用了會影響效能)。
分散式鎖的特點
- 互斥性,相同時間,只能有一個節點會獲取該鎖,其它節點要麼等待要麼直接返回失敗。
- 可重入(單個節點可重複獲取該鎖且不會發生阻塞),PHP的語言特性不支援。
- 安全(獲取鎖的節點崩潰或失去連線、鎖資源會釋放)。
可用的儲存元件選擇
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的監控過期,以及重入性問題。