在用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操作失败, 则这个锁就会一致存在, 导致数据无法获取.