在用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参考版本, 但是这个加锁方式的应用场景, 一直没理解....