浅谈 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

博客地址