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
命令加上选项已经可以完全取代SETNX, SETEX, PSETEX的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令。如图1
近期评论