PHP基于redis实现的并发锁(修正版)

在用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);
}

更多关于分布式锁的问题, 可以参考: https://xie.infoq.cn/article/4d571787a3280ef3094338f9b

类库里的lockFirst方法, 是 latrell/Lock的redis参考版本, 但是这个加锁方式的应用场景, 一直没理解....

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
基于 Redis 实现分布式可以利用 Redis 的原子性操作和超时特性来实现。下面是一个基本的实现思路: 1. 获取:使用 Redis 的 SETNX 命令,如果指定的 key 不存在,则设置该 key 的值为当前时间戳加上的超时时间,并返回成功;否则,返回失败。 2. 释放:使用 Redis 的 EVAL 命令,通过 Lua 脚本来实现原子性的删除。脚本的内容是先判断是否存在且超时,如果是则删除并返回成功;否则,返回失败。 下面是一个简单的 Python 代码示例: ```python import redis import time class RedisLock: def __init__(self, redis_client, lock_key, expire_time): self.redis = redis_client self.lock_key = lock_key self.expire_time = expire_time def acquire(self): while True: timestamp = int(time.time() * 1000) + self.expire_time acquired = self.redis.set(self.lock_key, timestamp, nx=True, px=self.expire_time) if acquired: return True time.sleep(0.001) def release(self): lua_script = """ if redis.call("exists", KEYS[1]) == 1 then local current_value = tonumber(redis.call("get", KEYS[1])) if current_value and current_value <= tonumber(ARGV[1]) then return redis.call("del", KEYS[1]) end end return 0 """ self.redis.eval(lua_script, 1, self.lock_key, int(time.time() * 1000) + self.expire_time) # 使用示例 redis_client = redis.Redis(host='localhost', port=6379, db=0) lock = RedisLock(redis_client, 'my_lock', 1000) # 的超时时间为 1000 毫秒 if lock.acquire(): try: # 执行需要加的代码 pass finally: lock.release() ``` 需要注意的是,以上代码仅是一个简单的实现示例,实际使用中还需要考虑异常处理、的可重入性、的可拥有时间等问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值