深入研究php的redlock算法

为了应对服务器高并发,可以采用锁进行控制。

如果redis是单节点部署,基本上不会出现什么问题。但如果redis是多节点的集群部署,那么使用redis集群作为分布式锁就会存在一些问题。本文参(抄)考(袭)了以下文章。
闲聊Redis分布式锁
基于Redis的分布式锁到底安全吗(上)?
基于Redis的分布式锁到底安全吗(下)?

一、单节点redis锁

1.1 首先客户端获取锁

SET resource_name my_random_value NX PX 30000

如果返回成功,则说明客户端获取锁成功,然后就可以访问公共资源了,如果失败则获取锁失败。对于这条命令,需要注意

my_random_value:必须是一个随机字符,并且唯一。如果不唯一,可能会出现以下情况:
1.客户端1获取资源成功
2.客户端1阻塞超时,锁自动释放
3.客户端2获取锁成功
4.客户端1从阻塞中醒来,释放了客户端2的锁

必须设置NX,表示只有resource_name不存在时才会设置成功,保证只有第一个请求的客户端获取锁成功

PX 30000 表示过期时间为30s,为了保证原子操作必须在SET时设置过期时间

1.2 客户端释放锁

释放锁时使用下面的redis lua脚本执行来保证原子性。
只有当resource_name的值和客户端持有的数据相等时才能够调用del删除resource_name,否则不进行删除操作。从而防止一个客户端释放另一个客户端持有的锁。


image.png

1.3 安全性和可靠性:

分析一下redis锁的原理,我们在redis实例中创建一个键值,同时设置该键值的超时时间。创建该键值的客户端获取锁成功,访问公共资源。同时如果客户端宕机则锁会自动释放。客户端需要释放锁时只需要删除该键即可。但一旦单节点的Redis宕机则不能再提供服务,即使是基于Master-Slave模式的故障切换也是不安全的,例如下面场景

客户端1从Master获取锁
Master宕机,但锁key还没有同步到Slave上
Slave升级为Master
客户端2从新的Master上获取锁成功

总之:当redis服务器宕机的时候会出现bug

二、分布式Redlock

算法实现

  1. 客户端获取当前时间start_time
  2. 客户端按照顺序依次向N个Redis节点获取锁操作,这个过程类似于上述单个节点获取锁过程。为了防止在获取某个Redis节点锁超时,客户端会设置一个很小的超时时间(timeout),timeout要远远小于锁本身超时时间。
  3. 当向所有Redis节点发送获取锁操作完成后,记录当前时间endtime。并且获取锁总消耗时间elapsed_time = (endtime-starttime),可用时长:validity = ttl - elapsed_time - drift,获取锁成功数n。当n > (N/2+1) && validity > 0(或其他值) 获取锁成功,并修改占有锁时长为validity
  4. 如果获取锁失败,则需要向所有客户端发起释放锁的操作

php源码实现

<?php
class RedLock
{
    private $retryDelay; // 重试的间隔时间
    private $retryCount;// 重试的次数
    private $clockDriftFactor = 0.01; // 不同服务器时间漂移比例因子 
    private $quorum;// (N/2+1)
    private $servers = array();// redis集群
    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);
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,753评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,668评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,090评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,010评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,054评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,806评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,484评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,380评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,873评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,021评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,158评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,838评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,499评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,044评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,159评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,449评论 3 374
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,136评论 2 356

推荐阅读更多精彩内容