在用redis做緩存時, 如果不考慮併發問題, 在緩存不存在或過期時, 會導致很多請求直接進入數據庫,造成很多"意外"的負載.
所以, 需要對緩存不存在->走數據庫查詢的處理過程中, 增加一個鎖, 來避免該問題, 這就是併發鎖.
加鎖的過程:
- 請求的緩存不存在, 嘗試加鎖(必須使用redis的setnx), 開始循環處理.
- 如果鎖存在, 則休眠, 等待下一次循環.
- 如果鎖不存在
- 加鎖成功, 則執行業務查詢(走數據庫),然後解鎖
- 加鎖失敗, 休眠, 等待下一次循環(下一循環時, 鎖應該存在, 繼續循環)
- 循環第二次開始, 如果鎖不存在, 則認爲緩存已經生成, 去查詢這個緩存. 如果查詢不到, 則認爲系統異常.
這裏的加鎖返回三個狀態:
- 1: 獲取鎖成功(執行業務後需要unlock)
- 2: 加鎖失敗,但是嘗試期間鎖已經不存在(可以判斷緩存數據是否存在)
- 0: 獲取鎖失敗(或超時)
這裏的鎖的失效時間, 建議不要設置太長.
類庫文件: RedisLockUtility.php
/**
* redis併發鎖
* @author aben
*
* Class RedisLockUtility
*/
class RedisLockUtility {
/**
* 給key加鎖
*
* @param \ClsRedis $redis_connection redis連接對象
* @param string $key_prefix 指定的key前綴
* @param int $timeout 鎖定/超時時間, 默認2秒
* @param float $retry_wait_sec 重新嘗試獲取key的時間間隔, 默認0.1秒
* @return int 結果. 1:獲取鎖成功(執行業務後需要unlock), 2:加鎖失敗,但是嘗試期間鎖已經不存在(可以判斷緩存數據是否存在), 0: 獲取鎖失敗(或超時)
*/
public static function lock($redis_connection, $key_prefix, $timeout = 2, $retry_wait_sec = 0.1){
$cache_lock_key = self::getLockKey($key_prefix);
//重試間隔 (秒轉換爲微秒)
$retry_interval = $retry_wait_sec * 1000000;
//重試次數
$retry_times = $timeout / $retry_wait_sec;
for($i=0; $i < $retry_times; $i++) {
if($redis_connection->get($cache_lock_key)){
//鎖存在, 則休眠
usleep($retry_interval);
}
else {
//(第一次循環)沒有鎖, 加鎖, 查詢數據
if($i == 0){
//準備加鎖
$rt_lock = $redis_connection->setnx($cache_lock_key, 1);//使用setnx命令, 保證鎖的唯一
if($rt_lock === true) {
//加鎖成功
//設置`鎖的有效時間`
$redis_connection->expire($cache_lock_key, $timeout);
//加鎖成功後, 可以執行數據庫查詢, 然後解鎖(unlock) !!!
return 1;
}
else {
//加鎖失敗, 繼續嘗試
usleep($retry_interval);
}
}
else {
//鎖不存在了. 後續可以判斷有沒有緩存.
return 2;
}
}
}
return 0;
}
/**
* 解鎖
*
* @param \ClsRedis $redis_connection redis連接對象
* @param string $key_prefix
*/
public static function unlock($redis_connection, $key_prefix){
$redis_connection->del(self::getLockKey($key_prefix));
}
/**
* 拼接完整的鎖的key
*
* @param string $key_prefix
* @return string
*/
public static function getLockKey($key_prefix){
return $key_prefix.':lock';
}
}
實際調用:
$cacheKey = 'index_products';
/** @var \ClsRedis $cache */
$cache = ClsRedis::getInstance();//redis連接實例
$res = $cache->get($cacheKey);
$getAllDataSuccess = false;
if ($res === false) {
//數據緩存不存在
//處理併發: 加鎖
$lock_key_prefix = 'index_products';
switch(RedisLockUtility::lock($cache, $lock_key_prefix, 2, 0.1)){
case 1://獲取鎖成功
//去數據庫查詢
/**
* 這裏執行數據庫查詢, 得到數據$res
*/
//寫入緩存
$cache->setex($cacheKey, 180, json_encode($res));
//sleep(2); //併發測試時可以休眠一下, 注意: 這個時間不要超過`鎖的有效時間`!
$getAllDataSuccess = true;
//移除鎖
RedisLockUtility::unlock($cache, $lock_key_prefix);
break;
case 2://加鎖失敗,但是嘗試期間鎖已經不存在(可以判斷緩存數據是否存在)
//鎖不存在了, 判斷有沒有緩存.
$data = $cache->get($cacheKey);
if($data !== false){
$res = json_decode($data, true);
$getAllDataSuccess = true;
}
break;
default://失敗
}
if($getAllDataSuccess === false){
echo json_encode(['code' => 0, 'msg'=>'獲取數據失敗', 'res' => []]);
exit;
}
if (empty($res)){
echo json_encode(['code' => 200, 'msg'=>'沒有數據', 'res' => []]);
exit;
}
}
else {
$res = json_decode($res, true);
}
這個方案有個問題. 因爲setnx和expire是2個操作, 如果expire操作失敗, 則這個鎖就會一致存在, 導致數據無法獲取.
本方案待完善....
關於分佈式鎖的問題, 可以參考: https://xie.infoq.cn/article/4d571787a3280ef3094338f9b
github上也有其他人寫的類, latrell/Lock等, 請自行搜索.