Redis分布式锁笔记

Redis分布式锁笔记

锁是什么?

锁是一种可以封锁资源的东西。这种资源通常是共享的,通常会发生使用竞争的。

为什么需要锁?

需要保护共享资源正常使用,不出乱子。比方说,公司只有一间厕所,这是个共享资源,大家需要共同使用这个厕所,所以避免不了有时候会发生竞争。如果一个人正在使用,另外一个人进去了,咋办呢?如果两个人同时钻进了一个厕所,那该怎么办?结果如何?谁先用,还是一起使用?特别的,假如是一男一女同时钻进了厕所,事情会怎样呢?……

如果这个时候厕所门前有个锁,每个人都没法随便进入,而是需要先得到锁,才能进去。而得到这个锁,就需要里边的人先出来。这样就可以保证同一时刻,只有一个人在使用厕所,这个人在上厕所的期间不会有不安全的事情发生,不会中途被人闯进来了。

// 连接Redis
$redis = new Redis();
$redis->connect('127.0.0.1',6379);
// 第一次,A客户端获取到销量的时候,数据符合要求,对数据进行加锁,如果不加锁,就会产生goodNum2甚至更大的数据
// B客户端访问的时候,数据加锁,不允许访问,解锁之后,才能访问,数据符合要求,继续执行
$num = $redis->get('goodNum');
if($num < 1){
    sleep(5);  // IO阻塞,等待5秒钟,模拟阻塞情况
    $redis->incr('goodNum');
    $newNum = $redis->get('goodNum');
    var_dump($newNum);
}else{
    echo '商品已卖完';
}

单机的锁

在编码的时候,为了保护共享资源,使得多线程环境下,不会出现“不好的结果”。我们可以使用锁来进行同步。于是我们可以根据具体的情况,使用悲观锁(比如文件方式的排它锁)来锁住一段代码。这段代码就像是前文中提到的“受保护的厕所,加锁的厕所”。

$fp = fopen('/tmp/file.lock', "a+");
//进行排他型锁定,阻塞等待
$res = flock($fp, LOCK_EX);
if($res) {
    fwrite($fp, "lock success\n");
    //执行业务逻辑
    sleep(10);
    flock($fp, LOCK_UN); //释放锁定
} else {
    echo "文件正在被其他进程占用";
}
fclose($fp);

分布式锁

上面我们所说的,其实他们的作用范围是啥,就是当前的应用。你的代码被部署在 A 机器上。那么实际上我们写的排它锁,就是在当前的机器在执行代码的时候发生作用的。假设这个代码被部署到了三台机器上 A,B,C。那么 A 机器中的部署的代码使用排它锁并不能控制 B,C 中的内容。

三台机器上运行某段程序的时候,很显然,这个时候我们需要的锁,是需要协同这三个节点的,于是,分布式锁就需要上场了,他就像是在A,B,C的外面加了一个层,通过它来实现锁的控制。

分布式锁的基本条件

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下几个条件:

1、互斥性。在任意时刻,只有一个客户端能持有锁。

2、不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。

3、解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。

为什么使用redis+lua?

什么是Lua?Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能

Redis嵌入lua的优势

(1)减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;

(2)原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;

(3)复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.

Redis当中使用lua

在Lua脚本中采用如下两个不同的Lua函数进行Redis命令调用:redis.call()和redis.pcall()

redis.call()类似于redis.pcall(),唯一的区别是如果Redis命令调用将导致错误,redis.call()将引发Lua错误,反过来会强制EVAL返回错误到命令调用者,而redis.pcall将捕获错误并返回一个Lua错误码。

示例代码如下:

class Lock {
    protected $redis;
    // 请求A,业务逻辑执行超时,锁失效了,请求B开始执行,创建锁成功,A业务逻辑执行完成之后,删除锁,这个时候,B正在执行业务逻辑,B的锁被删除了
    // 这个时候,需要添加一个锁的标识
    protected $lockId;  // ID
    public function __construct($redis){
        $this->redis = $redis;
    }
    
    /**
     * 加锁
     * @param string $sence   应用场景
     * @param int    $expire  锁的有效时间
     * @param int    $retry   获取锁尝试次数
     * @param int    $sleep   等待时间
     * @return bool
     */
    public function lock($sence='seckill', $expire = 5, $retry = 5, $sleep=10000){
        // 同一时刻只能有一个用户持有锁,并且不能出现死锁
        $isLock = false;
        while($retry-- > 0){
            $value = session_create_id();  // 生成不重复的字符串(唯一的值)
            // 对不存在的key进行赋值,如果已经存在的话,则赋值不成功
            $res = $this->redis->set($sence, $value, ['NX','EX'=>$expire]);
            if($res){
                $this->lockId[$sence] = $value;
                // 加锁成功了
                $isLock = true;
                break;
            }
            echo '尝试获取锁'.PHP_EOL;
            usleep($sleep);
        }
        return $isLock;
    }
    
    /**
     * Redis解锁
     * @param string $sence 应用场景
     * @return bool
     */
    public function unLock($sence=''){
        // 能够删除自己的锁,而不应该删除别人的锁,但在极端情况下,还是会出现误删锁
        if(isset($this->lockId[$sence])){
            $id    = $this->lockId[$sence];
            $value = $this->redis->get($sence);  // 先取出当前数据库中记录的锁
            // redis当中迁入lua脚本
            // 从当前Redis中获取到的ID跟当前记录的ID是否是同一个
            if($id == $value){
//                sleep(5);  // 客户端A发生了阻塞(原子性)
                return $this->redis->del($sence);
            }
        }
        return false;
    }
    
    /**
     * Lua脚本Redis解锁
     * @param string $sence
     * @return mixed
     */
    public function luaUnLock($sence=''){
        if(isset($this->lockId[$sence])){
            $id     = $this->lockId[$sence];
            // 从Redis中获取,如果相同则删除,两条命令合并成一条命令,减少了对Redis的请求,也不存在并发的情况
            $script = <<<LUA
    local key   = KEYS[1]
    local value = ARGV[1]
    if redis.call('get',key) == value
    then
        return redis.call('del',key)
    else
        return false
    end
LUA;
            // 将参数传递到redis当中,利用lua统一执行
            return $this->redis->eval($script,[$sence,$id],1);
        }
    }

}

    /**
     * Lua脚本Redis锁续期
     * @param string $sence
     * @return mixed
     */
    public function luaConLock($sence='', $expire=1000){
        if(isset($this->lockId[$sence])){
            $id     = $this->lockId[$sence];
            // 从Redis中获取,如果相同则删除,两条命令合并成一条命令,减少了对Redis的请求,也不存在并发的情况
            $script = <<<LUA
    local key   = KEYS[1]
    local value = ARGV[1]
    local expire= ARGV[2]
    if redis.call('get',key) == value
    then
        return redis.call('expire',key,expire)
    else
        return false
    end
LUA;
            // 将参数传递到redis当中,利用lua统一执行
            return $this->redis->eval($script,[$sence,$id,$expire],1);
        }
    }

}

// 连接Redis
$redis = new Redis();
$redis->connect('127.0.0.1',6379);
$lock = new Lock($redis);
$sence = 'seckill';
// 如果加锁成功,每个业务只允许一个用户操作
if($lock->lock($sence, 5)){  // 加锁
    // 锁续期监控,续期需求:如果业务需要保证A请求执行完成之后,才能执行B的请求,就需要开启一个新的进程对锁添加监控了
    // 监控频率:每隔2/3的缓存时间续期一次,防止锁过期导致B请求获取到锁,解锁完成之后,更改监控状态,关闭续期
    // 方案:可以使用PHP自带的Thread开启新的进程,也可以使用swoole框架,这里就不再进行详细的代码描述了
    var_dump('执行业务逻辑');
    sleep(4);  // 模拟业务执行逻辑,当机了,业务内存不够了
    $res = $lock->luaUnLock($sence);  // 解锁操作
    var_dump($res);
    return;
}
var_dump('获取锁失败');

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据