分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
实现原理
在进程请求执行操作前进行判断,加锁是否成功,加锁成功允许执行下步操作;
- 如果不成功,则判断锁的值(时间戳)是否大于当前时间,如果大于当前时间,则获取锁失败不允许执行下步操作
- 如果锁的值(时间戳)小于当前时间,并且GETSET命令获取到的锁的旧值依然小于当前时间,则获取锁成功允许执行下步操作
- 如果锁的值(时间戳)小于当前时间,并且GETSET命令获取到的锁的旧值大于当前时间,则获取锁失败不允许执行下步操作
至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了
Redis发展到现在,几种常见的部署架构有:
- 单机模式
- 主从模式
- 哨兵模式
- 集群模式
从分布式锁的角度来说, 无论是单机模式、主从模式、哨兵模式、集群模式,其原理都是类同的。 只是主从模式、哨兵模式、集群模式的更加的高可用、或者更加高并发。
所以,接下来先基于单机模式:
首先看两个命令:
Redis分布式锁机制,主要借助setnx和expire两个命令完成。
setnx命令:
SETNX 是SET if Not eXists的简写。将 key 的值设为 value,当且仅当 key 不存在; 若给定的 key 已经存在,则 SETNX 不做任何动作。
expire命令:
expire命令为 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除. 其格式为:
EXPIRE key seconds
通过Redis的setnx、expire命令可以实现简单的锁机制:
key不存在时创建,并设置value和过期时间,返回值为1;成功获取到锁;
如key存在时直接返回0,抢锁失败;
持有锁的线程释放锁时,手动删除key; 或者过期时间到,key自动删除,锁释放。
线程调用setnx方法成功返回1认为加锁成功,其他线程要等到当前线程业务操作完成释放锁后,才能再次调用setnx加锁成功。
在这里插入图片描述
具体实现代码:
/**
* @desc 加锁方法
*
*当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以秒为单位,返回 key 的剩余生存时间。
*/
public function getLock($lockName, $timeout = 2)
{
$identifier = uniqid(); #获取唯一标识符
$end = time() + $timeout;
while (time() < $end) #循环获取锁
{
if ($this->redis->setnx($lockName, $identifier)) #查看$lockName是否被上锁
{
$this->redis->expire($lockName, $timeout); #为$lockName设置过期时间,防止死锁
return $identifier; #返回一维标识符
} elseif ($this->redis->ttl($lockName) === -1) {
$this->redis->expire($lockName, $timeout); #检测是否有设置过期时间,没有则加上(假设,客户端A上一步没能设置时间就进程奔溃了,客户端B就可检测出来,并设置时间)
}
usleep(0.001); #停止0.001ms
}
return false;
}
/**
* @desc 释放锁
*
*/
public function releaseLock($lockName, $identifier)
{
if ($this->redis->get($lockName) == $identifier) #判断是锁有没有被其他客户端修改
{
$this->redis->multi();
$this->redis->del($lockName); #释放锁
$this->redis->exec();
return true;
} else {
return false; #其他客户端修改了锁,不能删除别人的锁
}
}
以上简单redis分布式锁的问题:
如果出现了这么一个问题:如果setnx是成功的,但是expire设置失败,一旦出现了释放锁失败,或者没有手工释放,那么这个锁永远被占用,其他线程永远也抢不到锁。
所以,需要保障setnx和expire两个操作的原子性,要么全部执行,要么全部不执行,二者不能分开。
解决的办法有两种:
使用set的命令时,同时设置过期时间,不再单独使用 expire命令
使用lua脚本,将加锁的命令放在lua脚本中原子性的执行
**
使用set的命令时,同时设置过期时间,不再单独使用 expire命令
**
使用set的命令时,同时设置过期时间 这样就完美的解决了分布式锁的原子性; set 命令的完整格式:
set key value [EX seconds] [PX milliseconds] [NX|XX]
EX seconds:设置失效时长,单位秒
PX milliseconds:设置失效时长,单位毫秒
NX:key不存在时设置value,成功返回OK,失败返回(nil)
XX:key存在时设置value,成功返回OK,失败返回(nil)
请求某一台redis,如果key=resource不存在就设置value=requestid(算法生成,全局唯一),并且redis会在ttl时间后自动删除这个key
private function initInstances()
{
if (empty($this->instances)) {
foreach ($this->servers as $server) {
list($host, $port, $timeout) = $server;
$redis = new \Redis();
$redis->connect($host, $port, $timeout);
$this->instances[] = $redis;
}
}
}
private function lockInstance($instance, $resource, $requestId, $ttl)
{
return $instance->set($resource, $requestId, ['NX', 'PX' => $ttl]);
}
执行上面的set()方法就只会导致两种结果:
- 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
- 已有锁存在,不做任何操作
我们的加锁代码满足前面描述的四个条件中的三个。
-
首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。
-
其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会被永远占用(而发生死锁)。
-
最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。
-
由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑
使用lua脚本,将加锁的命令放在lua脚本中原子性的执行
private function unlockInstance($instance, $resource, $token)
{
$script = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
';
return $instance->eval($script, [$resource, $token], 1);
}
- 请求某一台redis,给它发送一段lua脚本,如果resource的value不等于lock时设置的token则说明锁已被它人占用无需释放,否则说明是自己上的锁可以DEL删除。
- lua脚本在redis里原子执行,在这里即保障GET和DEL的原子性。
那么为什么要使用Lua语言来实现呢?
因为要确保上述操作是原子性的。那么为什么执行eval()方法可以确保原子性,源于Redis的特性.
简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命
加锁的Lua脚本: lock.lua
--- -1 failed
--- 1 success
---
local key = KEYS[1]
local requestId = KEYS[2]
local ttl = tonumber(KEYS[3])
local result = redis.call('setnx', key, requestId)
if result == 1 then
--PEXPIRE:以毫秒的形式指定过期时间
redis.call('pexpire', key, ttl)
else
result = -1;
-- 如果value相同,则认为是同一个线程的请求,则认为重入锁
local value = redis.call('get', key)
if (value == requestId) then
result = 1;
redis.call('pexpire', key, ttl)
end
end
-- 如果获取锁成功,则返回 1
return result
解锁的Lua脚本: unlock.lua:
--- -1 failed
--- 1 success
-- unlock key
local key = KEYS[1]
local requestId = KEYS[2]
local value = redis.call('get', key)
if value == requestId then
redis.call('del', key);
return 1;
end
return -1