php、redis实现分布式锁的正确写法(原子操作 通用类 加讲解)

最终代码(通用类)

1 面试中、实际工作中,经常涉及到 redis 分布式锁,正确写法如下。先奉上代码,再讲解。

connect('192.168.4.147',6179);
        return $redis;
    }
    /**
     * 加锁(原子操作)
     * @param string $key 要加锁的key
     * @param string $value 必须是唯一值
     * @param int $expires 锁的过期时间(秒)
     * @return bool
     * @throws \RedisException
     */
    public static function lock(string $key,string $value,int $expires): bool
    {
        # redis实例
        $redis = self::redis();
        # 原子操作 -- 设置锁且设置过期时间
        return $redis->set($key,$value,['nx','ex'=>$expires]);
    }
    /**
     * 解锁(原子操作)
     * @param string $key 要解锁的key
     * @param string $value 加锁时的值
     * @return mixed|\Redis
     * @throws \RedisException
     */
    public static function unlock(string $key,string $value)
    {
        # redis实例
        $redis = self::redis();
        # lua 脚本
        $lua = "
        if redis.call('GET',KEYS[1]) == ARGV[1] then
           return redis.call('DEL',KEYS[1])
        else
           return 0
        end
        ";
        return $redis->eval($lua,[$key,$value],1);
    }
    /**
     * 生成唯一值
     * @return string
     */
    public static function generateValue(): string
    {
        return microtime(true).mt_rand(10000,99999);
    }
}

2 使用方式

use app\common\library\Lock;
# 抢到锁
if(Lock::lock($key,$value,300)){
    try{
        # 业务逻辑
    }
    catch (\Exception $e){}
    finally {
        # 释放锁
        Lock::unlock($key,$value);
    }
}

讲解

使用场景

抢红包、秒杀下单、扣库存、…

目的

在并发情况下,避免业务逻辑的重复执行,导致性能低下,或数据不一致。重复执行没意义的工作,浪费性能,比如数据清理、数据归档、检测日志等 。重复执行有意义的工作,导致数据出错,比如重复多扣了库存。

一些常见的写法

1 setnx + expire
说到 redis 的分布式锁,很多同学马上会想到 setnx + expire,先用 setnx 抢锁,如果抢到之后,再用expire 给锁设置一个过期时间防止忘记释放。

setnx 是 set if not exists 的缩写,表示如果 key 不存在,则去设置,成功返回1,否则返回0。

//抢锁
if($redis->setnx($key,$value)){
    //设置过期时间
    $redis->expire($key,300);
    try{
        //业务逻辑
    }
    catch (\Exception $e){}
    finally {
        //释放锁
        $redis->del($key);
    }
}

注:这个方案的问题在于,setnx 和 expire 两个命令分开了,不是原子操作。如果执行完加锁操作(setnx)后正要执行 expire 设置过期时间,进程崩了或者要重启维护,那么这个锁就长生不老了,别的线程永远也获取不到这个锁了。

2 使用Lua脚本(包含setnx + expire两条指令)
抢锁的改进如下,解决了上面写法1抢锁时的非原子操作。利用 lua 脚本把多个指令一起执行,达到原子操作的目的。

 # lua脚本
 $lua = "
 if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
    redis.call('expire',KEYS[1],ARGV[2])
 else
    return 0
 end
 ";
 # 执行lua脚本,并传入参数
 return $redis->eval($lua,[$key,$value,$expires],1);

3 SET的扩展命令(SET EX PX NX)
效果等同于写法2,也是原子性的。

//抢锁
if($redis->set($key,$value,['NX','EX'=>300])){
    try{
        //业务逻辑
    }
    catch (\Exception $e){}
    finally {
        //释放锁
        $redis->del($key);
    }
}

SET key value[EX seconds][PX milliseconds][NX|XX]

NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
EX seconds :设定key的过期时间,时间单位是秒。
PX milliseconds: 设定key的过期时间,单位为毫秒
XX: 仅当key存在时设置值

注:但是,这个方案还有个问题:「锁被别的线程误删」
比如线程A执行完后,去释放锁,但它不知道当前的锁可能是线程B持有。线程A就把线程B的锁释放了,但线程B临界区业务代码可能还没执行完成。

释放锁的代码改进如下,判断所有者是否是自己,然后再释放。也用原子操作:

# redis实例
$redis = $this->redis();
# lua 脚本
$lua = "
if redis.call('GET',KEYS[1]) == ARGV[1] then
   return redis.call('DEL',KEYS[1])
else
   return 0
end
";
return $redis->eval($lua,[$key,$value],1);

4 SET EX PX NX + 校验所有者,再删除
改进后,最终的代码,见文章开头。

后记

注意,请根据实际业务情况合理设置锁的过期时间 expires 。

一、
无论如何,依然存在一个问题:「锁过期释放了,业务还没执行完」
假设线程A获取锁成功,一直在执行业务代码,300秒过后,它还没执行完。这时候锁就过期了,别的线程又请求进来获取到锁了,也开始执行业务代码。问题就来了,业务代码并不是严格串行执行。

一般情况中小项目中,做好日志、容错判断等即可。如果你项目到了一定规模,如果你追求锁的决定安全性,解决方案是:自动续期。

这个话题,值得再另写一篇文章讲解。自行上网搜索学习,此处省略。

JAVA 提供了很好的一个分布式锁框架: Redisson ,它很好的解决了此问题。PHP 暂时我还没找到好的类库。

还有就是,自己实现自动续期。

二、
多个 redis 实例、集群模式时,解决方案请看官方提供的 RedLock。这又值得另写一篇文章讲解。自行上网搜索学习,此处省略。

你可能感兴趣的:(系统架构,redis,redis,分布式,系统架构)