在用redis做缓存时, 如果不考虑并发问题, 在缓存不存在或过期时, 会导致很多请求直接进入数据库,造成很多"意外"的负载.
所以, 需要对缓存不存在->走数据库查询的处理过程中, 增加一个锁, 来避免该问题, 这就是并发锁.
加锁的过程:
- 请求的缓存不存在, 尝试加锁(必须使用redis的setnx), 开始循环处理.
- 如果锁存在, 则休眠, 等待下一次循环.
- 如果锁不存在
- 加锁成功, 则执行业务查询(走数据库),然后解锁
- 加锁失败, 休眠, 等待下一次循环(下一循环时, 锁应该存在, 继续循环)
- 循环第二次开始, 如果锁不存在, 则认为缓存已经生成, 去查询这个缓存. 如果查询不到, 则认为系统异常.
这里的加锁返回三个状态:
- 1: 获取锁成功(执行业务后需要unlock)
- 2: 加锁失败,但是尝试期间锁已经不存在(可以判断缓存数据是否存在)
- 0: 获取锁失败(或超时)
这里的锁的失效时间, 建议不要设置太长.
针对前一版本expire设置失败导致死锁的潜在bug, 本版本解决死锁和redis多个操作(setnx+expire)非原子性的问题:
- 设置锁的值为失效时间戳+随机id (1000个并发下重复的概率应该极小).
- 在锁已经存在但失效的情况下, 设置锁的值后再次获取并比对. 匹配的才算是抢到了锁.
还增加了一个参数: max_timeout
当然这个非原子操作问题也有很多其他的解决方案, 比如Lua脚本.
如果使用memcache, 则没有setnx方法, 只能每次set后再次get比较值是否匹配, 如果不匹配则是被其他请求给抢走了, 算失败
类库文件: RedisLockUtility.php
/**
* redis并发锁
* @author aben
*
* Class RedisLockUtility
*/
class RedisLockUtility {
/**
* 给key加锁
*
* 循环中: 先判断有没有锁. 如果有锁, 过期了则重新加锁, 否则继续循环; (循环第一次)没有锁, 直接加锁; 第二次开始, 锁不存在了则返回状态2.
* @param ClsRedis $redisConnection redis连接对象
* @param string $name key名称
* @param int $timeout 锁超时时间, 默认2秒
* @param int $max_timeout 上锁最大超时时间, 默认5秒
* @param float $retry_wait_sec 重新尝试获取key的时间间隔, 默认0.1秒
* @return int 结果. 1:获取锁成功(执行业务后需要unlock), 2:加锁失败,但是尝试期间锁已经不存在(业务端可以判断缓存数据是否存在), 0: 获取锁失败(或超时)
*/
public static function lock($redisConnection, $name, $timeout = 2, $max_timeout = 5, $retry_wait_sec = 0.1){
$key = self::getKey($name);
//重试间隔 (秒转换为微秒)
$retry_interval = $retry_wait_sec * 1000000;
$time = time();
$i = 0;
$uid = rand(1000000, 9999999);//用来识别请求, 可以降低redis非原子操作导致的并发
while (time() - $time < $max_timeout) {
$value = $redisConnection->get($key);
if($value !== false){
//锁存在
//检查当前锁是否已过期,并重新锁定
$exp = static::getExpFromValue($value);
if ($exp < time()) {
//新锁的值: 过期时间+请求识别号
$lockValue = (string)(time() + $timeout) . ':' . $uid;
$redisConnection->setex($key, $timeout, $lockValue);
//防止被其他请求修改
if($redisConnection->get($key) == $lockValue) {
return 1;
}
}
//锁存在且仍有效, 则休眠
usleep($retry_interval);
}
else {
//(第一次循环)没有锁, 加锁, 查询数据
if($i == 0){
//准备加锁
$lockValue = (string)(time() + $timeout) . ':' . $uid;
$rt_lock = $redisConnection->setnx($key, $lockValue);//使用setnx命令, 保证锁的唯一
if($rt_lock === true) {
//加锁成功, 设置`锁的有效时间`
$redisConnection->expire($key, $timeout);
return 1;
}
//加锁失败, 则休眠
usleep($retry_interval);
}
else {
//锁不存在了. 后续可以判断有没有缓存.
return 2;
}
}
$i ++;
}
return 0;
}
/**
* 给key加锁(每次都先尝试加锁)
*
* 每次都先尝试加锁. 如果加锁失败(有锁), 过期了则重新加锁, 否则继续循环.
* @param ClsRedis $redisConnection redis连接对象
* @param string $name key名称
* @param int $timeout 锁超时时间, 默认2秒
* @param int $max_timeout 上锁最大超时时间, 默认5秒
* @param float $retry_wait_sec 重新尝试获取key的时间间隔, 默认0.1秒
* @return int 结果. 1:获取锁成功(执行业务后需要unlock), 0: 获取锁失败(或超时, 业务端可以判断缓存数据是否存在)
*/
public static function lockFirst($redisConnection, $name, $timeout = 2, $max_timeout = 5, $retry_wait_sec = 0.1){
$key = self::getKey($name);
//重试间隔 (秒转换为微秒)
$retry_interval = $retry_wait_sec * 1000000;
$time = time();
$uid = rand(1000000, 9999999);//用来识别请求, 可以降低redis非原子操作导致的并发
while (time() - $time < $max_timeout) {
$lockValue = (string)(time() + $timeout) . ':' . $uid;
$rt_lock = $redisConnection->setnx($key, $lockValue);//使用setnx命令, 保证锁的唯一
if($rt_lock === true) {
//加锁成功, 设置`锁的有效时间`
$redisConnection->expire($key, $timeout);//这个操作可能失败, 导致锁一直存在, 取值时必须判断锁的值
return 1;
}
//检查当前锁是否已过期,并重新锁定
$value = $redisConnection->get($key);
//获取原锁值的过期时间
if (static::getExpFromValue($value) < time()) {
//新锁的值: 过期时间+请求识别号
$lockValue = (string)(time() + $timeout) . ':' . $uid;
$redisConnection->setex($key, $timeout, $lockValue);
//防止被其他请求修改
if($redisConnection->get($key) == $lockValue) {
return 1;
}
}
//加锁失败, 则休眠
usleep($retry_interval);
}
return 0;
}
/**
* 解锁
*
* @param ClsRedis $redisConnection redis连接对象
* @param string $name
*/
public static function unlock($redisConnection, $name){
$key = self::getKey($name);
if($redisConnection->ttl($key)){
$redisConnection->del($key);
}
}
/**
* 从锁的值里面解析出过期时间
* @param string $value
* @return int
*/
public static function getExpFromValue($value){
$ix = strpos($value, ':');
return (int)substr($value, 0, $ix);
}
/**
* 解析锁的值为exp和uid的数组
*
* @param string $value
* @return array ['exp'=>, 'uid'=>]
*/
public static function parseValue($value){
$ix = strpos($value, ':');
$exp = substr($value, 0, $ix);
$uid = substr($value, $ix + 1);
return [
'exp' => $exp,
'uid' => $uid
];
}
/**
* 拼接完整的锁的key
*
* @param string $name
* @return string
*/
public static function getKey($name){
return 'lock:'.$name;
}
}
实际调用:
$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, 5, 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);
}
类库里的lockFirst
方法, 是 latrell/Lock的redis参考版本, 但是这个加锁方式的应用场景, 一直没理解....