分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了
分析代码
首先打开代码: https://github.com/ronnylt/redlock-php/blob/master/src/RedLock.php,这是PHP的官方推荐实现版本,它基于composer安装构造函数
function __construct(array $servers, $retryDelay = 200, $retryCount = 3) {
$this->servers = $servers;
$this->retryDelay = $retryDelay;
$this->retryCount = $retryCount;
$this->quorum = min(count($servers), (count($servers) / 2 + 1));
}
- 需要传入的是redis的若干master节点地址,并且这些master是纯内存模式且无slave的。
- retryDelay是设置每一轮lock失败或者异常,多久之后重新尝试下一轮lock。
- retryCount是指最多几轮lock失败后彻底放弃。
- quorum体现了分布式里一个有名的”鸽巢原理”,也就是如果大于半数的节点操作成功则认为整个集群是操作成功的;在这里的意思是,如果超过1/2的(>=N/2+1)redis master调用锁成功,则认为获得了整个redis集群的锁,假设A用户获得了集群的锁,那么接下来的B用户只能获得<=1/2的redis master的锁,相当于无法获得集群的锁。
初始化redis连接
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;
}
}
}
- 遍历每个redis master,建立到它们的连接并保存起来;
- 因为需要用到”鸽巢原理”,也就是redis数量足够产生”大多数”这个目的:因此redis master数量最好>=3台,因为2台的话大多数是2台(2/2+1),这样任何1台故障就无法产生”大多数”,那么整个分布式锁就不可用了。
请求1个redis上锁
private function lockInstance($instance, $resource, $token, $ttl)
{
return $instance->set($resource, $token, ['NX', 'PX' => $ttl]);
}
- 请求某一台redis,如果key=resource不存在就设置value=token(算法生成,全局唯一),并且redis会在ttl时间后自动删除这个key
请求1个redis放锁
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的原子性。
请求集群锁
public function lock($resource, $ttl)
{
$this->initInstances();
$token = uniqid();
$retry = $this->retryCount;
do {
$n = 0;
$startTime = microtime(true) * 1000;
foreach ($this->instances as $instance) {
if ($this->lockInstance($instance, $resource, $token, $ttl)) {
$n++;
}
}
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min drift
# for small TTLs.
$drift = ($ttl * $this->clockDriftFactor) + 2;
$validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift;
if ($n >= $this->quorum && $validityTime > 0) {
return [
'validity' => $validityTime,
'resource' => $resource,
'token' => $token,
];
} else {
foreach ($this->instances as $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
// Wait a random delay before to retry
$delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay);
usleep($delay * 1000);
$retry--;
} while ($retry > 0);
return false;
}
- 首先整个lock过程最多会重试retry次,因此外层有do while。
- 为了获取”大多数”的锁,因此遍历每个redis master去lock,统计成功的次数。
- 因为遍历redis master进行逐个上锁需要花费一定的时间,因此在第1个redis上锁前记录时间T1,结束最后一个redis上锁动作的时间点T2,此时第1个redis的TTL已经消逝了T2-T1这么长的时间。
- 为了保障在锁内计算期间锁不会失效,我们剩余可以占用锁的时间实际上是TTL – (T2 – T1),因为越靠前上锁的redis其剩余时间越少,最少的就是第1个redis了。
- drift值用于补偿不同机器时钟的精度差异,怎么理解呢:
- 在我们的程序看来时间过去了(T2-T1),剩余的锁时间认为是TTL-(T2-T1),在接下来的剩余时间内进行计算应该不会超过锁的有效期。
- 但是第1台redis机器的机器时钟也许跑的比较快(比如时钟多前进了1毫秒),那么数据会提前1毫秒淘汰,然而我们认为TTL-(T2-T1)秒内锁有效,而redis相当于TTL-(T2-T1)-1秒内锁有效,这可能导致我们在锁外计算。(drift+1)
- 另外,我们计算(T2-T1)之后到返回给lock的调用者之间还有一段代码在运行,这段代码的花费也将占用一些时间,所以drift应该也考虑这个。(drift+1)
- 最后,ttl * 0.01的意思是ttl越长,那么时钟可能差异越大,所以这里做了一个动态计算的补偿,比如ttl=100ms,那么就补偿1ms的时钟误差,尽量避免遇到锁已过期而我们仍旧在计算的情况发生。
- 如果锁redis成功的次数>1/2,并且整个遍历redis+锁定的过程的耗时 没有超过锁的有效期,那么lock成功,将剩余的锁时间(TTL减去上锁花费的时间)+ 锁的标识token 返回给用户。
- 如果上锁中途失败(返回key已存在)或者异常(不知道操作结果),那么都认为上锁失败;如果上锁失败的数量超过1/2,那么本次上锁失败,需要遍历所有redis进行回滚(回滚失败也没有办法,其他人只能等待我们的key过期,并不会有什么错误)。
释放集群锁
public function unlock(array $lock)
{
$this->initInstances();
$resource = $lock['resource'];
$token = $lock['token'];
foreach ($this->instances as $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
- 遍历所有redis,利用lua脚本原子的安全的释放自己建立的锁。
故障处理
这里所有redis都是master,不开启持久化,也不需要slave。
如果某台redis宕机,那么不要立即重启它,因为宕机后redis没有任何数据,如果你此时重启它,那么其他进程就可以可以锁住一个本应还没有过期的key,这可能导致2个调用者同时在锁内进行计算,举个例子吧:
3个redis,两个用户A和B,有这么1个典型流程来说明上述情况:
- A发起lock,锁住了2个redis(r1+r2),超过3/2+1(大多数),开始执行锁内操作。
- r0() r1(A) r2(A)
- r1宕机,立即重启,数据全部丢失;A仍旧在进行锁内计算,并不知情。
- r0() r1() r2(A)
- B发起lock,锁住了2个redis(r0+r1),超过3/2+1(大多数),开始执行锁内操作。
- r0(B) r1(B) r2(A)
悲剧的事情发生了,因为r1宕机立即重启导致B可以成功锁住”大多数”redis,导致A和B并发操作。
红色字体就是解决这个问题的:不要立即重启,保持r1无法联通,这样的话B只能锁住r0,没有达到”大多数”从而上锁失败。那么何时重启r1呢?根据业务最大的TTL判断,当过了TTL秒后redis中所有key都会过期,遵守规则的A用户的计算也应早已结束,此时B获得锁也可以保证独占。
当然,无论宕机几台原理都是一样的,不要立即重启,等待最大TTL过期后再启动redis,你可以自己分析上述例子,假设r0和r1一起宕机看看又会发生什么。
<?php
class RedLock
{
private $retryDelay;
private $retryCount;
private $clockDriftFactor = 0.01;
private $quorum;
private $servers = array();
private $instances = array();
function __construct(array $servers, $retryDelay = 200, $retryCount = 3) {
$this->servers = $servers;
$this->retryDelay = $retryDelay;
$this->retryCount = $retryCount;
$this->quorum = min(count($servers), (count($servers) / 2 + 1));
}
public function lock($resource, $ttl) {
$this->initInstances();
$token = uniqid();
$retry = $this->retryCount;
do {
$n = 0;
$startTime = microtime(true) * 1000;
foreach ($this->instances as $instance) {
if ($this->lockInstance($instance, $resource, $token, $ttl)) {
$n++;
}
}
# Add 2 milliseconds to the drift to account for Redis expires
# precision, which is 1 millisecond, plus 1 millisecond min drift
# for small TTLs.
$drift = ($ttl * $this->clockDriftFactor) + 2;
$validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift;
if ($n >= $this->quorum && $validityTime > 0) {
return [
'validity' => $validityTime,
'resource' => $resource,
'token' => $token,
];
} else {
foreach ($this->instances as $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
// Wait a random delay before to retry
$delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay);
usleep($delay * 1000);
$retry--;
} while ($retry > 0);
return false;
}
public function unlock(array $lock) {
$this->initInstances();
$resource = $lock['resource'];
$token = $lock['token'];
foreach ($this->instances as $instance) {
$this->unlockInstance($instance, $resource, $token);
}
}
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, $token, $ttl) {
return $instance->set($resource, $token, ['NX', 'PX' => $ttl]);
}
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);
}
}
调用方法
<?php
require_once __DIR__ . '/../src/RedLock.php';
$servers = [
['127.0.0.1', 6379, 0.01],
['127.0.0.1', 6389, 0.01],
['127.0.0.1', 6399, 0.01],
];
$redLock = new RedLock($servers);
while (true) {
$lock = $redLock->lock('test', 10000);
if ($lock) {
print_r($lock);
} else {
print "Lock not acquired\n";
}
}
个人理解在项目中使用消息队列一般是有如下几个原因:
把瞬间服务器的请求处理换成异步处理,缓解服务器的压力
实现数据顺序排列获取
把瞬间服务器的请求处理换成异步处理,缓解服务器的压力
实现数据顺序排列获取
redis设计用来做缓存的,但是由于它自身的某种特性使得它可以用来做消息队列,它有几个阻塞式的API可以使用,正是这些阻塞式的API让其有能力做消息队列;
程序只考虑了最简单的状况,对于邮件发送失败的情况只是打了日志,并未做任何处理。可以在数据库邮件详情表增加记录邮件发送失败次数的字段。邮件发送失败后,累加记录失败次数,设置一个最大失败次数,小于最大失败次数则重新入队列,再次发送。直到达到最大失败次数之后不再发送,以此来提高邮件的发送成功率。
程序只考虑了最简单的状况,对于邮件发送失败的情况只是打了日志,并未做任何处理。可以在数据库邮件详情表增加记录邮件发送失败次数的字段。邮件发送失败后,累加记录失败次数,设置一个最大失败次数,小于最大失败次数则重新入队列,再次发送。直到达到最大失败次数之后不再发送,以此来提高邮件的发送成功率。
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$password = '123456';
$redis->auth($password);
$arr = array('h', 'e', 'l', 'l', 'o', 'w', 'o', 'r', 'l', 'd');
foreach ($arr as $k => $v) {
$redis->rpush("mylist", $v);
}
出队列
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$password = '123456';
$redis->auth($password);
//list类型出队操作
$value = $redis->lpop('mylist');
if ($value) {
echo "出队的值" . $value;
} else {
echo "出队完成";
}
?>