首页 > 文章详情 > 浅谈 Redis 分布式锁实现

浅谈 Redis 分布式锁实现

YuanDong 2019-10-06 浏览量(210)
文章简介 在分布式系统当中,Redis 锁是一个很常用的工具。举个很常见的例子就是:某个接口需要去查询数据库的数据,但是请求量却又很大,所以我们一般会加一层缓存,并且设定过期时间...

在分布式系统当中,Redis 锁是一个很常用的工具。举个很常见的例子就是:某个接口需要去查询数据库的数据,但是请求量却又很大,所以我们一般会加一层缓存,并且设定过期时间。但是这里存在一个问题就是当并发量很大的情况下,在缓存过期的瞬间,会有大量的请求穿透去数据库请求数据,造成缓存雪崩效应。这时候如果有锁的机制,那么就可以控制单个请求去更新缓存.

其实对于 Redis 锁的看法,网上已经有很多了,只是大部分都是基于 Java 来实现的,这里给出一个 PHP 实现的版本。这里考虑的只是单机部署 Redis 的情况,相对会简单好理解,而且也更加的实用。如果有分布式 Redis 部署的情况,可以参考下 Redlock 算法的实现.

基本要求

实现一个分布式锁定,我们至少要考虑它能满足一下的这些需求:

  • 互斥,就是要在任何的时刻,同一个锁只能够有一个客户端用户锁定.
  • 不会死锁,就算持有锁的客户端在持有期间崩溃了,但是也不会影响后续的客户端加锁
  • 谁加锁谁解锁,很好理解,加锁和解锁的必须是同一个客户端

具体代码如下:

<?php
namespace App\Helpers;

/**
 * Redis 单机分布式锁
 * Class RedLock
 * @package App\Helpers
 */
class RedLock
{

    //加锁前缀标识
    const PREFIX     = 'lock';

    static $redis;

    /**
     * 获取Redis实例
     *
     * @return \Illuminate\Foundation\Application|mixed
     */
    public static function getRedis()
    {
        if(!self::$redis){
            //这里是获取Laravel Redis 实例,请根据自己项目替换为自己的获取 Redis 连接。
            self::$redis =  app('redis.connection');
        }

        return self::$redis;
    }

    /**
     * 获得锁,如果锁被占用,阻塞,直到获得锁或者超时。
     * -- 1、如果 $timeout 参数为 0,则立即返回锁。
     * -- 2、建议 timeout 设置为 0,避免 redis 因为阻塞导致性能下降。请根据实际需求进行设置。
     *
     * @param  string  $key         缓存KEY
     * @param  string  $requestId   客户端请求唯一ID
     * @param  int     $lockSecond  锁定时间 单位(秒)
     * @param  int     $timeout     取锁超时时间。单位(秒)。等于0,如果当前锁被占用,则立即返回失败。如果大于0,则反复尝试获取锁直到达到该超时时间。
     * @param  int     $sleep       取锁间隔时间 单位(微秒)。当锁为占用状态时。每隔多久尝试去取锁。默认 0.1 秒一次取锁。
     * @return bool
     * @throws \Exception
     */
    public static function lock(string $key,string $requestId, $lockSecond = 20 , $timeout = 0,  $sleep = 100000)
    {
        if (empty($key)) {
            throw new \Exception('获取锁的KEY值没有设置');
        }

        $start = self::getMicroTime();
        $redis = self::getRedis();

        do {

            /**
             *  注:当前写法是根据laravel 中 Redis 的使用方法 , 若 $redis 是自定义 new \Redis() 的实例 应使用 redis 原生写法
             *  $acquired = $redis->set(self::getLockKey($key), $requestId,['NX', 'EX', $lockSecond]); 这个方法
             */
            $acquired = $redis->set(self::getLockKey($key), $requestId , 'NX' , 'EX', $lockSecond);
            if ($acquired) {
                break;
            }

            if ($timeout === 0) {
                break;
            }

            usleep($sleep);
        } while (!is_numeric($timeout) || (self::getMicroTime()) < ($start + ($timeout * 1000000)));

        return $acquired ? true : false;
    }

    /**
     * 释放锁
     *
     * @param string $key 被加锁的KEY
     * @param string $requestId   客户端请求唯一ID
     * @return bool
     */
    public static function release(string $key,string $requestId)
    {
        if (strlen($key) === 0) {
            return false;
        }

        $lua =<<<LAU
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            else
                return 0
            end
LAU;

        /**
         *  注:当前写法是根据laravel 中 Redis 的使用方法 , 若 $redis 是自定义 new \Redis() 的实例 应使用 redis 原生写法
         *  return self::getRedis()->eval($lua, [self::getLockKey($key), $requestId],1);
         */
        return self::getRedis()->eval($lua,1, self::getLockKey($key), $requestId);
    }

    /**
     * 获取锁 Key
     *
     * @param string $key      需要加锁的KEY
     * @return string
     */
    public static function getLockKey(string $key){
        return self::PREFIX.':'.$key;
    }

    /**
     * 获取当前微秒
     *
     * @return bigint
     */
    protected static function getMicroTime()
    {
        return bcmul(microtime(true), 1000000);
    }
}

加锁的实现

$acquired = $redis->set(self::getLockKey($key), $requestId , 'NX' , 'EX', $lockSecond);

这里简单解释下这个 set 方法的五个参数:

  • 第一个 key 是锁的名字,这个由具体业务逻辑控制,保证唯一即可
  • 第二个是请求 ID, 可能不好理解。这样做的目的主要是为了保证加解锁的唯一性。这样我们就可以知道该锁是哪个客户端加的.
  • 第三个参数是 NX, 表示当 key 不存在时我们才进行 set 操作
  • 第四个参数是一个标识符,标识时间戳以秒为最小单位(EX以秒为最小单位、PX以毫秒为最小单位)
  • 具体的过期时间

简单解释下上面的那段代码,设置 NX 保证了只能有一个客户端获取到锁,满足互斥性;加入了过期时间,保证在客户端崩溃后不会造成死锁;请求 ID 的作用是用来标识客户端,这样客户端在解锁的时候可以进行校验是否同一个客户端.

解锁的实现

当锁拥有的客户端完成了对共享资源的操作后,释放锁需要用到 Lua 脚本,也很简单:

    public static function release(string $key,string $requestId)
    {
        if (strlen($key) === 0) {
            return false;
        }

        $lua =<<<LAU
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            else
                return 0
            end
LAU;

        return self::getRedis()->eval($lua, [self::getLockKey($key), self::LOCK_VALUE], 1);
    }

没想到一个简单的解锁操作也要用到 Lua 脚本,待会会说说常见的几种错误解锁的方式。其实为什么要用 Lua 脚本来实现,主要是为了保证原子性. Redis 的 eval 可以保证原子性,主要还是源于 Redis 的特性,可以看看官网的介绍

常见错误

错误加锁

    public static function wrong1(String $key, String $requestId, int $expireTime) {
        $redis = self::getRedis();
        $result = $redis->setnx($key, $requestId);

        if ($result == 1) {
            // 这里程序挂了或者expire操作失败,则无法设置过期时间,将发生死锁
            $redis->expire($key, $expireTime);
        }
    }

这是比较常见的一种错误实现,先通过 setnx 加锁,然后在通过 expire 设置过期时间。这样乍一看和上面的不都一样吗?其实不然,这是两条 Redis 命令,不具有原子性,如果在 setnx 之后程序挂了,会使得锁没有设置过期时间,这样就会发生死锁定.

错误解锁 1

    public static function wrongRelease1(String $key) {
        self::getRedis()->del($key);
    }

这是最典型的错误了,这样的做法没判断锁的拥有者,会使得任何一个客户端都可以解锁,甚至会把别人的锁给解除了.

错误解锁 2

    public static function wrongRelease2(String $key, String $requestId)
    {
        $redis = self::getRedis();
        // 判断加锁与解锁是不是同一个客户端
        if ($requestId === $redis->get($key)) {
            // 若在此时,这把锁突然不是这个客户端的,则会误解锁
            $redis->del($key);
        }
    }

上面的解锁也是没有保证原子性,注释说的很明白了,有这样的场景来复现:
客户端 A 加锁成功后一段时间再来解锁,在执行删除 del 操作的时候锁过期了,而且这时候又有其他客户端 B 来加锁 (这时候加锁是肯定成功的,因为客户端 A 的锁过期了), 这是客户端 A 再执行删除 del 操作,会把客户端 B 的锁给清了.

总结

这样就基本上实现了一个简单的基于 Redis 的分布式锁。其实分布式锁的实现远比想象的复杂,特别是在多机部署 Redis 的情况下。当然实现的方式也不仅仅包括 Redis, 还可以用 Zookeeper 来实现。随着对分布式系统的深入理解,可以再来慢慢地思考这个问题.

热门评论 (0)

网友评论 0 条评论 / 0 人参与