浅谈 Redis 分布式锁实现

在分布式系统当中,Redis 锁是一个很常用的工具。举个很常见的例子就是:某个接口需要去查询数据库的数据,但是请求量却又很大,所以我们一般会加一层缓存,并且设定过期时间。但是这里存在一个问题就是当并发量很大的情况下,在缓存过期的瞬间,会有大量的请求穿透去数据库请求数据,造成缓存雪崩效应。这时候如果有锁的机制,那么就可以控制单个请求去更新缓存.
其实对于 Redis 锁的看法,网上已经有很多了,只是大部分都是基于 Java 来实现的,这里给出一个 PHP 实现的版本。这里考虑的只是单机部署 Redis 的情况,相对会简单好理解,而且也更加的实用。如果有分布式 Redis 部署的情况,可以参考下 Redlock 算法的实现.

基本要求#

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

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

加锁#

我们这里使用的是 Predis 这个这个 PHP 的客户端,其他客户端也是同理。先来看看代码:

class RedisTool {
    const LOCK_SUCCESS = 'OK';
    const IF_NOT_EXIST = 'NX';
    const MILLISECONDS_EXPIRE_TIME = 'PX';

    const RELEASE_SUCCESS = 1;
    /**
     * 尝试获取锁
     * @param \Predis\Client $redis     redis客户端
     * @param String $key               锁
     * @param String $requestId         请求id
     * @param int $expireTime           过期时间
     * @return bool                     是否获取成功
     */
    public static function tryGetLock(\Predis\Client $redis, String $key, String $requestId, int $expireTime) {
        $result = $redis->set($key, $requestId, self::MILLISECONDS_EXPIRE_TIME, $expireTime, self::IF_NOT_EXIST);

        return self::LOCK_SUCCESS === (string)$result;
    }
}

定义一些 Redis 的操作符作为常量,加锁的代码其实很简单,一行代码即可。简单解释下这个 set 方法的五个参数:

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

PS. 请求的唯一性 ID 生成方式很多,可以参考下这个 chronos, 该库是 Java 版本的,下回给出一个简单的 PHP 实现.

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

解锁#

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

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

PHP 代码实现:

class RedisTool {
    const RELEASE_SUCCESS = 1;

    public static function releaseLock(\Predis\Client $redis, String $key, String $requestId) {
        $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

        $result = $redis->eval($lua, 1, $key, $requestId);
        return self::RELEASE_SUCCESS === $result;
    }
}

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

常见错误#

  1. 错误加锁 1

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

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

  1. 错误加锁 2

    public static function wrong2(\Predis\Client $redis, String $key, int $expireTime) {
        $expires = floor(microtime(true) * 1000) + $expireTime;
    
            // 如果当前锁不存在,返回加锁成功
        if ($redis->setnx($key, $expires) == 1) {
            return true;
        }
    
        // 如果锁存在,获取锁的过期时间
        $currentValue = floor($redis->get($key));
        if ($currentValue != null && $currentValue < floor(microtime(true) * 1000)) {
            // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
            $oldValue = floor($redis->getSet($key, $expires));
    
            if ($oldValue != null && $oldValue === $currentValue) {
                // 考虑并发的情况,只有设置值和当前值相同,它才有权利加锁
                return true;
            }
        }
    
        // 其他情况,一律返回加锁失败
        return false;
    }

    这个例子实现原理是使用 setnx 来加锁,如果锁已经存在的话则获取锁的过期时间并且与当前的时间比较,过期则设置新的时间,并且返回加锁成功。虽然这样也可以加锁,但是会存在几个问题:

  • 因为时间是客户端生成的,这样就必须要保证在分布式环境下客户端的时间必须要同步
  • 当锁过期后,多个客户端同时执行 getSet 方法,虽然可以保证互斥性,只适合这个锁的过期时间在高并发或者多线程的情况下有一定的可能被其他客户端给覆盖
  • 锁没有客户端的标识,这样任何一个客户端都能够解锁
  1. 错误解锁 1

    public static function wrongRelease1(\Predis\Client $redis, String $key) {
        $redis->del([$key]);
    }

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

  2. 错误解锁 2

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

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

总结#

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

微信与订阅号,欢迎关注 :smile:
file

博客地址

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 7年前 加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 10

蛮厉害的

7年前 评论

官方 Laravel 源码 Redis 锁,是这样实现的,出自 Illuminate/Cache/RedisLock.php

    /**
     * Attempt to acquire the lock.
     *
     * @return bool
     */
    public function acquire()
    {
        $result = $this->redis->setnx($this->name, 1);

        if ($result === 1 && $this->seconds > 0) {
            $this->redis->expire($this->name, $this->seconds);
        }

        return $result === 1;
    }

    /**
     * Release the lock.
     *
     * @return void
     */
    public function release()
    {
        $this->redis->del($this->name);
    }

这样是不够严格的吧!

6年前 评论

@纸牌屋弗兰克 对的,因为 expire 这个操作有可能失败,那么就死锁了

6年前 评论
  $lua = "local current current = redis.call('incr',KEYS[1]) if tonumber(current) == 1 then redis.call('expire',KEYS[1],ARGV[1]) end return current";
        return $this->redis->eval(
            $lua, 1, $key, (int) max(1, $maintainSeconds)
        );
6年前 评论
sushengbuhuo

厉害了

6年前 评论

能分享下生成唯一请求 ID 的代码吗

5年前 评论

@returnfalse 唯一 ID 的生成是个比较复杂的话题,常规应用建议直接使用 uuid 即可,分布式情况下可以考虑用美团开源的 leaf 组件

5年前 评论

生成 uuid SnowFlakeID 可以用这个 https://github.com/wujunze/laravel-id-gene... :grin:

5年前 评论

多台 redis 同时生成 key 的时候,未同步到其他 redis,我还是没看想明白,其他 redis 服务怎么限制生成 key 的。。。。

4年前 评论
txjlrk 4年前