Redis分布式锁笔记
锁是什么?
锁是一种可以封锁资源的东西。这种资源通常是共享的,通常会发生使用竞争的。
为什么需要锁?
需要保护共享资源正常使用,不出乱子。比方说,公司只有一间厕所,这是个共享资源,大家需要共同使用这个厕所,所以避免不了有时候会发生竞争。如果一个人正在使用,另外一个人进去了,咋办呢?如果两个人同时钻进了一个厕所,那该怎么办?结果如何?谁先用,还是一起使用?特别的,假如是一男一女同时钻进了厕所,事情会怎样呢?……
如果这个时候厕所门前有个锁,每个人都没法随便进入,而是需要先得到锁,才能进去。而得到这个锁,就需要里边的人先出来。这样就可以保证同一时刻,只有一个人在使用厕所,这个人在上厕所的期间不会有不安全的事情发生,不会中途被人闯进来了。
// 连接Redis $redis = new Redis(); $redis->connect('127.0.0.1',6379); // 第一次,A客户端获取到销量的时候,数据符合要求,对数据进行加锁,如果不加锁,就会产生goodNum为2甚至更大的数据 // 当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('获取锁失败');