Redis 的锁定实现,基于 setnx 不具备过期时间的功能,弥补的方案一、二、三的对比分析

1、参考网址:https://www.shuijingwanwq.com/2017/01/08/1505/ ,在 Yii2.0 下实现 Redis 的锁定机制的流程,其核心是使用 Redis setnx。

2、一般来说,在加锁成功后,执行相应的业务逻辑,然后删除锁。但是,如果业务逻辑因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在。因此,需要给锁加一个过期时间以防万一。

3、由于 Redis setnx 不具备过期时间的功能。方案一:借助 Expire 来设置,同时我们需要把两者用 Multi/Exec 包裹起来以确保请求的原子性,以免 setnx 成功了 Expire 却失败了。并且只有当加锁成功后,才设置过期时间。Lua 脚本如下所示:

local key = KEYS[1]
local value = KEYS[2]
local ttl = KEYS[3]

local ok = redis.call('setnx', key, value)

if ok == 1 then
redis.call('expire', key, ttl)
end

return ok

4、由于要使用到 Lua 脚本,还是过于麻烦了些。其实 Redis 从 2.6.12 起,SET 涵盖了 SETEX 的功能,并且 SET 本身已经包含了设置过期时间的功能,也就是说,我们前面需要的功能只用 SET 就可以实现。方案二的代码如下所示:

<?php

$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));

if ($ok) {
    // 业务逻辑代码
    $redis->del($key);
}

?>

5、但是如上实现仍然存在问题,设想一下,如果一个请求业务逻辑代码的执行时间比较长,甚至比锁的有效期还要长,导致在执行过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在执行完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况,所以我们在创建锁的时候需要引入一个随机值。方案二的优化代码如下所示:

<?php

$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));

if ($ok) {
    // 业务逻辑代码

    if ($redis->get($key) == $random) {
        $redis->del($key);
    }
}

?>

6、在 Yii2.0 下实现 Redis 的锁定机制的流程 属于 方案三。其核心是使用 Redis setnx,且未使用 Expire 来设置过期时间。

<?php

namespace common\logics\redis;

use Yii;

/**
 * This is the model class for table "{{%lock}}".
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */class Lock extends \yii\redis\ActiveRecord
{

    /**
     * Redis模型的锁定实现
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @param int $timeOut Redis锁定超时时间,单位为秒
     * 格式如下:3
     *
     * @return bool 成功返回真/失败返回假
     * 格式如下:
     *
     * true //状态,获取锁定成功,可继续执行
     * 
     * 或者
     * 
     * false //状态,获取锁定失败,不可继续执行
     *
     */    public function lock($lockKeyName, $timeOut = 3)
    {
        // 设置锁定的过期时间,获取相关锁定参数
        $time = time();
        $lockKey = Yii::$app->params['redisLock']['keyPrefix'] . $lockKeyName;
        $lockExpire = $time + $timeOut;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 获取锁定
        $executeCommandResult = $redis->setnx($lockKey, $lockExpire);
        // 返回0,表示已经被其他客户端锁定
        if ($executeCommandResult == 0) {
            // 防止死锁,获取当前锁的过期时间
            $lockCurrentExpire = $redis->get($lockKey);
            // 判断锁是否过期,如果已经过期
            if ($time > $lockCurrentExpire) {
                // 防止并发锁定,检查存储在 key 的旧值是否仍然是过期的时间戳,如果是,则获取锁定,否则返回假
                $executeCommandResult = $redis->getset($lockKey, $lockExpire);
                if ($lockCurrentExpire != $executeCommandResult) {
                    return false;
                }
            }

            // 返回0,表示已经被其他客户端锁定,且不存在死锁,返回假
            if ($executeCommandResult == 0) {
                return false;
            }

        }
        
        return true;
    }
    
    /**
     * 判断Redis模型的锁定是否存在
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @return bool 锁定是否存在
     * 格式如下:
     *
     * true //状态:已存在
     * 
     * 或者
     * 
     * false //状态:不存在
     *
     */    public function isLockExist($lockKeyName)
    {
        // 获取相关锁定参数
        $time = time();
        $lockKey = Yii::$app->params['redisLock']['keyPrefix'] . $lockKeyName;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 获取锁定
        $executeCommandResult = $redis->get($lockKey);

        // 返回NULL,表示不存在锁定,否则表示存在
        if ($executeCommandResult === null) {
            return false;
        } else {
            // 如果存在锁定,判断锁是否过期,如果已经过期,则仍然认定为不存在锁定
            if ($time > $executeCommandResult) {
                // 如果已经过期,则释放锁定
                $redis->del($lockKey);
                return false;
            }
            
        }

        return true;
    }

    /**
     * Redis模型的释放锁定实现
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @return integer 被删除的keys的数量
     * 格式如下:
     *
     * 1 //被删除的keys的数量
     * 
     * 或者
     * 
     * 0 //被删除的keys的数量
     *
     */    public function unlock($lockKeyName)
    {
        // 获取相关锁定参数
        $lockKey = Yii::$app->params['redisLock']['keyPrefix'] . $lockKeyName;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 释放锁定
        return $redis->del($lockKey);
    }
}

7、其具体方案为加锁时,设置 value 的值为:当前服务器时间 + 过期时间。在加锁时,即使已经被其他客户端锁定,为了防止死锁,获取当前锁的过期时间。通过与当前服务器时间的比较,判断是否过期。再使用 getset,仍然有可能加锁成功。总体来看,方案三在实际的生产环境中经受了考验,不存在 Bug。

8、方案三 无法解决 方案二在步骤5中存在的问题。一般而言,在方案三下,如果要想避免步骤5中存在的问题。只能够尽量避免出现执行时间大于过期时间的情况了的。

9、且不论是方案一、二、三,还存在另外一个问题,与步骤5中的问题类似,即如果一个请求业务逻辑代码的执行时间比较长,甚至比锁的有效期还要长,导致在执行过程中,锁就失效了,此时另一个请求会获取锁,然后业务逻辑代码可能就会重复执行,甚至是并行执行,如果业务逻辑代码不支持重复执行与并行执行的话,就会产生新的问题。最终导致出现预期之外的脏数据之类的问题。此问题的解决方案为最好在业务逻辑代码层面再增加一个 MySQL 的乐观锁之类的实现。以避免出现重复或者并行执行的问题。以最大概率的降低此类问题出现的概率。

10、参考网址:http://www.redis.cn/commands/set.html 。方案三 的代码实现逻辑仍然过于复杂,相对于 方案二 而言。且为了尽量避免执行到直接加锁的流程,还实现了一个判断锁定是否存在的方法。计划后续有空余时间后,准备将 锁定实现 调整为 方案二。方案二的逻辑更为简单,可读性更好。由于SET命令加上选项已经可以完全取代SETNXSETEXPSETEX的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令。如图1

图1

 

永夜